diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index a55278b5b..9282a49e9 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 b41b0564f..f42b66e2c 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -490,6 +490,153 @@ 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 + subject string + 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) { + currentBranches, err := self.getCurrentBranchNames() + 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, currentBranches), nil +} + +// getCurrentBranchNames returns the short names of all local branches. +func (self *BranchLoader) getCurrentBranchNames() ([]string, error) { + output, err := self.cmd.New( + NewGitCmd("for-each-ref"). + Arg("--format=%(refname:short)"). + Arg("refs/heads"). + ToArgv(), + ).DontLog().RunWithOutput() + if err != nil { + return nil, err + } + return strings.Split(strings.TrimSpace(output), "\n"), 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, currentBranchNames []string) []*models.DeletedBranch { + currentBranches := set.NewFromSlice(currentBranchNames) + + // 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 != "" { + currentBranch = entry.to + continue + } + + if currentBranch != "" && currentBranch != "HEAD" { + branchTip[currentBranch] = entry.hash + branchTimestamp[currentBranch] = entry.timestamp + } + } + + deleted := make([]*models.DeletedBranch, 0, len(branchTip)) + for name, tip := range branchTip { + if name == "HEAD" || currentBranches.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 +} + // 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 000000000..2b3b5d207 --- /dev/null +++ b/pkg/commands/git_commands/deleted_branch_test.go @@ -0,0 +1,172 @@ +package git_commands + +import ( + "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 + currentBranchNames []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}, + }, + currentBranchNames: []string{"main"}, + 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 + }, + currentBranchNames: []string{"main"}, + 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"}, + }, + currentBranchNames: []string{"main", "other"}, + expected: nil, + }, + { + testName: "no checkout entries means nothing recoverable", + entries: []*reflogEntry{ + {hash: "a", timestamp: 300}, + {hash: "b", timestamp: 200}, + }, + currentBranchNames: []string{"main"}, + 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}, + }, + currentBranchNames: []string{"main"}, + expected: nil, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + result := obtainDeletedBranches(s.entries, s.currentBranchNames) + assert.Equal(t, s.expected, result) + }) + } +} + +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() + }) + } +}