From b15fc3c9ed7d736114d6a8ab4ba905fcb36ccd91 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 20 Feb 2026 16:54:19 -0800 Subject: [PATCH 01/14] feat(git): add ListLocalBranches method Lists all local branch names via `git for-each-ref`. Needed for auto-detection of untracked branches. --- internal/git/git.go | 16 ++++++++++++++++ internal/git/git_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/internal/git/git.go b/internal/git/git.go index c71232e..1bd7fa5 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -243,6 +243,22 @@ func (g *Git) ListRemoteBranches() (map[string]bool, error) { return branches, nil } +// ListLocalBranches returns the names of all local branches. +func (g *Git) ListLocalBranches() ([]string, error) { + out, err := g.run("for-each-ref", "--format=%(refname:short)", "refs/heads/") + if err != nil { + return nil, err + } + var branches []string + for line := range strings.SplitSeq(out, "\n") { + line = strings.TrimSpace(line) + if line != "" { + branches = append(branches, line) + } + } + return branches, nil +} + // RebaseOnto rebases a branch onto a new base, replaying only commits after oldBase. // Checks out the branch first, then runs: git rebase --onto // Useful when a parent branch was squash-merged and we need to replay only diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 5fc45a6..35ccabd 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -709,6 +709,39 @@ func TestIsRebaseInProgressWorktree(t *testing.T) { } } +func TestListLocalBranches(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + trunk, _ := g.CurrentBranch() + + // Create some branches + g.CreateBranch("feature-a") + g.CreateBranch("feature-b") + g.CreateBranch("feature-c") + + branches, err := g.ListLocalBranches() + if err != nil { + t.Fatalf("ListLocalBranches failed: %v", err) + } + + // Should contain trunk + 3 feature branches + if len(branches) != 4 { + t.Errorf("expected 4 branches, got %d: %v", len(branches), branches) + } + + // Check all expected branches are present + branchSet := make(map[string]bool) + for _, b := range branches { + branchSet[b] = true + } + for _, expected := range []string{trunk, "feature-a", "feature-b", "feature-c"} { + if !branchSet[expected] { + t.Errorf("expected branch %q in list, got %v", expected, branches) + } + } +} + func TestRemoteBranchExists(t *testing.T) { // Create main repo dir := setupTestRepo(t) From 619930bd05a3283d13619d3429826fa15e60d161 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 20 Feb 2026 16:58:39 -0800 Subject: [PATCH 02/14] 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. --- internal/git/git.go | 14 +++++++++++++ internal/git/git_test.go | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/internal/git/git.go b/internal/git/git.go index 1bd7fa5..3340300 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -149,6 +149,20 @@ func (g *Git) GetMergeBase(a, b string) (string, error) { return g.run("merge-base", a, b) } +// RevListCount returns the number of commits reachable from 'to' but not from 'from'. +// Equivalent to `git rev-list --count from..to`. +func (g *Git) RevListCount(from, to string) (int, error) { + out, err := g.run("rev-list", "--count", from+".."+to) + if err != nil { + return 0, err + } + var count int + if _, scanErr := fmt.Sscanf(out, "%d", &count); scanErr != nil { + return 0, fmt.Errorf("parse rev-list count %q: %w", out, scanErr) + } + return count, nil +} + // GetTip returns the commit SHA at the tip of a branch. func (g *Git) GetTip(branch string) (string, error) { return g.run("rev-parse", branch) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 35ccabd..4d061c5 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -2,6 +2,7 @@ package git_test import ( + "fmt" "os" "os/exec" "path/filepath" @@ -781,3 +782,46 @@ func TestRemoteBranchExists(t *testing.T) { t.Error("RemoteBranchExists(nonexistent) = true, want false") } } + +func TestRevListCount(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + + trunk, _ := g.CurrentBranch() + + // Create feature branch with 3 commits + g.CreateAndCheckout("feature") + for i := range 3 { + fname := filepath.Join(dir, fmt.Sprintf("file%d.txt", i)) + os.WriteFile(fname, fmt.Appendf(nil, "content%d", i), 0644) + exec.Command("git", "-C", dir, "add", ".").Run() + exec.Command("git", "-C", dir, "commit", "-m", fmt.Sprintf("commit %d", i)).Run() + } + + // Count commits from trunk to feature (should be 3) + count, err := g.RevListCount(trunk, "feature") + if err != nil { + t.Fatalf("RevListCount failed: %v", err) + } + if count != 3 { + t.Errorf("expected 3 commits, got %d", count) + } + + // Count from feature to trunk (should be 0 since trunk is behind) + count, err = g.RevListCount("feature", trunk) + if err != nil { + t.Fatalf("RevListCount failed: %v", err) + } + if count != 0 { + t.Errorf("expected 0 commits, got %d", count) + } + + // Count from same ref (should be 0) + count, err = g.RevListCount(trunk, trunk) + if err != nil { + t.Fatalf("RevListCount failed: %v", err) + } + if count != 0 { + t.Errorf("expected 0 commits, got %d", count) + } +} From eb12eab9af48e87119cb646125928c24088f3dbd Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 20 Feb 2026 17:07:55 -0800 Subject: [PATCH 03/14] 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. --- internal/detect/detect.go | 170 +++++++++++++++++++++++ internal/detect/detect_test.go | 246 +++++++++++++++++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 internal/detect/detect.go create mode 100644 internal/detect/detect_test.go diff --git a/internal/detect/detect.go b/internal/detect/detect.go new file mode 100644 index 0000000..ae18c94 --- /dev/null +++ b/internal/detect/detect.go @@ -0,0 +1,170 @@ +// Package detect provides automatic parent branch detection for untracked branches. +package detect + +import ( + "cmp" + "fmt" + "slices" + + "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/github" +) + +// Confidence represents how certain the detection is. +type Confidence int + +const ( + // Ambiguous means multiple candidates tied or no signal was found. + Ambiguous Confidence = iota + // Medium means a unique merge-base winner was found. + Medium + // High means a PR base branch matched a tracked branch or trunk. + High +) + +// String returns a human-readable confidence label. +func (c Confidence) String() string { + switch c { + case High: + return "high" + case Medium: + return "medium" + case Ambiguous: + return "ambiguous" + default: + return "unknown" + } +} + +// Result holds the outcome of a parent detection attempt. +type Result struct { + Parent string + Confidence Confidence + PRNumber int // non-zero if detected via PR + Candidates []string // populated when Ambiguous, for prompting +} + +// candidate holds a scored potential parent. +type candidate struct { + name string + distance int +} + +// DetectParentLocal detects the parent branch using only local git data (no network). +// It computes merge-base distance between the untracked branch and each candidate +// (trunk + tracked branches), returning the closest unique match. +func DetectParentLocal(branch string, tracked []string, trunk string, g *git.Git) (*Result, error) { + return rankCandidates(branch, tracked, trunk, g) +} + +// DetectParent detects the parent branch using PR data first, falling back to +// local merge-base analysis. The GitHub client may be nil, in which case only +// local detection is used. +func DetectParent(branch string, tracked []string, trunk string, g *git.Git, gh *github.Client) (*Result, error) { + // Step 1: Try PR-based detection if GitHub client is available + if gh != nil { + pr, prErr := gh.FindPRByHead(branch) + if prErr == nil && pr != nil { + base := pr.Base.Ref + if isCandidate(base, tracked, trunk) { + return &Result{ + Parent: base, + Confidence: High, + PRNumber: pr.Number, + }, nil + } + // PR exists but base is not tracked -- fall through + } + // No PR or error -- fall through to merge-base + } + + // Step 2: Fall back to merge-base ranking + return rankCandidates(branch, tracked, trunk, g) +} + +// rankCandidates computes merge-base distance for each candidate and returns +// the closest unique match. +func rankCandidates(branch string, tracked []string, trunk string, g *git.Git) (*Result, error) { + // Build candidate set: trunk + all tracked branches + seen := make(map[string]bool) + var candidates []candidate + + allCandidates := append([]string{trunk}, tracked...) + for _, name := range allCandidates { + if seen[name] || name == branch { + continue + } + seen[name] = true + + mergeBase, err := g.GetMergeBase(branch, name) + if err != nil { + continue // skip candidates we can't compare + } + + distance, err := g.RevListCount(mergeBase, branch) + if err != nil { + continue + } + + candidates = append(candidates, candidate{name: name, distance: distance}) + } + + if len(candidates) == 0 { + return &Result{Confidence: Ambiguous}, nil + } + + // Sort by distance ascending (closest fork = most likely parent) + slices.SortFunc(candidates, func(a, b candidate) int { + return cmp.Compare(a.distance, b.distance) + }) + + best := candidates[0] + + // Check for tie with second-best + if len(candidates) > 1 && candidates[1].distance == best.distance { + // Ambiguous -- collect all tied candidates for prompting + var tied []string + for _, c := range candidates { + if c.distance == best.distance { + tied = append(tied, c.name) + } + } + return &Result{ + Confidence: Ambiguous, + Candidates: tied, + }, nil + } + + return &Result{ + Parent: best.name, + Confidence: Medium, + }, nil +} + +// isCandidate returns true if the given branch name is trunk or in the tracked set. +func isCandidate(name string, tracked []string, trunk string) bool { + return name == trunk || slices.Contains(tracked, name) +} + +// FindUntrackedCandidates returns local branches that are not tracked and not trunk. +// These are candidates for auto-detection. +func FindUntrackedCandidates(g *git.Git, tracked []string, trunk string) ([]string, error) { + all, err := g.ListLocalBranches() + if err != nil { + return nil, fmt.Errorf("list local branches: %w", err) + } + + trackedSet := make(map[string]bool) + trackedSet[trunk] = true + for _, b := range tracked { + trackedSet[b] = true + } + + var candidates []string + for _, b := range all { + if !trackedSet[b] { + candidates = append(candidates, b) + } + } + return candidates, nil +} diff --git a/internal/detect/detect_test.go b/internal/detect/detect_test.go new file mode 100644 index 0000000..20c16d4 --- /dev/null +++ b/internal/detect/detect_test.go @@ -0,0 +1,246 @@ +package detect_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/boneskull/gh-stack/internal/detect" + "github.com/boneskull/gh-stack/internal/git" +) + +func setupTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("git %v failed: %v", args, err) + } + } + + run("init") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + f := filepath.Join(dir, "README.md") + os.WriteFile(f, []byte("# Test"), 0644) + run("add", ".") + run("commit", "-m", "initial") + + return dir +} + +func addCommit(t *testing.T, dir, filename, content string) { + t.Helper() + os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) + exec.Command("git", "-C", dir, "add", ".").Run() + exec.Command("git", "-C", dir, "commit", "-m", "add "+filename).Run() +} + +// Linear stack: main -> A -> B -> C (untracked) +// C should detect B as parent with Medium confidence +func TestDetectParentLocal_LinearStack(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk, _ := g.CurrentBranch() + + g.CreateAndCheckout("feature-a") + addCommit(t, dir, "a.txt", "a") + + g.CreateAndCheckout("feature-b") + addCommit(t, dir, "b.txt", "b") + + g.CreateAndCheckout("feature-c") + addCommit(t, dir, "c.txt", "c") + + tracked := []string{"feature-a", "feature-b"} + result, err := detect.DetectParentLocal("feature-c", tracked, trunk, g) + if err != nil { + t.Fatalf("DetectParentLocal failed: %v", err) + } + if result.Parent != "feature-b" { + t.Errorf("expected parent 'feature-b', got %q", result.Parent) + } + if result.Confidence != detect.Medium { + t.Errorf("expected Medium confidence, got %v", result.Confidence) + } +} + +// Two branches off main at the same commit: ambiguous +func TestDetectParentLocal_Ambiguous(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk, _ := g.CurrentBranch() + + g.CreateBranch("feature-a") + g.CreateBranch("feature-b") + + g.CreateAndCheckout("feature-c") + addCommit(t, dir, "c.txt", "c") + + tracked := []string{"feature-a", "feature-b"} + result, err := detect.DetectParentLocal("feature-c", tracked, trunk, g) + if err != nil { + t.Fatalf("DetectParentLocal failed: %v", err) + } + if result.Confidence != detect.Ambiguous { + t.Errorf("expected Ambiguous confidence, got %v", result.Confidence) + } + if len(result.Candidates) < 2 { + t.Errorf("expected at least 2 tied candidates, got %v", result.Candidates) + } +} + +// Branch forked directly from trunk; no tracked branches are closer. +func TestDetectParentLocal_TrunkIsParent(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk, _ := g.CurrentBranch() + + g.CreateAndCheckout("feature-a") + addCommit(t, dir, "a.txt", "a") + + g.Checkout(trunk) + addCommit(t, dir, "main1.txt", "main1") + + g.CreateAndCheckout("feature-x") + addCommit(t, dir, "x.txt", "x") + + tracked := []string{"feature-a"} + result, err := detect.DetectParentLocal("feature-x", tracked, trunk, g) + if err != nil { + t.Fatalf("DetectParentLocal failed: %v", err) + } + if result.Parent != trunk { + t.Errorf("expected parent %q (trunk), got %q", trunk, result.Parent) + } + if result.Confidence != detect.Medium { + t.Errorf("expected Medium confidence, got %v", result.Confidence) + } +} + +// No tracked branches, only trunk as candidate +func TestDetectParentLocal_NoTrackedBranches(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk, _ := g.CurrentBranch() + + g.CreateAndCheckout("feature-x") + addCommit(t, dir, "x.txt", "x") + + result, err := detect.DetectParentLocal("feature-x", nil, trunk, g) + if err != nil { + t.Fatalf("DetectParentLocal failed: %v", err) + } + if result.Parent != trunk { + t.Errorf("expected parent %q (trunk), got %q", trunk, result.Parent) + } + if result.Confidence != detect.Medium { + t.Errorf("expected Medium confidence, got %v", result.Confidence) + } +} + +// DetectParent with nil GitHub client should produce the same result as DetectParentLocal +func TestDetectParent_NilGitHub(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk, _ := g.CurrentBranch() + + g.CreateAndCheckout("feature-a") + addCommit(t, dir, "a.txt", "a") + + g.CreateAndCheckout("feature-b") + addCommit(t, dir, "b.txt", "b") + + tracked := []string{"feature-a"} + result, err := detect.DetectParent("feature-b", tracked, trunk, g, nil) + if err != nil { + t.Fatalf("DetectParent failed: %v", err) + } + if result.Parent != "feature-a" { + t.Errorf("expected parent 'feature-a', got %q", result.Parent) + } + if result.Confidence != detect.Medium { + t.Errorf("expected Medium confidence, got %v", result.Confidence) + } + if result.PRNumber != 0 { + t.Errorf("expected no PR number, got %d", result.PRNumber) + } +} + +// FindUntrackedCandidates should return branches not in tracked set and not trunk +func TestFindUntrackedCandidates(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk, _ := g.CurrentBranch() + + g.CreateBranch("tracked-a") + g.CreateBranch("tracked-b") + g.CreateBranch("untracked-x") + g.CreateBranch("untracked-y") + + tracked := []string{"tracked-a", "tracked-b"} + candidates, err := detect.FindUntrackedCandidates(g, tracked, trunk) + if err != nil { + t.Fatalf("FindUntrackedCandidates failed: %v", err) + } + + candidateSet := make(map[string]bool) + for _, c := range candidates { + candidateSet[c] = true + } + + // Trunk and tracked branches should NOT be in candidates + for _, excluded := range []string{trunk, "tracked-a", "tracked-b"} { + if candidateSet[excluded] { + t.Errorf("expected %q to be excluded from candidates", excluded) + } + } + + // Untracked branches SHOULD be in candidates + for _, expected := range []string{"untracked-x", "untracked-y"} { + if !candidateSet[expected] { + t.Errorf("expected %q in candidates, got %v", expected, candidates) + } + } +} + +// FindUntrackedCandidates with empty tracked list +func TestFindUntrackedCandidates_EmptyTracked(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + trunk, _ := g.CurrentBranch() + + g.CreateBranch("some-branch") + + candidates, err := detect.FindUntrackedCandidates(g, nil, trunk) + if err != nil { + t.Fatalf("FindUntrackedCandidates failed: %v", err) + } + + if len(candidates) != 1 || candidates[0] != "some-branch" { + t.Errorf("expected [some-branch], got %v", candidates) + } +} + +func TestConfidence_String(t *testing.T) { + tests := []struct { + c detect.Confidence + want string + }{ + {detect.Ambiguous, "ambiguous"}, + {detect.Medium, "medium"}, + {detect.High, "high"}, + {detect.Confidence(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.c.String(); got != tt.want { + t.Errorf("Confidence(%d).String() = %q, want %q", tt.c, got, tt.want) + } + } +} From 304fe74c7f24224b860f54ca0179f28ff2c5cfee Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 20 Feb 2026 17:22:18 -0800 Subject: [PATCH 04/14] 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. --- cmd/log_test.go | 36 ++++++++++++++++++++++++++++++++++++ internal/tree/tree.go | 41 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/cmd/log_test.go b/cmd/log_test.go index b39ea73..c35d523 100644 --- a/cmd/log_test.go +++ b/cmd/log_test.go @@ -2,6 +2,7 @@ package cmd_test import ( + "strings" "testing" "github.com/boneskull/gh-stack/internal/config" @@ -108,3 +109,38 @@ func TestLogMultipleBranches(t *testing.T) { t.Errorf("expected 'feature-a-sub', got %q", featureA.Children[0].Name) } } + +func TestLogTreeWithDetectedNode(t *testing.T) { + dir := setupTestRepo(t) + cfg, _ := config.Load(dir) + cfg.SetTrunk("main") + cfg.SetParent("feature-a", "main") + + root, err := tree.Build(cfg) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + // Manually inject a detected node (simulating what log would do) + featureA := tree.FindNode(root, "feature-a") + if featureA == nil { + t.Fatal("feature-a not found") + } + + detected := &tree.Node{ + Name: "feature-b", + Parent: featureA, + Detected: true, + Confidence: tree.ConfidenceMedium, + } + featureA.Children = append(featureA.Children, detected) + + // Verify FormatTree includes the detected node with annotation + output := tree.FormatTree(root, tree.FormatOptions{}) + if !strings.Contains(output, "feature-b") { + t.Errorf("expected output to contain 'feature-b', got:\n%s", output) + } + if !strings.Contains(output, "detected") { + t.Errorf("expected output to contain 'detected' annotation, got:\n%s", output) + } +} diff --git a/internal/tree/tree.go b/internal/tree/tree.go index 9bc5600..a2eeca3 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -10,12 +10,28 @@ import ( "github.com/boneskull/gh-stack/internal/style" ) +// ConfidenceLevel represents how certain a detection is. +type ConfidenceLevel int + +const ( + // ConfidenceNone is the zero value for tracked (non-detected) nodes. + ConfidenceNone ConfidenceLevel = iota + // ConfidenceAmbiguous means multiple candidates tied. + ConfidenceAmbiguous + // ConfidenceMedium means a unique merge-base winner was found. + ConfidenceMedium + // ConfidenceHigh means a PR base branch matched. + ConfidenceHigh +) + // Node represents a branch in the stack tree. type Node struct { - Name string - PR int // 0 if no PR - Parent *Node - Children []*Node + Name string + PR int // 0 if no PR + Parent *Node + Children []*Node + Detected bool // true for auto-detected, not yet persisted + Confidence ConfidenceLevel // detection confidence (only meaningful if Detected) } // Build constructs the stack tree from config. @@ -161,11 +177,28 @@ func formatNode(sb *strings.Builder, node *Node, prefix string, isLast bool, opt } } + // Detected branch annotation + detectedInfo := "" + if node.Detected { + switch node.Confidence { + case ConfidenceMedium: + detectedInfo = " (detected)" + case ConfidenceAmbiguous: + detectedInfo = " (detected, ambiguous)" + case ConfidenceHigh: + detectedInfo = " (detected, via PR)" + } + if opts.Style != nil { + detectedInfo = opts.Style.Muted(detectedInfo) + } + } + sb.WriteString(prefix) sb.WriteString(connector) sb.WriteString(marker) sb.WriteString(branchDisplay) sb.WriteString(prInfo) + sb.WriteString(detectedInfo) sb.WriteString("\n") // Prepare prefix for children From d1c2ebb25e9e231b0996c579511276a3724bb293 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 20 Feb 2026 17:25:47 -0800 Subject: [PATCH 05/14] feat(log): auto-detect untracked branches in tree display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/log.go | 53 +++++++++++++++++++++++ cmd/log_internal_test.go | 92 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 cmd/log_internal_test.go diff --git a/cmd/log.go b/cmd/log.go index d53c163..517de6e 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/detect" "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/github" "github.com/boneskull/gh-stack/internal/style" @@ -26,11 +27,13 @@ var logCmd = &cobra.Command{ var ( logAllFlag bool logPorcelainFlag bool + logNoDetectFlag bool ) func init() { logCmd.Flags().BoolVar(&logAllFlag, "all", false, "show all branches") logCmd.Flags().BoolVar(&logPorcelainFlag, "porcelain", false, "machine-readable output") + logCmd.Flags().BoolVar(&logNoDetectFlag, "no-detect", false, "skip auto-detection of untracked branches") rootCmd.AddCommand(logCmd) } @@ -51,6 +54,12 @@ func runLog(cmd *cobra.Command, args []string) error { } g := git.New(cwd) + + // Auto-detect untracked branches (read-only — injects virtual nodes) + if !logNoDetectFlag { + injectDetectedNodes(root, cfg, g) + } + currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine for display // Try to get GitHub client for PR URLs (optional - may fail if not in a GitHub repo) @@ -74,6 +83,50 @@ func runLog(cmd *cobra.Command, args []string) error { return nil } +// injectDetectedNodes discovers untracked branches via merge-base analysis +// and injects them as virtual (Detected) nodes in the tree. This is strictly +// read-only — no git config is modified. +func injectDetectedNodes(root *tree.Node, cfg *config.Config, g *git.Git) { + trunk := root.Name + tracked, err := cfg.ListTrackedBranches() + if err != nil { + return // silent failure for read-only preview + } + + candidates, err := detect.FindUntrackedCandidates(g, tracked, trunk) + if err != nil { + return + } + + for _, branch := range candidates { + result, detectErr := detect.DetectParentLocal(branch, tracked, trunk, g) + if detectErr != nil || result.Confidence == detect.Ambiguous { + continue + } + + parentNode := tree.FindNode(root, result.Parent) + if parentNode == nil { + continue + } + + var cl tree.ConfidenceLevel + switch result.Confidence { + case detect.Medium: + cl = tree.ConfidenceMedium + case detect.High: + cl = tree.ConfidenceHigh + } + + node := &tree.Node{ + Name: branch, + Parent: parentNode, + Detected: true, + Confidence: cl, + } + parentNode.Children = append(parentNode.Children, node) + } +} + // printPorcelain outputs stack information in table format. // In TTY mode, outputs nicely formatted columns. // In non-TTY mode (piped/scripted), outputs tab-separated values. diff --git a/cmd/log_internal_test.go b/cmd/log_internal_test.go new file mode 100644 index 0000000..385908c --- /dev/null +++ b/cmd/log_internal_test.go @@ -0,0 +1,92 @@ +// cmd/log_internal_test.go +// +// This file uses package cmd (not cmd_test) to unit-test the unexported +// injectDetectedNodes function directly. +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/tree" +) + +// setupInternalTestRepo creates a temp git repo with an initial commit. +// It mirrors setupTestRepo from init_test.go but lives in the internal +// test package so we can call unexported functions. +func setupInternalTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Run() + } + + run("init") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + f := filepath.Join(dir, "README.md") + os.WriteFile(f, []byte("# Test"), 0644) + run("add", ".") + run("commit", "-m", "initial") + + return dir +} + +// addCommit creates a file and commits it in the given repo directory. +func addCommit(t *testing.T, dir, filename, content string) { + t.Helper() + os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) + cmd := exec.Command("git", "-C", dir, "add", ".") + cmd.Run() + cmd = exec.Command("git", "-C", dir, "commit", "-m", "add "+filename) + cmd.Run() +} + +func TestLogDetectsUntrackedBranches(t *testing.T) { + dir := setupInternalTestRepo(t) + g := git.New(dir) + cfg, _ := config.Load(dir) + trunk, _ := g.CurrentBranch() + cfg.SetTrunk(trunk) + + // Create tracked branch A off main + g.CreateAndCheckout("feature-a") + addCommit(t, dir, "a.txt", "a") + cfg.SetParent("feature-a", trunk) + + // Create untracked branch B off A + g.CreateAndCheckout("feature-b") + addCommit(t, dir, "b.txt", "b") + + // Build tree WITHOUT detection -- B should not appear + root, err := tree.Build(cfg) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + if tree.FindNode(root, "feature-b") != nil { + t.Error("feature-b should NOT be in tracked tree") + } + + // Now run detection and inject into tree + injectDetectedNodes(root, cfg, g) + + // Now B should appear as detected child of A + nodeB := tree.FindNode(root, "feature-b") + if nodeB == nil { + t.Fatal("feature-b should appear after detection") + } + if !nodeB.Detected { + t.Error("feature-b should be marked as Detected") + } + if nodeB.Parent.Name != "feature-a" { + t.Errorf("expected parent 'feature-a', got %q", nodeB.Parent.Name) + } +} From 46e76103456dc3e77af73a894e1aec0f5bdc9d7b Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 20 Feb 2026 17:35:57 -0800 Subject: [PATCH 06/14] 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. --- cmd/adopt.go | 67 ++++++++++++++++++++-- cmd/adopt_test.go | 119 +++++++++++++++++++++++++++++++++++++++ cmd/init_test.go | 1 + cmd/log_internal_test.go | 1 + 4 files changed, 182 insertions(+), 6 deletions(-) diff --git a/cmd/adopt.go b/cmd/adopt.go index da8c49d..aa10142 100644 --- a/cmd/adopt.go +++ b/cmd/adopt.go @@ -7,19 +7,26 @@ import ( "os" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/detect" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/github" + "github.com/boneskull/gh-stack/internal/prompt" "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" "github.com/spf13/cobra" ) var adoptCmd = &cobra.Command{ - Use: "adopt ", + Use: "adopt [parent]", Short: "Start tracking an existing branch", Long: `Start tracking an existing branch by setting its parent. +When no parent is specified, the parent is auto-detected using PR base branch +and merge-base analysis. If the result is ambiguous, you will be prompted to +choose (interactive) or an error is returned (non-interactive). + By default, adopts the current branch. Use --branch to specify a different branch.`, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: runAdopt, } @@ -42,9 +49,7 @@ func runAdopt(cmd *cobra.Command, args []string) error { } g := git.New(cwd) - - // Parent is the required positional argument - parent := args[0] + s := style.New() // Determine branch to adopt (from flag or current branch) var branchName string @@ -73,6 +78,52 @@ func runAdopt(cmd *cobra.Command, args []string) error { return err } + var parent string + var detectedPRNumber int + + if len(args) > 0 { + // Explicit parent provided + parent = args[0] + } else { + // Auto-detect parent + tracked, listErr := cfg.ListTrackedBranches() + if listErr != nil { + return fmt.Errorf("list tracked branches: %w", listErr) + } + + // Try to get GitHub client (may fail if no auth -- that's ok) + gh, _ := github.NewClient() //nolint:errcheck // nil client is fine for local-only detection + + result, detectErr := detect.DetectParent(branchName, tracked, trunk, g, gh) + if detectErr != nil { + return fmt.Errorf("auto-detect parent: %w", detectErr) + } + + switch result.Confidence { + case detect.High, detect.Medium: + parent = result.Parent + fmt.Printf("%s Detected parent %s %s\n", + s.SuccessIcon(), s.Branch(parent), s.Muted("("+result.Confidence.String()+" confidence)")) + case detect.Ambiguous: + if len(result.Candidates) == 0 { + return fmt.Errorf("could not detect parent for %s; specify one explicitly", s.Branch(branchName)) + } + if !prompt.IsInteractive() { + return fmt.Errorf("ambiguous parent for %s (candidates: %v); specify one explicitly", + s.Branch(branchName), result.Candidates) + } + idx, promptErr := prompt.Select( + fmt.Sprintf("Multiple parent candidates for %s:", branchName), + result.Candidates, 0) + if promptErr != nil { + return fmt.Errorf("prompt: %w", promptErr) + } + parent = result.Candidates[idx] + } + + detectedPRNumber = result.PRNumber + } + if parent != trunk { if _, parentErr := cfg.GetParent(parent); parentErr != nil { return fmt.Errorf("parent %q is not tracked", parent) @@ -105,7 +156,11 @@ func runAdopt(cmd *cobra.Command, args []string) error { _ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort } - s := style.New() + // Store PR number if detected + if detectedPRNumber > 0 { + _ = cfg.SetPR(branchName, detectedPRNumber) //nolint:errcheck // best effort + } + fmt.Printf("%s Adopted branch %s with parent %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(parent)) return nil } diff --git a/cmd/adopt_test.go b/cmd/adopt_test.go index 9f71a80..d2dd4ad 100644 --- a/cmd/adopt_test.go +++ b/cmd/adopt_test.go @@ -2,13 +2,34 @@ package cmd_test import ( + "os" + "os/exec" + "path/filepath" "testing" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/detect" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" ) +// addCommit creates a file with the given content and commits it. +func addCommit(t *testing.T, dir, filename, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatalf("write %s: %v", filename, err) + } + cmd := exec.Command("git", "-C", dir, "add", ".") + if err := cmd.Run(); err != nil { + t.Fatalf("git add: %v", err) + } + cmd = exec.Command("git", "-C", dir, "commit", "-m", "add "+filename) + if err := cmd.Run(); err != nil { + t.Fatalf("git commit: %v", err) + } +} + func TestAdoptBranch(t *testing.T) { dir := setupTestRepo(t) @@ -148,3 +169,101 @@ func TestAdoptStoresForkPoint(t *testing.T) { t.Errorf("fork point = %s, want %s", storedFP, trunkTip) } } + +// TestAdoptAutoDetect exercises the full detection-to-adoption pipeline: +// detect parent, validate, set parent, store fork point. +func TestAdoptAutoDetect(t *testing.T) { + dir := setupTestRepo(t) + g := git.New(dir) + cfg, _ := config.Load(dir) + trunk, _ := g.CurrentBranch() + cfg.SetTrunk(trunk) + + // Create tracked branch A + g.CreateAndCheckout("feature-a") + addCommit(t, dir, "a.txt", "a") + cfg.SetParent("feature-a", trunk) + + // Create untracked branch B off A + g.CreateAndCheckout("feature-b") + addCommit(t, dir, "b.txt", "b") + + // feature-b should not be tracked yet + if _, err := cfg.GetParent("feature-b"); err == nil { + t.Fatal("feature-b should not be tracked yet") + } + + // Simulate what runAdopt does when no parent arg is given: + // 1. Detect parent + tracked, _ := cfg.ListTrackedBranches() + result, detectErr := detect.DetectParent("feature-b", tracked, trunk, g, nil) + if detectErr != nil { + t.Fatalf("detection failed: %v", detectErr) + } + if result.Confidence == detect.Ambiguous { + t.Fatal("expected non-ambiguous detection") + } + if result.Parent != "feature-a" { + t.Errorf("expected detected parent 'feature-a', got %q", result.Parent) + } + + // 2. Validate parent is tracked (same check as runAdopt) + if result.Parent != trunk { + if _, parentErr := cfg.GetParent(result.Parent); parentErr != nil { + t.Fatalf("detected parent %q is not tracked: %v", result.Parent, parentErr) + } + } + + // 3. Set parent (same as runAdopt) + if err := cfg.SetParent("feature-b", result.Parent); err != nil { + t.Fatalf("SetParent failed: %v", err) + } + + // 4. Store fork point (same as runAdopt) + forkPoint, fpErr := g.GetMergeBase("feature-b", result.Parent) + if fpErr != nil { + t.Fatalf("GetMergeBase failed: %v", fpErr) + } + _ = cfg.SetForkPoint("feature-b", forkPoint) + + // Verify the full adoption persisted correctly + parent, err := cfg.GetParent("feature-b") + if err != nil { + t.Fatalf("feature-b should be tracked now: %v", err) + } + if parent != "feature-a" { + t.Errorf("expected parent 'feature-a', got %q", parent) + } + + storedFP, fpGetErr := cfg.GetForkPoint("feature-b") + if fpGetErr != nil { + t.Fatalf("GetForkPoint failed: %v", fpGetErr) + } + if storedFP != forkPoint { + t.Errorf("fork point mismatch: stored=%s, expected=%s", storedFP, forkPoint) + } + + // Verify tree now includes feature-b + root, buildErr := tree.Build(cfg) + if buildErr != nil { + t.Fatalf("Build failed: %v", buildErr) + } + nodeB := tree.FindNode(root, "feature-b") + if nodeB == nil { + t.Fatal("feature-b should appear in tree after adoption") + } + if nodeB.Parent.Name != "feature-a" { + t.Errorf("expected parent node 'feature-a', got %q", nodeB.Parent.Name) + } +} + +// TestAdoptAutoDetect_PrintsConfidence verifies that the detection message +// includes the confidence level. +func TestAdoptAutoDetect_PrintsConfidence(t *testing.T) { + // Verify the style.New().Muted() call matches what adopt.go uses + s := style.New() + msg := s.Muted("(medium confidence)") + if msg == "" { + t.Error("expected non-empty muted confidence string") + } +} diff --git a/cmd/init_test.go b/cmd/init_test.go index 6f2b2ff..a4bcf7c 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -23,6 +23,7 @@ func setupTestRepo(t *testing.T) string { run("init") run("config", "user.email", "test@test.com") run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") // Create main branch with initial commit f := filepath.Join(dir, "README.md") diff --git a/cmd/log_internal_test.go b/cmd/log_internal_test.go index 385908c..b938d5b 100644 --- a/cmd/log_internal_test.go +++ b/cmd/log_internal_test.go @@ -31,6 +31,7 @@ func setupInternalTestRepo(t *testing.T) string { run("init") run("config", "user.email", "test@test.com") run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") f := filepath.Join(dir, "README.md") os.WriteFile(f, []byte("# Test"), 0644) From 69d972b7f24eb5d41e542e4bedccd3f1886d1ac8 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 20 Feb 2026 17:47:16 -0800 Subject: [PATCH 07/14] 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. --- cmd/detect_helpers.go | 116 ++++++++++++++++++++++++++++++++++++++++++ cmd/sync.go | 9 ++++ 2 files changed, 125 insertions(+) create mode 100644 cmd/detect_helpers.go diff --git a/cmd/detect_helpers.go b/cmd/detect_helpers.go new file mode 100644 index 0000000..e98f921 --- /dev/null +++ b/cmd/detect_helpers.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "fmt" + "slices" + + "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/detect" + "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/github" + "github.com/boneskull/gh-stack/internal/prompt" + "github.com/boneskull/gh-stack/internal/style" + "github.com/boneskull/gh-stack/internal/tree" +) + +// autoDetectAndAdopt finds untracked branches and adopts them using full detection +// (PR + merge-base). Prompts for ambiguous cases in interactive mode. +func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *style.Style) error { + trunk, err := cfg.GetTrunk() + if err != nil { + return err + } + + tracked, err := cfg.ListTrackedBranches() + if err != nil { + return err + } + + candidates, err := detect.FindUntrackedCandidates(g, tracked, trunk) + if err != nil { + return err + } + + if len(candidates) == 0 { + return nil + } + + // Build tree for cycle checking + root, err := tree.Build(cfg) + if err != nil { + return err + } + + for _, branch := range candidates { + result, detectErr := detect.DetectParent(branch, tracked, trunk, g, gh) + if detectErr != nil { + fmt.Printf("%s could not detect parent for %s: %v\n", + s.WarningIcon(), s.Branch(branch), detectErr) + continue + } + + var parent string + switch result.Confidence { + case detect.High, detect.Medium: + parent = result.Parent + case detect.Ambiguous: + if prompt.IsInteractive() && len(result.Candidates) > 0 { + selected, selErr := prompt.Select( + fmt.Sprintf("Select parent for %s:", branch), + result.Candidates, 0) + if selErr != nil { + continue + } + parent = result.Candidates[selected] + } else { + fmt.Printf("%s could not determine parent for %s; run 'gh stack adopt --branch %s'\n", + s.WarningIcon(), s.Branch(branch), branch) + continue + } + } + + // Cycle check: if branch is already an ancestor of the detected parent, + // adopting it as a child would create a cycle. + parentNode := tree.FindNode(root, parent) + if parentNode != nil { + ancestors := tree.GetAncestors(parentNode) + if slices.ContainsFunc(ancestors, func(n *tree.Node) bool { + return n.Name == branch + }) { + fmt.Printf("%s skipping %s: would create a cycle\n", + s.WarningIcon(), s.Branch(branch)) + continue + } + } + + // Commit adoption + if setErr := cfg.SetParent(branch, parent); setErr != nil { + fmt.Printf("%s failed to adopt %s: %v\n", + s.WarningIcon(), s.Branch(branch), setErr) + continue + } + + // Store fork point + forkPoint, fpErr := g.GetMergeBase(branch, parent) + if fpErr == nil { + _ = cfg.SetForkPoint(branch, forkPoint) //nolint:errcheck // best effort + } + + // Store PR number if detected via PR + if result.PRNumber > 0 { + _ = cfg.SetPR(branch, result.PRNumber) //nolint:errcheck // best effort + } + + confidenceLabel := "" + if result.Confidence == detect.High { + confidenceLabel = " (via PR)" + } + fmt.Printf("%s Auto-adopted %s with parent %s%s\n", + s.SuccessIcon(), s.Branch(branch), s.Branch(parent), confidenceLabel) + + // Update tracked list for subsequent detections + tracked = append(tracked, branch) + } + + return nil +} diff --git a/cmd/sync.go b/cmd/sync.go index 85b2cc3..cf29cdb 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -28,12 +28,14 @@ var ( syncNoCascadeFlag bool syncDryRunFlag bool syncWorktreesFlag bool + syncNoDetectFlag bool ) func init() { syncCmd.Flags().BoolVar(&syncNoCascadeFlag, "no-restack", false, "skip restacking branches") syncCmd.Flags().BoolVar(&syncDryRunFlag, "dry-run", false, "show what would be done") syncCmd.Flags().BoolVar(&syncWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place") + syncCmd.Flags().BoolVar(&syncNoDetectFlag, "no-detect", false, "skip auto-detection of untracked branches") rootCmd.AddCommand(syncCmd) } @@ -160,6 +162,13 @@ func runSync(cmd *cobra.Command, args []string) error { _ = g.Checkout(currentBranch) //nolint:errcheck // best effort } + // Auto-detect and adopt untracked branches + if !syncNoDetectFlag { + if adoptErr := autoDetectAndAdopt(cfg, g, gh, s); adoptErr != nil { + fmt.Printf("%s auto-detection: %v\n", s.WarningIcon(), adoptErr) + } + } + // Check for merged PRs branches, err := cfg.ListTrackedBranches() if err != nil { From 4032eddd7fd9846bde41b12832e45be36f4aa41d Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 20 Feb 2026 17:47:52 -0800 Subject: [PATCH 08/14] 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. --- cmd/cascade.go | 11 +++++++++++ cmd/detect_helpers.go | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cmd/cascade.go b/cmd/cascade.go index e43a139..8957606 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -8,6 +8,7 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/github" "github.com/boneskull/gh-stack/internal/state" "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" @@ -30,12 +31,14 @@ var ( cascadeOnlyFlag bool cascadeDryRunFlag bool cascadeWorktreesFlag bool + cascadeNoDetectFlag bool ) func init() { cascadeCmd.Flags().BoolVar(&cascadeOnlyFlag, "only", false, "only restack current branch, not descendants") cascadeCmd.Flags().BoolVar(&cascadeDryRunFlag, "dry-run", false, "show what would be done") cascadeCmd.Flags().BoolVar(&cascadeWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place") + cascadeCmd.Flags().BoolVar(&cascadeNoDetectFlag, "no-detect", false, "skip auto-detection of untracked branches") rootCmd.AddCommand(cascadeCmd) } @@ -64,6 +67,14 @@ func runCascade(cmd *cobra.Command, args []string) error { return err } + // Auto-detect and adopt untracked branches + if !cascadeNoDetectFlag { + gh, _ := github.NewClient() //nolint:errcheck // nil is fine, skip PR detection + if adoptErr := autoDetectAndAdopt(cfg, g, gh, s); adoptErr != nil { + fmt.Printf("%s auto-detection: %v\n", s.WarningIcon(), adoptErr) + } + } + // Build tree root, err := tree.Build(cfg) if err != nil { diff --git a/cmd/detect_helpers.go b/cmd/detect_helpers.go index e98f921..e4faea4 100644 --- a/cmd/detect_helpers.go +++ b/cmd/detect_helpers.go @@ -108,8 +108,11 @@ func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *st fmt.Printf("%s Auto-adopted %s with parent %s%s\n", s.SuccessIcon(), s.Branch(branch), s.Branch(parent), confidenceLabel) - // Update tracked list for subsequent detections + // Update tracked list and rebuild tree for subsequent detections tracked = append(tracked, branch) + if newRoot, buildErr := tree.Build(cfg); buildErr == nil { + root = newRoot + } } return nil From 5f1b2e08aecd6c35fc7ca183e457d347adeab4bf Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 7 Apr 2026 15:19:44 -0700 Subject: [PATCH 09/14] fix: address PR review comments for robustness and correctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/adopt_test.go | 12 --- cmd/detect_helpers.go | 139 +++++++++++++++++++-------------- cmd/log.go | 9 +++ cmd/log_internal_test.go | 22 ++++-- internal/detect/detect.go | 21 ++++- internal/detect/detect_test.go | 16 +++- internal/git/git_test.go | 24 ++++-- 7 files changed, 154 insertions(+), 89 deletions(-) diff --git a/cmd/adopt_test.go b/cmd/adopt_test.go index d2dd4ad..f793add 100644 --- a/cmd/adopt_test.go +++ b/cmd/adopt_test.go @@ -10,7 +10,6 @@ import ( "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/detect" "github.com/boneskull/gh-stack/internal/git" - "github.com/boneskull/gh-stack/internal/style" "github.com/boneskull/gh-stack/internal/tree" ) @@ -256,14 +255,3 @@ func TestAdoptAutoDetect(t *testing.T) { t.Errorf("expected parent node 'feature-a', got %q", nodeB.Parent.Name) } } - -// TestAdoptAutoDetect_PrintsConfidence verifies that the detection message -// includes the confidence level. -func TestAdoptAutoDetect_PrintsConfidence(t *testing.T) { - // Verify the style.New().Muted() call matches what adopt.go uses - s := style.New() - msg := s.Muted("(medium confidence)") - if msg == "" { - t.Error("expected non-empty muted confidence string") - } -} diff --git a/cmd/detect_helpers.go b/cmd/detect_helpers.go index e4faea4..1184efe 100644 --- a/cmd/detect_helpers.go +++ b/cmd/detect_helpers.go @@ -41,77 +41,102 @@ func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *st return err } - for _, branch := range candidates { - result, detectErr := detect.DetectParent(branch, tracked, trunk, g, gh) - if detectErr != nil { - fmt.Printf("%s could not detect parent for %s: %v\n", - s.WarningIcon(), s.Branch(branch), detectErr) - continue - } + // Loop until no progress: untracked chains (e.g., C based on untracked B) + // may require multiple passes since a branch can only be detected once its + // parent has been adopted into the tracked set. + adopted := make(map[string]bool) + for { + progress := false + for _, branch := range candidates { + if adopted[branch] { + continue + } - var parent string - switch result.Confidence { - case detect.High, detect.Medium: - parent = result.Parent - case detect.Ambiguous: - if prompt.IsInteractive() && len(result.Candidates) > 0 { - selected, selErr := prompt.Select( - fmt.Sprintf("Select parent for %s:", branch), - result.Candidates, 0) - if selErr != nil { + result, detectErr := detect.DetectParent(branch, tracked, trunk, g, gh) + if detectErr != nil { + fmt.Printf("%s could not detect parent for %s: %v\n", + s.WarningIcon(), s.Branch(branch), detectErr) + continue + } + + var parent string + switch result.Confidence { + case detect.High, detect.Medium: + parent = result.Parent + case detect.Ambiguous: + if prompt.IsInteractive() && len(result.Candidates) > 0 { + selected, selErr := prompt.Select( + fmt.Sprintf("Select parent for %s:", branch), + result.Candidates, 0) + if selErr != nil { + continue + } + parent = result.Candidates[selected] + } else { + continue + } + } + + // Cycle check: if branch is already an ancestor of the detected parent, + // adopting it as a child would create a cycle. + parentNode := tree.FindNode(root, parent) + if parentNode != nil { + ancestors := tree.GetAncestors(parentNode) + if slices.ContainsFunc(ancestors, func(n *tree.Node) bool { + return n.Name == branch + }) { + fmt.Printf("%s skipping %s: would create a cycle\n", + s.WarningIcon(), s.Branch(branch)) + adopted[branch] = true continue } - parent = result.Candidates[selected] - } else { - fmt.Printf("%s could not determine parent for %s; run 'gh stack adopt --branch %s'\n", - s.WarningIcon(), s.Branch(branch), branch) - continue } - } - // Cycle check: if branch is already an ancestor of the detected parent, - // adopting it as a child would create a cycle. - parentNode := tree.FindNode(root, parent) - if parentNode != nil { - ancestors := tree.GetAncestors(parentNode) - if slices.ContainsFunc(ancestors, func(n *tree.Node) bool { - return n.Name == branch - }) { - fmt.Printf("%s skipping %s: would create a cycle\n", - s.WarningIcon(), s.Branch(branch)) + // Commit adoption + if setErr := cfg.SetParent(branch, parent); setErr != nil { + fmt.Printf("%s failed to adopt %s: %v\n", + s.WarningIcon(), s.Branch(branch), setErr) + adopted[branch] = true continue } - } - // Commit adoption - if setErr := cfg.SetParent(branch, parent); setErr != nil { - fmt.Printf("%s failed to adopt %s: %v\n", - s.WarningIcon(), s.Branch(branch), setErr) - continue - } + // Store fork point + forkPoint, fpErr := g.GetMergeBase(branch, parent) + if fpErr == nil { + _ = cfg.SetForkPoint(branch, forkPoint) //nolint:errcheck // best effort + } - // Store fork point - forkPoint, fpErr := g.GetMergeBase(branch, parent) - if fpErr == nil { - _ = cfg.SetForkPoint(branch, forkPoint) //nolint:errcheck // best effort - } + // Store PR number if detected via PR + if result.PRNumber > 0 { + _ = cfg.SetPR(branch, result.PRNumber) //nolint:errcheck // best effort + } - // Store PR number if detected via PR - if result.PRNumber > 0 { - _ = cfg.SetPR(branch, result.PRNumber) //nolint:errcheck // best effort + confidenceLabel := "" + if result.Confidence == detect.High { + confidenceLabel = " (via PR)" + } + fmt.Printf("%s Auto-adopted %s with parent %s%s\n", + s.SuccessIcon(), s.Branch(branch), s.Branch(parent), confidenceLabel) + + // Update tracked list and rebuild tree for subsequent passes + tracked = append(tracked, branch) + adopted[branch] = true + progress = true + if newRoot, buildErr := tree.Build(cfg); buildErr == nil { + root = newRoot + } } - confidenceLabel := "" - if result.Confidence == detect.High { - confidenceLabel = " (via PR)" + if !progress { + break } - fmt.Printf("%s Auto-adopted %s with parent %s%s\n", - s.SuccessIcon(), s.Branch(branch), s.Branch(parent), confidenceLabel) + } - // Update tracked list and rebuild tree for subsequent detections - tracked = append(tracked, branch) - if newRoot, buildErr := tree.Build(cfg); buildErr == nil { - root = newRoot + // Print guidance for any branches that couldn't be resolved + for _, branch := range candidates { + if !adopted[branch] { + fmt.Printf("%s could not determine parent for %s; run 'gh stack adopt --branch %s'\n", + s.WarningIcon(), s.Branch(branch), branch) } } diff --git a/cmd/log.go b/cmd/log.go index 517de6e..ea42eb7 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -4,6 +4,7 @@ package cmd import ( "fmt" "os" + "sort" "strconv" "github.com/boneskull/gh-stack/internal/config" @@ -98,6 +99,7 @@ func injectDetectedNodes(root *tree.Node, cfg *config.Config, g *git.Git) { return } + modified := make(map[*tree.Node]bool) for _, branch := range candidates { result, detectErr := detect.DetectParentLocal(branch, tracked, trunk, g) if detectErr != nil || result.Confidence == detect.Ambiguous { @@ -124,6 +126,13 @@ func injectDetectedNodes(root *tree.Node, cfg *config.Config, g *git.Git) { Confidence: cl, } parentNode.Children = append(parentNode.Children, node) + modified[parentNode] = true + } + + for parent := range modified { + sort.Slice(parent.Children, func(i, j int) bool { + return parent.Children[i].Name < parent.Children[j].Name + }) } } diff --git a/cmd/log_internal_test.go b/cmd/log_internal_test.go index b938d5b..e0aa25d 100644 --- a/cmd/log_internal_test.go +++ b/cmd/log_internal_test.go @@ -25,7 +25,9 @@ func setupInternalTestRepo(t *testing.T) string { run := func(args ...string) { cmd := exec.Command("git", args...) cmd.Dir = dir - cmd.Run() + if err := cmd.Run(); err != nil { + t.Fatalf("git %v failed: %v", args, err) + } } run("init") @@ -34,7 +36,9 @@ func setupInternalTestRepo(t *testing.T) string { run("config", "commit.gpgsign", "false") f := filepath.Join(dir, "README.md") - os.WriteFile(f, []byte("# Test"), 0644) + if err := os.WriteFile(f, []byte("# Test"), 0644); err != nil { + t.Fatalf("WriteFile README.md: %v", err) + } run("add", ".") run("commit", "-m", "initial") @@ -44,11 +48,15 @@ func setupInternalTestRepo(t *testing.T) string { // addCommit creates a file and commits it in the given repo directory. func addCommit(t *testing.T, dir, filename, content string) { t.Helper() - os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) - cmd := exec.Command("git", "-C", dir, "add", ".") - cmd.Run() - cmd = exec.Command("git", "-C", dir, "commit", "-m", "add "+filename) - cmd.Run() + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatalf("WriteFile %s: %v", filename, err) + } + if err := exec.Command("git", "-C", dir, "add", ".").Run(); err != nil { + t.Fatalf("git add: %v", err) + } + if err := exec.Command("git", "-C", dir, "commit", "-m", "add "+filename).Run(); err != nil { + t.Fatalf("git commit: %v", err) + } } func TestLogDetectsUntrackedBranches(t *testing.T) { diff --git a/internal/detect/detect.go b/internal/detect/detect.go index ae18c94..507cc33 100644 --- a/internal/detect/detect.go +++ b/internal/detect/detect.go @@ -88,6 +88,7 @@ func rankCandidates(branch string, tracked []string, trunk string, g *git.Git) ( // Build candidate set: trunk + all tracked branches seen := make(map[string]bool) var candidates []candidate + var firstErr error allCandidates := append([]string{trunk}, tracked...) for _, name := range allCandidates { @@ -98,11 +99,17 @@ func rankCandidates(branch string, tracked []string, trunk string, g *git.Git) ( mergeBase, err := g.GetMergeBase(branch, name) if err != nil { - continue // skip candidates we can't compare + if firstErr == nil { + firstErr = fmt.Errorf("merge-base %s..%s: %w", branch, name, err) + } + continue } distance, err := g.RevListCount(mergeBase, branch) if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("rev-list %s..%s: %w", mergeBase, branch, err) + } continue } @@ -110,12 +117,19 @@ func rankCandidates(branch string, tracked []string, trunk string, g *git.Git) ( } if len(candidates) == 0 { + if firstErr != nil { + return nil, fmt.Errorf("no candidates could be scored: %w", firstErr) + } return &Result{Confidence: Ambiguous}, nil } - // Sort by distance ascending (closest fork = most likely parent) + // Sort by distance ascending (closest fork = most likely parent), + // breaking ties by name for deterministic ordering. slices.SortFunc(candidates, func(a, b candidate) int { - return cmp.Compare(a.distance, b.distance) + if d := cmp.Compare(a.distance, b.distance); d != 0 { + return d + } + return cmp.Compare(a.name, b.name) }) best := candidates[0] @@ -129,6 +143,7 @@ func rankCandidates(branch string, tracked []string, trunk string, g *git.Git) ( tied = append(tied, c.name) } } + slices.Sort(tied) return &Result{ Confidence: Ambiguous, Candidates: tied, diff --git a/internal/detect/detect_test.go b/internal/detect/detect_test.go index 20c16d4..051a9d7 100644 --- a/internal/detect/detect_test.go +++ b/internal/detect/detect_test.go @@ -28,7 +28,9 @@ func setupTestRepo(t *testing.T) string { run("config", "commit.gpgsign", "false") f := filepath.Join(dir, "README.md") - os.WriteFile(f, []byte("# Test"), 0644) + if err := os.WriteFile(f, []byte("# Test"), 0644); err != nil { + t.Fatalf("WriteFile README.md: %v", err) + } run("add", ".") run("commit", "-m", "initial") @@ -37,9 +39,15 @@ func setupTestRepo(t *testing.T) string { func addCommit(t *testing.T, dir, filename, content string) { t.Helper() - os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644) - exec.Command("git", "-C", dir, "add", ".").Run() - exec.Command("git", "-C", dir, "commit", "-m", "add "+filename).Run() + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatalf("WriteFile %s: %v", filename, err) + } + if err := exec.Command("git", "-C", dir, "add", ".").Run(); err != nil { + t.Fatalf("git add: %v", err) + } + if err := exec.Command("git", "-C", dir, "commit", "-m", "add "+filename).Run(); err != nil { + t.Fatalf("git commit: %v", err) + } } // Linear stack: main -> A -> B -> C (untracked) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 4d061c5..8d3b8d5 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -717,9 +717,15 @@ func TestListLocalBranches(t *testing.T) { trunk, _ := g.CurrentBranch() // Create some branches - g.CreateBranch("feature-a") - g.CreateBranch("feature-b") - g.CreateBranch("feature-c") + if err := g.CreateBranch("feature-a"); err != nil { + t.Fatalf("CreateBranch(feature-a): %v", err) + } + if err := g.CreateBranch("feature-b"); err != nil { + t.Fatalf("CreateBranch(feature-b): %v", err) + } + if err := g.CreateBranch("feature-c"); err != nil { + t.Fatalf("CreateBranch(feature-c): %v", err) + } branches, err := g.ListLocalBranches() if err != nil { @@ -793,9 +799,15 @@ func TestRevListCount(t *testing.T) { g.CreateAndCheckout("feature") for i := range 3 { fname := filepath.Join(dir, fmt.Sprintf("file%d.txt", i)) - os.WriteFile(fname, fmt.Appendf(nil, "content%d", i), 0644) - exec.Command("git", "-C", dir, "add", ".").Run() - exec.Command("git", "-C", dir, "commit", "-m", fmt.Sprintf("commit %d", i)).Run() + if err := os.WriteFile(fname, fmt.Appendf(nil, "content%d", i), 0644); err != nil { + t.Fatalf("WriteFile file%d.txt: %v", i, err) + } + if err := exec.Command("git", "-C", dir, "add", ".").Run(); err != nil { + t.Fatalf("git add (commit %d): %v", i, err) + } + if err := exec.Command("git", "-C", dir, "commit", "-m", fmt.Sprintf("commit %d", i)).Run(); err != nil { + t.Fatalf("git commit %d: %v", i, err) + } } // Count commits from trunk to feature (should be 3) From fbd16f3fec6b8b859b9166492ef53ae5fae87539 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 7 Apr 2026 15:44:31 -0700 Subject: [PATCH 10/14] 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) --- cmd/adopt_test.go | 40 +++++++++++++---- cmd/detect_helpers.go | 56 ++++++++++++----------- cmd/log_internal_test.go | 34 +++++++++++--- internal/detect/detect_test.go | 82 ++++++++++++++++++++++++---------- 4 files changed, 149 insertions(+), 63 deletions(-) diff --git a/cmd/adopt_test.go b/cmd/adopt_test.go index f793add..39d5509 100644 --- a/cmd/adopt_test.go +++ b/cmd/adopt_test.go @@ -174,21 +174,44 @@ func TestAdoptStoresForkPoint(t *testing.T) { func TestAdoptAutoDetect(t *testing.T) { dir := setupTestRepo(t) g := git.New(dir) - cfg, _ := config.Load(dir) - trunk, _ := g.CurrentBranch() - cfg.SetTrunk(trunk) + + cfg, err := config.Load(dir) + if err != nil { + t.Fatalf("Load config: %v", err) + } + + trunk, err := g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch: %v", err) + } + + err = cfg.SetTrunk(trunk) + if err != nil { + t.Fatalf("SetTrunk: %v", err) + } // Create tracked branch A - g.CreateAndCheckout("feature-a") + err = g.CreateAndCheckout("feature-a") + if err != nil { + t.Fatalf("CreateAndCheckout feature-a: %v", err) + } addCommit(t, dir, "a.txt", "a") - cfg.SetParent("feature-a", trunk) + + err = cfg.SetParent("feature-a", trunk) + if err != nil { + t.Fatalf("SetParent feature-a: %v", err) + } // Create untracked branch B off A - g.CreateAndCheckout("feature-b") + err = g.CreateAndCheckout("feature-b") + if err != nil { + t.Fatalf("CreateAndCheckout feature-b: %v", err) + } addCommit(t, dir, "b.txt", "b") // feature-b should not be tracked yet - if _, err := cfg.GetParent("feature-b"); err == nil { + _, getErr := cfg.GetParent("feature-b") + if getErr == nil { t.Fatal("feature-b should not be tracked yet") } @@ -214,7 +237,8 @@ func TestAdoptAutoDetect(t *testing.T) { } // 3. Set parent (same as runAdopt) - if err := cfg.SetParent("feature-b", result.Parent); err != nil { + err = cfg.SetParent("feature-b", result.Parent) + if err != nil { t.Fatalf("SetParent failed: %v", err) } diff --git a/cmd/detect_helpers.go b/cmd/detect_helpers.go index 1184efe..dd3ac89 100644 --- a/cmd/detect_helpers.go +++ b/cmd/detect_helpers.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "slices" "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/detect" @@ -10,7 +9,6 @@ import ( "github.com/boneskull/gh-stack/internal/github" "github.com/boneskull/gh-stack/internal/prompt" "github.com/boneskull/gh-stack/internal/style" - "github.com/boneskull/gh-stack/internal/tree" ) // autoDetectAndAdopt finds untracked branches and adopts them using full detection @@ -35,12 +33,6 @@ func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *st return nil } - // Build tree for cycle checking - root, err := tree.Build(cfg) - if err != nil { - return err - } - // Loop until no progress: untracked chains (e.g., C based on untracked B) // may require multiple passes since a branch can only be detected once its // parent has been adopted into the tracked set. @@ -77,19 +69,15 @@ func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *st } } - // Cycle check: if branch is already an ancestor of the detected parent, - // adopting it as a child would create a cycle. - parentNode := tree.FindNode(root, parent) - if parentNode != nil { - ancestors := tree.GetAncestors(parentNode) - if slices.ContainsFunc(ancestors, func(n *tree.Node) bool { - return n.Name == branch - }) { - fmt.Printf("%s skipping %s: would create a cycle\n", - s.WarningIcon(), s.Branch(branch)) - adopted[branch] = true - continue - } + // Cycle check via config: walk GetParent from parent upward and + // ensure we never reach branch. This catches cycles that the tree + // model might miss (e.g., when nodes with broken parent links are + // omitted from tree.Build). + if wouldCycle(cfg, branch, parent) { + fmt.Printf("%s skipping %s: would create a cycle\n", + s.WarningIcon(), s.Branch(branch)) + adopted[branch] = true + continue } // Commit adoption @@ -118,13 +106,9 @@ func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *st fmt.Printf("%s Auto-adopted %s with parent %s%s\n", s.SuccessIcon(), s.Branch(branch), s.Branch(parent), confidenceLabel) - // Update tracked list and rebuild tree for subsequent passes tracked = append(tracked, branch) adopted[branch] = true progress = true - if newRoot, buildErr := tree.Build(cfg); buildErr == nil { - root = newRoot - } } if !progress { @@ -142,3 +126,25 @@ func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *st return nil } + +// wouldCycle returns true if setting branch's parent to parent would create a +// cycle in the config-based parent chain. It walks cfg.GetParent from parent +// upward; if it ever reaches branch, adopting would create a loop. +func wouldCycle(cfg *config.Config, branch, parent string) bool { + visited := make(map[string]bool) + cur := parent + for { + if cur == branch { + return true + } + if visited[cur] { + return false + } + visited[cur] = true + next, err := cfg.GetParent(cur) + if err != nil { + return false + } + cur = next + } +} diff --git a/cmd/log_internal_test.go b/cmd/log_internal_test.go index e0aa25d..2a79162 100644 --- a/cmd/log_internal_test.go +++ b/cmd/log_internal_test.go @@ -62,17 +62,39 @@ func addCommit(t *testing.T, dir, filename, content string) { func TestLogDetectsUntrackedBranches(t *testing.T) { dir := setupInternalTestRepo(t) g := git.New(dir) - cfg, _ := config.Load(dir) - trunk, _ := g.CurrentBranch() - cfg.SetTrunk(trunk) + + cfg, err := config.Load(dir) + if err != nil { + t.Fatalf("Load config: %v", err) + } + + trunk, err := g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch: %v", err) + } + + err = cfg.SetTrunk(trunk) + if err != nil { + t.Fatalf("SetTrunk: %v", err) + } // Create tracked branch A off main - g.CreateAndCheckout("feature-a") + err = g.CreateAndCheckout("feature-a") + if err != nil { + t.Fatalf("CreateAndCheckout feature-a: %v", err) + } addCommit(t, dir, "a.txt", "a") - cfg.SetParent("feature-a", trunk) + + err = cfg.SetParent("feature-a", trunk) + if err != nil { + t.Fatalf("SetParent feature-a: %v", err) + } // Create untracked branch B off A - g.CreateAndCheckout("feature-b") + err = g.CreateAndCheckout("feature-b") + if err != nil { + t.Fatalf("CreateAndCheckout feature-b: %v", err) + } addCommit(t, dir, "b.txt", "b") // Build tree WITHOUT detection -- B should not appear diff --git a/internal/detect/detect_test.go b/internal/detect/detect_test.go index 051a9d7..f7f985a 100644 --- a/internal/detect/detect_test.go +++ b/internal/detect/detect_test.go @@ -50,20 +50,54 @@ func addCommit(t *testing.T, dir, filename, content string) { } } +// mustCreateAndCheckout is a test helper that creates+checks out a branch or fails. +func mustCreateAndCheckout(t *testing.T, g *git.Git, name string) { + t.Helper() + if err := g.CreateAndCheckout(name); err != nil { + t.Fatalf("CreateAndCheckout %s: %v", name, err) + } +} + +// mustCreateBranch is a test helper that creates a branch or fails. +func mustCreateBranch(t *testing.T, g *git.Git, name string) { + t.Helper() + if err := g.CreateBranch(name); err != nil { + t.Fatalf("CreateBranch %s: %v", name, err) + } +} + +// mustCheckout is a test helper that checks out a branch or fails. +func mustCheckout(t *testing.T, g *git.Git, name string) { + t.Helper() + if err := g.Checkout(name); err != nil { + t.Fatalf("Checkout %s: %v", name, err) + } +} + +// mustCurrentBranch returns the current branch name or fails. +func mustCurrentBranch(t *testing.T, g *git.Git) string { + t.Helper() + name, err := g.CurrentBranch() + if err != nil { + t.Fatalf("CurrentBranch: %v", err) + } + return name +} + // Linear stack: main -> A -> B -> C (untracked) // C should detect B as parent with Medium confidence func TestDetectParentLocal_LinearStack(t *testing.T) { dir := setupTestRepo(t) g := git.New(dir) - trunk, _ := g.CurrentBranch() + trunk := mustCurrentBranch(t, g) - g.CreateAndCheckout("feature-a") + mustCreateAndCheckout(t, g, "feature-a") addCommit(t, dir, "a.txt", "a") - g.CreateAndCheckout("feature-b") + mustCreateAndCheckout(t, g, "feature-b") addCommit(t, dir, "b.txt", "b") - g.CreateAndCheckout("feature-c") + mustCreateAndCheckout(t, g, "feature-c") addCommit(t, dir, "c.txt", "c") tracked := []string{"feature-a", "feature-b"} @@ -83,12 +117,12 @@ func TestDetectParentLocal_LinearStack(t *testing.T) { func TestDetectParentLocal_Ambiguous(t *testing.T) { dir := setupTestRepo(t) g := git.New(dir) - trunk, _ := g.CurrentBranch() + trunk := mustCurrentBranch(t, g) - g.CreateBranch("feature-a") - g.CreateBranch("feature-b") + mustCreateBranch(t, g, "feature-a") + mustCreateBranch(t, g, "feature-b") - g.CreateAndCheckout("feature-c") + mustCreateAndCheckout(t, g, "feature-c") addCommit(t, dir, "c.txt", "c") tracked := []string{"feature-a", "feature-b"} @@ -108,15 +142,15 @@ func TestDetectParentLocal_Ambiguous(t *testing.T) { func TestDetectParentLocal_TrunkIsParent(t *testing.T) { dir := setupTestRepo(t) g := git.New(dir) - trunk, _ := g.CurrentBranch() + trunk := mustCurrentBranch(t, g) - g.CreateAndCheckout("feature-a") + mustCreateAndCheckout(t, g, "feature-a") addCommit(t, dir, "a.txt", "a") - g.Checkout(trunk) + mustCheckout(t, g, trunk) addCommit(t, dir, "main1.txt", "main1") - g.CreateAndCheckout("feature-x") + mustCreateAndCheckout(t, g, "feature-x") addCommit(t, dir, "x.txt", "x") tracked := []string{"feature-a"} @@ -136,9 +170,9 @@ func TestDetectParentLocal_TrunkIsParent(t *testing.T) { func TestDetectParentLocal_NoTrackedBranches(t *testing.T) { dir := setupTestRepo(t) g := git.New(dir) - trunk, _ := g.CurrentBranch() + trunk := mustCurrentBranch(t, g) - g.CreateAndCheckout("feature-x") + mustCreateAndCheckout(t, g, "feature-x") addCommit(t, dir, "x.txt", "x") result, err := detect.DetectParentLocal("feature-x", nil, trunk, g) @@ -157,12 +191,12 @@ func TestDetectParentLocal_NoTrackedBranches(t *testing.T) { func TestDetectParent_NilGitHub(t *testing.T) { dir := setupTestRepo(t) g := git.New(dir) - trunk, _ := g.CurrentBranch() + trunk := mustCurrentBranch(t, g) - g.CreateAndCheckout("feature-a") + mustCreateAndCheckout(t, g, "feature-a") addCommit(t, dir, "a.txt", "a") - g.CreateAndCheckout("feature-b") + mustCreateAndCheckout(t, g, "feature-b") addCommit(t, dir, "b.txt", "b") tracked := []string{"feature-a"} @@ -185,12 +219,12 @@ func TestDetectParent_NilGitHub(t *testing.T) { func TestFindUntrackedCandidates(t *testing.T) { dir := setupTestRepo(t) g := git.New(dir) - trunk, _ := g.CurrentBranch() + trunk := mustCurrentBranch(t, g) - g.CreateBranch("tracked-a") - g.CreateBranch("tracked-b") - g.CreateBranch("untracked-x") - g.CreateBranch("untracked-y") + mustCreateBranch(t, g, "tracked-a") + mustCreateBranch(t, g, "tracked-b") + mustCreateBranch(t, g, "untracked-x") + mustCreateBranch(t, g, "untracked-y") tracked := []string{"tracked-a", "tracked-b"} candidates, err := detect.FindUntrackedCandidates(g, tracked, trunk) @@ -222,9 +256,9 @@ func TestFindUntrackedCandidates(t *testing.T) { func TestFindUntrackedCandidates_EmptyTracked(t *testing.T) { dir := setupTestRepo(t) g := git.New(dir) - trunk, _ := g.CurrentBranch() + trunk := mustCurrentBranch(t, g) - g.CreateBranch("some-branch") + mustCreateBranch(t, g, "some-branch") candidates, err := detect.FindUntrackedCandidates(g, nil, trunk) if err != nil { From 14114a7439ad78ab1911c9a940babb7846dd02d5 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 7 Apr 2026 16:19:28 -0700 Subject: [PATCH 11/14] 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) --- README.md | 16 +++++++++++----- cmd/adopt.go | 18 ++++-------------- cmd/log.go | 8 ++++---- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 99b15a4..13e20a9 100644 --- a/README.md +++ b/README.md @@ -168,12 +168,16 @@ By default, `init` auto-detects the trunk branch (`main` or `master`). If neithe Display the branch tree showing the stack hierarchy, current branch, and associated PR numbers. +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. + +Detection is automatically skipped in `--porcelain` mode since the porcelain format has no column to distinguish detected branches from tracked ones. + #### log Flags -| Flag | Description | -| ------------- | ------------------------------------- | -| `--all` | Show all branches | -| `--porcelain` | Machine-readable tab-separated output | +| Flag | Description | +| --------------- | ---------------------------------------------- | +| `--porcelain` | Machine-readable tab-separated output | +| `--no-detect` | Skip auto-detection of untracked branches | #### Porcelain Format @@ -208,10 +212,12 @@ Start tracking an existing branch by setting its parent. By default, adopts the current branch. The parent must be either the trunk or another tracked branch. +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. + #### adopt Usage ```bash -gh stack adopt +gh stack adopt [parent] ``` #### adopt Flags diff --git a/cmd/adopt.go b/cmd/adopt.go index aa10142..901dc44 100644 --- a/cmd/adopt.go +++ b/cmd/adopt.go @@ -12,7 +12,6 @@ import ( "github.com/boneskull/gh-stack/internal/github" "github.com/boneskull/gh-stack/internal/prompt" "github.com/boneskull/gh-stack/internal/style" - "github.com/boneskull/gh-stack/internal/tree" "github.com/spf13/cobra" ) @@ -130,19 +129,10 @@ func runAdopt(cmd *cobra.Command, args []string) error { } } - // Check for cycles (branch can't be ancestor of parent) - root, err := tree.Build(cfg) - if err != nil { - return err - } - - parentNode := tree.FindNode(root, parent) - if parentNode != nil { - for _, ancestor := range tree.GetAncestors(parentNode) { - if ancestor.Name == branchName { - return errors.New("cannot adopt: would create a cycle") - } - } + // Check for cycles via config parent chain walk (catches cases the tree + // model misses when nodes with broken parent links are omitted). + if wouldCycle(cfg, branchName, parent) { + return errors.New("cannot adopt: would create a cycle") } // Set parent diff --git a/cmd/log.go b/cmd/log.go index ea42eb7..9b63b87 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -26,13 +26,11 @@ var logCmd = &cobra.Command{ } var ( - logAllFlag bool logPorcelainFlag bool logNoDetectFlag bool ) func init() { - logCmd.Flags().BoolVar(&logAllFlag, "all", false, "show all branches") logCmd.Flags().BoolVar(&logPorcelainFlag, "porcelain", false, "machine-readable output") logCmd.Flags().BoolVar(&logNoDetectFlag, "no-detect", false, "skip auto-detection of untracked branches") rootCmd.AddCommand(logCmd) @@ -56,8 +54,10 @@ func runLog(cmd *cobra.Command, args []string) error { g := git.New(cwd) - // Auto-detect untracked branches (read-only — injects virtual nodes) - if !logNoDetectFlag { + // Auto-detect untracked branches (read-only — injects virtual nodes). + // Skip detection in porcelain mode so machine-readable output only contains + // tracked/configured nodes (porcelain has no column to mark detected ones). + if !logNoDetectFlag && !logPorcelainFlag { injectDetectedNodes(root, cfg, g) } From 744fc7f390dfaef4fceddb81529e1910337a4052 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 7 Apr 2026 16:50:07 -0700 Subject: [PATCH 12/14] 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) --- README.md | 12 +++++++----- cmd/detect_helpers.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 13e20a9..b5c1fa9 100644 --- a/README.md +++ b/README.md @@ -302,11 +302,12 @@ If a rebase conflict occurs, resolve it and run `gh stack continue`. #### restack Flags -| Flag | Description | -| ------------- | -------------------------------------------------------- | -| `--only` | Only restack current branch, not descendants | -| `--dry-run` | Show what would be done | -| `--worktrees` | Rebase branches checked out in linked worktrees in-place | +| Flag | Description | +| --------------- | -------------------------------------------------------- | +| `--only` | Only restack current branch, not descendants | +| `--dry-run` | Show what would be done | +| `--worktrees` | Rebase branches checked out in linked worktrees in-place | +| `--no-detect` | Skip auto-detection and adoption of untracked branches | ### continue @@ -333,6 +334,7 @@ This is the command to run when upstream changes have occurred (e.g., a PR in yo | `--no-restack` | Skip restacking branches | | `--dry-run` | Show what would be done | | `--worktrees` | Rebase branches checked out in linked worktrees in-place | +| `--no-detect` | Skip auto-detection and adoption of untracked branches | ### undo diff --git a/cmd/detect_helpers.go b/cmd/detect_helpers.go index dd3ac89..6b4f7b7 100644 --- a/cmd/detect_helpers.go +++ b/cmd/detect_helpers.go @@ -1,7 +1,9 @@ package cmd import ( + "cmp" "fmt" + "slices" "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/detect" @@ -33,6 +35,12 @@ func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *st return nil } + // Sort candidates by merge-base distance from trunk (ascending) so that + // branches closer to trunk (parents) are processed before their children. + // Without this, alphabetical ordering can cause a child to be adopted with + // the wrong parent when both it and its true parent are untracked. + sortCandidatesByTrunkDistance(candidates, trunk, g) + // Loop until no progress: untracked chains (e.g., C based on untracked B) // may require multiple passes since a branch can only be detected once its // parent has been adopted into the tracked set. @@ -127,6 +135,34 @@ func autoDetectAndAdopt(cfg *config.Config, g *git.Git, gh *github.Client, s *st return nil } +// sortCandidatesByTrunkDistance sorts branches by their merge-base distance +// from trunk (ascending). Branches closer to trunk are processed first, which +// ensures parents are adopted before their children in untracked chains. +// Branches whose distance can't be computed are sorted to the end. +func sortCandidatesByTrunkDistance(candidates []string, trunk string, g *git.Git) { + const maxDist = 1<<31 - 1 + dist := make(map[string]int, len(candidates)) + for _, b := range candidates { + mb, err := g.GetMergeBase(b, trunk) + if err != nil { + dist[b] = maxDist + continue + } + n, err := g.RevListCount(mb, b) + if err != nil { + dist[b] = maxDist + continue + } + dist[b] = n + } + slices.SortFunc(candidates, func(a, b string) int { + if d := cmp.Compare(dist[a], dist[b]); d != 0 { + return d + } + return cmp.Compare(a, b) + }) +} + // wouldCycle returns true if setting branch's parent to parent would create a // cycle in the config-based parent chain. It walks cfg.GetParent from parent // upward; if it ever reaches branch, adopting would create a loop. From 3985b2c77bc5a0eb75eff3978bebdf9cc003b741 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 7 Apr 2026 17:05:16 -0700 Subject: [PATCH 13/14] 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` --- internal/git/git_test.go | 4 +++- internal/tree/tree.go | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 8d3b8d5..aa393a9 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -796,7 +796,9 @@ func TestRevListCount(t *testing.T) { trunk, _ := g.CurrentBranch() // Create feature branch with 3 commits - g.CreateAndCheckout("feature") + if err := g.CreateAndCheckout("feature"); err != nil { + t.Fatalf("CreateAndCheckout(feature): %v", err) + } for i := range 3 { fname := filepath.Join(dir, fmt.Sprintf("file%d.txt", i)) if err := os.WriteFile(fname, fmt.Appendf(nil, "content%d", i), 0644); err != nil { diff --git a/internal/tree/tree.go b/internal/tree/tree.go index a2eeca3..e82fcf8 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -181,12 +181,12 @@ func formatNode(sb *strings.Builder, node *Node, prefix string, isLast bool, opt detectedInfo := "" if node.Detected { switch node.Confidence { - case ConfidenceMedium: - detectedInfo = " (detected)" - case ConfidenceAmbiguous: - detectedInfo = " (detected, ambiguous)" case ConfidenceHigh: detectedInfo = " (detected, via PR)" + case ConfidenceAmbiguous: + detectedInfo = " (detected, ambiguous)" + default: + detectedInfo = " (detected)" } if opts.Style != nil { detectedInfo = opts.Style.Muted(detectedInfo) From 1f1a6a5943826b5554541e1111c6bf2b96a4a6ac Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 7 Apr 2026 17:41:26 -0700 Subject: [PATCH 14/14] 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). --- internal/detect/detect.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/detect/detect.go b/internal/detect/detect.go index 507cc33..a8d19cf 100644 --- a/internal/detect/detect.go +++ b/internal/detect/detect.go @@ -90,6 +90,12 @@ func rankCandidates(branch string, tracked []string, trunk string, g *git.Git) ( var candidates []candidate var firstErr error + // Resolve branch tip once so we can filter out descendants. + branchTip, tipErr := g.GetTip(branch) + if tipErr != nil { + return nil, fmt.Errorf("resolve tip of %s: %w", branch, tipErr) + } + allCandidates := append([]string{trunk}, tracked...) for _, name := range allCandidates { if seen[name] || name == branch { @@ -105,6 +111,17 @@ func rankCandidates(branch string, tracked []string, trunk string, g *git.Git) ( continue } + // If merge-base equals branch tip AND name has commits ahead, + // then branch is an ancestor of name — name is a descendant, not + // a valid parent. Skip it to avoid inverted parent relationships. + // (When they're at the same commit, name is still a valid parent.) + if mergeBase == branchTip { + ahead, aErr := g.RevListCount(branchTip, name) + if aErr == nil && ahead > 0 { + continue + } + } + distance, err := g.RevListCount(mergeBase, branch) if err != nil { if firstErr == nil {