Archived
Add switch branch (b): stage the tip and restore to it
b opens a centered popup with a search line and the local and remote branches of the selection. Enter stages the branch tip as the pin of a submodule, restores it (and everything below) to it, and puts HEAD on the branch; a remote branch gets a local tracking branch unless one exists elsewhere. Works on the superproject too. Losses are asked like restore. The backdrop behind the popup is dimmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// switchDir returns the directory of path ("" is the superproject), and the
|
||||
// submodule's info if it is one.
|
||||
func (p *Provider) switchDir(path string) (dir string, in info, sub bool, err error) {
|
||||
if path == "" {
|
||||
return p.root, info{}, false, nil
|
||||
}
|
||||
p.mu.RLock()
|
||||
in, ok := p.mods[path]
|
||||
p.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", info{}, false, fmt.Errorf("unknown submodule %q", path)
|
||||
}
|
||||
if !initialized(in.dir) {
|
||||
return "", info{}, false, errors.New("not initialized; restore it first")
|
||||
}
|
||||
return in.dir, in, true, nil
|
||||
}
|
||||
|
||||
// Branches lists the local and remote-tracking branches of path, local ones
|
||||
// first, each group by the date of its tip, newest first. It reads only what
|
||||
// is on disk.
|
||||
func (p *Provider) Branches(ctx context.Context, path string) ([]submodule.Branch, error) {
|
||||
dir, _, _, err := p.switchDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := run(ctx, dir, "for-each-ref",
|
||||
"--format=%(refname)%00%(objectname)%00%(committerdate:unix)%00%(HEAD)%00%(symref)",
|
||||
"refs/heads", "refs/remotes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var branches []submodule.Branch
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Split(line, "\x00")
|
||||
if len(f) != 5 || f[4] != "" { // a symref is origin/HEAD
|
||||
continue
|
||||
}
|
||||
b := submodule.Branch{Ref: f[0], SHA: f[1], Current: f[3] == "*"}
|
||||
if secs, err := strconv.ParseInt(f[2], 10, 64); err == nil {
|
||||
b.When = time.Unix(secs, 0)
|
||||
}
|
||||
if name, ok := strings.CutPrefix(f[0], "refs/heads/"); ok {
|
||||
b.Name, b.Local = name, name
|
||||
} else if name, ok := strings.CutPrefix(f[0], "refs/remotes/"); ok {
|
||||
_, local, _ := strings.Cut(name, "/")
|
||||
b.Name, b.Local, b.Remote = name, local, true
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
branches = append(branches, b)
|
||||
}
|
||||
slices.SortStableFunc(branches, func(a, b submodule.Branch) int {
|
||||
switch {
|
||||
case a.Remote != b.Remote:
|
||||
if a.Remote {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
default:
|
||||
return b.When.Compare(a.When)
|
||||
}
|
||||
})
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// SwitchLoss reports what switching path to b would discard: the same as a
|
||||
// restore to b's tip.
|
||||
func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Branch) (submodule.Loss, error) {
|
||||
dir, _, _, err := p.switchDir(path)
|
||||
if err != nil {
|
||||
return submodule.Loss{}, err
|
||||
}
|
||||
return p.lossOf(ctx, dir, b.SHA), nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) (string, error) {
|
||||
dir, in, sub, err := p.switchDir(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sha, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", b.Ref+"^{commit}"); err != nil {
|
||||
return "", err
|
||||
} else if !ok || sha != b.SHA {
|
||||
return "", fmt.Errorf("%s moved; open the list again", b.Name)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if err := p.restoreRepo(ctx, dir, b.SHA); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
g := func(args ...string) (string, error) {
|
||||
return p.exec(ctx, dir, nil, append(append([]string{}, restoreArgs...), args...)...)
|
||||
}
|
||||
if !b.Remote {
|
||||
_, 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); {
|
||||
case err != nil:
|
||||
return "", err
|
||||
case !ok:
|
||||
_, err := g("switch", "-q", "-c", b.Local, "--track", b.Name)
|
||||
return b.Local, err
|
||||
case tip == b.SHA:
|
||||
_, err := g("switch", "-q", b.Local)
|
||||
return b.Local, err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
func findBranch(t *testing.T, bs []submodule.Branch, name string) submodule.Branch {
|
||||
t.Helper()
|
||||
for _, b := range bs {
|
||||
if b.Name == name {
|
||||
return b
|
||||
}
|
||||
}
|
||||
t.Fatalf("no branch %q in %v", name, bs)
|
||||
return submodule.Branch{}
|
||||
}
|
||||
|
||||
func TestBranchesListsLocalAndRemote(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
_ = root
|
||||
bs, err := p.Branches(context.Background(), "libs/feature")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
seenRemote := false
|
||||
for _, b := range bs {
|
||||
names = append(names, b.Name)
|
||||
if b.Remote {
|
||||
seenRemote = true
|
||||
} else if seenRemote {
|
||||
t.Errorf("local branch %s after a remote one: %v", b.Name, names)
|
||||
}
|
||||
if b.Name == "origin/HEAD" {
|
||||
t.Error("origin/HEAD must not be listed")
|
||||
}
|
||||
}
|
||||
rb := findBranch(t, bs, "origin/feat/search")
|
||||
if !rb.Remote || rb.Local != "feat/search" {
|
||||
t.Errorf("origin/feat/search = %+v", rb)
|
||||
}
|
||||
findBranch(t, bs, "origin/main")
|
||||
}
|
||||
|
||||
func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
sub := filepath.Join(super, "libs", "feature")
|
||||
ctx := context.Background()
|
||||
|
||||
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" {
|
||||
t.Errorf("Switch returned %q, want feat/search", got)
|
||||
}
|
||||
if head := out(t, sub, "branch", "--show-current"); head != "feat/search" {
|
||||
t.Errorf("branch = %q", head)
|
||||
}
|
||||
if up := out(t, sub, "rev-parse", "--abbrev-ref", "feat/search@{upstream}"); up != "origin/feat/search" {
|
||||
t.Errorf("upstream = %q", up)
|
||||
}
|
||||
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
|
||||
t.Errorf("HEAD = %s, want %s", head, b.SHA)
|
||||
}
|
||||
if staged := out(t, super, "ls-files", "-s", "--", "libs/feature"); !strings.Contains(staged, b.SHA) {
|
||||
t.Errorf("index = %q, want the tip %s staged", staged, b.SHA)
|
||||
}
|
||||
assertExact(t, sub)
|
||||
}
|
||||
|
||||
func TestSwitchNeverMovesAnExistingLocalBranch(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
|
||||
before := out(t, sub, "rev-parse", "feat/search")
|
||||
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 != "" {
|
||||
t.Errorf("Switch returned %q, want detached", got)
|
||||
}
|
||||
if head := out(t, sub, "branch", "--show-current"); head != "" {
|
||||
t.Errorf("HEAD on %q, want detached", head)
|
||||
}
|
||||
if after := out(t, sub, "rev-parse", "feat/search"); after != before {
|
||||
t.Errorf("branch moved: %s -> %s", before, after)
|
||||
}
|
||||
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
|
||||
t.Errorf("HEAD = %s, want %s", head, b.SHA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
sub := filepath.Join(root, "super", "libs", "feature")
|
||||
ctx := context.Background()
|
||||
|
||||
bs, err := p.Branches(ctx, "libs/feature")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := findBranch(t, bs, "origin/main")
|
||||
b.SHA = out(t, sub, "rev-parse", "origin/feat/search") // what the list showed is stale
|
||||
if _, err := p.Switch(ctx, "libs/feature", b); err == nil || !strings.Contains(err.Error(), "moved") {
|
||||
t.Errorf("err = %v, want a moved error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchSuperprojectRestoresSubmodulesToItsPins(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
ctx := context.Background()
|
||||
|
||||
git(t, super, "branch", "older", "HEAD~1")
|
||||
bs, err := p.Branches(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := findBranch(t, bs, "older")
|
||||
got, err := p.Switch(ctx, "", b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "older" || out(t, super, "branch", "--show-current") != "older" {
|
||||
t.Errorf("Switch = %q, branch = %q", got, out(t, super, "branch", "--show-current"))
|
||||
}
|
||||
assertExact(t, super)
|
||||
}
|
||||
|
||||
func TestSwitchLossCountsChangedFiles(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
sub := filepath.Join(root, "super", "libs", "feature")
|
||||
ctx := context.Background()
|
||||
|
||||
bs, err := p.Branches(ctx, "libs/feature")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := findBranch(t, bs, "origin/feat/search")
|
||||
loss, err := p.SwitchLoss(ctx, "libs/feature", b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loss.Empty() {
|
||||
t.Errorf("clean checkout: loss = %+v", loss)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "new.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loss, err = p.SwitchLoss(ctx, "libs/feature", b); err != nil || loss.Files != 1 {
|
||||
t.Errorf("loss = %+v, %v; want 1 file", loss, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchesOfAMissingSubmoduleSaysSo(t *testing.T) {
|
||||
p, _ := ownFixture(t)
|
||||
if _, err := p.Branches(context.Background(), "libs/missing"); err == nil || !strings.Contains(err.Error(), "not initialized") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user