diff --git a/docs-master/Config.md b/docs-master/Config.md index 857a4e35980..459b06cd308 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -777,6 +777,7 @@ keybinding: fetchRemote: f addForkRemote: F sortOrder: s + restoreBranch: commits: squashDown: s renameCommit: r diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 3ec731bf292..4d889320726 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -187,6 +187,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 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. | | `` d `` | Delete | View delete options for local/remote branch. | +| `` `` | 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. | | `` 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. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 6a3d9b5c1d6..1641553f2b2 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -385,6 +385,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 直前のブランチにチェックアウト | | | `` F `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 | | `` d `` | 削除 | ローカル/リモートブランチの削除オプションを表示します。 | +| `` `` | 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 `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 | | `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) | | `` f `` | ブランチを最新化(fast-forward) | 選択したブランチを対応するアップストリームの最新状態に追いつかせます(fast-forward)。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index a0e5d84dc95..bd54ae3cae0 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -221,6 +221,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous 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. | +| `` `` | 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. | | `` 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. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index cf9934b40d0..f0933dc52b6 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -111,6 +111,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 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. | | `` d `` | Verwijderen | View delete options for local/remote branch. | +| `` `` | 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. | | `` 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. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index ba46f7c463c..78c08f9eaf2 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -173,6 +173,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 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łąź. | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnej/odległej gałęzi. | +| `` `` | 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łąź. | | `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. | | `` f `` | Szybkie przewijanie | Szybkie przewijanie wybranej gałęzi z jej źródła. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 3071613bef3..85d5dd21ec9 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -103,6 +103,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 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 | | `` d `` | Apagar | Ver opções de exclusão para a branch local/remoto. | +| `` `` | 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 | | `` 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. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 3d03f9ca686..dfe2e02653d 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -221,6 +221,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous 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. | +| `` `` | 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. | | `` 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. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 9819cb982bf..220b6dd1e66 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -236,6 +236,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 签出上一个分支 | | | `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 | | `` d `` | 删除 | 查看本地/远程分支的删除选项 | +| `` `` | 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 `` | 变基 | 将检出的分支变基到所选的分支上。 | | `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) | | `` f `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 1706a627e92..ae641fa2a2e 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -296,6 +296,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous 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. | +| `` `` | 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. | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 | diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index a55278b5b0a..9282a49e910 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -132,7 +132,47 @@ func (self *BranchCommands) PreviousRef() (string, error) { 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 { cmdArgs := NewGitCmd("branch"). ArgIfElse(force, "-D", "-d"). diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index b41b0564ff7..532167acf6c 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -490,6 +490,222 @@ func parseDifference(track string, regexStr string) string { 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 // int form against the branch to recalculate the time ago func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch { diff --git a/pkg/commands/git_commands/deleted_branch_test.go b/pkg/commands/git_commands/deleted_branch_test.go new file mode 100644 index 00000000000..3c62ea63b84 --- /dev/null +++ b/pkg/commands/git_commands/deleted_branch_test.go @@ -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() + }) + } +} diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go index 4dc48a88d8d..e02d85389e2 100644 --- a/pkg/commands/models/branch.go +++ b/pkg/commands/models/branch.go @@ -5,6 +5,39 @@ import ( "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 // duplicating this for now type Branch struct { diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 9738186d93a..eeda84784c1 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -610,6 +610,7 @@ type KeybindingBranchesConfig struct { FetchRemote Keybinding `yaml:"fetchRemote"` AddForkRemote Keybinding `yaml:"addForkRemote"` SortOrder Keybinding `yaml:"sortOrder"` + RestoreBranch Keybinding `yaml:"restoreBranch"` } type KeybindingCommitsConfig struct { @@ -1125,6 +1126,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { FetchRemote: Keybinding{"f"}, AddForkRemote: Keybinding{"F"}, SortOrder: Keybinding{"s"}, + RestoreBranch: Keybinding{""}, }, Commits: KeybindingCommitsConfig{ SquashDown: Keybinding{"s"}, diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index cfc46b50360..e6babc435b7 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -124,6 +124,13 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty OpensMenu: 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), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)), @@ -325,6 +332,14 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc 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( self.c.Tr.ViewUpstreamResetOptions, 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} } + // 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{ viewDivergenceItem, viewDivergenceFromBaseBranchItem, unsetUpstreamItem, setUpstreamItem, + restoreUpstreamItem, upstreamResetItem, upstreamRebaseItem, } @@ -580,6 +606,48 @@ func (self *BranchesController) localAndRemoteDelete(branches []*models.Branch) 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 { checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef() 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 { return self.c.Helpers().Tags.OpenCreateTagPrompt(branch.FullRefName(), func() {}) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index deb55d6e915..eb49cdd7dc6 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -127,6 +127,12 @@ type TranslationSet struct { DeleteRemoteBranchesPrompt string DeleteLocalAndRemoteBranchPrompt string DeleteLocalAndRemoteBranchesPrompt string + RestoreBranch string + RestoreBranchTooltip string + RestoreBranchTitle string + NoDeletedBranches string + RestoredBranch string + RestoredBranchUpstream string ForceDeleteBranchTitle string ForceDeleteBranchMessage string ForceDeleteBranchesMessage string @@ -569,6 +575,7 @@ type TranslationSet struct { SetAsUpstreamTooltip string SetUpstream string UnsetUpstream string + RestoreUpstreamBranch string ViewDivergenceFromUpstream string ViewDivergenceFromBaseBranch string CouldNotDetermineBaseBranch string @@ -640,6 +647,7 @@ type TranslationSet struct { ViewBranchUpstreamOptions string ViewBranchUpstreamOptionsTooltip string UpstreamNotSetError string + UpstreamNotGoneError string UpstreamsNotSetError string NewGitFlowBranchPrompt string RenameBranchWarning string @@ -1010,6 +1018,7 @@ type Actions struct { CheckoutBranch string CheckoutBranchOrCommit string ForceCheckoutBranch string + RestoreBranch string DeleteLocalBranch string Merge string SquashMerge string @@ -1017,6 +1026,7 @@ type Actions struct { RenameBranch string CreateBranch string FastForwardBranch string + RestoreUpstreamBranch string AutoForwardBranches string CherryPick string CheckoutFile string @@ -1276,6 +1286,12 @@ func EnglishTranslationSet() *TranslationSet { 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}}'?", 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", 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?", @@ -1727,6 +1743,7 @@ func EnglishTranslationSet() *TranslationSet { SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", SetUpstream: "Set upstream of selected branch", UnsetUpstream: "Unset upstream of selected branch", + RestoreUpstreamBranch: "Restore upstream branch", ViewDivergenceFromUpstream: "View divergence from upstream", ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})", CouldNotDetermineBaseBranch: "Couldn't determine base branch", @@ -1794,6 +1811,7 @@ func EnglishTranslationSet() *TranslationSet { ViewBranchUpstreamOptions: "View upstream options", 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)", + UpstreamNotGoneError: "The selected branch's upstream still exists", UpstreamsNotSetError: "Some of the selected branches have no upstream (or the upstream is not stored locally)", Upstream: "Upstream", NewBranchNamePrompt: "Enter new branch name for branch", @@ -2122,6 +2140,7 @@ func EnglishTranslationSet() *TranslationSet { CheckoutBranch: "Checkout branch", ForceCheckoutBranch: "Force checkout branch", CheckoutBranchOrCommit: "Checkout branch or commit", + RestoreBranch: "Restore deleted branch", DeleteLocalBranch: "Delete local branch", Merge: "Merge", SquashMerge: "Squash merge", @@ -2227,6 +2246,7 @@ func EnglishTranslationSet() *TranslationSet { MixedReset: "Mixed reset", HardReset: "Hard reset", FastForwardBranch: "Fast forward branch", + RestoreUpstreamBranch: "Restore upstream branch", AutoForwardBranches: "Auto-forward branches", Undo: "Undo", Redo: "Redo", diff --git a/pkg/integration/components/git.go b/pkg/integration/components/git.go index 1b07e5cf802..913c9fae4d8 100644 --- a/pkg/integration/components/git.go +++ b/pkg/integration/components/git.go @@ -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 { 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) diff --git a/pkg/integration/tests/branch/restore_deleted_branch.go b/pkg/integration/tests/branch/restore_deleted_branch.go new file mode 100644 index 00000000000..cdc73764da0 --- /dev/null +++ b/pkg/integration/tests/branch/restore_deleted_branch.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/branch/restore_upstream_branch.go b/pkg/integration/tests/branch/restore_upstream_branch.go new file mode 100644 index 00000000000..422c1fb878e --- /dev/null +++ b/pkg/integration/tests/branch/restore_upstream_branch.go @@ -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") + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a6b5aeafdf0..57177e0a009 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -80,6 +80,8 @@ var tests = []*components.IntegrationTest{ branch.ResetToDuplicateNamedTag, branch.ResetToDuplicateNamedUpstream, branch.ResetToUpstream, + branch.RestoreDeletedBranch, + branch.RestoreUpstreamBranch, branch.SelectCommitsOfCurrentBranch, branch.SetUpstream, branch.ShowDivergenceFromBaseBranch, diff --git a/schema-master/config.json b/schema-master/config.json index 45a2b9efe4b..31e51c6efa1 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1258,6 +1258,20 @@ } ], "default": "s" + }, + "restoreBranch": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+r\u003e" } }, "additionalProperties": false,