Skip to content

Commit 0f62bdf

Browse files
authored
feat: auto-detect parent branches for untracked local branches (#53)
* feat(git): add ListLocalBranches method Lists all local branch names via `git for-each-ref`. Needed for auto-detection of untracked branches. * feat(git): add RevListCount method Counts commits between two refs using `git rev-list --count`. Used by parent auto-detection to rank candidate parents by distance. * feat(detect): add parent branch auto-detection package Introduces `DetectParentLocal` (merge-base only) and `DetectParent` (PR + merge-base) for inferring parent branches of untracked local branches. Returns confidence levels: High (PR match), Medium (unique merge-base winner), Ambiguous (tie/no signal). Also includes `FindUntrackedCandidates` for discovering local branches not yet in a stack. * feat(tree): add Detected and Confidence fields to Node Detected nodes are displayed with an annotation like '(detected)' in tree output. This supports read-only auto-detection preview in the log command. * feat(log): auto-detect untracked branches in tree display The log command now shows untracked branches that can be inferred via merge-base analysis, annotated with '(detected)'. This is strictly read-only — no git config is modified. Use --no-detect to disable. * feat(adopt): support auto-detection when parent is omitted Running `gh stack adopt` without a parent argument now auto-detects the parent via PR base branch and merge-base analysis. Prompts when ambiguous in interactive mode; errors in non-interactive. * feat(sync): auto-detect and adopt untracked branches The sync command now detects untracked branches via PR base refs and merge-base analysis, adopting them before restacking. Use `--no-detect` to skip. A shared `autoDetectAndAdopt` helper in `cmd/detect_helpers.go` handles the full workflow: finding candidates, detecting parents (with PR + merge-base fallback), prompting for ambiguous cases, checking for cycles, and committing the adoption. * feat(restack): auto-detect untracked branches before restacking The restack/cascade command now detects and adopts untracked branches before processing. Use `--no-detect` to skip. * fix: address PR review comments for robustness and correctness - Add deterministic tie-breaker (by name) to `rankCandidates` sort and sort tied candidates for stable `Result.Candidates` ordering - Return captured error when all merge-base comparisons fail instead of silently returning `Ambiguous` with no error - Handle errors in test helpers (`addCommit`, `setupTestRepo`, `setupInternalTestRepo`) — fail fast on `WriteFile`/`git` failures - Assert `CreateBranch` return values in `TestListLocalBranches` - Check `WriteFile`/`git add`/`commit` errors in `TestRevListCount` - Remove `TestAdoptAutoDetect_PrintsConfidence` (trivially-true assertion that tested `style.Muted()` not actual adopt behavior) - Re-sort parent children after injecting detected nodes in `injectDetectedNodes` to maintain alphabetical ordering - Loop `autoDetectAndAdopt` until no progress to handle untracked chains where a branch depends on another untracked branch * fix: address second round of PR review comments - Check all `CreateAndCheckout`/`CreateBranch`/`Checkout`/`CurrentBranch` errors in test helpers and test functions (detect_test.go, log_internal_test.go, adopt_test.go) using `must*` helpers to avoid variable shadowing lint errors - Check `config.Load`, `SetTrunk`, and `SetParent` errors in tests - Replace tree-based cycle check in `autoDetectAndAdopt` with a config-based parent chain walk (`wouldCycle`) that catches cycles the tree model misses (e.g., nodes omitted due to broken parent links) * fix: address third round of PR review comments - Skip auto-detection in `--porcelain` mode since porcelain format has no column to distinguish detected branches from tracked ones - Remove unused `--all` flag from `log` command - Update README: document auto-detect behavior for `adopt`, document `--no-detect` flag for `log`, remove stale `--all` flag docs - Replace tree-based cycle check in `adopt.go` with config-based `wouldCycle()` (same fix as detect_helpers.go from round 2) * fix: sort candidates by trunk distance and document --no-detect flags - Sort `autoDetectAndAdopt` candidates by merge-base distance from trunk (ascending) so parents are processed before children in untracked chains, preventing mis-adoption when a child branch alphabetically sorts before its untracked parent - Document `--no-detect` flag in README for `restack` and `sync` commands (was already implemented but undocumented) * fix: default detected annotation and check CreateAndCheckout error - Add default case in `formatNode` so detected nodes with any confidence level (including zero value) always show "(detected)" - Check `CreateAndCheckout` error in `TestRevListCount` * fix: filter out descendant branches in rankCandidates When `branch` is an ancestor of a candidate, the merge-base equals `branch`'s tip, giving distance 0 and incorrectly selecting the descendant as the parent. Now resolves `branch` tip up front and skips candidates where merge-base == branch tip AND the candidate has commits ahead (i.e., is a true descendant, not just at the same commit).
1 parent 6e7329f commit 0f62bdf

15 files changed

Lines changed: 1292 additions & 36 deletions

README.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,16 @@ By default, `init` auto-detects the trunk branch (`main` or `master`). If neithe
168168

169169
Display the branch tree showing the stack hierarchy, current branch, and associated PR numbers.
170170

171+
By default, `log` also shows untracked local branches in the tree if it can detect their likely parent via merge-base analysis. These "detected" branches are annotated but not persisted. Use `--no-detect` to disable this.
172+
173+
Detection is automatically skipped in `--porcelain` mode since the porcelain format has no column to distinguish detected branches from tracked ones.
174+
171175
#### log Flags
172176

173-
| Flag | Description |
174-
| ------------- | ------------------------------------- |
175-
| `--all` | Show all branches |
176-
| `--porcelain` | Machine-readable tab-separated output |
177+
| Flag | Description |
178+
| --------------- | ---------------------------------------------- |
179+
| `--porcelain` | Machine-readable tab-separated output |
180+
| `--no-detect` | Skip auto-detection of untracked branches |
177181

178182
#### Porcelain Format
179183

@@ -208,10 +212,12 @@ Start tracking an existing branch by setting its parent.
208212

209213
By default, adopts the current branch. The parent must be either the trunk or another tracked branch.
210214

215+
When no parent is specified, `adopt` auto-detects the parent using PR base branch data (if available) and local merge-base analysis. If the result is ambiguous in interactive mode, you'll be prompted to choose; in non-interactive mode an error is returned.
216+
211217
#### adopt Usage
212218

213219
```bash
214-
gh stack adopt <parent>
220+
gh stack adopt [parent]
215221
```
216222

217223
#### adopt Flags
@@ -296,11 +302,12 @@ If a rebase conflict occurs, resolve it and run `gh stack continue`.
296302

297303
#### restack Flags
298304

299-
| Flag | Description |
300-
| ------------- | -------------------------------------------------------- |
301-
| `--only` | Only restack current branch, not descendants |
302-
| `--dry-run` | Show what would be done |
303-
| `--worktrees` | Rebase branches checked out in linked worktrees in-place |
305+
| Flag | Description |
306+
| --------------- | -------------------------------------------------------- |
307+
| `--only` | Only restack current branch, not descendants |
308+
| `--dry-run` | Show what would be done |
309+
| `--worktrees` | Rebase branches checked out in linked worktrees in-place |
310+
| `--no-detect` | Skip auto-detection and adoption of untracked branches |
304311

305312
### continue
306313

@@ -327,6 +334,7 @@ This is the command to run when upstream changes have occurred (e.g., a PR in yo
327334
| `--no-restack` | Skip restacking branches |
328335
| `--dry-run` | Show what would be done |
329336
| `--worktrees` | Rebase branches checked out in linked worktrees in-place |
337+
| `--no-detect` | Skip auto-detection and adoption of untracked branches |
330338

331339
### undo
332340

cmd/adopt.go

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,25 @@ import (
77
"os"
88

99
"github.com/boneskull/gh-stack/internal/config"
10+
"github.com/boneskull/gh-stack/internal/detect"
1011
"github.com/boneskull/gh-stack/internal/git"
12+
"github.com/boneskull/gh-stack/internal/github"
13+
"github.com/boneskull/gh-stack/internal/prompt"
1114
"github.com/boneskull/gh-stack/internal/style"
12-
"github.com/boneskull/gh-stack/internal/tree"
1315
"github.com/spf13/cobra"
1416
)
1517

1618
var adoptCmd = &cobra.Command{
17-
Use: "adopt <parent>",
19+
Use: "adopt [parent]",
1820
Short: "Start tracking an existing branch",
1921
Long: `Start tracking an existing branch by setting its parent.
2022
23+
When no parent is specified, the parent is auto-detected using PR base branch
24+
and merge-base analysis. If the result is ambiguous, you will be prompted to
25+
choose (interactive) or an error is returned (non-interactive).
26+
2127
By default, adopts the current branch. Use --branch to specify a different branch.`,
22-
Args: cobra.ExactArgs(1),
28+
Args: cobra.MaximumNArgs(1),
2329
RunE: runAdopt,
2430
}
2531

@@ -42,9 +48,7 @@ func runAdopt(cmd *cobra.Command, args []string) error {
4248
}
4349

4450
g := git.New(cwd)
45-
46-
// Parent is the required positional argument
47-
parent := args[0]
51+
s := style.New()
4852

4953
// Determine branch to adopt (from flag or current branch)
5054
var branchName string
@@ -73,25 +77,62 @@ func runAdopt(cmd *cobra.Command, args []string) error {
7377
return err
7478
}
7579

80+
var parent string
81+
var detectedPRNumber int
82+
83+
if len(args) > 0 {
84+
// Explicit parent provided
85+
parent = args[0]
86+
} else {
87+
// Auto-detect parent
88+
tracked, listErr := cfg.ListTrackedBranches()
89+
if listErr != nil {
90+
return fmt.Errorf("list tracked branches: %w", listErr)
91+
}
92+
93+
// Try to get GitHub client (may fail if no auth -- that's ok)
94+
gh, _ := github.NewClient() //nolint:errcheck // nil client is fine for local-only detection
95+
96+
result, detectErr := detect.DetectParent(branchName, tracked, trunk, g, gh)
97+
if detectErr != nil {
98+
return fmt.Errorf("auto-detect parent: %w", detectErr)
99+
}
100+
101+
switch result.Confidence {
102+
case detect.High, detect.Medium:
103+
parent = result.Parent
104+
fmt.Printf("%s Detected parent %s %s\n",
105+
s.SuccessIcon(), s.Branch(parent), s.Muted("("+result.Confidence.String()+" confidence)"))
106+
case detect.Ambiguous:
107+
if len(result.Candidates) == 0 {
108+
return fmt.Errorf("could not detect parent for %s; specify one explicitly", s.Branch(branchName))
109+
}
110+
if !prompt.IsInteractive() {
111+
return fmt.Errorf("ambiguous parent for %s (candidates: %v); specify one explicitly",
112+
s.Branch(branchName), result.Candidates)
113+
}
114+
idx, promptErr := prompt.Select(
115+
fmt.Sprintf("Multiple parent candidates for %s:", branchName),
116+
result.Candidates, 0)
117+
if promptErr != nil {
118+
return fmt.Errorf("prompt: %w", promptErr)
119+
}
120+
parent = result.Candidates[idx]
121+
}
122+
123+
detectedPRNumber = result.PRNumber
124+
}
125+
76126
if parent != trunk {
77127
if _, parentErr := cfg.GetParent(parent); parentErr != nil {
78128
return fmt.Errorf("parent %q is not tracked", parent)
79129
}
80130
}
81131

82-
// Check for cycles (branch can't be ancestor of parent)
83-
root, err := tree.Build(cfg)
84-
if err != nil {
85-
return err
86-
}
87-
88-
parentNode := tree.FindNode(root, parent)
89-
if parentNode != nil {
90-
for _, ancestor := range tree.GetAncestors(parentNode) {
91-
if ancestor.Name == branchName {
92-
return errors.New("cannot adopt: would create a cycle")
93-
}
94-
}
132+
// Check for cycles via config parent chain walk (catches cases the tree
133+
// model misses when nodes with broken parent links are omitted).
134+
if wouldCycle(cfg, branchName, parent) {
135+
return errors.New("cannot adopt: would create a cycle")
95136
}
96137

97138
// Set parent
@@ -105,7 +146,11 @@ func runAdopt(cmd *cobra.Command, args []string) error {
105146
_ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort
106147
}
107148

108-
s := style.New()
149+
// Store PR number if detected
150+
if detectedPRNumber > 0 {
151+
_ = cfg.SetPR(branchName, detectedPRNumber) //nolint:errcheck // best effort
152+
}
153+
109154
fmt.Printf("%s Adopted branch %s with parent %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(parent))
110155
return nil
111156
}

cmd/adopt_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,33 @@
22
package cmd_test
33

44
import (
5+
"os"
6+
"os/exec"
7+
"path/filepath"
58
"testing"
69

710
"github.com/boneskull/gh-stack/internal/config"
11+
"github.com/boneskull/gh-stack/internal/detect"
812
"github.com/boneskull/gh-stack/internal/git"
913
"github.com/boneskull/gh-stack/internal/tree"
1014
)
1115

16+
// addCommit creates a file with the given content and commits it.
17+
func addCommit(t *testing.T, dir, filename, content string) {
18+
t.Helper()
19+
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
20+
t.Fatalf("write %s: %v", filename, err)
21+
}
22+
cmd := exec.Command("git", "-C", dir, "add", ".")
23+
if err := cmd.Run(); err != nil {
24+
t.Fatalf("git add: %v", err)
25+
}
26+
cmd = exec.Command("git", "-C", dir, "commit", "-m", "add "+filename)
27+
if err := cmd.Run(); err != nil {
28+
t.Fatalf("git commit: %v", err)
29+
}
30+
}
31+
1232
func TestAdoptBranch(t *testing.T) {
1333
dir := setupTestRepo(t)
1434

@@ -148,3 +168,114 @@ func TestAdoptStoresForkPoint(t *testing.T) {
148168
t.Errorf("fork point = %s, want %s", storedFP, trunkTip)
149169
}
150170
}
171+
172+
// TestAdoptAutoDetect exercises the full detection-to-adoption pipeline:
173+
// detect parent, validate, set parent, store fork point.
174+
func TestAdoptAutoDetect(t *testing.T) {
175+
dir := setupTestRepo(t)
176+
g := git.New(dir)
177+
178+
cfg, err := config.Load(dir)
179+
if err != nil {
180+
t.Fatalf("Load config: %v", err)
181+
}
182+
183+
trunk, err := g.CurrentBranch()
184+
if err != nil {
185+
t.Fatalf("CurrentBranch: %v", err)
186+
}
187+
188+
err = cfg.SetTrunk(trunk)
189+
if err != nil {
190+
t.Fatalf("SetTrunk: %v", err)
191+
}
192+
193+
// Create tracked branch A
194+
err = g.CreateAndCheckout("feature-a")
195+
if err != nil {
196+
t.Fatalf("CreateAndCheckout feature-a: %v", err)
197+
}
198+
addCommit(t, dir, "a.txt", "a")
199+
200+
err = cfg.SetParent("feature-a", trunk)
201+
if err != nil {
202+
t.Fatalf("SetParent feature-a: %v", err)
203+
}
204+
205+
// Create untracked branch B off A
206+
err = g.CreateAndCheckout("feature-b")
207+
if err != nil {
208+
t.Fatalf("CreateAndCheckout feature-b: %v", err)
209+
}
210+
addCommit(t, dir, "b.txt", "b")
211+
212+
// feature-b should not be tracked yet
213+
_, getErr := cfg.GetParent("feature-b")
214+
if getErr == nil {
215+
t.Fatal("feature-b should not be tracked yet")
216+
}
217+
218+
// Simulate what runAdopt does when no parent arg is given:
219+
// 1. Detect parent
220+
tracked, _ := cfg.ListTrackedBranches()
221+
result, detectErr := detect.DetectParent("feature-b", tracked, trunk, g, nil)
222+
if detectErr != nil {
223+
t.Fatalf("detection failed: %v", detectErr)
224+
}
225+
if result.Confidence == detect.Ambiguous {
226+
t.Fatal("expected non-ambiguous detection")
227+
}
228+
if result.Parent != "feature-a" {
229+
t.Errorf("expected detected parent 'feature-a', got %q", result.Parent)
230+
}
231+
232+
// 2. Validate parent is tracked (same check as runAdopt)
233+
if result.Parent != trunk {
234+
if _, parentErr := cfg.GetParent(result.Parent); parentErr != nil {
235+
t.Fatalf("detected parent %q is not tracked: %v", result.Parent, parentErr)
236+
}
237+
}
238+
239+
// 3. Set parent (same as runAdopt)
240+
err = cfg.SetParent("feature-b", result.Parent)
241+
if err != nil {
242+
t.Fatalf("SetParent failed: %v", err)
243+
}
244+
245+
// 4. Store fork point (same as runAdopt)
246+
forkPoint, fpErr := g.GetMergeBase("feature-b", result.Parent)
247+
if fpErr != nil {
248+
t.Fatalf("GetMergeBase failed: %v", fpErr)
249+
}
250+
_ = cfg.SetForkPoint("feature-b", forkPoint)
251+
252+
// Verify the full adoption persisted correctly
253+
parent, err := cfg.GetParent("feature-b")
254+
if err != nil {
255+
t.Fatalf("feature-b should be tracked now: %v", err)
256+
}
257+
if parent != "feature-a" {
258+
t.Errorf("expected parent 'feature-a', got %q", parent)
259+
}
260+
261+
storedFP, fpGetErr := cfg.GetForkPoint("feature-b")
262+
if fpGetErr != nil {
263+
t.Fatalf("GetForkPoint failed: %v", fpGetErr)
264+
}
265+
if storedFP != forkPoint {
266+
t.Errorf("fork point mismatch: stored=%s, expected=%s", storedFP, forkPoint)
267+
}
268+
269+
// Verify tree now includes feature-b
270+
root, buildErr := tree.Build(cfg)
271+
if buildErr != nil {
272+
t.Fatalf("Build failed: %v", buildErr)
273+
}
274+
nodeB := tree.FindNode(root, "feature-b")
275+
if nodeB == nil {
276+
t.Fatal("feature-b should appear in tree after adoption")
277+
}
278+
if nodeB.Parent.Name != "feature-a" {
279+
t.Errorf("expected parent node 'feature-a', got %q", nodeB.Parent.Name)
280+
}
281+
}

cmd/cascade.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/boneskull/gh-stack/internal/config"
1010
"github.com/boneskull/gh-stack/internal/git"
11+
"github.com/boneskull/gh-stack/internal/github"
1112
"github.com/boneskull/gh-stack/internal/state"
1213
"github.com/boneskull/gh-stack/internal/style"
1314
"github.com/boneskull/gh-stack/internal/tree"
@@ -30,12 +31,14 @@ var (
3031
cascadeOnlyFlag bool
3132
cascadeDryRunFlag bool
3233
cascadeWorktreesFlag bool
34+
cascadeNoDetectFlag bool
3335
)
3436

3537
func init() {
3638
cascadeCmd.Flags().BoolVar(&cascadeOnlyFlag, "only", false, "only restack current branch, not descendants")
3739
cascadeCmd.Flags().BoolVar(&cascadeDryRunFlag, "dry-run", false, "show what would be done")
3840
cascadeCmd.Flags().BoolVar(&cascadeWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place")
41+
cascadeCmd.Flags().BoolVar(&cascadeNoDetectFlag, "no-detect", false, "skip auto-detection of untracked branches")
3942
rootCmd.AddCommand(cascadeCmd)
4043
}
4144

@@ -64,6 +67,14 @@ func runCascade(cmd *cobra.Command, args []string) error {
6467
return err
6568
}
6669

70+
// Auto-detect and adopt untracked branches
71+
if !cascadeNoDetectFlag {
72+
gh, _ := github.NewClient() //nolint:errcheck // nil is fine, skip PR detection
73+
if adoptErr := autoDetectAndAdopt(cfg, g, gh, s); adoptErr != nil {
74+
fmt.Printf("%s auto-detection: %v\n", s.WarningIcon(), adoptErr)
75+
}
76+
}
77+
6778
// Build tree
6879
root, err := tree.Build(cfg)
6980
if err != nil {

0 commit comments

Comments
 (0)