mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Merge e3cab9777c into e0b2a5081d
This commit is contained in:
commit
3c12d7a509
|
|
@ -777,6 +777,7 @@ keybinding:
|
|||
fetchRemote: f
|
||||
addForkRemote: F
|
||||
sortOrder: s
|
||||
restoreBranch: <ctrl+r>
|
||||
commits:
|
||||
squashDown: s
|
||||
renameCommit: r
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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)。 |
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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 `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 |
|
||||
|
|
|
|||
|
|
@ -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 `` | 從上游快進此分支 | 從遠端快進所選的分支 |
|
||||
|
|
|
|||
|
|
@ -132,7 +132,47 @@ func (self *BranchCommands) PreviousRef() (string, error) {
|
|||
return strings.TrimSpace(output), nil
|
||||
}
|
||||
|
||||
// LocalDelete delete branch locally
|
||||
// RestoreBranch recreates a deleted local branch at the given commit hash and,
|
||||
// if exactly one remote-tracking branch with the same name still exists,
|
||||
// re-attaches it as the upstream. Returns the upstream ref name that was
|
||||
// re-attached (or "" if none was).
|
||||
func (self *BranchCommands) RestoreBranch(name string, commitHash string) (string, error) {
|
||||
cmdArgs := NewGitCmd("branch").
|
||||
Arg(name, commitHash).
|
||||
ToArgv()
|
||||
|
||||
if err := self.cmd.New(cmdArgs).Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
upstream := ""
|
||||
remoteRefs, err := self.cmd.New(
|
||||
NewGitCmd("for-each-ref").
|
||||
Arg("--format=%(refname:short)").
|
||||
Arg("refs/remotes").
|
||||
ToArgv(),
|
||||
).DontLog().RunWithOutput()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
matchingRefs := lo.Filter(strings.Split(strings.TrimSpace(remoteRefs), "\n"), func(ref string, _ int) bool {
|
||||
return "refs/remotes/"+ref == "refs/remotes/"+name || strings.HasSuffix(ref, "/"+name)
|
||||
})
|
||||
if len(matchingRefs) == 1 {
|
||||
matchingRef := strings.TrimSpace(matchingRefs[0])
|
||||
parts := strings.SplitN(matchingRef, "/", 2)
|
||||
if len(parts) == 2 {
|
||||
if err := self.SetUpstream(parts[0], parts[1], name); err == nil {
|
||||
upstream = matchingRef
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return upstream, nil
|
||||
}
|
||||
|
||||
// LocalDelete delete local branch
|
||||
func (self *BranchCommands) LocalDelete(branches []string, force bool) error {
|
||||
cmdArgs := NewGitCmd("branch").
|
||||
ArgIfElse(force, "-D", "-d").
|
||||
|
|
|
|||
|
|
@ -490,6 +490,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 {
|
||||
|
|
|
|||
311
pkg/commands/git_commands/deleted_branch_test.go
Normal file
311
pkg/commands/git_commands/deleted_branch_test.go
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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() {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
51
pkg/integration/tests/branch/restore_deleted_branch.go
Normal file
51
pkg/integration/tests/branch/restore_deleted_branch.go
Normal 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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
52
pkg/integration/tests/branch/restore_upstream_branch.go
Normal file
52
pkg/integration/tests/branch/restore_upstream_branch.go
Normal 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")
|
||||
},
|
||||
})
|
||||
|
|
@ -80,6 +80,8 @@ var tests = []*components.IntegrationTest{
|
|||
branch.ResetToDuplicateNamedTag,
|
||||
branch.ResetToDuplicateNamedUpstream,
|
||||
branch.ResetToUpstream,
|
||||
branch.RestoreDeletedBranch,
|
||||
branch.RestoreUpstreamBranch,
|
||||
branch.SelectCommitsOfCurrentBranch,
|
||||
branch.SetUpstream,
|
||||
branch.ShowDivergenceFromBaseBranch,
|
||||
|
|
|
|||
|
|
@ -1258,6 +1258,20 @@
|
|||
}
|
||||
],
|
||||
"default": "s"
|
||||
},
|
||||
"restoreBranch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"default": "\u003cctrl+r\u003e"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue