Skip to content

Commit 97fbc5c

Browse files
authored
Merge pull request #38 from hack-dance/fix-track-stale-adoption-anchor
fix: use merge-base anchors for stale branch adoption
2 parents 1dc615a + f786f4d commit 97fbc5c

5 files changed

Lines changed: 141 additions & 1 deletion

File tree

docs/adopting-existing-prs.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ stack track feature/runtime --parent feature/parser
7373
stack track feature/ui --parent feature/runtime
7474
```
7575

76+
When the parent branch has moved since a child branch was originally cut,
77+
`stack track` records a merge-base-style restack anchor instead of blindly
78+
assuming the current parent tip. That makes stale existing PR heads adoptable
79+
without breaking the first `stack restack`.
80+
7681
If the first parent chain is wrong, that is fine. Get a draft graph in place
7782
first.
7883

docs/usage.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ Or adopt an existing branch and make the parent explicit:
2121
stack track feature/child --parent feature/base
2222
```
2323

24+
If the branch is stale and `feature/base` has moved since it was cut, `track`
25+
records a repairable restack anchor from shared history instead of the current
26+
parent tip.
27+
2428
If you already have a larger set of open PRs and want to turn them into an
2529
explicit stack after the fact, use
2630
[adopting-existing-prs.md](adopting-existing-prs.md).

internal/cmd/root.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,10 @@ func newTrackCommand(runtime *stackruntime.Runtime) *cobra.Command {
221221
return err
222222
}
223223

224-
parentOID, _ := runtime.Git.ResolveRef(runtime.Context, parent)
224+
parentOID, err := trackRestackAnchor(runtime, branch, parent)
225+
if err != nil {
226+
return err
227+
}
225228
state.Branches[branch] = store.BranchRecord{
226229
ParentBranch: parent,
227230
RemoteName: state.DefaultRemote,
@@ -1351,6 +1354,31 @@ func ensureTrackedParentAllowed(state store.RepoState, parent string) error {
13511354
return fmt.Errorf("parent branch %q is not tracked in local metadata; track it first or move under %s", parent, state.Trunk)
13521355
}
13531356

1357+
func trackRestackAnchor(runtime *stackruntime.Runtime, branch string, parent string) (string, error) {
1358+
parentOID, err := runtime.Git.ResolveRef(runtime.Context, parent)
1359+
if err != nil {
1360+
return "", err
1361+
}
1362+
1363+
validAnchor, err := runtime.Git.IsAncestor(runtime.Context, parentOID, branch)
1364+
if err != nil {
1365+
return "", err
1366+
}
1367+
if validAnchor {
1368+
return parentOID, nil
1369+
}
1370+
1371+
mergeBase, ok, err := runtime.Git.MergeBase(runtime.Context, parent, branch)
1372+
if err != nil {
1373+
return "", err
1374+
}
1375+
if !ok || mergeBase == "" {
1376+
return "", fmt.Errorf("branch %q does not share a repairable merge base with parent %q; rebase it first or choose a different parent", branch, parent)
1377+
}
1378+
1379+
return mergeBase, nil
1380+
}
1381+
13541382
func resolveOID(runtime *stackruntime.Runtime, ref string) string {
13551383
oid, err := runtime.Git.ResolveRef(runtime.Context, ref)
13561384
if err != nil {

internal/cmd/root_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,79 @@ func TestTrackRejectsUntrackedParent(t *testing.T) {
918918
}
919919
}
920920

921+
func TestTrackUsesMergeBaseAnchorForStaleAdoption(t *testing.T) {
922+
repo := testutil.SetupGitRepo(t)
923+
924+
testutil.Run(t, repo, "git", "switch", "-c", "feature/base")
925+
testutil.WriteFile(t, filepath.Join(repo, "feature-base.txt"), "feature base\n")
926+
testutil.Run(t, repo, "git", "add", "feature-base.txt")
927+
testutil.Run(t, repo, "git", "commit", "-m", "add feature base")
928+
originalFeatureBaseHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD"))
929+
930+
testutil.Run(t, repo, "git", "switch", "-c", "feature/child")
931+
testutil.WriteFile(t, filepath.Join(repo, "feature-child.txt"), "feature child\n")
932+
testutil.Run(t, repo, "git", "add", "feature-child.txt")
933+
testutil.Run(t, repo, "git", "commit", "-m", "add feature child")
934+
originalFeatureChildHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD"))
935+
936+
testutil.Run(t, repo, "git", "switch", "feature/base")
937+
testutil.WriteFile(t, filepath.Join(repo, "feature-base.txt"), "feature base advanced\n")
938+
testutil.Run(t, repo, "git", "add", "feature-base.txt")
939+
testutil.Run(t, repo, "git", "commit", "-m", "advance feature base")
940+
advancedFeatureBaseHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD"))
941+
942+
runtime := newTestRuntime(repo)
943+
mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main")
944+
if err != nil {
945+
t.Fatalf("resolve main head: %v", err)
946+
}
947+
state := store.RepoState{
948+
Version: 1,
949+
Repo: "hack-dance/stack",
950+
DefaultRemote: "origin",
951+
Trunk: "main",
952+
Branches: map[string]store.BranchRecord{},
953+
}
954+
if err := runtime.Store.WriteState(runtime.Context, state); err != nil {
955+
t.Fatalf("write state: %v", err)
956+
}
957+
958+
executeCommand(t, runtime, "track", "feature/base", "--parent", "main")
959+
executeCommand(t, runtime, "track", "feature/child", "--parent", "feature/base")
960+
961+
state, err = runtime.Store.ReadState(runtime.Context)
962+
if err != nil {
963+
t.Fatalf("read state after track: %v", err)
964+
}
965+
if got := state.Branches["feature/base"].Restack.LastParentHeadOID; got != mainHead {
966+
t.Fatalf("expected feature/base anchor %q, got %q", mainHead, got)
967+
}
968+
if got := state.Branches["feature/child"].Restack.LastParentHeadOID; got != originalFeatureBaseHead {
969+
t.Fatalf("expected feature/child merge-base anchor %q, got %q", originalFeatureBaseHead, got)
970+
}
971+
if originalFeatureBaseHead == advancedFeatureBaseHead {
972+
t.Fatalf("expected feature/base head to advance")
973+
}
974+
975+
executeCommand(t, runtime, "restack", "--all", "--yes")
976+
977+
state, err = runtime.Store.ReadState(runtime.Context)
978+
if err != nil {
979+
t.Fatalf("read state after restack: %v", err)
980+
}
981+
if got := state.Branches["feature/child"].Restack.LastParentHeadOID; got != advancedFeatureBaseHead {
982+
t.Fatalf("expected feature/child anchor to update to %q, got %q", advancedFeatureBaseHead, got)
983+
}
984+
if got := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "feature/child")); got == originalFeatureChildHead {
985+
t.Fatalf("expected feature/child head to change after restack")
986+
}
987+
988+
mergeBase := strings.TrimSpace(testutil.Run(t, repo, "git", "merge-base", "feature/base", "feature/child"))
989+
if mergeBase != advancedFeatureBaseHead {
990+
t.Fatalf("expected feature/child merge-base %q after restack, got %q", advancedFeatureBaseHead, mergeBase)
991+
}
992+
}
993+
921994
func TestVersionCommandPrintsBuildInfo(t *testing.T) {
922995
repo := testutil.SetupGitRepo(t)
923996
runtime := newTestRuntime(repo)

internal/git/client.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,36 @@ func (c *Client) IsAncestor(ctx context.Context, ancestor string, descendant str
114114
return false, err
115115
}
116116

117+
func (c *Client) MergeBase(ctx context.Context, left string, right string) (string, bool, error) {
118+
cmd := exec.CommandContext(ctx, "git", "merge-base", left, right)
119+
cmd.Dir = c.cwd
120+
121+
var stdout bytes.Buffer
122+
var stderr bytes.Buffer
123+
cmd.Stdout = &stdout
124+
cmd.Stderr = &stderr
125+
126+
err := cmd.Run()
127+
if err == nil {
128+
return strings.TrimSpace(stdout.String()), true, nil
129+
}
130+
131+
var exitErr *exec.ExitError
132+
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
133+
return "", false, nil
134+
}
135+
136+
message := strings.TrimSpace(stderr.String())
137+
if message == "" {
138+
message = strings.TrimSpace(stdout.String())
139+
}
140+
if message == "" {
141+
message = err.Error()
142+
}
143+
144+
return "", false, fmt.Errorf("git merge-base %s %s: %s", left, right, message)
145+
}
146+
117147
func (c *Client) SwitchCreate(ctx context.Context, branch string) error {
118148
_, err := c.run(ctx, "switch", "-c", branch)
119149
return err

0 commit comments

Comments
 (0)