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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ base, head, and remote state, then hands that PR to GitHub auto-merge or merge
queue. After the merge lands, `stack sync` helps the rest of the stack catch up
without guessing through ambiguous cases.

That handoff uses GitHub's own auto-merge path via `gh pr merge --auto`, so the
repository must have auto-merge enabled. If the repo also uses merge queue,
GitHub decides whether the PR goes straight to auto-merge or enters the queue.

## How it differs from Graphite and similar tools

`stack` is closest in spirit to tools that keep explicit local stack metadata,
Expand Down Expand Up @@ -75,6 +79,9 @@ stack submit --all
stack queue feature/base
```

Before using `stack queue`, make sure the GitHub repository has auto-merge
enabled.

For the full daily workflow, start with [docs/usage.md](docs/usage.md).

## Starting from existing PRs
Expand Down
4 changes: 4 additions & 0 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ branch, and PR head all match, then hands that PR to GitHub auto-merge or merge
queue. After the merge lands, `stack sync` helps advance the remaining stack to
the next safe state.

Because `stack` delegates that final step to GitHub, the repository must have
auto-merge enabled. On repos with merge queue configured, GitHub applies queue
policy after the handoff.

## How this differs from Graphite and similar tools

The important difference is workflow shape: `stack` keeps branches and PRs
Expand Down
5 changes: 5 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Then choose a repair path deliberately.
Check:

- `gh auth status`
- GitHub repository auto-merge is enabled
- the branch is pushed to the expected remote
- the tracked PR is open and on the expected base
- the local head still matches the pushed head
Expand All @@ -83,6 +84,10 @@ stack submit <branch>
stack queue <branch>
```

If the CLI reports multiple open PRs for one head branch, it is refusing to
guess which live PR owns that branch. Close or retarget the duplicate until one
open PR remains for that head name, then rerun `stack submit`.

## Release automation does not update the tap

The release workflow needs:
Expand Down
5 changes: 5 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ and cached PR state.
4. Run `stack queue <branch>` only when the bottom branch targets trunk and is healthy.
5. Run `stack sync` after merges or GitHub-side base changes.

For `stack queue`, GitHub repository auto-merge must be enabled. `stack` hands
off through `gh pr merge --auto`, then GitHub applies the repo's normal
auto-merge or merge-queue policy.

## Repair loop

Use `stack sync` first when local metadata and GitHub disagree.
Expand All @@ -68,3 +72,4 @@ clean recovery point.
- `move`, `restack`, `submit`, and `queue` preview before destructive work unless you pass `--yes`
- `sync` stops on ambiguous merged-parent cases instead of guessing
- `queue` is only for a healthy bottom-of-stack PR
- `queue` requires GitHub repository auto-merge to be enabled
37 changes: 33 additions & 4 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ func newCreateCommand(runtime *stackruntime.Runtime) *cobra.Command {
if err != nil {
return err
}
if err := ensureCreateParentAllowed(state, parent); err != nil {
return err
}

if err := runtime.Git.SwitchCreate(runtime.Context, args[0]); err != nil {
return err
Expand Down Expand Up @@ -211,6 +214,9 @@ func newTrackCommand(runtime *stackruntime.Runtime) *cobra.Command {
if parent != state.Trunk && !runtime.Git.BranchExists(runtime.Context, parent) {
return fmt.Errorf("parent branch %q does not exist locally", parent)
}
if err := ensureTrackedParentAllowed(state, parent); err != nil {
return err
}
if err := stack.EnsureBranchCanParent(state, branch, parent); err != nil {
return err
}
Expand Down Expand Up @@ -463,10 +469,8 @@ stack move feature/b --parent main --yes
if !runtime.Git.BranchExists(runtime.Context, parent) && parent != state.Trunk {
return fmt.Errorf("parent branch %q does not exist locally", parent)
}
if parent != state.Trunk {
if _, ok := state.Branches[parent]; !ok {
return fmt.Errorf("parent branch %q is not tracked in local metadata; track it first or move under %s", parent, state.Trunk)
}
if err := ensureTrackedParentAllowed(state, parent); err != nil {
return err
}
if err := stack.EnsureBranchCanParent(state, branch, parent); err != nil {
return err
Expand Down Expand Up @@ -1322,6 +1326,31 @@ func validateTrackedPR(branch string, record store.BranchRecord) error {
return nil
}

func ensureCreateParentAllowed(state store.RepoState, parent string) error {
parent = strings.TrimSpace(parent)
if parent == "" {
return fmt.Errorf("cannot create a tracked branch from detached HEAD; switch to %s or another tracked branch first", state.Trunk)
}
if parent == state.Trunk {
return nil
}
if _, ok := state.Branches[parent]; ok {
return nil
}
return fmt.Errorf("current branch %q is not tracked in local metadata; track it first or switch to %s", parent, state.Trunk)
}

func ensureTrackedParentAllowed(state store.RepoState, parent string) error {
parent = strings.TrimSpace(parent)
if parent == "" || parent == state.Trunk {
return nil
}
if _, ok := state.Branches[parent]; ok {
return nil
}
return fmt.Errorf("parent branch %q is not tracked in local metadata; track it first or move under %s", parent, state.Trunk)
}

func resolveOID(runtime *stackruntime.Runtime, ref string) string {
oid, err := runtime.Git.ResolveRef(runtime.Context, ref)
if err != nil {
Expand Down
97 changes: 97 additions & 0 deletions internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,103 @@ func TestSubmitCreatesAndTracksPR(t *testing.T) {
}
}

func TestCreateRejectsDetachedHEAD(t *testing.T) {
repo := testutil.SetupGitRepo(t)
testutil.Run(t, repo, "git", "checkout", "--detach")

runtime := newTestRuntime(repo)
state := store.RepoState{
Version: 1,
Repo: "hack-dance/stack",
DefaultRemote: "origin",
Trunk: "main",
Branches: map[string]store.BranchRecord{},
}
if err := runtime.Store.WriteState(runtime.Context, state); err != nil {
t.Fatalf("write state: %v", err)
}

err := executeCommandExpectError(runtime, "create", "feature/a")
if err == nil || !strings.Contains(err.Error(), "detached HEAD") {
t.Fatalf("expected detached HEAD error, got %v", err)
}

state, err = runtime.Store.ReadState(runtime.Context)
if err != nil {
t.Fatalf("read state: %v", err)
}
if len(state.Branches) != 0 {
t.Fatalf("expected no tracked branches, got %+v", state.Branches)
}
if runtime.Git.BranchExists(runtime.Context, "feature/a") {
t.Fatalf("expected feature/a branch to not be created")
}
}

func TestCreateRejectsUntrackedCurrentParent(t *testing.T) {
repo := testutil.SetupGitRepo(t)
testutil.Run(t, repo, "git", "switch", "-c", "feature/base")

runtime := newTestRuntime(repo)
state := store.RepoState{
Version: 1,
Repo: "hack-dance/stack",
DefaultRemote: "origin",
Trunk: "main",
Branches: map[string]store.BranchRecord{},
}
if err := runtime.Store.WriteState(runtime.Context, state); err != nil {
t.Fatalf("write state: %v", err)
}

err := executeCommandExpectError(runtime, "create", "feature/child")
if err == nil || !strings.Contains(err.Error(), "not tracked in local metadata") {
t.Fatalf("expected untracked parent error, got %v", err)
}

state, err = runtime.Store.ReadState(runtime.Context)
if err != nil {
t.Fatalf("read state: %v", err)
}
if len(state.Branches) != 0 {
t.Fatalf("expected no tracked branches, got %+v", state.Branches)
}
if runtime.Git.BranchExists(runtime.Context, "feature/child") {
t.Fatalf("expected feature/child branch to not be created")
}
}

func TestTrackRejectsUntrackedParent(t *testing.T) {
repo := testutil.SetupGitRepo(t)
testutil.Run(t, repo, "git", "switch", "-c", "feature/base")
testutil.Run(t, repo, "git", "switch", "-c", "feature/child")

runtime := newTestRuntime(repo)
state := store.RepoState{
Version: 1,
Repo: "hack-dance/stack",
DefaultRemote: "origin",
Trunk: "main",
Branches: map[string]store.BranchRecord{},
}
if err := runtime.Store.WriteState(runtime.Context, state); err != nil {
t.Fatalf("write state: %v", err)
}

err := executeCommandExpectError(runtime, "track", "feature/child", "--parent", "feature/base")
if err == nil || !strings.Contains(err.Error(), "parent branch \"feature/base\" is not tracked in local metadata") {
t.Fatalf("expected untracked parent error, got %v", err)
}

state, err = runtime.Store.ReadState(runtime.Context)
if err != nil {
t.Fatalf("read state: %v", err)
}
if len(state.Branches) != 0 {
t.Fatalf("expected no tracked branches, got %+v", state.Branches)
}
}

func TestVersionCommandPrintsBuildInfo(t *testing.T) {
repo := testutil.SetupGitRepo(t)
runtime := newTestRuntime(repo)
Expand Down
20 changes: 19 additions & 1 deletion internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,25 @@ func (c *Client) FindPRByHead(ctx context.Context, branch string) (store.PullReq
return store.PullRequest{}, nil
}

return payload[0].toStorePullRequest(), nil
open := make([]pullRequestPayload, 0, len(payload))
for _, pr := range payload {
if pr.State == "OPEN" {
open = append(open, pr)
}
}

switch len(open) {
case 0:
return store.PullRequest{}, nil
case 1:
return open[0].toStorePullRequest(), nil
default:
numbers := make([]string, 0, len(open))
for _, pr := range open {
numbers = append(numbers, fmt.Sprintf("#%d", pr.Number))
}
return store.PullRequest{}, fmt.Errorf("multiple open pull requests match head %q: %s", branch, strings.Join(numbers, ", "))
}
}

func (c *Client) CreatePR(ctx context.Context, base string, head string, title string, body string, draft bool) (store.PullRequest, error) {
Expand Down
Loading
Loading