Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Comment thread
boneskull marked this conversation as resolved.

#### Porcelain Format

Expand Down Expand Up @@ -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 <parent>
gh stack adopt [parent]
```

#### adopt Flags
Expand Down Expand Up @@ -296,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

Expand All @@ -327,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

Expand Down
85 changes: 65 additions & 20 deletions cmd/adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,25 @@ 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 <parent>",
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),
Comment thread
boneskull marked this conversation as resolved.
RunE: runAdopt,
}

Expand All @@ -42,9 +48,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
Expand Down Expand Up @@ -73,25 +77,62 @@ 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)
Comment thread
boneskull marked this conversation as resolved.
}
}

// 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
Expand All @@ -105,7 +146,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
}
131 changes: 131 additions & 0 deletions cmd/adopt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,33 @@
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/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)

Expand Down Expand Up @@ -148,3 +168,114 @@ 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, 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
err = g.CreateAndCheckout("feature-a")
if err != nil {
t.Fatalf("CreateAndCheckout feature-a: %v", err)
}
addCommit(t, dir, "a.txt", "a")

err = cfg.SetParent("feature-a", trunk)
if err != nil {
t.Fatalf("SetParent feature-a: %v", err)
}

// Create untracked branch B off A
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
_, getErr := cfg.GetParent("feature-b")
if getErr == 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)
err = cfg.SetParent("feature-b", result.Parent)
if 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)
}
}
11 changes: 11 additions & 0 deletions cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
Comment thread
boneskull marked this conversation as resolved.
}
}

// Build tree
root, err := tree.Build(cfg)
if err != nil {
Expand Down
Loading
Loading