mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Merge e3cab9777c into e0b2a5081d
This commit is contained in:
commit
3c12d7a509
|
|
@ -777,6 +777,7 @@ keybinding:
|
||||||
fetchRemote: f
|
fetchRemote: f
|
||||||
addForkRemote: F
|
addForkRemote: F
|
||||||
sortOrder: s
|
sortOrder: s
|
||||||
|
restoreBranch: <ctrl+r>
|
||||||
commits:
|
commits:
|
||||||
squashDown: s
|
squashDown: s
|
||||||
renameCommit: r
|
renameCommit: r
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
| `` - `` | Checkout previous branch | |
|
| `` - `` | Checkout previous branch | |
|
||||||
| `` F `` | Force checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
| `` F `` | Force checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
||||||
| `` d `` | Delete | View delete options for local/remote branch. |
|
| `` d `` | Delete | View delete options for local/remote branch. |
|
||||||
|
| `` <ctrl+r> `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. |
|
||||||
| `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. |
|
| `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. |
|
||||||
| `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
| `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||||
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. |
|
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. |
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
| `` - `` | 直前のブランチにチェックアウト | |
|
| `` - `` | 直前のブランチにチェックアウト | |
|
||||||
| `` F `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 |
|
| `` F `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 |
|
||||||
| `` d `` | 削除 | ローカル/リモートブランチの削除オプションを表示します。 |
|
| `` d `` | 削除 | ローカル/リモートブランチの削除オプションを表示します。 |
|
||||||
|
| `` <ctrl+r> `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. |
|
||||||
| `` r `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 |
|
| `` r `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 |
|
||||||
| `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) |
|
| `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) |
|
||||||
| `` f `` | ブランチを最新化(fast-forward) | 選択したブランチを対応するアップストリームの最新状態に追いつかせます(fast-forward)。 |
|
| `` f `` | ブランチを最新化(fast-forward) | 選択したブランチを対応するアップストリームの最新状態に追いつかせます(fast-forward)。 |
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
| `` - `` | Checkout previous branch | |
|
| `` - `` | Checkout previous branch | |
|
||||||
| `` F `` | 강제 체크아웃 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
| `` F `` | 강제 체크아웃 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
||||||
| `` d `` | 삭제 | View delete options for local/remote branch. |
|
| `` d `` | 삭제 | View delete options for local/remote branch. |
|
||||||
|
| `` <ctrl+r> `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. |
|
||||||
| `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. |
|
| `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. |
|
||||||
| `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
| `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||||
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. |
|
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. |
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
| `` - `` | Vorige branch uitchecken | |
|
| `` - `` | Vorige branch uitchecken | |
|
||||||
| `` F `` | Forceer checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
| `` F `` | Forceer checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
||||||
| `` d `` | Verwijderen | View delete options for local/remote branch. |
|
| `` d `` | Verwijderen | View delete options for local/remote branch. |
|
||||||
|
| `` <ctrl+r> `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. |
|
||||||
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
|
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
|
||||||
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||||
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. |
|
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. |
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
| `` - `` | Przełącz na poprzednią gałąź | |
|
| `` - `` | Przełącz na poprzednią gałąź | |
|
||||||
| `` F `` | Wymuś przełączenie | Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź. |
|
| `` F `` | Wymuś przełączenie | Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź. |
|
||||||
| `` d `` | Usuń | Wyświetl opcje usuwania lokalnej/odległej gałęzi. |
|
| `` d `` | Usuń | Wyświetl opcje usuwania lokalnej/odległej gałęzi. |
|
||||||
|
| `` <ctrl+r> `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. |
|
||||||
| `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. |
|
| `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. |
|
||||||
| `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. |
|
| `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. |
|
||||||
| `` f `` | Szybkie przewijanie | Szybkie przewijanie wybranej gałęzi z jej źródła. |
|
| `` f `` | Szybkie przewijanie | Szybkie przewijanie wybranej gałęzi z jej źródła. |
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
| `` - `` | Checkout da branch anterior | |
|
| `` - `` | Checkout da branch anterior | |
|
||||||
| `` F `` | Forçar checagem | Forçar checagem da branch selecionada. Isso irá descartar todas as mudanças no seu diretório de trabalho antes cheque a branch selecionada |
|
| `` F `` | Forçar checagem | Forçar checagem da branch selecionada. Isso irá descartar todas as mudanças no seu diretório de trabalho antes cheque a branch selecionada |
|
||||||
| `` d `` | Apagar | Ver opções de exclusão para a branch local/remoto. |
|
| `` d `` | Apagar | Ver opções de exclusão para a branch local/remoto. |
|
||||||
|
| `` <ctrl+r> `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. |
|
||||||
| `` r `` | Refazer | Refazer a branch checada na branch selecionada |
|
| `` r `` | Refazer | Refazer a branch checada na branch selecionada |
|
||||||
| `` M `` | Mesclar | Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash) |
|
| `` M `` | Mesclar | Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash) |
|
||||||
| `` f `` | Avanço rápido | Encaminhamento rápido de branch selecionada a partir do upstream. |
|
| `` f `` | Avanço rápido | Encaminhamento rápido de branch selecionada a partir do upstream. |
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
| `` - `` | Checkout previous branch | |
|
| `` - `` | Checkout previous branch | |
|
||||||
| `` F `` | Принудительное переключение | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
| `` F `` | Принудительное переключение | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
||||||
| `` d `` | Delete | View delete options for local/remote branch. |
|
| `` d `` | Delete | View delete options for local/remote branch. |
|
||||||
|
| `` <ctrl+r> `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. |
|
||||||
| `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. |
|
| `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. |
|
||||||
| `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
| `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||||
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. |
|
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. |
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
| `` - `` | 签出上一个分支 | |
|
| `` - `` | 签出上一个分支 | |
|
||||||
| `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 |
|
| `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 |
|
||||||
| `` d `` | 删除 | 查看本地/远程分支的删除选项 |
|
| `` d `` | 删除 | 查看本地/远程分支的删除选项 |
|
||||||
|
| `` <ctrl+r> `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. |
|
||||||
| `` r `` | 变基 | 将检出的分支变基到所选的分支上。 |
|
| `` r `` | 变基 | 将检出的分支变基到所选的分支上。 |
|
||||||
| `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) |
|
| `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) |
|
||||||
| `` f `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 |
|
| `` f `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 |
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||||
| `` - `` | Checkout previous branch | |
|
| `` - `` | Checkout previous branch | |
|
||||||
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
|
||||||
| `` d `` | 刪除 | View delete options for local/remote branch. |
|
| `` d `` | 刪除 | View delete options for local/remote branch. |
|
||||||
|
| `` <ctrl+r> `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. |
|
||||||
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
|
||||||
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||||
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
|
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,47 @@ func (self *BranchCommands) PreviousRef() (string, error) {
|
||||||
return strings.TrimSpace(output), nil
|
return strings.TrimSpace(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocalDelete delete branch locally
|
// RestoreBranch recreates a deleted local branch at the given commit hash and,
|
||||||
|
// if exactly one remote-tracking branch with the same name still exists,
|
||||||
|
// re-attaches it as the upstream. Returns the upstream ref name that was
|
||||||
|
// re-attached (or "" if none was).
|
||||||
|
func (self *BranchCommands) RestoreBranch(name string, commitHash string) (string, error) {
|
||||||
|
cmdArgs := NewGitCmd("branch").
|
||||||
|
Arg(name, commitHash).
|
||||||
|
ToArgv()
|
||||||
|
|
||||||
|
if err := self.cmd.New(cmdArgs).Run(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream := ""
|
||||||
|
remoteRefs, err := self.cmd.New(
|
||||||
|
NewGitCmd("for-each-ref").
|
||||||
|
Arg("--format=%(refname:short)").
|
||||||
|
Arg("refs/remotes").
|
||||||
|
ToArgv(),
|
||||||
|
).DontLog().RunWithOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingRefs := lo.Filter(strings.Split(strings.TrimSpace(remoteRefs), "\n"), func(ref string, _ int) bool {
|
||||||
|
return "refs/remotes/"+ref == "refs/remotes/"+name || strings.HasSuffix(ref, "/"+name)
|
||||||
|
})
|
||||||
|
if len(matchingRefs) == 1 {
|
||||||
|
matchingRef := strings.TrimSpace(matchingRefs[0])
|
||||||
|
parts := strings.SplitN(matchingRef, "/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if err := self.SetUpstream(parts[0], parts[1], name); err == nil {
|
||||||
|
upstream = matchingRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return upstream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalDelete delete local branch
|
||||||
func (self *BranchCommands) LocalDelete(branches []string, force bool) error {
|
func (self *BranchCommands) LocalDelete(branches []string, force bool) error {
|
||||||
cmdArgs := NewGitCmd("branch").
|
cmdArgs := NewGitCmd("branch").
|
||||||
ArgIfElse(force, "-D", "-d").
|
ArgIfElse(force, "-D", "-d").
|
||||||
|
|
|
||||||
|
|
@ -490,6 +490,222 @@ func parseDifference(track string, regexStr string) string {
|
||||||
return "0"
|
return "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reflogEntry is a single parsed line of `git log -g` output (the reflog of
|
||||||
|
// HEAD). Entries are fed in the order git produces them, i.e. newest first.
|
||||||
|
type reflogEntry struct {
|
||||||
|
hash string // the commit HEAD pointed at when this reflog action occurred
|
||||||
|
timestamp int64 // commit timestamp of the `hash` commit
|
||||||
|
from string // set on "checkout: moving from X to Y" lines to the source branch X; "" otherwise
|
||||||
|
to string // set to the destination branch Y on checkout lines; "" otherwise
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeletedBranches returns branches that were deleted locally but can still
|
||||||
|
// be restored. It infers them by walking HEAD's reflog: any branch that was
|
||||||
|
// checked out (appears in a "checkout: moving from X to Y" line) but is no
|
||||||
|
// longer a local branch is a candidate, and its last-known commit
|
||||||
|
// (reconstructed from the reflog) is the commit it pointed at when it was
|
||||||
|
// deleted.
|
||||||
|
func (self *BranchLoader) GetDeletedBranches() ([]*models.DeletedBranch, error) {
|
||||||
|
existingRefs, err := self.getExistingRefNames()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawReflog, err := self.cmd.New(
|
||||||
|
NewGitCmd("log").
|
||||||
|
Config("log.showSignature=false").
|
||||||
|
Arg("-g").
|
||||||
|
Arg("--format=+%H%x00%ct%x00%gs").
|
||||||
|
ToArgv(),
|
||||||
|
).DontLog().RunWithOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := parseReflogEntries(rawReflog)
|
||||||
|
return obtainDeletedBranches(entries, existingRefs, self.isValidRefFormat), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidRefFormat reports whether git accepts `name` as a valid ref name,
|
||||||
|
// deferring the ref-name grammar (e.g. rejecting "HEAD~1", "main@{0}", trailing
|
||||||
|
// dots) to `git check-ref-format`. We pass --allow-onelevel because reflog
|
||||||
|
// checkout names are bare branch names (e.g. "master"), which are single-level
|
||||||
|
// refs. Note that git's rules only cover shape: things like tags, remote-tracking
|
||||||
|
// branches and abbreviated SHAs are all valid refs to git, so the caller still
|
||||||
|
// has to filter those out separately.
|
||||||
|
func (self *BranchLoader) isValidRefFormat(name string) bool {
|
||||||
|
return self.cmd.New(
|
||||||
|
NewGitCmd("check-ref-format").
|
||||||
|
Arg("--allow-onelevel").
|
||||||
|
Arg(name).
|
||||||
|
ToArgv(),
|
||||||
|
).DontLog().Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getExistingRefNames returns the short names of all refs (local branches,
|
||||||
|
// remote-tracking branches and tags) plus HEAD itself. A name present here is
|
||||||
|
// known not to be a deleted local branch, so it is excluded from recovery
|
||||||
|
// candidates.
|
||||||
|
func (self *BranchLoader) getExistingRefNames() ([]string, error) {
|
||||||
|
output, err := self.cmd.New(
|
||||||
|
NewGitCmd("for-each-ref").
|
||||||
|
Arg("--format=%(refname:short)").
|
||||||
|
Arg("refs/heads", "refs/remotes", "refs/tags").
|
||||||
|
ToArgv(),
|
||||||
|
).DontLog().RunWithOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(strings.Split(strings.TrimSpace(output), "\n"), "HEAD"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReflogEntries parses the raw output of
|
||||||
|
// `git log -g --format=+%H%x00%ct%x00%gs`. The output is newest-first; we
|
||||||
|
// preserve that order.
|
||||||
|
func parseReflogEntries(rawReflog string) []*reflogEntry {
|
||||||
|
entries := make([]*reflogEntry, 0)
|
||||||
|
for _, line := range strings.Split(rawReflog, "\n") {
|
||||||
|
line = strings.TrimPrefix(line, "+")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "\x00", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
from, to := parseReflogCheckoutSubject(parts[2])
|
||||||
|
entries = append(entries, &reflogEntry{
|
||||||
|
hash: parts[0],
|
||||||
|
timestamp: timestamp,
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
var reflogCheckoutRegex = regexp.MustCompile(`checkout: moving from ([\S]+) to ([\S]+)`)
|
||||||
|
|
||||||
|
// parseReflogCheckoutSubject extracts the branch moved from and the branch
|
||||||
|
// moved to from a "checkout: moving from X to Y" reflog subject. Returns "", ""
|
||||||
|
// for non-checkout subjects.
|
||||||
|
func parseReflogCheckoutSubject(subject string) (string, string) {
|
||||||
|
match := reflogCheckoutRegex.FindStringSubmatch(subject)
|
||||||
|
if len(match) != 3 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return match[1], match[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtainDeletedBranches reconstructs deleted branches from a newest-first
|
||||||
|
// reflog of HEAD. Returns branches that appear in the reflog as being checked
|
||||||
|
// out but are no longer local branches, together with the commit they pointed
|
||||||
|
// at when last seen. The result is ordered by recency (most recently committed
|
||||||
|
// to first).
|
||||||
|
func obtainDeletedBranches(entries []*reflogEntry, existingRefs []string, isValidRefFormat func(string) bool) []*models.DeletedBranch {
|
||||||
|
existing := set.NewFromSlice(existingRefs)
|
||||||
|
|
||||||
|
// currentBranch is the branch HEAD was on leading up to the current entry.
|
||||||
|
currentBranch := ""
|
||||||
|
branchTip := make(map[string]string)
|
||||||
|
branchTimestamp := make(map[string]int64)
|
||||||
|
|
||||||
|
for i := len(entries) - 1; i >= 0; i-- {
|
||||||
|
entry := entries[i]
|
||||||
|
|
||||||
|
if entry.from != "" && entry.to != "" {
|
||||||
|
// The hash of a checkout entry is the tip of the branch being
|
||||||
|
// moved to. Seeding it here means branches created via
|
||||||
|
// `git checkout -b` (and never committed to) are still
|
||||||
|
// recoverable. We never touch the source branch: its tip was
|
||||||
|
// recorded by the older entries that preceded this checkout, and
|
||||||
|
// overwriting it with the destination's tip would be wrong.
|
||||||
|
if isBranchName(entry.to, isValidRefFormat) {
|
||||||
|
branchTip[entry.to] = entry.hash
|
||||||
|
branchTimestamp[entry.to] = entry.timestamp
|
||||||
|
}
|
||||||
|
currentBranch = entry.to
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentBranch != "" && isBranchName(currentBranch, isValidRefFormat) {
|
||||||
|
branchTip[currentBranch] = entry.hash
|
||||||
|
branchTimestamp[currentBranch] = entry.timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := make([]*models.DeletedBranch, 0, len(branchTip))
|
||||||
|
for name, tip := range branchTip {
|
||||||
|
if existing.Includes(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleted = append(deleted, &models.DeletedBranch{
|
||||||
|
Name: name,
|
||||||
|
CommitHash: tip,
|
||||||
|
Recency: utils.UnixToTimeAgo(branchTimestamp[name]),
|
||||||
|
DisplayName: name,
|
||||||
|
UnixTimestamp: branchTimestamp[name],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(deleted) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(deleted, func(a, b *models.DeletedBranch) int {
|
||||||
|
if a.UnixTimestamp == b.UnixTimestamp {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if a.UnixTimestamp > b.UnixTimestamp {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBranchName returns true if the given string could be a local branch name
|
||||||
|
// (as opposed to a commit hash, a tag, a remote-tracking ref, or HEAD). This
|
||||||
|
// filters out reflog noise like "checkout: moving from HEAD to abc1234" or
|
||||||
|
// tag/remote checkouts, which would otherwise show up as phantom deleted
|
||||||
|
// branches. The ref-name grammar itself is validated by isValidRefFormat
|
||||||
|
// (which defers to `git check-ref-format`).
|
||||||
|
func isBranchName(name string, isValidRefFormat func(string) bool) bool {
|
||||||
|
if name == "" || name == "HEAD" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !isValidRefFormat(name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A name containing a slash is only treated as a branch if the part before
|
||||||
|
// the first slash is not a well-known remote marker (git writes
|
||||||
|
// "origin/feature" or "tags/v1.0" for remote/tag checkouts, and branch
|
||||||
|
// names can legitimately contain slashes, e.g. "feature/foo").
|
||||||
|
if strings.ContainsRune(name, '/') {
|
||||||
|
remote, _, _ := strings.Cut(name, "/")
|
||||||
|
return !lo.Contains([]string{"origin", "upstream", "fork", "tags", "remotes"}, remote)
|
||||||
|
}
|
||||||
|
return !looksLikeSha(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeSha returns true if the string looks like a commit hash: all hex
|
||||||
|
// characters and at least as long as git's minimum abbreviation. This covers
|
||||||
|
// both abbreviated and full-length hashes that git writes for detached-head
|
||||||
|
// checkouts. A branch name that happens to be all-hex would be missed, but
|
||||||
|
// that's an acceptable trade-off since such names are vanishingly rare.
|
||||||
|
func looksLikeSha(name string) bool {
|
||||||
|
if len(name) < 7 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range name {
|
||||||
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: only look at the new reflog commits, and otherwise store the recencies in
|
// TODO: only look at the new reflog commits, and otherwise store the recencies in
|
||||||
// int form against the branch to recalculate the time ago
|
// int form against the branch to recalculate the time ago
|
||||||
func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch {
|
func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch {
|
||||||
|
|
|
||||||
311
pkg/commands/git_commands/deleted_branch_test.go
Normal file
311
pkg/commands/git_commands/deleted_branch_test.go
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
package git_commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||||
|
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestObtainDeletedBranches(t *testing.T) {
|
||||||
|
type scenario struct {
|
||||||
|
testName string
|
||||||
|
entries []*reflogEntry
|
||||||
|
existingRefs []string
|
||||||
|
expected []*models.DeletedBranch
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []scenario{
|
||||||
|
{
|
||||||
|
testName: "recover deleted branch that was committed to",
|
||||||
|
// newest-first reflog like `git log -g --format=+%H%x00%ct%x00%gs`
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "feature", to: "main"},
|
||||||
|
{hash: "b", timestamp: 200},
|
||||||
|
{hash: "c", timestamp: 100, from: "main", to: "feature"},
|
||||||
|
{hash: "d", timestamp: 50},
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "HEAD"},
|
||||||
|
expected: []*models.DeletedBranch{
|
||||||
|
{Name: "feature", CommitHash: "b", DisplayName: "feature", Recency: "56y", UnixTimestamp: 200},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "deleted branch left in favor of another branch, both tracked",
|
||||||
|
// newest-first reflog
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "feat/a", to: "main"}, // newest: leave feat/a
|
||||||
|
{hash: "b", timestamp: 250}, // commit on feat/a
|
||||||
|
{hash: "a", timestamp: 200, from: "feat/b", to: "feat/a"},
|
||||||
|
{hash: "c", timestamp: 150}, // commit on feat/b
|
||||||
|
{hash: "a", timestamp: 100, from: "main", to: "feat/b"}, // oldest: create feat/b
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "HEAD"},
|
||||||
|
expected: []*models.DeletedBranch{
|
||||||
|
{Name: "feat/a", CommitHash: "b", DisplayName: "feat/a", Recency: "56y", UnixTimestamp: 250},
|
||||||
|
{Name: "feat/b", CommitHash: "c", DisplayName: "feat/b", Recency: "56y", UnixTimestamp: 150},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "existing branches are excluded",
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "main", to: "other"},
|
||||||
|
{hash: "b", timestamp: 200},
|
||||||
|
{hash: "c", timestamp: 100, from: "other", to: "main"},
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "other", "HEAD"},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "no checkout entries means nothing recoverable",
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300},
|
||||||
|
{hash: "b", timestamp: 200},
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "HEAD"},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "HEAD is not treated as a deleted branch",
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "main", to: "HEAD"},
|
||||||
|
{hash: "b", timestamp: 200},
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "HEAD"},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "branch created via checkout with no commits is recoverable",
|
||||||
|
// newest-first reflog: create feature, then immediately leave it
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "feature", to: "main"}, // leave feature
|
||||||
|
{hash: "b", timestamp: 200, from: "main", to: "feature"}, // create feature
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "HEAD"},
|
||||||
|
expected: []*models.DeletedBranch{
|
||||||
|
{Name: "feature", CommitHash: "b", DisplayName: "feature", Recency: "56y", UnixTimestamp: 200},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "detached head checkout is not treated as a deleted branch",
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "abc1234567890abcdefabcdefabcdefabcdefabcdef", to: "main"},
|
||||||
|
{hash: "b", timestamp: 200, from: "main", to: "abc1234567890abcdefabcdefabcdefabcdefabcdef"},
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "HEAD"},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "existing remote-tracking ref is excluded from candidates",
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "origin/feature", to: "main"},
|
||||||
|
{hash: "b", timestamp: 200, from: "main", to: "origin/feature"},
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "origin/feature", "HEAD"},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "existing tag checkout is not treated as a deleted branch",
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "v1.0.0", to: "main"},
|
||||||
|
{hash: "b", timestamp: 200, from: "main", to: "v1.0.0"},
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "v1.0.0", "HEAD"},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "checkout to a commit expression is not treated as a deleted branch",
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "HEAD~1", to: "main"},
|
||||||
|
{hash: "b", timestamp: 200, from: "main", to: "HEAD~1"},
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "HEAD"},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "checkout to a sha-256 hash is not treated as a deleted branch",
|
||||||
|
entries: []*reflogEntry{
|
||||||
|
{hash: "a", timestamp: 300, from: "8f0f1f2f3f4f5f6f7f8f9fafbfcfdfefff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff", to: "main"},
|
||||||
|
{hash: "b", timestamp: 200, from: "main", to: "8f0f1f2f3f4f5f6f7f8f9fafbfcfdfefff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"},
|
||||||
|
},
|
||||||
|
existingRefs: []string{"main", "HEAD"},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range scenarios {
|
||||||
|
t.Run(s.testName, func(t *testing.T) {
|
||||||
|
result := obtainDeletedBranches(s.entries, s.existingRefs, isValidRefFormatStub)
|
||||||
|
assert.Equal(t, s.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidRefFormatStub is a stand-in for `git check-ref-format` used to keep
|
||||||
|
// the pure tests independent of the subprocess. It applies the same ref-name
|
||||||
|
// grammar rules git enforces.
|
||||||
|
func isValidRefFormatStub(name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(name, ".") || strings.HasSuffix(name, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "..") || strings.Contains(name, "@{") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range name {
|
||||||
|
if c <= ' ' || strings.ContainsRune("~^:?*[\\", c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidRefFormat(t *testing.T) {
|
||||||
|
type scenario struct {
|
||||||
|
testName string
|
||||||
|
name string
|
||||||
|
isValid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []scenario{
|
||||||
|
{
|
||||||
|
testName: "valid branch name",
|
||||||
|
name: "feature/foo",
|
||||||
|
isValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// a sha-looking string is still a valid ref to git; filtering it is
|
||||||
|
// done separately by looksLikeSha
|
||||||
|
testName: "sha-like is a valid ref to git",
|
||||||
|
name: "8f0f1f2f3f4f",
|
||||||
|
isValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "rejects commit expression",
|
||||||
|
name: "HEAD~1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "rejects trailing slash",
|
||||||
|
name: "feature/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "rejects double dot",
|
||||||
|
name: "feature..other",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range scenarios {
|
||||||
|
t.Run(s.testName, func(t *testing.T) {
|
||||||
|
err := errors.New("invalid ref")
|
||||||
|
if s.isValid {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
runner := oscommands.NewFakeRunner(t).
|
||||||
|
ExpectGitArgs([]string{"check-ref-format", "--allow-onelevel", s.name}, "", err)
|
||||||
|
|
||||||
|
gitCommon := buildGitCommon(commonDeps{runner: runner})
|
||||||
|
loader := &BranchLoader{
|
||||||
|
Common: gitCommon.Common,
|
||||||
|
GitCommon: gitCommon,
|
||||||
|
cmd: gitCommon.cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, s.isValid, loader.isValidRefFormat(s.name))
|
||||||
|
runner.CheckForMissingCalls()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReflogCheckoutSubject(t *testing.T) {
|
||||||
|
type scenario struct {
|
||||||
|
testName string
|
||||||
|
subject string
|
||||||
|
expected []string
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []scenario{
|
||||||
|
{
|
||||||
|
testName: "normal checkout",
|
||||||
|
subject: "checkout: moving from feature to main",
|
||||||
|
expected: []string{"feature", "main"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "not a checkout",
|
||||||
|
subject: "commit: message",
|
||||||
|
expected: []string{"", ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "checkout from detached head",
|
||||||
|
subject: "checkout: moving from HEAD to main",
|
||||||
|
expected: []string{"HEAD", "main"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range scenarios {
|
||||||
|
t.Run(s.testName, func(t *testing.T) {
|
||||||
|
from, to := parseReflogCheckoutSubject(s.subject)
|
||||||
|
assert.Equal(t, s.expected[0], from)
|
||||||
|
assert.Equal(t, s.expected[1], to)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchRestoreBranch(t *testing.T) {
|
||||||
|
type scenario struct {
|
||||||
|
testName string
|
||||||
|
runner *oscommands.FakeCmdObjRunner
|
||||||
|
expectedErr bool
|
||||||
|
expectedUpstream string
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []scenario{
|
||||||
|
{
|
||||||
|
testName: "restore branch with no surviving remote branch",
|
||||||
|
runner: oscommands.NewFakeRunner(t).
|
||||||
|
ExpectGitArgs([]string{"branch", "feature", "abc123"}, "", nil).
|
||||||
|
ExpectGitArgs([]string{"for-each-ref", "--format=%(refname:short)", "refs/remotes"}, "", nil),
|
||||||
|
expectedErr: false,
|
||||||
|
expectedUpstream: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "restore branch and reattach upstream when remote branch survives",
|
||||||
|
runner: oscommands.NewFakeRunner(t).
|
||||||
|
ExpectGitArgs([]string{"branch", "feature", "abc123"}, "", nil).
|
||||||
|
ExpectGitArgs([]string{"for-each-ref", "--format=%(refname:short)", "refs/remotes"}, "origin/feature\n", nil).
|
||||||
|
ExpectGitArgs([]string{"branch", "--set-upstream-to=origin/feature", "feature"}, "", nil),
|
||||||
|
expectedErr: false,
|
||||||
|
expectedUpstream: "origin/feature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: "restore branch when multiple remotes share the name does not set upstream",
|
||||||
|
runner: oscommands.NewFakeRunner(t).
|
||||||
|
ExpectGitArgs([]string{"branch", "feature", "abc123"}, "", nil).
|
||||||
|
ExpectGitArgs([]string{"for-each-ref", "--format=%(refname:short)", "refs/remotes"}, "origin/feature\nfork/feature\n", nil),
|
||||||
|
expectedErr: false,
|
||||||
|
expectedUpstream: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range scenarios {
|
||||||
|
t.Run(s.testName, func(t *testing.T) {
|
||||||
|
instance := buildBranchCommands(commonDeps{runner: s.runner})
|
||||||
|
|
||||||
|
upstream, err := instance.RestoreBranch("feature", "abc123")
|
||||||
|
if s.expectedErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, s.expectedUpstream, upstream)
|
||||||
|
s.runner.CheckForMissingCalls()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,39 @@ import (
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DeletedBranch is a branch that was detected as no longer existing locally but
|
||||||
|
// whose commit history can still be recovered (e.g. from the reflog).
|
||||||
|
type DeletedBranch struct {
|
||||||
|
Name string
|
||||||
|
DisplayName string
|
||||||
|
// the commit hash the branch pointed at when it was last seen
|
||||||
|
CommitHash string
|
||||||
|
// indicator of when the branch was last checked out e.g. '2d', '3m'
|
||||||
|
Recency string
|
||||||
|
// unix timestamp of when the branch was last committed to; used for sorting
|
||||||
|
UnixTimestamp int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *DeletedBranch) FullRefName() string {
|
||||||
|
return "refs/heads/" + b.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *DeletedBranch) ID() string {
|
||||||
|
return b.RefName()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *DeletedBranch) RefName() string {
|
||||||
|
return b.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *DeletedBranch) URN() string {
|
||||||
|
return "deleted-branch-" + b.ID()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *DeletedBranch) Description() string {
|
||||||
|
return b.DisplayName
|
||||||
|
}
|
||||||
|
|
||||||
// Branch : A git branch
|
// Branch : A git branch
|
||||||
// duplicating this for now
|
// duplicating this for now
|
||||||
type Branch struct {
|
type Branch struct {
|
||||||
|
|
|
||||||
|
|
@ -610,6 +610,7 @@ type KeybindingBranchesConfig struct {
|
||||||
FetchRemote Keybinding `yaml:"fetchRemote"`
|
FetchRemote Keybinding `yaml:"fetchRemote"`
|
||||||
AddForkRemote Keybinding `yaml:"addForkRemote"`
|
AddForkRemote Keybinding `yaml:"addForkRemote"`
|
||||||
SortOrder Keybinding `yaml:"sortOrder"`
|
SortOrder Keybinding `yaml:"sortOrder"`
|
||||||
|
RestoreBranch Keybinding `yaml:"restoreBranch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeybindingCommitsConfig struct {
|
type KeybindingCommitsConfig struct {
|
||||||
|
|
@ -1125,6 +1126,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||||
FetchRemote: Keybinding{"f"},
|
FetchRemote: Keybinding{"f"},
|
||||||
AddForkRemote: Keybinding{"F"},
|
AddForkRemote: Keybinding{"F"},
|
||||||
SortOrder: Keybinding{"s"},
|
SortOrder: Keybinding{"s"},
|
||||||
|
RestoreBranch: Keybinding{"<ctrl+r>"},
|
||||||
},
|
},
|
||||||
Commits: KeybindingCommitsConfig{
|
Commits: KeybindingCommitsConfig{
|
||||||
SquashDown: Keybinding{"s"},
|
SquashDown: Keybinding{"s"},
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,13 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||||
OpensMenu: true,
|
OpensMenu: true,
|
||||||
DisplayOnScreen: true,
|
DisplayOnScreen: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Keys: opts.GetKeys(opts.Config.Branches.RestoreBranch),
|
||||||
|
Handler: self.restoreDeletedBranch,
|
||||||
|
Description: self.c.Tr.RestoreBranch,
|
||||||
|
Tooltip: self.c.Tr.RestoreBranchTooltip,
|
||||||
|
OpensMenu: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Keys: opts.GetKeys(opts.Config.Branches.RebaseBranch),
|
Keys: opts.GetKeys(opts.Config.Branches.RebaseBranch),
|
||||||
Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)),
|
Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)),
|
||||||
|
|
@ -325,6 +332,14 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
|
||||||
Keys: menuKey('s'),
|
Keys: menuKey('s'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restoreUpstreamItem := &types.MenuItem{
|
||||||
|
LabelColumns: []string{self.c.Tr.RestoreUpstreamBranch},
|
||||||
|
OnPress: func() error {
|
||||||
|
return self.pushBranchToUpstream(selectedBranch)
|
||||||
|
},
|
||||||
|
Keys: menuKey('p'),
|
||||||
|
}
|
||||||
|
|
||||||
upstreamResetOptions := utils.ResolvePlaceholderString(
|
upstreamResetOptions := utils.ResolvePlaceholderString(
|
||||||
self.c.Tr.ViewUpstreamResetOptions,
|
self.c.Tr.ViewUpstreamResetOptions,
|
||||||
map[string]string{"upstream": upstream},
|
map[string]string{"upstream": upstream},
|
||||||
|
|
@ -381,11 +396,22 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
|
||||||
upstreamRebaseItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
|
upstreamRebaseItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We can only restore an upstream that still has a tracking configuration
|
||||||
|
// but whose remote branch has been deleted (i.e. it shows "upstream gone").
|
||||||
|
if !selectedBranch.UpstreamGone {
|
||||||
|
disabledReason := self.c.Tr.UpstreamNotSetError
|
||||||
|
if selectedBranch.IsTrackingRemote() {
|
||||||
|
disabledReason = self.c.Tr.UpstreamNotGoneError
|
||||||
|
}
|
||||||
|
restoreUpstreamItem.DisabledReason = &types.DisabledReason{Text: disabledReason}
|
||||||
|
}
|
||||||
|
|
||||||
options := []*types.MenuItem{
|
options := []*types.MenuItem{
|
||||||
viewDivergenceItem,
|
viewDivergenceItem,
|
||||||
viewDivergenceFromBaseBranchItem,
|
viewDivergenceFromBaseBranchItem,
|
||||||
unsetUpstreamItem,
|
unsetUpstreamItem,
|
||||||
setUpstreamItem,
|
setUpstreamItem,
|
||||||
|
restoreUpstreamItem,
|
||||||
upstreamResetItem,
|
upstreamResetItem,
|
||||||
upstreamRebaseItem,
|
upstreamRebaseItem,
|
||||||
}
|
}
|
||||||
|
|
@ -580,6 +606,48 @@ func (self *BranchesController) localAndRemoteDelete(branches []*models.Branch)
|
||||||
return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branches)
|
return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (self *BranchesController) restoreDeletedBranch() error {
|
||||||
|
deletedBranches, err := self.c.Git().Loaders.BranchLoader.GetDeletedBranches()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deletedBranches) == 0 {
|
||||||
|
self.c.Toast(self.c.Tr.NoDeletedBranches)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items := lo.Map(deletedBranches, func(branch *models.DeletedBranch, _ int) *types.MenuItem {
|
||||||
|
return &types.MenuItem{
|
||||||
|
LabelColumns: []string{
|
||||||
|
branch.Name,
|
||||||
|
branch.Recency,
|
||||||
|
},
|
||||||
|
OnPress: func() error {
|
||||||
|
self.c.LogAction(self.c.Tr.Actions.RestoreBranch)
|
||||||
|
upstream, err := self.c.Git().Branch.RestoreBranch(branch.Name, branch.CommitHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
toast := utils.ResolvePlaceholderString(self.c.Tr.RestoredBranch, map[string]string{"branchName": branch.Name})
|
||||||
|
if upstream != "" {
|
||||||
|
toast = fmt.Sprintf("%s (%s)", toast, self.c.Tr.RestoredBranchUpstream)
|
||||||
|
}
|
||||||
|
self.c.Toast(toast)
|
||||||
|
self.c.Refresh(types.RefreshOptions{
|
||||||
|
Scope: []types.RefreshableView{types.BRANCHES},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return self.c.Menu(types.CreateMenuOptions{
|
||||||
|
Title: self.c.Tr.RestoreBranchTitle,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (self *BranchesController) delete(branches []*models.Branch) error {
|
func (self *BranchesController) delete(branches []*models.Branch) error {
|
||||||
checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef()
|
checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef()
|
||||||
isBranchCheckedOut := lo.SomeBy(branches, func(branch *models.Branch) bool {
|
isBranchCheckedOut := lo.SomeBy(branches, func(branch *models.Branch) bool {
|
||||||
|
|
@ -705,6 +773,27 @@ func (self *BranchesController) fastForward(branch *models.Branch) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushBranchToUpstream pushes the given branch to its configured upstream,
|
||||||
|
// recreating a remote branch that was deleted (e.g. on GitHub) so the branch
|
||||||
|
// no longer shows as "upstream gone".
|
||||||
|
func (self *BranchesController) pushBranchToUpstream(branch *models.Branch) error {
|
||||||
|
return self.c.WithInlineStatus(branch, types.ItemOperationPushing, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
||||||
|
self.c.LogAction(self.c.Tr.Actions.RestoreUpstreamBranch)
|
||||||
|
err := self.c.Git().Sync.Push(
|
||||||
|
task,
|
||||||
|
git_commands.PushOpts{
|
||||||
|
CurrentBranch: branch.Name,
|
||||||
|
UpstreamRemote: branch.UpstreamRemote,
|
||||||
|
UpstreamBranch: branch.UpstreamBranch,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS}})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (self *BranchesController) createTag(branch *models.Branch) error {
|
func (self *BranchesController) createTag(branch *models.Branch) error {
|
||||||
return self.c.Helpers().Tags.OpenCreateTagPrompt(branch.FullRefName(), func() {})
|
return self.c.Helpers().Tags.OpenCreateTagPrompt(branch.FullRefName(), func() {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,12 @@ type TranslationSet struct {
|
||||||
DeleteRemoteBranchesPrompt string
|
DeleteRemoteBranchesPrompt string
|
||||||
DeleteLocalAndRemoteBranchPrompt string
|
DeleteLocalAndRemoteBranchPrompt string
|
||||||
DeleteLocalAndRemoteBranchesPrompt string
|
DeleteLocalAndRemoteBranchesPrompt string
|
||||||
|
RestoreBranch string
|
||||||
|
RestoreBranchTooltip string
|
||||||
|
RestoreBranchTitle string
|
||||||
|
NoDeletedBranches string
|
||||||
|
RestoredBranch string
|
||||||
|
RestoredBranchUpstream string
|
||||||
ForceDeleteBranchTitle string
|
ForceDeleteBranchTitle string
|
||||||
ForceDeleteBranchMessage string
|
ForceDeleteBranchMessage string
|
||||||
ForceDeleteBranchesMessage string
|
ForceDeleteBranchesMessage string
|
||||||
|
|
@ -575,6 +581,7 @@ type TranslationSet struct {
|
||||||
SetAsUpstreamTooltip string
|
SetAsUpstreamTooltip string
|
||||||
SetUpstream string
|
SetUpstream string
|
||||||
UnsetUpstream string
|
UnsetUpstream string
|
||||||
|
RestoreUpstreamBranch string
|
||||||
ViewDivergenceFromUpstream string
|
ViewDivergenceFromUpstream string
|
||||||
ViewDivergenceFromBaseBranch string
|
ViewDivergenceFromBaseBranch string
|
||||||
CouldNotDetermineBaseBranch string
|
CouldNotDetermineBaseBranch string
|
||||||
|
|
@ -646,6 +653,7 @@ type TranslationSet struct {
|
||||||
ViewBranchUpstreamOptions string
|
ViewBranchUpstreamOptions string
|
||||||
ViewBranchUpstreamOptionsTooltip string
|
ViewBranchUpstreamOptionsTooltip string
|
||||||
UpstreamNotSetError string
|
UpstreamNotSetError string
|
||||||
|
UpstreamNotGoneError string
|
||||||
UpstreamsNotSetError string
|
UpstreamsNotSetError string
|
||||||
NewGitFlowBranchPrompt string
|
NewGitFlowBranchPrompt string
|
||||||
RenameBranchWarning string
|
RenameBranchWarning string
|
||||||
|
|
@ -1017,6 +1025,7 @@ type Actions struct {
|
||||||
CheckoutBranch string
|
CheckoutBranch string
|
||||||
CheckoutBranchOrCommit string
|
CheckoutBranchOrCommit string
|
||||||
ForceCheckoutBranch string
|
ForceCheckoutBranch string
|
||||||
|
RestoreBranch string
|
||||||
DeleteLocalBranch string
|
DeleteLocalBranch string
|
||||||
Merge string
|
Merge string
|
||||||
SquashMerge string
|
SquashMerge string
|
||||||
|
|
@ -1024,6 +1033,7 @@ type Actions struct {
|
||||||
RenameBranch string
|
RenameBranch string
|
||||||
CreateBranch string
|
CreateBranch string
|
||||||
FastForwardBranch string
|
FastForwardBranch string
|
||||||
|
RestoreUpstreamBranch string
|
||||||
AutoForwardBranches string
|
AutoForwardBranches string
|
||||||
CherryPick string
|
CherryPick string
|
||||||
CheckoutFile string
|
CheckoutFile string
|
||||||
|
|
@ -1283,6 +1293,12 @@ func EnglishTranslationSet() *TranslationSet {
|
||||||
DeleteRemoteBranchesPrompt: "Are you sure you want to delete the remote branches of the selected branches from their respective remotes?",
|
DeleteRemoteBranchesPrompt: "Are you sure you want to delete the remote branches of the selected branches from their respective remotes?",
|
||||||
DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?",
|
DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?",
|
||||||
DeleteLocalAndRemoteBranchesPrompt: "Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?",
|
DeleteLocalAndRemoteBranchesPrompt: "Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?",
|
||||||
|
RestoreBranch: "Restore deleted branch",
|
||||||
|
RestoreBranchTooltip: "Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists.",
|
||||||
|
RestoreBranchTitle: "Deleted branches",
|
||||||
|
NoDeletedBranches: "No deleted branches were found in the reflog",
|
||||||
|
RestoredBranch: "Restored branch '{{.branchName}}'",
|
||||||
|
RestoredBranchUpstream: "re-attached upstream",
|
||||||
ForceDeleteBranchTitle: "Force delete branch",
|
ForceDeleteBranchTitle: "Force delete branch",
|
||||||
ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?",
|
ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?",
|
||||||
ForceDeleteBranchesMessage: "Some of the selected branches are not fully merged. Are you sure you want to delete them?",
|
ForceDeleteBranchesMessage: "Some of the selected branches are not fully merged. Are you sure you want to delete them?",
|
||||||
|
|
@ -1740,6 +1756,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||||
SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.",
|
SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.",
|
||||||
SetUpstream: "Set upstream of selected branch",
|
SetUpstream: "Set upstream of selected branch",
|
||||||
UnsetUpstream: "Unset upstream of selected branch",
|
UnsetUpstream: "Unset upstream of selected branch",
|
||||||
|
RestoreUpstreamBranch: "Restore upstream branch",
|
||||||
ViewDivergenceFromUpstream: "View divergence from upstream",
|
ViewDivergenceFromUpstream: "View divergence from upstream",
|
||||||
ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})",
|
ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})",
|
||||||
CouldNotDetermineBaseBranch: "Couldn't determine base branch",
|
CouldNotDetermineBaseBranch: "Couldn't determine base branch",
|
||||||
|
|
@ -1807,6 +1824,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||||
ViewBranchUpstreamOptions: "View upstream options",
|
ViewBranchUpstreamOptions: "View upstream options",
|
||||||
ViewBranchUpstreamOptionsTooltip: "View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream.",
|
ViewBranchUpstreamOptionsTooltip: "View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream.",
|
||||||
UpstreamNotSetError: "The selected branch has no upstream (or the upstream is not stored locally)",
|
UpstreamNotSetError: "The selected branch has no upstream (or the upstream is not stored locally)",
|
||||||
|
UpstreamNotGoneError: "The selected branch's upstream still exists",
|
||||||
UpstreamsNotSetError: "Some of the selected branches have no upstream (or the upstream is not stored locally)",
|
UpstreamsNotSetError: "Some of the selected branches have no upstream (or the upstream is not stored locally)",
|
||||||
Upstream: "Upstream",
|
Upstream: "Upstream",
|
||||||
NewBranchNamePrompt: "Enter new branch name for branch",
|
NewBranchNamePrompt: "Enter new branch name for branch",
|
||||||
|
|
@ -2136,6 +2154,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||||
CheckoutBranch: "Checkout branch",
|
CheckoutBranch: "Checkout branch",
|
||||||
ForceCheckoutBranch: "Force checkout branch",
|
ForceCheckoutBranch: "Force checkout branch",
|
||||||
CheckoutBranchOrCommit: "Checkout branch or commit",
|
CheckoutBranchOrCommit: "Checkout branch or commit",
|
||||||
|
RestoreBranch: "Restore deleted branch",
|
||||||
DeleteLocalBranch: "Delete local branch",
|
DeleteLocalBranch: "Delete local branch",
|
||||||
Merge: "Merge",
|
Merge: "Merge",
|
||||||
SquashMerge: "Squash merge",
|
SquashMerge: "Squash merge",
|
||||||
|
|
@ -2241,6 +2260,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||||
MixedReset: "Mixed reset",
|
MixedReset: "Mixed reset",
|
||||||
HardReset: "Hard reset",
|
HardReset: "Hard reset",
|
||||||
FastForwardBranch: "Fast forward branch",
|
FastForwardBranch: "Fast forward branch",
|
||||||
|
RestoreUpstreamBranch: "Restore upstream branch",
|
||||||
AutoForwardBranches: "Auto-forward branches",
|
AutoForwardBranches: "Auto-forward branches",
|
||||||
Undo: "Undo",
|
Undo: "Undo",
|
||||||
Redo: "Redo",
|
Redo: "Redo",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ func (self *Git) RemoteTagDeleted(ref string, tagName string) *Git {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssertRemoteBranchExists asserts that the given branch still exists on the
|
||||||
|
// given remote, i.e. it has been pushed.
|
||||||
|
func (self *Git) AssertRemoteBranchExists(ref string, branchName string) *Git {
|
||||||
|
return self.expect([]string{"git", "ls-remote", ref, fmt.Sprintf("refs/heads/%s", branchName)}, func(s string) (bool, string) {
|
||||||
|
return len(s) > 0, fmt.Sprintf("Expected branch %s to still exist on %s", branchName, ref)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (self *Git) assert(cmdArgs []string, expected string) *Git {
|
func (self *Git) assert(cmdArgs []string, expected string) *Git {
|
||||||
self.expect(cmdArgs, func(output string) (bool, string) {
|
self.expect(cmdArgs, func(output string) (bool, string) {
|
||||||
return output == expected, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expected, output)
|
return output == expected, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expected, output)
|
||||||
|
|
|
||||||
51
pkg/integration/tests/branch/restore_deleted_branch.go
Normal file
51
pkg/integration/tests/branch/restore_deleted_branch.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package branch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jesseduffield/lazygit/pkg/config"
|
||||||
|
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||||
|
)
|
||||||
|
|
||||||
|
var RestoreDeletedBranch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||||
|
Description: "Restore a deleted local branch from the reflog",
|
||||||
|
ExtraCmdArgs: []string{},
|
||||||
|
Skip: false,
|
||||||
|
SetupConfig: func(config *config.AppConfig) {},
|
||||||
|
SetupRepo: func(shell *Shell) {
|
||||||
|
shell.
|
||||||
|
EmptyCommit("base commit").
|
||||||
|
NewBranch("feature").
|
||||||
|
EmptyCommit("on feature").
|
||||||
|
Checkout("master").
|
||||||
|
EmptyCommit("on master").
|
||||||
|
RunCommand([]string{"git", "branch", "-D", "feature"})
|
||||||
|
},
|
||||||
|
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||||
|
t.Views().Branches().
|
||||||
|
Focus().
|
||||||
|
Lines(
|
||||||
|
Contains("master").IsSelected(),
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Views().Branches().
|
||||||
|
Press(keys.Branches.RestoreBranch).
|
||||||
|
Tap(func() {
|
||||||
|
t.ExpectPopup().
|
||||||
|
Menu().
|
||||||
|
Title(Equals("Deleted branches")).
|
||||||
|
ContainsLines(
|
||||||
|
Contains("feature"),
|
||||||
|
).
|
||||||
|
Select(Contains("feature")).
|
||||||
|
Confirm()
|
||||||
|
}).
|
||||||
|
Tap(func() {
|
||||||
|
t.ExpectToast(Contains("Restored branch 'feature'"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Views().Branches().
|
||||||
|
Lines(
|
||||||
|
Contains("master").IsSelected(),
|
||||||
|
Contains("feature"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
52
pkg/integration/tests/branch/restore_upstream_branch.go
Normal file
52
pkg/integration/tests/branch/restore_upstream_branch.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package branch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jesseduffield/lazygit/pkg/config"
|
||||||
|
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||||
|
)
|
||||||
|
|
||||||
|
var RestoreUpstreamBranch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||||
|
Description: "Restore an upstream branch that was deleted on the remote",
|
||||||
|
ExtraCmdArgs: []string{},
|
||||||
|
Skip: false,
|
||||||
|
SetupConfig: func(config *config.AppConfig) {},
|
||||||
|
SetupRepo: func(shell *Shell) {
|
||||||
|
shell.
|
||||||
|
CloneIntoRemote("origin").
|
||||||
|
EmptyCommit("base commit").
|
||||||
|
NewBranch("feature").
|
||||||
|
EmptyCommit("on feature").
|
||||||
|
PushBranchAndSetUpstream("origin", "feature").
|
||||||
|
Checkout("master").
|
||||||
|
RunCommand([]string{"git", "-C", "../origin", "branch", "-D", "feature"}).
|
||||||
|
RunCommand([]string{"git", "fetch", "origin", "--prune"})
|
||||||
|
},
|
||||||
|
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||||
|
t.Views().Branches().
|
||||||
|
Focus().
|
||||||
|
Lines(
|
||||||
|
Contains("master").IsSelected(),
|
||||||
|
Contains("feature").Contains("upstream gone"),
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Views().Branches().
|
||||||
|
NavigateToLine(Contains("feature")).
|
||||||
|
Press(keys.Branches.SetUpstream).
|
||||||
|
Tap(func() {
|
||||||
|
t.ExpectPopup().
|
||||||
|
Menu().
|
||||||
|
Title(Equals("Upstream options")).
|
||||||
|
Select(Contains("Restore upstream branch")).
|
||||||
|
Confirm()
|
||||||
|
})
|
||||||
|
|
||||||
|
// the "upstream gone" message is gone and the remote branch is recreated
|
||||||
|
t.Views().Branches().
|
||||||
|
Lines(
|
||||||
|
Contains("master"),
|
||||||
|
Contains("feature").DoesNotContain("upstream gone"),
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Git().AssertRemoteBranchExists("origin", "feature")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -80,6 +80,8 @@ var tests = []*components.IntegrationTest{
|
||||||
branch.ResetToDuplicateNamedTag,
|
branch.ResetToDuplicateNamedTag,
|
||||||
branch.ResetToDuplicateNamedUpstream,
|
branch.ResetToDuplicateNamedUpstream,
|
||||||
branch.ResetToUpstream,
|
branch.ResetToUpstream,
|
||||||
|
branch.RestoreDeletedBranch,
|
||||||
|
branch.RestoreUpstreamBranch,
|
||||||
branch.SelectCommitsOfCurrentBranch,
|
branch.SelectCommitsOfCurrentBranch,
|
||||||
branch.SetUpstream,
|
branch.SetUpstream,
|
||||||
branch.ShowDivergenceFromBaseBranch,
|
branch.ShowDivergenceFromBaseBranch,
|
||||||
|
|
|
||||||
|
|
@ -1258,6 +1258,20 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "s"
|
"default": "s"
|
||||||
|
},
|
||||||
|
"restoreBranch": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "\u003cctrl+r\u003e"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue