mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
detect and restore deleted branches from reflog
This commit is contained in:
parent
59b9d7d222
commit
93599a389c
|
|
@ -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").
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
172
pkg/commands/git_commands/deleted_branch_test.go
Normal file
172
pkg/commands/git_commands/deleted_branch_test.go
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue