This commit is contained in:
Samuel Onoja 2026-09-04 11:04:50 +10:00 committed by GitHub
commit 3c12d7a509
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 849 additions and 1 deletions

View file

@ -777,6 +777,7 @@ keybinding:
fetchRemote: f
addForkRemote: F
sortOrder: s
restoreBranch: <ctrl+r>
commits:
squashDown: s
renameCommit: r

View file

@ -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. |
| `` <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. |
| `` 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. |

View file

@ -385,6 +385,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` - `` | 直前のブランチにチェックアウト | |
| `` F `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 |
| `` 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 `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 |
| `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) |
| `` f `` | ブランチを最新化fast-forward | 選択したブランチを対応するアップストリームの最新状態に追いつかせますfast-forward。 |

View file

@ -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. |
| `` <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. |
| `` 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. |

View file

@ -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. |
| `` <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. |
| `` 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. |

View file

@ -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. |
| `` <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łąź. |
| `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. |
| `` f `` | Szybkie przewijanie | Szybkie przewijanie wybranej gałęzi z jej źródła. |

View file

@ -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. |
| `` <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 |
| `` 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. |

View file

@ -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. |
| `` <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. |
| `` 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. |

View file

@ -236,6 +236,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` - `` | 签出上一个分支 | |
| `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 |
| `` 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 `` | 变基 | 将检出的分支变基到所选的分支上。 |
| `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) |
| `` f `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 |

View file

@ -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. |
| `` <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. |
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |

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

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

View file

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

View file

@ -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{"<ctrl+r>"},
},
Commits: KeybindingCommitsConfig{
SquashDown: Keybinding{"s"},

View file

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

View file

@ -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
@ -575,6 +581,7 @@ type TranslationSet struct {
SetAsUpstreamTooltip string
SetUpstream string
UnsetUpstream string
RestoreUpstreamBranch string
ViewDivergenceFromUpstream string
ViewDivergenceFromBaseBranch string
CouldNotDetermineBaseBranch string
@ -646,6 +653,7 @@ type TranslationSet struct {
ViewBranchUpstreamOptions string
ViewBranchUpstreamOptionsTooltip string
UpstreamNotSetError string
UpstreamNotGoneError string
UpstreamsNotSetError string
NewGitFlowBranchPrompt string
RenameBranchWarning string
@ -1017,6 +1025,7 @@ type Actions struct {
CheckoutBranch string
CheckoutBranchOrCommit string
ForceCheckoutBranch string
RestoreBranch string
DeleteLocalBranch string
Merge string
SquashMerge string
@ -1024,6 +1033,7 @@ type Actions struct {
RenameBranch string
CreateBranch string
FastForwardBranch string
RestoreUpstreamBranch string
AutoForwardBranches string
CherryPick 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?",
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?",
@ -1740,6 +1756,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",
@ -1807,6 +1824,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",
@ -2136,6 +2154,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",
@ -2241,6 +2260,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",

View file

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

View 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"),
)
},
})

View 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")
},
})

View file

@ -80,6 +80,8 @@ var tests = []*components.IntegrationTest{
branch.ResetToDuplicateNamedTag,
branch.ResetToDuplicateNamedUpstream,
branch.ResetToUpstream,
branch.RestoreDeletedBranch,
branch.RestoreUpstreamBranch,
branch.SelectCommitsOfCurrentBranch,
branch.SetUpstream,
branch.ShowDivergenceFromBaseBranch,

View file

@ -1258,6 +1258,20 @@
}
],
"default": "s"
},
"restoreBranch": {
"oneOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"default": "\u003cctrl+r\u003e"
}
},
"additionalProperties": false,