Update a pin that is behind with u; switch fast-forwards

`u` moves the pin of the selection to the tip of its ref when the pin is
only behind it: a switch to that branch with exactly the tip the row
shows, nothing fetched; if the ref moved meanwhile, nothing happens. On
the superproject it fast-forwards the branch to its upstream and restores
the submodules (git pull --ff-only from the last fetch).

Switch now fast-forwards a local branch that is only behind the chosen
remote branch, unless another worktree has it checked out, and keeps a
checked-out branch that tracks it under another name. A branch with
commits of its own is still never moved.

Stage pin is dropped: switching to the branch already stages it. The
detail pane's palette wraps instead of cutting off keys. Add D29 and F59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 22:39:18 +02:00
co-authored by Claude Opus 5.5
parent 99f1ba2bec
commit fbd233f58d
16 changed files with 686 additions and 105 deletions
+51 -8
View File
@@ -90,8 +90,11 @@ func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Bran
// Switch makes b's tip the staged pin of a submodule, restores the repository
// to it (see Restore) and puts HEAD on the branch. For a remote branch the
// local branch of that name is created, tracking it. A local branch that
// exists elsewhere is never moved: HEAD stays detached and "" is returned.
// local branch is the one checked out before if it tracks b, else the one
// of that name, created tracking b if there is none. A local branch that is
// only behind the tip is fast-forwarded; one with commits of its own, or
// checked out in another worktree, is never moved: HEAD stays detached and
// "" is returned.
func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) (string, error) {
dir, in, sub, err := p.switchDir(path)
if err != nil {
@@ -102,6 +105,10 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
} else if !ok || sha != b.SHA {
return "", fmt.Errorf("%s moved; open the list again", b.Name)
}
prev, _, err := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return "", err
}
if sub { // staged first, so a failed restore can be repeated with x
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+b.SHA+","+in.relPath); err != nil {
return "", err
@@ -118,15 +125,51 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
_, err := g("switch", "-q", b.Local)
return b.Local, err
}
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+b.Local); {
local := b.Local
if prev != "" && prev != local {
if up, ok, err := upstream(ctx, dir, prev); err != nil {
return "", err
} else if ok && up == b.Name {
local = prev // e.g. dev tracking origin/main: an update stays on dev
}
}
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+local); {
case err != nil:
return "", err
case !ok:
_, err := g("switch", "-q", "-c", b.Local, "--track", b.Name)
return b.Local, err
_, err := g("switch", "-q", "-c", local, "--track", b.Name)
return local, err
case tip == b.SHA:
_, err := g("switch", "-q", b.Local)
return b.Local, err
_, err := g("switch", "-q", local)
return local, err
default:
if ok, err := p.fastForward(ctx, dir, local, tip, b.SHA); err != nil || !ok {
return "", err
}
_, err := g("switch", "-q", local)
return local, err
}
return "", nil
}
// fastForward moves the local branch from tip to sha if that loses nothing:
// tip is an ancestor of sha and no worktree has the branch checked out. It
// reports whether the branch was moved; a branch that moved meanwhile is
// left alone.
func (p *Provider) fastForward(ctx context.Context, dir, local, tip, sha string) (bool, error) {
if _, ok, err := try(ctx, dir, "merge-base", "--is-ancestor", tip, sha); err != nil || !ok {
return false, err
}
wts, err := run(ctx, dir, "worktree", "list", "--porcelain")
if err != nil {
return false, err
}
for _, line := range strings.Split(wts, "\n") {
if line == "branch refs/heads/"+local {
return false, nil
}
}
if _, ok, err := try(ctx, dir, "update-ref", "-m", "submarine: fast-forward", "refs/heads/"+local, sha, tip); err != nil || !ok {
return false, err
}
return true, nil
}
+111 -2
View File
@@ -81,12 +81,15 @@ func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) {
assertExact(t, sub)
}
func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
func TestSwitchNeverMovesALocalBranchWithCommitsOfItsOwn(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
git(t, sub, "branch", "feat/search", "origin/main") // same name, elsewhere
// same name, diverged: a commit of its own on top of origin/main
git(t, sub, "switch", "-q", "-c", "feat/search", "origin/main")
git(t, sub, "commit", "-q", "--allow-empty", "-m", "own")
git(t, sub, "switch", "-q", "--detach", "HEAD~1")
before := out(t, sub, "rev-parse", "feat/search")
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
@@ -111,6 +114,112 @@ func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
}
}
func TestSwitchFastForwardsALocalBranchThatIsBehind(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
git(t, sub, "branch", "feat/search", "origin/feat/search~1") // behind only
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/feat/search")
got, err := p.Switch(ctx, "libs/feature", b)
if err != nil {
t.Fatal(err)
}
if got != "feat/search" || out(t, sub, "branch", "--show-current") != "feat/search" {
t.Errorf("Switch = %q, branch = %q; want feat/search", got, out(t, sub, "branch", "--show-current"))
}
if tip := out(t, sub, "rev-parse", "feat/search"); tip != b.SHA {
t.Errorf("feat/search = %s, want fast-forwarded to %s", tip, b.SHA)
}
assertExact(t, sub)
}
func TestSwitchSuperprojectFastForwardsItsBranch(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
ctx := context.Background()
// main, checked out, is one behind origin/main: a pull --ff-only
tip := out(t, super, "rev-parse", "HEAD")
git(t, super, "update-ref", "refs/remotes/origin/main", tip)
git(t, super, "update-ref", "refs/heads/main", tip+"~1")
bs, err := p.Branches(ctx, "")
if err != nil {
t.Fatal(err)
}
got, err := p.Switch(ctx, "", findBranch(t, bs, "origin/main"))
if err != nil {
t.Fatal(err)
}
if got != "main" || out(t, super, "branch", "--show-current") != "main" {
t.Errorf("Switch = %q, branch = %q; want main", got, out(t, super, "branch", "--show-current"))
}
if head := out(t, super, "rev-parse", "main"); head != tip {
t.Errorf("main = %s, want fast-forwarded to %s", head, tip)
}
assertExact(t, super)
}
func TestSwitchStaysOnTheCheckedOutBranchThatTracksIt(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
ctx := context.Background()
// dev, checked out, tracks origin/main and is one behind it
tip := out(t, super, "rev-parse", "HEAD")
git(t, super, "remote", "add", "origin", filepath.Join(root, "nowhere")) // never contacted
git(t, super, "update-ref", "refs/remotes/origin/main", tip)
git(t, super, "switch", "-q", "-c", "dev")
git(t, super, "update-ref", "refs/heads/dev", tip+"~1")
git(t, super, "branch", "-q", "--set-upstream-to=origin/main", "dev")
bs, err := p.Branches(ctx, "")
if err != nil {
t.Fatal(err)
}
got, err := p.Switch(ctx, "", findBranch(t, bs, "origin/main"))
if err != nil {
t.Fatal(err)
}
if got != "dev" || out(t, super, "branch", "--show-current") != "dev" {
t.Errorf("Switch = %q, branch = %q; want dev", got, out(t, super, "branch", "--show-current"))
}
if head := out(t, super, "rev-parse", "dev"); head != tip {
t.Errorf("dev = %s, want fast-forwarded to %s", head, tip)
}
if main := out(t, super, "rev-parse", "main"); main != tip {
t.Errorf("main = %s, must not have moved from %s", main, tip)
}
assertExact(t, super)
}
func TestSwitchNeverMovesABranchOfAnotherWorktree(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
git(t, sub, "branch", "feat/search", "origin/feat/search~1")
git(t, sub, "worktree", "add", "-q", filepath.Join(root, "wt"), "feat/search")
before := out(t, sub, "rev-parse", "feat/search")
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
got, err := p.Switch(ctx, "libs/feature", findBranch(t, bs, "origin/feat/search"))
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("Switch returned %q, want detached", got)
}
if after := out(t, sub, "rev-parse", "feat/search"); after != before {
t.Errorf("branch of the other worktree moved: %s -> %s", before, after)
}
}
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")