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 fetchRemote: f
addForkRemote: F addForkRemote: F
sortOrder: s sortOrder: s
restoreBranch: <ctrl+r>
commits: commits:
squashDown: s squashDown: s
renameCommit: r 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 | | | `` - `` | 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. | | `` 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. | | `` 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. | | `` 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) | | `` 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. | | `` 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 `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 | | `` F `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 |
| `` d `` | 削除 | ローカル/リモートブランチの削除オプションを表示します。 | | `` 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 `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 | | `` r `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 |
| `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) | | `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) |
| `` f `` | ブランチを最新化fast-forward | 選択したブランチを対応するアップストリームの最新状態に追いつかせますfast-forward。 | | `` 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 | | | `` - `` | Checkout previous branch | |
| `` F `` | 강제 체크아웃 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected 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. | | `` 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. | | `` 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) | | `` 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. | | `` 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 | | | `` - `` | 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. | | `` 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. | | `` 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. | | `` 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) | | `` 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. | | `` 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łąź | | | `` - `` | 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łąź. | | `` 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. | | `` 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łąź. | | `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. |
| `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. | | `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. |
| `` f `` | Szybkie przewijanie | Szybkie przewijanie wybranej gałęzi z jej źródła. | | `` 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 | | | `` - `` | 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 | | `` 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. | | `` 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 | | `` 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) | | `` 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. | | `` 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 | | | `` - `` | Checkout previous branch | |
| `` F `` | Принудительное переключение | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected 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. | | `` 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. | | `` 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) | | `` 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. | | `` 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 `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 | | `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 |
| `` d `` | 删除 | 查看本地/远程分支的删除选项 | | `` 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 `` | 变基 | 将检出的分支变基到所选的分支上。 | | `` r `` | 变基 | 将检出的分支变基到所选的分支上。 |
| `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) | | `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) |
| `` f `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 | | `` 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 | | | `` - `` | Checkout previous branch | |
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected 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. | | `` 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. | | `` 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) | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 | | `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |

View file

@ -132,7 +132,47 @@ func (self *BranchCommands) PreviousRef() (string, error) {
return strings.TrimSpace(output), nil 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 { func (self *BranchCommands) LocalDelete(branches []string, force bool) error {
cmdArgs := NewGitCmd("branch"). cmdArgs := NewGitCmd("branch").
ArgIfElse(force, "-D", "-d"). ArgIfElse(force, "-D", "-d").

View file

@ -490,6 +490,222 @@ func parseDifference(track string, regexStr string) string {
return "0" 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 // TODO: only look at the new reflog commits, and otherwise store the recencies in
// int form against the branch to recalculate the time ago // int form against the branch to recalculate the time ago
func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch { 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" "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 // Branch : A git branch
// duplicating this for now // duplicating this for now
type Branch struct { type Branch struct {

View file

@ -610,6 +610,7 @@ type KeybindingBranchesConfig struct {
FetchRemote Keybinding `yaml:"fetchRemote"` FetchRemote Keybinding `yaml:"fetchRemote"`
AddForkRemote Keybinding `yaml:"addForkRemote"` AddForkRemote Keybinding `yaml:"addForkRemote"`
SortOrder Keybinding `yaml:"sortOrder"` SortOrder Keybinding `yaml:"sortOrder"`
RestoreBranch Keybinding `yaml:"restoreBranch"`
} }
type KeybindingCommitsConfig struct { type KeybindingCommitsConfig struct {
@ -1125,6 +1126,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
FetchRemote: Keybinding{"f"}, FetchRemote: Keybinding{"f"},
AddForkRemote: Keybinding{"F"}, AddForkRemote: Keybinding{"F"},
SortOrder: Keybinding{"s"}, SortOrder: Keybinding{"s"},
RestoreBranch: Keybinding{"<ctrl+r>"},
}, },
Commits: KeybindingCommitsConfig{ Commits: KeybindingCommitsConfig{
SquashDown: Keybinding{"s"}, SquashDown: Keybinding{"s"},

View file

@ -124,6 +124,13 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
OpensMenu: true, OpensMenu: true,
DisplayOnScreen: 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), Keys: opts.GetKeys(opts.Config.Branches.RebaseBranch),
Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)),
@ -325,6 +332,14 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
Keys: menuKey('s'), 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( upstreamResetOptions := utils.ResolvePlaceholderString(
self.c.Tr.ViewUpstreamResetOptions, self.c.Tr.ViewUpstreamResetOptions,
map[string]string{"upstream": upstream}, 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} 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{ options := []*types.MenuItem{
viewDivergenceItem, viewDivergenceItem,
viewDivergenceFromBaseBranchItem, viewDivergenceFromBaseBranchItem,
unsetUpstreamItem, unsetUpstreamItem,
setUpstreamItem, setUpstreamItem,
restoreUpstreamItem,
upstreamResetItem, upstreamResetItem,
upstreamRebaseItem, upstreamRebaseItem,
} }
@ -580,6 +606,48 @@ func (self *BranchesController) localAndRemoteDelete(branches []*models.Branch)
return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branches) 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 { func (self *BranchesController) delete(branches []*models.Branch) error {
checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef() checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef()
isBranchCheckedOut := lo.SomeBy(branches, func(branch *models.Branch) bool { 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 { func (self *BranchesController) createTag(branch *models.Branch) error {
return self.c.Helpers().Tags.OpenCreateTagPrompt(branch.FullRefName(), func() {}) return self.c.Helpers().Tags.OpenCreateTagPrompt(branch.FullRefName(), func() {})
} }

View file

@ -127,6 +127,12 @@ type TranslationSet struct {
DeleteRemoteBranchesPrompt string DeleteRemoteBranchesPrompt string
DeleteLocalAndRemoteBranchPrompt string DeleteLocalAndRemoteBranchPrompt string
DeleteLocalAndRemoteBranchesPrompt string DeleteLocalAndRemoteBranchesPrompt string
RestoreBranch string
RestoreBranchTooltip string
RestoreBranchTitle string
NoDeletedBranches string
RestoredBranch string
RestoredBranchUpstream string
ForceDeleteBranchTitle string ForceDeleteBranchTitle string
ForceDeleteBranchMessage string ForceDeleteBranchMessage string
ForceDeleteBranchesMessage string ForceDeleteBranchesMessage string
@ -575,6 +581,7 @@ type TranslationSet struct {
SetAsUpstreamTooltip string SetAsUpstreamTooltip string
SetUpstream string SetUpstream string
UnsetUpstream string UnsetUpstream string
RestoreUpstreamBranch string
ViewDivergenceFromUpstream string ViewDivergenceFromUpstream string
ViewDivergenceFromBaseBranch string ViewDivergenceFromBaseBranch string
CouldNotDetermineBaseBranch string CouldNotDetermineBaseBranch string
@ -646,6 +653,7 @@ type TranslationSet struct {
ViewBranchUpstreamOptions string ViewBranchUpstreamOptions string
ViewBranchUpstreamOptionsTooltip string ViewBranchUpstreamOptionsTooltip string
UpstreamNotSetError string UpstreamNotSetError string
UpstreamNotGoneError string
UpstreamsNotSetError string UpstreamsNotSetError string
NewGitFlowBranchPrompt string NewGitFlowBranchPrompt string
RenameBranchWarning string RenameBranchWarning string
@ -1017,6 +1025,7 @@ type Actions struct {
CheckoutBranch string CheckoutBranch string
CheckoutBranchOrCommit string CheckoutBranchOrCommit string
ForceCheckoutBranch string ForceCheckoutBranch string
RestoreBranch string
DeleteLocalBranch string DeleteLocalBranch string
Merge string Merge string
SquashMerge string SquashMerge string
@ -1024,6 +1033,7 @@ type Actions struct {
RenameBranch string RenameBranch string
CreateBranch string CreateBranch string
FastForwardBranch string FastForwardBranch string
RestoreUpstreamBranch string
AutoForwardBranches string AutoForwardBranches string
CherryPick string CherryPick string
CheckoutFile 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?", 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}}'?", 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?", 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", ForceDeleteBranchTitle: "Force delete branch",
ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", 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?", 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.", SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.",
SetUpstream: "Set upstream of selected branch", SetUpstream: "Set upstream of selected branch",
UnsetUpstream: "Unset upstream of selected branch", UnsetUpstream: "Unset upstream of selected branch",
RestoreUpstreamBranch: "Restore upstream branch",
ViewDivergenceFromUpstream: "View divergence from upstream", ViewDivergenceFromUpstream: "View divergence from upstream",
ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})", ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})",
CouldNotDetermineBaseBranch: "Couldn't determine base branch", CouldNotDetermineBaseBranch: "Couldn't determine base branch",
@ -1807,6 +1824,7 @@ func EnglishTranslationSet() *TranslationSet {
ViewBranchUpstreamOptions: "View upstream options", ViewBranchUpstreamOptions: "View upstream options",
ViewBranchUpstreamOptionsTooltip: "View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream.", 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)", 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)", UpstreamsNotSetError: "Some of the selected branches have no upstream (or the upstream is not stored locally)",
Upstream: "Upstream", Upstream: "Upstream",
NewBranchNamePrompt: "Enter new branch name for branch", NewBranchNamePrompt: "Enter new branch name for branch",
@ -2136,6 +2154,7 @@ func EnglishTranslationSet() *TranslationSet {
CheckoutBranch: "Checkout branch", CheckoutBranch: "Checkout branch",
ForceCheckoutBranch: "Force checkout branch", ForceCheckoutBranch: "Force checkout branch",
CheckoutBranchOrCommit: "Checkout branch or commit", CheckoutBranchOrCommit: "Checkout branch or commit",
RestoreBranch: "Restore deleted branch",
DeleteLocalBranch: "Delete local branch", DeleteLocalBranch: "Delete local branch",
Merge: "Merge", Merge: "Merge",
SquashMerge: "Squash merge", SquashMerge: "Squash merge",
@ -2241,6 +2260,7 @@ func EnglishTranslationSet() *TranslationSet {
MixedReset: "Mixed reset", MixedReset: "Mixed reset",
HardReset: "Hard reset", HardReset: "Hard reset",
FastForwardBranch: "Fast forward branch", FastForwardBranch: "Fast forward branch",
RestoreUpstreamBranch: "Restore upstream branch",
AutoForwardBranches: "Auto-forward branches", AutoForwardBranches: "Auto-forward branches",
Undo: "Undo", Undo: "Undo",
Redo: "Redo", 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 { func (self *Git) assert(cmdArgs []string, expected string) *Git {
self.expect(cmdArgs, func(output string) (bool, string) { 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) 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.ResetToDuplicateNamedTag,
branch.ResetToDuplicateNamedUpstream, branch.ResetToDuplicateNamedUpstream,
branch.ResetToUpstream, branch.ResetToUpstream,
branch.RestoreDeletedBranch,
branch.RestoreUpstreamBranch,
branch.SelectCommitsOfCurrentBranch, branch.SelectCommitsOfCurrentBranch,
branch.SetUpstream, branch.SetUpstream,
branch.ShowDivergenceFromBaseBranch, branch.ShowDivergenceFromBaseBranch,

View file

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