detect and restore deleted branches from reflog

This commit is contained in:
Samuel Onoja 2026-08-03 11:16:40 +01:00
parent 59b9d7d222
commit 93599a389c
No known key found for this signature in database
3 changed files with 360 additions and 1 deletions

View file

@ -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").

View file

@ -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 {

View file

@ -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()
})
}
}