Skip to content
1 change: 1 addition & 0 deletions docs-master/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,7 @@ keybinding:
fetchRemote: f
addForkRemote: F
sortOrder: s
restoreBranch: <ctrl+r>
commits:
squashDown: s
renameCommit: r
Expand Down
1 change: 1 addition & 0 deletions docs-master/keybindings/Keybindings_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs-master/keybindings/Keybindings_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。 |
Expand Down
1 change: 1 addition & 0 deletions docs-master/keybindings/Keybindings_ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs-master/keybindings/Keybindings_nl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs-master/keybindings/Keybindings_pl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs-master/keybindings/Keybindings_pt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs-master/keybindings/Keybindings_ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs-master/keybindings/Keybindings_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 |
Expand Down
1 change: 1 addition & 0 deletions docs-master/keybindings/Keybindings_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `` | 從上游快進此分支 | 從遠端快進所選的分支 |
Expand Down
42 changes: 41 additions & 1 deletion pkg/commands/git_commands/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down
216 changes: 216 additions & 0 deletions pkg/commands/git_commands/branch_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading