From 920c7b4ab3e23e7d029a7a86b8ba74c54996247b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 26 Mar 2026 13:53:31 -0400 Subject: [PATCH 1/9] feat: add landing orchestration workflows --- docs/cli/stack.md | 7 +- docs/cli/stack_adopt.md | 23 + docs/cli/stack_adopt_pr.md | 35 + docs/cli/stack_closeout.md | 32 + docs/cli/stack_compose.md | 37 + docs/cli/stack_queue.md | 5 +- docs/cli/stack_supersede.md | 37 + docs/cli/stack_verify.md | 24 + docs/cli/stack_verify_add.md | 40 + docs/cli/stack_verify_list.md | 26 + internal/cmd/root.go | 2380 +++++++++++++++++++++++++------- internal/cmd/root_test.go | 1141 +++++++++++++++ internal/git/client.go | 48 + internal/github/client.go | 59 +- internal/stack/summary.go | 148 +- internal/stack/summary_test.go | 137 ++ internal/store/store.go | 8 + internal/store/types.go | 32 +- internal/testutil/gh_stub.go | 14 + internal/ui/render.go | 63 + 20 files changed, 3710 insertions(+), 586 deletions(-) create mode 100644 docs/cli/stack_adopt.md create mode 100644 docs/cli/stack_adopt_pr.md create mode 100644 docs/cli/stack_closeout.md create mode 100644 docs/cli/stack_compose.md create mode 100644 docs/cli/stack_supersede.md create mode 100644 docs/cli/stack_verify.md create mode 100644 docs/cli/stack_verify_add.md create mode 100644 docs/cli/stack_verify_list.md diff --git a/docs/cli/stack.md b/docs/cli/stack.md index 6f88a72..8cab424 100644 --- a/docs/cli/stack.md +++ b/docs/cli/stack.md @@ -25,16 +25,21 @@ stack [flags] ### SEE ALSO * [stack abort](stack_abort.md) - Abort an interrupted restack and clear the operation journal +* [stack adopt](stack_adopt.md) - Adopt existing pull requests into explicit stack metadata +* [stack closeout](stack_closeout.md) - Plan read-only post-merge closeout for a landing branch +* [stack compose](stack_compose.md) - Compose a strict landing branch from selected tracked branches * [stack continue](stack_continue.md) - Continue an interrupted restack after conflicts are resolved * [stack create](stack_create.md) - Create a tracked branch on top of the current branch * [stack init](stack_init.md) - Initialize stack metadata for this repository * [stack move](stack_move.md) - Change a branch parent and restack the affected subtree -* [stack queue](stack_queue.md) - Hand one healthy bottom-of-stack PR to GitHub auto-merge or merge queue +* [stack queue](stack_queue.md) - Hand one verified trunk-bound PR or landing PR to GitHub auto-merge or merge queue * [stack restack](stack_restack.md) - Rebase a branch or subtree onto its configured parent * [stack status](stack_status.md) - Show stack health, hierarchy, and cached PR state * [stack submit](stack_submit.md) - Push tracked branches and create or update one normal PR per branch +* [stack supersede](stack_supersede.md) - Mark original PRs as superseded by a landing PR * [stack sync](stack_sync.md) - Refresh cached PR metadata and inspect or apply safe repairs * [stack track](stack_track.md) - Adopt an existing branch into the explicit stack graph * [stack tui](stack_tui.md) - Open the read-only stack dashboard +* [stack verify](stack_verify.md) - Attach and inspect lightweight verification records * [stack version](stack_version.md) - Print version, commit, and build date diff --git a/docs/cli/stack_adopt.md b/docs/cli/stack_adopt.md new file mode 100644 index 0000000..9283504 --- /dev/null +++ b/docs/cli/stack_adopt.md @@ -0,0 +1,23 @@ +# stack_adopt + +Generated from the current Cobra command tree. + +## stack adopt + +Adopt existing pull requests into explicit stack metadata + +### Synopsis + +Use explicit operator-chosen parents to adopt existing PR heads into the local stack graph. + +### Options + +``` + -h, --help help for adopt +``` + +### SEE ALSO + +* [stack](stack.md) - Manage explicit stacked PR workflows with Git and GitHub +* [stack adopt pr](stack_adopt_pr.md) - Adopt one open pull request into the stack graph + diff --git a/docs/cli/stack_adopt_pr.md b/docs/cli/stack_adopt_pr.md new file mode 100644 index 0000000..865bfda --- /dev/null +++ b/docs/cli/stack_adopt_pr.md @@ -0,0 +1,35 @@ +# stack_adopt_pr + +Generated from the current Cobra command tree. + +## stack adopt pr + +Adopt one open pull request into the stack graph + +### Synopsis + +Look up one open pull request, optionally fetch its head branch locally, then track it under an explicit parent branch. + +``` +stack adopt pr [flags] +``` + +### Examples + +``` +stack adopt pr 353 --parent main +stack adopt pr 354 --parent pr/353 --yes +``` + +### Options + +``` + -h, --help help for pr + --parent string Parent branch or trunk + --yes Skip confirmation +``` + +### SEE ALSO + +* [stack adopt](stack_adopt.md) - Adopt existing pull requests into explicit stack metadata + diff --git a/docs/cli/stack_closeout.md b/docs/cli/stack_closeout.md new file mode 100644 index 0000000..6caaea0 --- /dev/null +++ b/docs/cli/stack_closeout.md @@ -0,0 +1,32 @@ +# stack_closeout + +Generated from the current Cobra command tree. + +## stack closeout + +Plan read-only post-merge closeout for a landing branch + +### Synopsis + +Use recorded landing composition, pull request state, and verification records to show which original PRs and inferred tickets are safe to close now versus still blocked on deploy checks. + +``` +stack closeout [flags] +``` + +### Examples + +``` +stack closeout stack/discovery-core +``` + +### Options + +``` + -h, --help help for closeout +``` + +### SEE ALSO + +* [stack](stack.md) - Manage explicit stacked PR workflows with Git and GitHub + diff --git a/docs/cli/stack_compose.md b/docs/cli/stack_compose.md new file mode 100644 index 0000000..b1e27d1 --- /dev/null +++ b/docs/cli/stack_compose.md @@ -0,0 +1,37 @@ +# stack_compose + +Generated from the current Cobra command tree. + +## stack compose + +Compose a strict landing branch from selected tracked branches + +### Synopsis + +Create one ordinary local landing branch from an explicit linear branch selection and replay only the selected commits in order. + +``` +stack compose [flags] +``` + +### Examples + +``` +stack compose discovery-core --from feature/a --to feature/c +stack compose discovery-core --branches feature/a --branches feature/b --yes +``` + +### Options + +``` + --branches stringArray Tracked branches to include in explicit order + --from string Bottom branch of a contiguous tracked path + -h, --help help for compose + --to string Top branch of a contiguous tracked path + --yes Skip confirmation +``` + +### SEE ALSO + +* [stack](stack.md) - Manage explicit stacked PR workflows with Git and GitHub + diff --git a/docs/cli/stack_queue.md b/docs/cli/stack_queue.md index bb2b07c..1d93ba2 100644 --- a/docs/cli/stack_queue.md +++ b/docs/cli/stack_queue.md @@ -4,11 +4,11 @@ Generated from the current Cobra command tree. ## stack queue -Hand one healthy bottom-of-stack PR to GitHub auto-merge or merge queue +Hand one verified trunk-bound PR or landing PR to GitHub auto-merge or merge queue ### Synopsis -Validate that one tracked branch is ready for trunk handoff, then ask GitHub to auto-merge or enqueue the PR using the current head commit. +Validate that one tracked trunk branch or recorded landing branch is ready for handoff, then ask GitHub to auto-merge or enqueue the PR using the current head commit. ``` stack queue [flags] @@ -18,6 +18,7 @@ stack queue [flags] ``` stack queue feature/a +stack queue stack/discovery-core stack queue feature/a --strategy squash stack queue feature/a --yes ``` diff --git a/docs/cli/stack_supersede.md b/docs/cli/stack_supersede.md new file mode 100644 index 0000000..73b47ec --- /dev/null +++ b/docs/cli/stack_supersede.md @@ -0,0 +1,37 @@ +# stack_supersede + +Generated from the current Cobra command tree. + +## stack supersede + +Mark original PRs as superseded by a landing PR + +### Synopsis + +Record explicit superseded PR linkage in local landing metadata and optionally comment on the original PRs with the landing PR reference. + +``` +stack supersede [flags] +``` + +### Examples + +``` +stack supersede --landing stack/discovery-core --prs 353,354,363,364 +stack supersede --landing stack/discovery-core --prs 353 --prs 354 --no-comment --yes +``` + +### Options + +``` + -h, --help help for supersede + --landing string Landing branch to use as the superseding target + --no-comment Record superseded linkage locally without posting GitHub comments + --prs stringArray Original PR numbers, comma-separated or repeated + --yes Skip confirmation +``` + +### SEE ALSO + +* [stack](stack.md) - Manage explicit stacked PR workflows with Git and GitHub + diff --git a/docs/cli/stack_verify.md b/docs/cli/stack_verify.md new file mode 100644 index 0000000..78a8030 --- /dev/null +++ b/docs/cli/stack_verify.md @@ -0,0 +1,24 @@ +# stack_verify + +Generated from the current Cobra command tree. + +## stack verify + +Attach and inspect lightweight verification records + +### Synopsis + +Store local-first verification evidence for tracked branches or composed landing branches. + +### Options + +``` + -h, --help help for verify +``` + +### SEE ALSO + +* [stack](stack.md) - Manage explicit stacked PR workflows with Git and GitHub +* [stack verify add](stack_verify_add.md) - Record one verification result for a branch +* [stack verify list](stack_verify_list.md) - List stored verification records for a branch + diff --git a/docs/cli/stack_verify_add.md b/docs/cli/stack_verify_add.md new file mode 100644 index 0000000..9049a82 --- /dev/null +++ b/docs/cli/stack_verify_add.md @@ -0,0 +1,40 @@ +# stack_verify_add + +Generated from the current Cobra command tree. + +## stack verify add + +Record one verification result for a branch + +### Synopsis + +Record local verification evidence against the current head of a tracked branch or composed landing branch. + +``` +stack verify add [flags] +``` + +### Examples + +``` +stack verify add stack/discovery-core --type sim --run-id abc123 --passed --score 100 +stack verify add feature/a --type manual --identifier smoke-check-42 --failed --note "deploy blocked" +``` + +### Options + +``` + --failed Mark the verification as failed + -h, --help help for add + --identifier string Verification identifier such as a run id, URL, or external check reference + --note string Optional operator note + --passed Mark the verification as passed + --run-id string Convenience alias for --identifier when recording a run id + --score int Optional numeric score + --type string Verification type such as sim, unit, integration, manual, deploy, or smoke +``` + +### SEE ALSO + +* [stack verify](stack_verify.md) - Attach and inspect lightweight verification records + diff --git a/docs/cli/stack_verify_list.md b/docs/cli/stack_verify_list.md new file mode 100644 index 0000000..a066643 --- /dev/null +++ b/docs/cli/stack_verify_list.md @@ -0,0 +1,26 @@ +# stack_verify_list + +Generated from the current Cobra command tree. + +## stack verify list + +List stored verification records for a branch + +### Synopsis + +Show local verification evidence stored for a tracked branch or composed landing branch. + +``` +stack verify list [flags] +``` + +### Options + +``` + -h, --help help for list +``` + +### SEE ALSO + +* [stack verify](stack_verify.md) - Attach and inspect lightweight verification records + diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6da2eac..19872db 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -3,7 +3,10 @@ package cmd import ( "encoding/json" "fmt" - "os" + "io" + "regexp" + "sort" + "strconv" "strings" "time" @@ -31,7 +34,7 @@ to GitHub merge queue via the gh CLI. SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { - return runStatus(runtime, false) + return runStatus(runtime, false, cmd.OutOrStdout()) }, } @@ -43,6 +46,11 @@ to GitHub merge queue via the gh CLI. newInitCommand(runtime), newCreateCommand(runtime), newTrackCommand(runtime), + newAdoptCommand(runtime), + newComposeCommand(runtime), + newVerifyCommand(runtime), + newCloseoutCommand(runtime), + newSupersedeCommand(runtime), newStatusCommand(runtime), newVersionCommand(), newTUICommand(runtime), @@ -60,7 +68,7 @@ to GitHub merge queue via the gh CLI. Short: "Alias for `stack status`", Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { - return runStatus(runtime, false) + return runStatus(runtime, false, cmd.OutOrStdout()) }, }) @@ -249,62 +257,40 @@ func newTrackCommand(runtime *stackruntime.Runtime) *cobra.Command { return cmd } -func newStatusCommand(runtime *stackruntime.Runtime) *cobra.Command { - var asJSON bool - +func newAdoptCommand(runtime *stackruntime.Runtime) *cobra.Command { cmd := &cobra.Command{ - Use: "status", - Short: "Show stack health, hierarchy, and cached PR state", - Long: "Inspect the current stack graph, branch health, cached PR state, and repo-level metadata issues.", - Example: strings.TrimSpace(` -stack status -stack status --json - `), - RunE: func(cmd *cobra.Command, args []string) error { - return runStatus(runtime, asJSON) - }, + Use: "adopt", + Short: "Adopt existing pull requests into explicit stack metadata", + Long: "Use explicit operator-chosen parents to adopt existing PR heads into the local stack graph.", } - cmd.Flags().BoolVar(&asJSON, "json", false, "Render status as JSON") + cmd.AddCommand(newAdoptPRCommand(runtime)) return cmd } -func newTUICommand(runtime *stackruntime.Runtime) *cobra.Command { - return &cobra.Command{ - Use: "tui", - Short: "Open the read-only stack dashboard", - Long: "Open a read-only Bubble Tea dashboard for browsing the stack tree, health details, and cached PR state.", - Example: strings.TrimSpace(` -stack tui - `), - RunE: func(cmd *cobra.Command, args []string) error { - state, err := runtime.Store.ReadState(runtime.Context) - if err != nil { - return err - } - summary, err := stack.BuildSummary(runtime.Context, runtime.Git, state) - if err != nil { - return err - } - return tui.Run(summary) - }, - } -} - -func newRestackCommand(runtime *stackruntime.Runtime) *cobra.Command { - var all bool +func newAdoptPRCommand(runtime *stackruntime.Runtime) *cobra.Command { + var parent string var yes bool cmd := &cobra.Command{ - Use: "restack [branch]", - Short: "Rebase a branch or subtree onto its configured parent", - Long: "Preview and restack one tracked branch or subtree. The command refuses invalid anchors and stops instead of guessing.", + Use: "pr ", + Short: "Adopt one open pull request into the stack graph", + Long: "Look up one open pull request, optionally fetch its head branch locally, then track it under an explicit parent branch.", Example: strings.TrimSpace(` -stack restack -stack restack feature/a -stack restack --all --yes +stack adopt pr 353 --parent main +stack adopt pr 354 --parent pr/353 --yes `), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if parent == "" { + return fmt.Errorf("--parent is required") + } + + prNumber, err := strconv.Atoi(args[0]) + if err != nil || prNumber <= 0 { + return fmt.Errorf("invalid pull request number %q", args[0]) + } + state, err := runtime.Store.ReadState(runtime.Context) if err != nil { return err @@ -316,143 +302,126 @@ stack restack --all --yes return err } - steps, err := restackSteps(runtime, state, args, all) + 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 + } + + pr, err := runtime.GitHub.ViewPR(runtime.Context, prNumber) if err != nil { return err } - if len(steps) == 0 { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Restack", []string{"No branches need restacking."})) - return nil + if pr.State != "" && pr.State != "OPEN" { + return fmt.Errorf("pull request #%d is %s; only open PRs can be adopted", prNumber, strings.ToLower(pr.State)) } - lines := make([]string, 0, len(steps)) - for _, step := range steps { - lines = append(lines, fmt.Sprintf("%s -> %s", step.Branch, step.Parent)) + branch := strings.TrimSpace(pr.HeadRefName) + if branch == "" { + return fmt.Errorf("pull request #%d has no head branch name", prNumber) } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Restack preview", lines)) + if _, exists := state.Branches[branch]; exists { + return fmt.Errorf("branch %q is already tracked", branch) + } + if err := stack.EnsureBranchCanParent(state, branch, parent); err != nil { + return err + } + + needsFetch := !runtime.Git.BranchExists(runtime.Context, branch) + preview := []string{ + fmt.Sprintf("pr: #%d", pr.Number), + fmt.Sprintf("branch: %s", branch), + fmt.Sprintf("parent: %s", parent), + } + if needsFetch { + preview = append(preview, fmt.Sprintf("fetch: %s/%s -> %s", state.DefaultRemote, branch, branch)) + } + if pr.BaseRefName != "" && pr.BaseRefName != parent { + preview = append(preview, fmt.Sprintf("note: PR #%d currently targets %s; `stack submit %s` will retarget it later", pr.Number, pr.BaseRefName, branch)) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Adopt PR preview", preview)) if !yes { - confirmed, err := forms.Confirm("Restack branches", "This will rewrite branch history for the listed branches.") + confirmed, err := forms.Confirm("Adopt pull request", "This may fetch a remote branch head and will update local stack metadata.") if err != nil { return err } if !confirmed { - return fmt.Errorf("restack cancelled") + return fmt.Errorf("adopt cancelled") } } - return runRestackPlan(runtime, state, steps, nil) - }, - } - - cmd.Flags().BoolVar(&all, "all", false, "Restack all tracked branches") - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") - return cmd -} - -func newContinueCommand(runtime *stackruntime.Runtime) *cobra.Command { - return &cobra.Command{ - Use: "continue", - Short: "Continue an interrupted restack after conflicts are resolved", - Long: "Resume a recorded restack only when the current worktree and git rebase state match the saved operation journal.", - Example: strings.TrimSpace(` -stack continue - `), - RunE: func(cmd *cobra.Command, args []string) error { - op, err := runtime.Store.ReadOperation(runtime.Context) - if err != nil { - return fmt.Errorf("no interrupted operation found") - } - if err := validateOperationContext(runtime, op); err != nil { - return err + if needsFetch { + if err := runtime.Git.FetchBranch(runtime.Context, state.DefaultRemote, branch, branch); err != nil { + return fmt.Errorf("fetch pull request #%d head %q from %s: %w", pr.Number, branch, state.DefaultRemote, err) + } } - rebaseInProgress, err := runtime.Git.RebaseInProgress(runtime.Context) + + parentOID, usedMergeBase, err := trackRestackAnchorDetail(runtime, branch, parent) if err != nil { return err } - if !rebaseInProgress { - return fmt.Errorf("no git rebase is currently in progress for this worktree") - } - if err := runtime.Git.RebaseContinue(runtime.Context); err != nil { - return err + record := store.BranchRecord{ + ParentBranch: parent, + RemoteName: state.DefaultRemote, + PR: pr, + Restack: store.RestackMetadata{ + LastParentHeadOID: parentOID, + }, } - - state, err := runtime.Store.ReadState(runtime.Context) - if err != nil { + plannedState := cloneRepoState(state) + plannedState.Branches[branch] = record + if err := ensureStateWritable(plannedState); err != nil { return err } - if err := ensureStateWritable(state); err != nil { + if err := runtime.Store.WriteState(runtime.Context, plannedState); err != nil { return err } - if record, ok := state.Branches[op.Active.Branch]; ok { - parentOID, _ := runtime.Git.ResolveRef(runtime.Context, op.Active.Parent) - record.Restack.LastParentHeadOID = parentOID - record.Restack.LastRestackedAt = time.Now().UTC().Format(time.RFC3339) - state.Branches[op.Active.Branch] = record - if err := runtime.Store.WriteState(runtime.Context, state); err != nil { - return err - } - } - - if len(op.Pending) == 0 { - if err := runtime.Store.ClearOperation(runtime.Context); err != nil { - return err - } - return restoreOriginalBranch(runtime, op) + lines := []string{ + fmt.Sprintf("branch: %s", branch), + fmt.Sprintf("parent: %s", parent), + fmt.Sprintf("pr: #%d", pr.Number), } - - return runRestackPlan(runtime, state, op.Pending, &op) - }, - } -} - -func newAbortCommand(runtime *stackruntime.Runtime) *cobra.Command { - return &cobra.Command{ - Use: "abort", - Short: "Abort an interrupted restack and clear the operation journal", - Long: "Abort the active git rebase in this worktree, clear the saved operation journal, and switch back to the original branch when possible.", - Example: strings.TrimSpace(` -stack abort - `), - RunE: func(cmd *cobra.Command, args []string) error { - op, _ := runtime.Store.ReadOperation(runtime.Context) - rebaseInProgress, err := runtime.Git.RebaseInProgress(runtime.Context) - if err != nil { - return err + if needsFetch { + lines = append(lines, fmt.Sprintf("fetched: %s/%s", state.DefaultRemote, branch)) } - if rebaseInProgress { - if err := runtime.Git.RebaseAbort(runtime.Context); err != nil { - return err - } + if usedMergeBase { + lines = append(lines, "restack anchor: merge-base with the selected parent") + } else { + lines = append(lines, "restack anchor: current parent head") } - if err := runtime.Store.ClearOperation(runtime.Context); err != nil { - return err + if pr.BaseRefName != "" && pr.BaseRefName != parent { + lines = append(lines, fmt.Sprintf("next: run `stack submit %s` when you want GitHub to target %s", branch, parent)) } - return restoreOriginalBranch(runtime, op) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Adopted pull request", lines)) + return nil }, } + + cmd.Flags().StringVar(&parent, "parent", "", "Parent branch or trunk") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") + return cmd } -func newMoveCommand(runtime *stackruntime.Runtime) *cobra.Command { - var parent string +func newComposeCommand(runtime *stackruntime.Runtime) *cobra.Command { + var branches []string + var from string + var to string var yes bool cmd := &cobra.Command{ - Use: "move ", - Short: "Change a branch parent and restack the affected subtree", - Long: "Change one tracked branch to a new parent, preview the rewrite, update metadata, and restack from the recorded anchor.", + Use: "compose ", + Short: "Compose a strict landing branch from selected tracked branches", + Long: "Create one ordinary local landing branch from an explicit linear branch selection and replay only the selected commits in order.", Example: strings.TrimSpace(` -stack move feature/b --parent feature/a -stack move feature/b --parent main --yes +stack compose discovery-core --from feature/a --to feature/c +stack compose discovery-core --branches feature/a --branches feature/b --yes `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if parent == "" { - return fmt.Errorf("--parent is required") - } - state, err := runtime.Store.ReadState(runtime.Context) if err != nil { return err @@ -464,540 +433,1858 @@ stack move feature/b --parent main --yes return err } - branch := args[0] - record, ok := state.Branches[branch] - if !ok { - return fmt.Errorf("branch %q is not tracked", branch) - } - if !runtime.Git.BranchExists(runtime.Context, parent) && parent != state.Trunk { - return fmt.Errorf("parent branch %q does not exist locally", parent) - } - if err := ensureTrackedParentAllowed(state, parent); err != nil { + selectedBranches, err := resolveComposeBranches(state, branches, from, to) + if err != nil { return err } - if err := stack.EnsureBranchCanParent(state, branch, parent); err != nil { + + plan, err := buildComposePlan(runtime, state, composeDestinationBranch(args[0]), selectedBranches) + if err != nil { return err } - preview := []string{fmt.Sprintf("%s: %s -> %s", branch, record.ParentBranch, parent)} - for _, descendant := range collectSubtree(state, branch)[1:] { - preview = append(preview, fmt.Sprintf("%s: restack on top of rewritten %s", descendant, state.Branches[descendant].ParentBranch)) - } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Move preview", preview)) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Compose preview", renderComposePreview(plan))) if !yes { - confirmed, err := forms.Confirm("Move branch", "This updates stack metadata and may rewrite descendant history.") + confirmed, err := forms.Confirm("Compose landing branch", "This creates a new local branch and cherry-picks the selected commits onto trunk.") if err != nil { return err } if !confirmed { - return fmt.Errorf("move cancelled") + return fmt.Errorf("compose cancelled") } } - previousParentHead := record.Restack.LastParentHeadOID - if previousParentHead == "" { - return fmt.Errorf("branch %q has no recorded restack anchor; cannot move safely", branch) + if err := runtime.Git.SwitchCreateFrom(runtime.Context, plan.Destination, plan.Base); err != nil { + return err } - plannedState := cloneRepoState(state) - record.ParentBranch = parent - plannedState.Branches[branch] = record + for _, branchPlan := range plan.Branches { + for _, commit := range branchPlan.Commits { + if err := runtime.Git.CherryPick(runtime.Context, commit.OID); err != nil { + return fmt.Errorf("compose stopped while replaying %s from %s onto %s: %w; inspect %s, resolve the cherry-pick, or run `git cherry-pick --abort` manually", shortOID(commit.OID), branchPlan.Name, plan.Destination, err, plan.Destination) + } + } + } - steps, err := restackStepsForTargets(runtime, plannedState, []string{branch}) - if err != nil { + state.Landings[plan.Destination] = store.LandingRecord{ + BaseBranch: plan.Base, + SourceBranches: append([]string(nil), selectedBranches...), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { return err } - if len(steps) == 0 { - steps = []store.RestackStep{{ - Branch: branch, - Parent: parent, - PreviousParentHead: previousParentHead, - PreviousBranchHead: resolveOID(runtime, branch), - }} + + lines := []string{ + fmt.Sprintf("branch: %s", plan.Destination), + fmt.Sprintf("base: %s", plan.Base), + fmt.Sprintf("replayed commits: %d", plan.CommitCount()), } - return runRestackPlan(runtime, plannedState, steps, nil) + lines = append(lines, plan.Warnings...) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Composed landing branch", lines)) + return nil }, } - cmd.Flags().StringVar(&parent, "parent", "", "New parent branch") + cmd.Flags().StringArrayVar(&branches, "branches", nil, "Tracked branches to include in explicit order") + cmd.Flags().StringVar(&from, "from", "", "Bottom branch of a contiguous tracked path") + cmd.Flags().StringVar(&to, "to", "", "Top branch of a contiguous tracked path") cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") return cmd } -func newSubmitCommand(runtime *stackruntime.Runtime) *cobra.Command { - var all bool - var noRestack bool - var draft bool - var yes bool +func newVerifyCommand(runtime *stackruntime.Runtime) *cobra.Command { + cmd := &cobra.Command{ + Use: "verify", + Short: "Attach and inspect lightweight verification records", + Long: "Store local-first verification evidence for tracked branches or composed landing branches.", + } + + cmd.AddCommand( + newVerifyAddCommand(runtime), + newVerifyListCommand(runtime), + ) + return cmd +} + +func newVerifyAddCommand(runtime *stackruntime.Runtime) *cobra.Command { + var checkType string + var identifier string + var runID string + var note string + var score int + var passed bool + var failed bool cmd := &cobra.Command{ - Use: "submit [branch]", - Short: "Push tracked branches and create or update one normal PR per branch", - Long: "Fetch, optionally restack, preview the push plan, then push tracked branches and create or refresh one normal GitHub PR per branch.", + Use: "add ", + Short: "Record one verification result for a branch", + Long: "Record local verification evidence against the current head of a tracked branch or composed landing branch.", Example: strings.TrimSpace(` -stack submit -stack submit feature/a --draft -stack submit --all --yes +stack verify add stack/discovery-core --type sim --run-id abc123 --passed --score 100 +stack verify add feature/a --type manual --identifier smoke-check-42 --failed --note "deploy blocked" `), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - state, err := runtime.Store.ReadState(runtime.Context) - if err != nil { - return err + branch := strings.TrimSpace(args[0]) + if branch == "" { + return fmt.Errorf("branch is required") } - if err := ensureStateWritable(state); err != nil { - return err + if strings.TrimSpace(checkType) == "" { + return fmt.Errorf("--type is required") } - if err := ensureNoPendingOperation(runtime); err != nil { - return err + if passed == failed { + return fmt.Errorf("set exactly one of --passed or --failed") + } + if strings.TrimSpace(identifier) != "" && strings.TrimSpace(runID) != "" { + return fmt.Errorf("use either --identifier or --run-id, not both") } - targets, err := selectTargets(runtime, state, args, all) + state, err := runtime.Store.ReadState(runtime.Context) if err != nil { return err } - - if !noRestack { - steps, err := restackStepsForTargets(runtime, state, targets) - if err != nil { - return err - } - if len(steps) > 0 { - if err := runRestackPlan(runtime, state, steps, nil); err != nil { - return err - } - state, err = runtime.Store.ReadState(runtime.Context) - if err != nil { - return err - } - } + if !runtime.Git.BranchExists(runtime.Context, branch) { + return fmt.Errorf("branch %q does not exist locally", branch) } - if err := runtime.Git.FetchPrune(runtime.Context, state.DefaultRemote); err != nil { + headOID, err := runtime.Git.ResolveRef(runtime.Context, branch) + if err != nil { return err } - plans := make([]submitPlan, 0, len(targets)) - for _, branch := range targets { - plan, err := buildSubmitPlan(runtime, state, branch) - if err != nil { - return err - } - plans = append(plans, plan) + resolvedIdentifier := strings.TrimSpace(identifier) + if resolvedIdentifier == "" { + resolvedIdentifier = strings.TrimSpace(runID) } - previewLines := make([]string, 0, len(plans)*3) - for _, plan := range plans { - previewLines = append(previewLines, plan.Preview...) + record := store.VerificationRecord{ + CheckType: strings.TrimSpace(checkType), + Identifier: resolvedIdentifier, + Passed: passed, + HeadOID: headOID, + RecordedAt: time.Now().UTC().Format(time.RFC3339), + Note: strings.TrimSpace(note), } - if len(previewLines) == 0 { - previewLines = append(previewLines, "Nothing to submit.") + if cmd.Flags().Changed("score") { + record.Score = &score } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Submit preview", previewLines)) - if !yes { - confirmed, err := forms.Confirm("Submit branches", "This may push new branch tips and update pull request state on GitHub.") - if err != nil { - return err + state.Verifications[branch] = append(state.Verifications[branch], record) + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + return err + } + + lines := []string{ + fmt.Sprintf("branch: %s", branch), + fmt.Sprintf("type: %s", record.CheckType), + fmt.Sprintf("result: %s", verificationResult(record)), + fmt.Sprintf("head: %s", shortOID(record.HeadOID)), + } + if record.Identifier != "" { + lines = append(lines, fmt.Sprintf("identifier: %s", record.Identifier)) + } + if record.Score != nil { + lines = append(lines, fmt.Sprintf("score: %d", *record.Score)) + } + if record.Note != "" { + lines = append(lines, fmt.Sprintf("note: %s", record.Note)) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Verification recorded", lines)) + return nil + }, + } + + cmd.Flags().StringVar(&checkType, "type", "", "Verification type such as sim, unit, integration, manual, deploy, or smoke") + cmd.Flags().StringVar(&identifier, "identifier", "", "Verification identifier such as a run id, URL, or external check reference") + cmd.Flags().StringVar(&runID, "run-id", "", "Convenience alias for --identifier when recording a run id") + cmd.Flags().StringVar(¬e, "note", "", "Optional operator note") + cmd.Flags().IntVar(&score, "score", 0, "Optional numeric score") + cmd.Flags().BoolVar(&passed, "passed", false, "Mark the verification as passed") + cmd.Flags().BoolVar(&failed, "failed", false, "Mark the verification as failed") + return cmd +} + +func newVerifyListCommand(runtime *stackruntime.Runtime) *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Short: "List stored verification records for a branch", + Long: "Show local verification evidence stored for a tracked branch or composed landing branch.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + branch := strings.TrimSpace(args[0]) + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err + } + + records := append([]store.VerificationRecord(nil), state.Verifications[branch]...) + if len(records) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Verification records", []string{ + fmt.Sprintf("branch: %s", branch), + "no verification records stored", + })) + return nil + } + + lines := []string{fmt.Sprintf("branch: %s", branch)} + for index := len(records) - 1; index >= 0; index-- { + record := records[index] + line := fmt.Sprintf("%s %s %s", record.RecordedAt, record.CheckType, verificationResult(record)) + if record.Identifier != "" { + line += fmt.Sprintf(" %s", record.Identifier) } - if !confirmed { - return fmt.Errorf("submit cancelled") + lines = append(lines, line) + lines = append(lines, fmt.Sprintf(" head: %s", shortOID(record.HeadOID))) + if record.Score != nil { + lines = append(lines, fmt.Sprintf(" score: %d", *record.Score)) + } + if record.Note != "" { + lines = append(lines, fmt.Sprintf(" note: %s", record.Note)) } } - for _, plan := range plans { - record := plan.Record - if err := runtime.Git.PushBranch(runtime.Context, state.DefaultRemote, plan.Branch, plan.RemoteHead); err != nil { - return err - } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Verification records", lines)) + return nil + }, + } - if record.PR.Number == 0 { - pr, err := runtime.GitHub.CreatePR(runtime.Context, record.ParentBranch, plan.Branch, plan.Metadata.Title, plan.Metadata.Body, draft) - if err != nil { - return err - } - record.PR = pr - } else if err := validateTrackedPR(plan.Branch, record); err != nil { - return err - } else if record.PR.BaseRefName != record.ParentBranch { - if err := runtime.GitHub.EditPRBase(runtime.Context, record.PR.Number, record.ParentBranch); err != nil { - return err - } - } + return cmd +} - record, err = refreshTrackedPR(runtime, state, plan.Branch, record) - if err != nil { - return err - } - state.Branches[plan.Branch] = record +func newCloseoutCommand(runtime *stackruntime.Runtime) *cobra.Command { + cmd := &cobra.Command{ + Use: "closeout ", + Short: "Plan read-only post-merge closeout for a landing branch", + Long: "Use recorded landing composition, pull request state, and verification records to show which original PRs and inferred tickets are safe to close now versus still blocked on deploy checks.", + Example: strings.TrimSpace(` +stack closeout stack/discovery-core + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err } - return runtime.Store.WriteState(runtime.Context, state) + plan, err := buildCloseoutPlan(runtime, state, strings.TrimSpace(args[0])) + if err != nil { + return err + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Closeout plan", renderCloseoutPlan(plan))) + return nil }, } - cmd.Flags().BoolVar(&all, "all", false, "Submit all tracked branches") - cmd.Flags().BoolVar(&noRestack, "no-restack", false, "Skip restack before submit") - cmd.Flags().BoolVar(&draft, "draft", false, "Create PRs as drafts when needed") - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") return cmd } -func newSyncCommand(runtime *stackruntime.Runtime) *cobra.Command { - var apply bool +func newSupersedeCommand(runtime *stackruntime.Runtime) *cobra.Command { + var landingBranch string + var prValues []string + var noComment bool + var yes bool cmd := &cobra.Command{ - Use: "sync", - Short: "Refresh cached PR metadata and inspect or apply safe repairs", - Long: "Refresh cached PR metadata from GitHub and either report or apply clean, classified repairs. Ambiguous cases stop for review.", + Use: "supersede", + Short: "Mark original PRs as superseded by a landing PR", + Long: "Record explicit superseded PR linkage in local landing metadata and optionally comment on the original PRs with the landing PR reference.", Example: strings.TrimSpace(` -stack sync -stack sync --apply +stack supersede --landing stack/discovery-core --prs 353,354,363,364 +stack supersede --landing stack/discovery-core --prs 353 --prs 354 --no-comment --yes `), RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(landingBranch) == "" { + return fmt.Errorf("--landing is required") + } + + prNumbers, err := parsePRNumbers(prValues) + if err != nil { + return err + } + if len(prNumbers) == 0 { + return fmt.Errorf("at least one PR number is required via --prs") + } + state, err := runtime.Store.ReadState(runtime.Context) if err != nil { return err } - if err := runtime.Git.FetchPrune(runtime.Context, state.DefaultRemote); err != nil { + plan, err := buildSupersedePlan(runtime, state, strings.TrimSpace(landingBranch), prNumbers) + if err != nil { return err } - repairs := make([]string, 0) - for _, branch := range orderedBranches(state) { - record := state.Branches[branch] - if record.PR.Number == 0 { - if runtime.Git.RemoteBranchExists(runtime.Context, state.DefaultRemote, branch) { - repairs = append(repairs, fmt.Sprintf("%s: remote branch exists but no PR is linked in local metadata; run `stack submit %s` to relink or create the PR", branch, branch)) - } - continue - } + lines := []string{ + fmt.Sprintf("landing branch: %s", plan.LandingBranch), + fmt.Sprintf("landing PR: #%d", plan.LandingPR.Number), + } + for _, pr := range plan.SupersededPRs { + lines = append(lines, fmt.Sprintf("superseded PR: #%d %s", pr.Number, strings.ToLower(pr.State))) + } + if noComment { + lines = append(lines, "comments: skipped by --no-comment") + } else { + lines = append(lines, fmt.Sprintf("comments: %d GitHub PR comments will be posted", len(plan.SupersededPRs))) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Supersede preview", lines)) - pr, err := runtime.GitHub.ViewPR(runtime.Context, record.PR.Number) + if !yes { + confirmed, err := forms.Confirm("Supersede PRs", "This records superseded PR linkage locally and may post comments on GitHub.") if err != nil { - repairs = append(repairs, fmt.Sprintf("%s: tracked PR #%d could not be loaded; run `stack status` and inspect the PR before resubmitting", branch, record.PR.Number)) - continue - } - record.PR = pr - state.Branches[branch] = record - - if pr.HeadRefName != "" && pr.HeadRefName != branch { - repairs = append(repairs, fmt.Sprintf("%s: PR head is %s, expected %s; repair or relink the PR before submitting again", branch, pr.HeadRefName, branch)) - } - if pr.BaseRefName != "" && pr.BaseRefName != record.ParentBranch { - repairs = append(repairs, fmt.Sprintf("%s: PR base is %s, expected %s; run `stack submit %s` to retarget it", branch, pr.BaseRefName, record.ParentBranch, branch)) - if apply && pr.State == "OPEN" && pr.HeadRefName == branch { - if err := runtime.GitHub.EditPRBase(runtime.Context, pr.Number, record.ParentBranch); err != nil { - return err - } - updatedPR, err := runtime.GitHub.ViewPR(runtime.Context, pr.Number) - if err != nil { - return err - } - record.PR = updatedPR - state.Branches[branch] = record - } + return err } - if !runtime.Git.RemoteBranchExists(runtime.Context, state.DefaultRemote, branch) { - repairs = append(repairs, fmt.Sprintf("%s: remote branch is missing; run `stack submit %s` to republish it", branch, branch)) + if !confirmed { + return fmt.Errorf("supersede cancelled") } + } - cleanMergedParent := pr.State == "MERGED" && - (pr.HeadRefName == "" || pr.HeadRefName == branch) && - (pr.BaseRefName == "" || pr.BaseRefName == record.ParentBranch) - - if pr.State == "MERGED" && !cleanMergedParent { - repairs = append(repairs, fmt.Sprintf("%s: merged parent has drifted PR metadata; run `stack status` and inspect the merged PR before reparenting children", branch)) - } + landing := state.Landings[plan.LandingBranch] + landing.SupersededPRs = append([]int(nil), prNumbers...) + landing.SupersededAt = time.Now().UTC().Format(time.RFC3339) + state.Landings[plan.LandingBranch] = landing + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + return err + } - if cleanMergedParent { - parentHeadOID := pr.LastSeenHeadOID - for _, child := range stack.Children(state, branch) { - childRecord := state.Branches[child] - if parentHeadOID != "" && childRecord.Restack.LastParentHeadOID == parentHeadOID { - repairs = append(repairs, fmt.Sprintf("%s: clean reparent %s -> %s", child, branch, record.ParentBranch)) - } else { - repairs = append(repairs, fmt.Sprintf("%s: merged parent %s needs manual review before reparenting; repair the branch graph, then rerun `stack sync --apply`", child, branch)) - continue - } - if apply { - childRecord.ParentBranch = record.ParentBranch - state.Branches[child] = childRecord - } + if !noComment { + body := fmt.Sprintf("This PR is superseded by landing PR #%d (%s) from `%s`.", plan.LandingPR.Number, plan.LandingPR.URL, plan.LandingBranch) + for _, pr := range plan.SupersededPRs { + if err := runtime.GitHub.CommentPR(runtime.Context, pr.Number, body); err != nil { + return err } - } else if pr.State == "CLOSED" { - repairs = append(repairs, fmt.Sprintf("%s: PR is closed without merge; relink or clear local metadata before continuing", branch)) } } - if err := runtime.Store.WriteState(runtime.Context, state); err != nil { - return err + result := []string{ + fmt.Sprintf("landing branch: %s", plan.LandingBranch), + fmt.Sprintf("landing PR: #%d", plan.LandingPR.Number), + fmt.Sprintf("superseded PRs: %s", joinPRNumbers(prNumbers)), } + if noComment { + result = append(result, "github comments: skipped") + } else { + result = append(result, "github comments: posted") + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Superseded PRs recorded", result)) + return nil + }, + } - title := "Sync report" - if apply { - title = "Sync applied" + cmd.Flags().StringVar(&landingBranch, "landing", "", "Landing branch to use as the superseding target") + cmd.Flags().StringArrayVar(&prValues, "prs", nil, "Original PR numbers, comma-separated or repeated") + cmd.Flags().BoolVar(&noComment, "no-comment", false, "Record superseded linkage locally without posting GitHub comments") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") + return cmd +} + +func newStatusCommand(runtime *stackruntime.Runtime) *cobra.Command { + var asJSON bool + + cmd := &cobra.Command{ + Use: "status", + Short: "Show stack health, hierarchy, and cached PR state", + Long: "Inspect the current stack graph, branch health, cached PR state, and repo-level metadata issues.", + Example: strings.TrimSpace(` +stack status +stack status --json + `), + RunE: func(cmd *cobra.Command, args []string) error { + return runStatus(runtime, asJSON, cmd.OutOrStdout()) + }, + } + + cmd.Flags().BoolVar(&asJSON, "json", false, "Render status as JSON") + return cmd +} + +func newTUICommand(runtime *stackruntime.Runtime) *cobra.Command { + return &cobra.Command{ + Use: "tui", + Short: "Open the read-only stack dashboard", + Long: "Open a read-only Bubble Tea dashboard for browsing the stack tree, health details, and cached PR state.", + Example: strings.TrimSpace(` +stack tui + `), + RunE: func(cmd *cobra.Command, args []string) error { + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err } - if len(repairs) == 0 { - repairs = append(repairs, "No clean repairs detected.") + summary, err := stack.BuildSummary(runtime.Context, runtime.Git, state) + if err != nil { + return err } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview(title, repairs)) - return nil + return tui.Run(summary) }, } +} + +func newRestackCommand(runtime *stackruntime.Runtime) *cobra.Command { + var all bool + var yes bool + + cmd := &cobra.Command{ + Use: "restack [branch]", + Short: "Rebase a branch or subtree onto its configured parent", + Long: "Preview and restack one tracked branch or subtree. The command refuses invalid anchors and stops instead of guessing.", + Example: strings.TrimSpace(` +stack restack +stack restack feature/a +stack restack --all --yes + `), + RunE: func(cmd *cobra.Command, args []string) error { + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err + } + if err := ensureStateWritable(state); err != nil { + return err + } + if err := ensureNoPendingOperation(runtime); err != nil { + return err + } + + steps, err := restackSteps(runtime, state, args, all) + if err != nil { + return err + } + if len(steps) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Restack", []string{"No branches need restacking."})) + return nil + } + + lines := make([]string, 0, len(steps)) + for _, step := range steps { + lines = append(lines, fmt.Sprintf("%s -> %s", step.Branch, step.Parent)) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Restack preview", lines)) + + if !yes { + confirmed, err := forms.Confirm("Restack branches", "This will rewrite branch history for the listed branches.") + if err != nil { + return err + } + if !confirmed { + return fmt.Errorf("restack cancelled") + } + } + + return runRestackPlan(runtime, state, steps, nil) + }, + } + + cmd.Flags().BoolVar(&all, "all", false, "Restack all tracked branches") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") + return cmd +} + +func newContinueCommand(runtime *stackruntime.Runtime) *cobra.Command { + return &cobra.Command{ + Use: "continue", + Short: "Continue an interrupted restack after conflicts are resolved", + Long: "Resume a recorded restack only when the current worktree and git rebase state match the saved operation journal.", + Example: strings.TrimSpace(` +stack continue + `), + RunE: func(cmd *cobra.Command, args []string) error { + op, err := runtime.Store.ReadOperation(runtime.Context) + if err != nil { + return fmt.Errorf("no interrupted operation found") + } + if err := validateOperationContext(runtime, op); err != nil { + return err + } + rebaseInProgress, err := runtime.Git.RebaseInProgress(runtime.Context) + if err != nil { + return err + } + if !rebaseInProgress { + return fmt.Errorf("no git rebase is currently in progress for this worktree") + } + + if err := runtime.Git.RebaseContinue(runtime.Context); err != nil { + return err + } + + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err + } + if err := ensureStateWritable(state); err != nil { + return err + } + + if record, ok := state.Branches[op.Active.Branch]; ok { + parentOID, _ := runtime.Git.ResolveRef(runtime.Context, op.Active.Parent) + record.Restack.LastParentHeadOID = parentOID + record.Restack.LastRestackedAt = time.Now().UTC().Format(time.RFC3339) + state.Branches[op.Active.Branch] = record + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + return err + } + } + + if len(op.Pending) == 0 { + if err := runtime.Store.ClearOperation(runtime.Context); err != nil { + return err + } + return restoreOriginalBranch(runtime, op) + } + + return runRestackPlan(runtime, state, op.Pending, &op) + }, + } +} + +func newAbortCommand(runtime *stackruntime.Runtime) *cobra.Command { + return &cobra.Command{ + Use: "abort", + Short: "Abort an interrupted restack and clear the operation journal", + Long: "Abort the active git rebase in this worktree, clear the saved operation journal, and switch back to the original branch when possible.", + Example: strings.TrimSpace(` +stack abort + `), + RunE: func(cmd *cobra.Command, args []string) error { + op, _ := runtime.Store.ReadOperation(runtime.Context) + rebaseInProgress, err := runtime.Git.RebaseInProgress(runtime.Context) + if err != nil { + return err + } + if rebaseInProgress { + if err := runtime.Git.RebaseAbort(runtime.Context); err != nil { + return err + } + } + if err := runtime.Store.ClearOperation(runtime.Context); err != nil { + return err + } + return restoreOriginalBranch(runtime, op) + }, + } +} + +func newMoveCommand(runtime *stackruntime.Runtime) *cobra.Command { + var parent string + var yes bool + + cmd := &cobra.Command{ + Use: "move ", + Short: "Change a branch parent and restack the affected subtree", + Long: "Change one tracked branch to a new parent, preview the rewrite, update metadata, and restack from the recorded anchor.", + Example: strings.TrimSpace(` +stack move feature/b --parent feature/a +stack move feature/b --parent main --yes + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if parent == "" { + return fmt.Errorf("--parent is required") + } + + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err + } + if err := ensureStateWritable(state); err != nil { + return err + } + if err := ensureNoPendingOperation(runtime); err != nil { + return err + } + + branch := args[0] + record, ok := state.Branches[branch] + if !ok { + return fmt.Errorf("branch %q is not tracked", branch) + } + if !runtime.Git.BranchExists(runtime.Context, parent) && parent != state.Trunk { + 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 + } + + preview := []string{fmt.Sprintf("%s: %s -> %s", branch, record.ParentBranch, parent)} + for _, descendant := range collectSubtree(state, branch)[1:] { + preview = append(preview, fmt.Sprintf("%s: restack on top of rewritten %s", descendant, state.Branches[descendant].ParentBranch)) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Move preview", preview)) + + if !yes { + confirmed, err := forms.Confirm("Move branch", "This updates stack metadata and may rewrite descendant history.") + if err != nil { + return err + } + if !confirmed { + return fmt.Errorf("move cancelled") + } + } + + previousParentHead := record.Restack.LastParentHeadOID + if previousParentHead == "" { + return fmt.Errorf("branch %q has no recorded restack anchor; cannot move safely", branch) + } + + plannedState := cloneRepoState(state) + record.ParentBranch = parent + plannedState.Branches[branch] = record + + steps, err := restackStepsForTargets(runtime, plannedState, []string{branch}) + if err != nil { + return err + } + if len(steps) == 0 { + steps = []store.RestackStep{{ + Branch: branch, + Parent: parent, + PreviousParentHead: previousParentHead, + PreviousBranchHead: resolveOID(runtime, branch), + }} + } + return runRestackPlan(runtime, plannedState, steps, nil) + }, + } + + cmd.Flags().StringVar(&parent, "parent", "", "New parent branch") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") + return cmd +} + +func newSubmitCommand(runtime *stackruntime.Runtime) *cobra.Command { + var all bool + var noRestack bool + var draft bool + var yes bool + + cmd := &cobra.Command{ + Use: "submit [branch]", + Short: "Push tracked branches and create or update one normal PR per branch", + Long: "Fetch, optionally restack, preview the push plan, then push tracked branches and create or refresh one normal GitHub PR per branch.", + Example: strings.TrimSpace(` +stack submit +stack submit feature/a --draft +stack submit --all --yes + `), + RunE: func(cmd *cobra.Command, args []string) error { + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err + } + if err := ensureStateWritable(state); err != nil { + return err + } + if err := ensureNoPendingOperation(runtime); err != nil { + return err + } + + targets, err := selectTargets(runtime, state, args, all) + if err != nil { + return err + } + + if !noRestack { + steps, err := restackStepsForTargets(runtime, state, targets) + if err != nil { + return err + } + if len(steps) > 0 { + if err := runRestackPlan(runtime, state, steps, nil); err != nil { + return err + } + state, err = runtime.Store.ReadState(runtime.Context) + if err != nil { + return err + } + } + } + + if err := runtime.Git.FetchPrune(runtime.Context, state.DefaultRemote); err != nil { + return err + } + + plans := make([]submitPlan, 0, len(targets)) + for _, branch := range targets { + plan, err := buildSubmitPlan(runtime, state, branch) + if err != nil { + return err + } + plans = append(plans, plan) + } + + previewLines := make([]string, 0, len(plans)*3) + for _, plan := range plans { + previewLines = append(previewLines, plan.Preview...) + } + if len(previewLines) == 0 { + previewLines = append(previewLines, "Nothing to submit.") + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Submit preview", previewLines)) + + if !yes { + confirmed, err := forms.Confirm("Submit branches", "This may push new branch tips and update pull request state on GitHub.") + if err != nil { + return err + } + if !confirmed { + return fmt.Errorf("submit cancelled") + } + } + + for _, plan := range plans { + record := plan.Record + if err := runtime.Git.PushBranch(runtime.Context, state.DefaultRemote, plan.Branch, plan.RemoteHead); err != nil { + return err + } + + if record.PR.Number == 0 { + pr, err := runtime.GitHub.CreatePR(runtime.Context, record.ParentBranch, plan.Branch, plan.Metadata.Title, plan.Metadata.Body, draft) + if err != nil { + return err + } + record.PR = pr + } else if err := validateTrackedPR(plan.Branch, record); err != nil { + return err + } else if record.PR.BaseRefName != record.ParentBranch { + if err := runtime.GitHub.EditPRBase(runtime.Context, record.PR.Number, record.ParentBranch); err != nil { + return err + } + } + + record, err = refreshTrackedPR(runtime, state, plan.Branch, record) + if err != nil { + return err + } + state.Branches[plan.Branch] = record + } + + return runtime.Store.WriteState(runtime.Context, state) + }, + } + + cmd.Flags().BoolVar(&all, "all", false, "Submit all tracked branches") + cmd.Flags().BoolVar(&noRestack, "no-restack", false, "Skip restack before submit") + cmd.Flags().BoolVar(&draft, "draft", false, "Create PRs as drafts when needed") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") + return cmd +} + +func newSyncCommand(runtime *stackruntime.Runtime) *cobra.Command { + var apply bool + + cmd := &cobra.Command{ + Use: "sync", + Short: "Refresh cached PR metadata and inspect or apply safe repairs", + Long: "Refresh cached PR metadata from GitHub and either report or apply clean, classified repairs. Ambiguous cases stop for review.", + Example: strings.TrimSpace(` +stack sync +stack sync --apply + `), + RunE: func(cmd *cobra.Command, args []string) error { + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err + } + + if err := runtime.Git.FetchPrune(runtime.Context, state.DefaultRemote); err != nil { + return err + } + + repairs := make([]string, 0) + for _, branch := range orderedBranches(state) { + record := state.Branches[branch] + if record.PR.Number == 0 { + if runtime.Git.RemoteBranchExists(runtime.Context, state.DefaultRemote, branch) { + repairs = append(repairs, fmt.Sprintf("%s: remote branch exists but no PR is linked in local metadata; run `stack submit %s` to relink or create the PR", branch, branch)) + } + continue + } + + pr, err := runtime.GitHub.ViewPR(runtime.Context, record.PR.Number) + if err != nil { + repairs = append(repairs, fmt.Sprintf("%s: tracked PR #%d could not be loaded; run `stack status` and inspect the PR before resubmitting", branch, record.PR.Number)) + continue + } + record.PR = pr + state.Branches[branch] = record + + if pr.HeadRefName != "" && pr.HeadRefName != branch { + repairs = append(repairs, fmt.Sprintf("%s: PR head is %s, expected %s; repair or relink the PR before submitting again", branch, pr.HeadRefName, branch)) + } + if pr.BaseRefName != "" && pr.BaseRefName != record.ParentBranch { + repairs = append(repairs, fmt.Sprintf("%s: PR base is %s, expected %s; run `stack submit %s` to retarget it", branch, pr.BaseRefName, record.ParentBranch, branch)) + if apply && pr.State == "OPEN" && pr.HeadRefName == branch { + if err := runtime.GitHub.EditPRBase(runtime.Context, pr.Number, record.ParentBranch); err != nil { + return err + } + updatedPR, err := runtime.GitHub.ViewPR(runtime.Context, pr.Number) + if err != nil { + return err + } + record.PR = updatedPR + state.Branches[branch] = record + } + } + if !runtime.Git.RemoteBranchExists(runtime.Context, state.DefaultRemote, branch) { + repairs = append(repairs, fmt.Sprintf("%s: remote branch is missing; run `stack submit %s` to republish it", branch, branch)) + } + + cleanMergedParent := pr.State == "MERGED" && + (pr.HeadRefName == "" || pr.HeadRefName == branch) && + (pr.BaseRefName == "" || pr.BaseRefName == record.ParentBranch) + + if pr.State == "MERGED" && !cleanMergedParent { + repairs = append(repairs, fmt.Sprintf("%s: merged parent has drifted PR metadata; run `stack status` and inspect the merged PR before reparenting children", branch)) + } + + if cleanMergedParent { + parentHeadOID := pr.LastSeenHeadOID + for _, child := range stack.Children(state, branch) { + childRecord := state.Branches[child] + if parentHeadOID != "" && childRecord.Restack.LastParentHeadOID == parentHeadOID { + repairs = append(repairs, fmt.Sprintf("%s: clean reparent %s -> %s", child, branch, record.ParentBranch)) + } else { + repairs = append(repairs, fmt.Sprintf("%s: merged parent %s needs manual review before reparenting; repair the branch graph, then rerun `stack sync --apply`", child, branch)) + continue + } + if apply { + childRecord.ParentBranch = record.ParentBranch + state.Branches[child] = childRecord + } + } + } else if pr.State == "CLOSED" { + repairs = append(repairs, fmt.Sprintf("%s: PR is closed without merge; relink or clear local metadata before continuing", branch)) + } + } + + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + return err + } + + title := "Sync report" + if apply { + title = "Sync applied" + } + if len(repairs) == 0 { + repairs = append(repairs, "No clean repairs detected.") + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview(title, repairs)) + return nil + }, + } + + cmd.Flags().BoolVar(&apply, "apply", false, "Apply clean merged-parent repairs") + return cmd +} + +func newQueueCommand(runtime *stackruntime.Runtime) *cobra.Command { + var yes bool + var strategy string + + cmd := &cobra.Command{ + Use: "queue ", + Short: "Hand one verified trunk-bound PR or landing PR to GitHub auto-merge or merge queue", + Long: "Validate that one tracked trunk branch or recorded landing branch is ready for handoff, then ask GitHub to auto-merge or enqueue the PR using the current head commit.", + Example: strings.TrimSpace(` +stack queue feature/a +stack queue stack/discovery-core +stack queue feature/a --strategy squash +stack queue feature/a --yes + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !isValidQueueStrategy(strategy) { + return fmt.Errorf("invalid queue strategy %q; expected merge, squash, or rebase", strategy) + } + + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err + } + if err := ensureStateWritable(state); err != nil { + return err + } + if err := runtime.Git.FetchPrune(runtime.Context, state.DefaultRemote); err != nil { + return err + } + + plan, err := buildQueuePlan(runtime, state, args[0]) + if err != nil { + return err + } + + lines := []string{ + fmt.Sprintf("branch: %s", plan.Branch), + fmt.Sprintf("pr: #%d", plan.PR.Number), + fmt.Sprintf("strategy: %s", strategy), + fmt.Sprintf("head: %s", plan.HeadOID), + } + if plan.IsLanding { + lines = append(lines, "type: landing") + } else { + lines = append(lines, "type: tracked") + } + if plan.Verification != nil { + lines = append(lines, fmt.Sprintf("verification: %s", renderQueueVerification(*plan.Verification))) + } + if len(plan.ExcludedPRs) > 0 { + lines = append(lines, fmt.Sprintf("keep out of queue: %s", joinPRNumbers(plan.ExcludedPRs))) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Queue handoff", lines)) + + if !yes { + confirmed, err := forms.Confirm("Queue branch", "This hands the current PR head to GitHub auto-merge or merge queue.") + if err != nil { + return err + } + if !confirmed { + return fmt.Errorf("queue cancelled") + } + } + + if err := runtime.GitHub.MergePR(runtime.Context, plan.PR.Number, plan.HeadOID, strategy); err != nil { + return err + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Next steps", plan.NextSteps)) + return nil + }, + } + + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") + cmd.Flags().StringVar(&strategy, "strategy", "merge", "Merge strategy: merge, squash, or rebase") + return cmd +} + +func isValidQueueStrategy(strategy string) bool { + switch strategy { + case "merge", "squash", "rebase": + return true + default: + return false + } +} + +func runStatus(runtime *stackruntime.Runtime, asJSON bool, writer io.Writer) error { + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + return err + } + + summary, err := stack.BuildSummary(runtime.Context, runtime.Git, state) + if err != nil { + return err + } + + if asJSON { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(summary) + } + + _, _ = fmt.Fprintln(writer, ui.RenderStatus(summary)) + return nil +} + +type submitPlan struct { + Branch string + LocalHead string + RemoteHead string + RemoteExists bool + Metadata submitPRMetadata + Record store.BranchRecord + Preview []string +} + +type submitPRMetadata struct { + Title string + Body string + TitleSource string + BodySource string +} + +type composePlan struct { + Destination string + Base string + Branches []composeBranchPlan + Warnings []string +} + +type composeBranchPlan struct { + Name string + Parent string + Commits []composeCommitPlan +} + +type composeCommitPlan struct { + OID string + Subject string +} + +type closeoutPlan struct { + LandingBranch string + BaseBranch string + LandingPR *store.PullRequest + LandingPRAmbiguous []store.PullRequest + SourceBranches []closeoutBranchPlan + TicketsSafeToClose []string + TicketsPendingPostDeploy []string + SupersededNow []string + SupersededPending []string + FollowUps []string +} + +type closeoutBranchPlan struct { + Name string + PR *store.PullRequest +} + +type supersedePlan struct { + LandingBranch string + LandingPR store.PullRequest + SupersededPRs []store.PullRequest +} + +type queuePlan struct { + Branch string + PR store.PullRequest + HeadOID string + IsLanding bool + Verification *queueVerification + ExcludedPRs []int + NextSteps []string +} + +type queueVerification struct { + Latest store.VerificationRecord + HeadMatchesCurrent bool +} + +func (plan composePlan) CommitCount() int { + total := 0 + for _, branchPlan := range plan.Branches { + total += len(branchPlan.Commits) + } + return total +} + +func buildSubmitPlan(runtime *stackruntime.Runtime, state store.RepoState, branch string) (submitPlan, error) { + record, ok := state.Branches[branch] + if !ok { + return submitPlan{}, fmt.Errorf("branch %q is no longer tracked", branch) + } + + localHeadOID, err := runtime.Git.ResolveRef(runtime.Context, branch) + if err != nil { + return submitPlan{}, err + } + + record, err = refreshTrackedPR(runtime, state, branch, record) + if err != nil { + return submitPlan{}, err + } + + remoteHeadOID, remoteExists, err := runtime.Git.RemoteBranchOID(runtime.Context, state.DefaultRemote, branch) + if err != nil { + return submitPlan{}, err + } + if err := validateTrackedPR(branch, record); err != nil && record.PR.Number > 0 { + return submitPlan{}, err + } + if err := validateRemoteAgreement(branch, record, localHeadOID, remoteHeadOID, remoteExists); err != nil { + return submitPlan{}, err + } + + preview := []string{fmt.Sprintf("%s -> %s", branch, record.ParentBranch)} + metadata := submitPRMetadata{} + if !remoteExists { + preview = append(preview, "push new remote branch") + } else if remoteHeadOID != localHeadOID { + preview = append(preview, "force-push remote branch with explicit lease") + } else { + preview = append(preview, "remote branch already matches local head") + } + + if record.PR.Number == 0 { + metadata, err = resolveSubmitPRMetadata(runtime, branch, record.ParentBranch) + if err != nil { + return submitPlan{}, err + } + preview = append(preview, "create GitHub pull request") + preview = append(preview, fmt.Sprintf("PR title: %q (%s)", metadata.Title, metadata.TitleSource)) + preview = append(preview, fmt.Sprintf("PR body: %s", metadata.BodySource)) + } else if record.PR.BaseRefName != "" && record.PR.BaseRefName != record.ParentBranch { + preview = append(preview, fmt.Sprintf("retarget PR #%d base to %s", record.PR.Number, record.ParentBranch)) + } else { + preview = append(preview, fmt.Sprintf("refresh tracked PR #%d", record.PR.Number)) + } + + return submitPlan{ + Branch: branch, + LocalHead: localHeadOID, + RemoteHead: remoteHeadOID, + RemoteExists: remoteExists, + Metadata: metadata, + Record: record, + Preview: preview, + }, nil +} + +func resolveSubmitPRMetadata(runtime *stackruntime.Runtime, branch string, parent string) (submitPRMetadata, error) { + title, body, err := runtime.Git.CommitMessage(runtime.Context, branch) + if err != nil { + return submitPRMetadata{}, err + } + + metadata := submitPRMetadata{ + Title: strings.TrimSpace(title), + Body: strings.TrimSpace(body), + TitleSource: "commit subject", + BodySource: "commit body", + } + + if metadata.Title == "" { + metadata.Title = branch + metadata.TitleSource = "branch name fallback" + } + + if metadata.Body == "" { + metadata.Body = fmt.Sprintf("Stack branch `%s` targeting `%s`.\n\nGenerated by `stack submit` because the tip commit body was empty.", branch, parent) + metadata.BodySource = "generated default" + } + + return metadata, nil +} + +func buildComposePlan(runtime *stackruntime.Runtime, state store.RepoState, destination string, branches []string) (composePlan, error) { + if len(branches) == 0 { + return composePlan{}, fmt.Errorf("compose needs at least one tracked branch") + } + if runtime.Git.BranchExists(runtime.Context, destination) { + return composePlan{}, fmt.Errorf("branch %q already exists locally", destination) + } + if !runtime.Git.BranchExists(runtime.Context, state.Trunk) { + return composePlan{}, fmt.Errorf("trunk branch %q does not exist locally", state.Trunk) + } + + plan := composePlan{ + Destination: destination, + Base: state.Trunk, + Branches: make([]composeBranchPlan, 0, len(branches)), + } + + for index, branch := range branches { + record, ok := state.Branches[branch] + if !ok { + return composePlan{}, fmt.Errorf("branch %q is not tracked", branch) + } + if !runtime.Git.BranchExists(runtime.Context, branch) { + return composePlan{}, fmt.Errorf("branch %q does not exist locally", branch) + } + if record.ParentBranch != state.Trunk && !runtime.Git.BranchExists(runtime.Context, record.ParentBranch) { + return composePlan{}, fmt.Errorf("parent branch %q for %q does not exist locally", record.ParentBranch, branch) + } + if index > 0 && record.ParentBranch != branches[index-1] { + return composePlan{}, fmt.Errorf("compose branches must form a contiguous parent chain; expected %q to parent %q, got %q", branches[index-1], branch, record.ParentBranch) + } + + commits, err := runtime.Git.RangeCommits(runtime.Context, record.ParentBranch, branch) + if err != nil { + return composePlan{}, err + } + + branchPlan := composeBranchPlan{ + Name: branch, + Parent: record.ParentBranch, + Commits: make([]composeCommitPlan, 0, len(commits)), + } + for _, commit := range commits { + branchPlan.Commits = append(branchPlan.Commits, composeCommitPlan{ + OID: commit.OID, + Subject: commit.Subject, + }) + } + plan.Branches = append(plan.Branches, branchPlan) + } + + firstParent := plan.Branches[0].Parent + if firstParent != state.Trunk { + plan.Warnings = append(plan.Warnings, fmt.Sprintf("warning: %s currently targets %s; compose will replay only its unique commits onto %s", plan.Branches[0].Name, firstParent, state.Trunk)) + } + if plan.CommitCount() == 0 { + return composePlan{}, fmt.Errorf("selected branches have no unique commits to compose") + } + + return plan, nil +} + +func renderComposePreview(plan composePlan) []string { + lines := []string{ + fmt.Sprintf("destination: %s", plan.Destination), + fmt.Sprintf("base: %s", plan.Base), + } + lines = append(lines, plan.Warnings...) + + for _, branchPlan := range plan.Branches { + lines = append(lines, fmt.Sprintf("branch: %s (relative to %s)", branchPlan.Name, branchPlan.Parent)) + if len(branchPlan.Commits) == 0 { + lines = append(lines, " no unique commits") + continue + } + + for _, commit := range branchPlan.Commits { + subject := commit.Subject + if subject == "" { + subject = "(empty subject)" + } + lines = append(lines, fmt.Sprintf(" %s %s", shortOID(commit.OID), subject)) + } + } + + lines = append(lines, fmt.Sprintf("total commits: %d", plan.CommitCount())) + return lines +} + +func resolveComposeBranches(state store.RepoState, explicit []string, from string, to string) ([]string, error) { + if len(explicit) > 0 { + if from != "" || to != "" { + return nil, fmt.Errorf("use either --branches or --from/--to, not both") + } + return validateComposeExplicitBranches(state, explicit) + } + + if from == "" && to == "" { + return nil, fmt.Errorf("compose requires either repeated --branches or both --from and --to") + } + if from == "" || to == "" { + return nil, fmt.Errorf("compose requires both --from and --to") + } + + if _, ok := state.Branches[from]; !ok { + return nil, fmt.Errorf("branch %q is not tracked", from) + } + if _, ok := state.Branches[to]; !ok { + return nil, fmt.Errorf("branch %q is not tracked", to) + } + + path := make([]string, 0) + current := to + for { + path = append(path, current) + if current == from { + break + } + + record, ok := state.Branches[current] + if !ok || record.ParentBranch == "" || record.ParentBranch == state.Trunk { + return nil, fmt.Errorf("branch %q is not an ancestor of %q in tracked metadata", from, to) + } + current = record.ParentBranch + } + + for left, right := 0, len(path)-1; left < right; left, right = left+1, right-1 { + path[left], path[right] = path[right], path[left] + } + return path, nil +} + +func validateComposeExplicitBranches(state store.RepoState, explicit []string) ([]string, error) { + seen := map[string]bool{} + ordered := make([]string, 0, len(explicit)) + + for index, branch := range explicit { + if _, ok := state.Branches[branch]; !ok { + return nil, fmt.Errorf("branch %q is not tracked", branch) + } + if seen[branch] { + return nil, fmt.Errorf("branch %q was selected more than once", branch) + } + if index > 0 { + parent := state.Branches[branch].ParentBranch + if parent != explicit[index-1] { + return nil, fmt.Errorf("compose branches must form a contiguous parent chain; expected %q to parent %q, got %q", explicit[index-1], branch, parent) + } + } + seen[branch] = true + ordered = append(ordered, branch) + } + + return ordered, nil +} + +func composeDestinationBranch(name string) string { + trimmed := strings.TrimSpace(name) + if strings.Contains(trimmed, "/") { + return trimmed + } + return "stack/" + trimmed +} + +func buildCloseoutPlan(runtime *stackruntime.Runtime, state store.RepoState, landingBranch string) (closeoutPlan, error) { + landing, ok := state.Landings[landingBranch] + if !ok { + return closeoutPlan{}, fmt.Errorf("landing branch %q is not recorded in local compose metadata; compose it first or repair the landing record", landingBranch) + } + + plan := closeoutPlan{ + LandingBranch: landingBranch, + BaseBranch: landing.BaseBranch, + SourceBranches: make([]closeoutBranchPlan, 0, len(landing.SourceBranches)), + } + + landingPRs, err := runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) + if err != nil { + return closeoutPlan{}, err + } + switch len(landingPRs) { + case 0: + case 1: + pr := landingPRs[0] + plan.LandingPR = &pr + default: + plan.LandingPRAmbiguous = append(plan.LandingPRAmbiguous, landingPRs...) + } + + ticketSet := map[string]bool{} + for _, branch := range landing.SourceBranches { + branchPlan := closeoutBranchPlan{Name: branch} + record, tracked := state.Branches[branch] + if tracked { + record, err = refreshTrackedPR(runtime, state, branch, record) + if err == nil && record.PR.Number > 0 { + pr := record.PR + branchPlan.PR = &pr + } + } + plan.SourceBranches = append(plan.SourceBranches, branchPlan) + + for _, ticket := range extractTicketRefs(branch) { + ticketSet[ticket] = true + } + } + if len(landing.SupersededPRs) > 0 { + plan.SourceBranches = plan.SourceBranches[:0] + for _, number := range landing.SupersededPRs { + pr, err := runtime.GitHub.ViewPR(runtime.Context, number) + if err != nil { + plan.FollowUps = append(plan.FollowUps, fmt.Sprintf("tracked superseded PR #%d could not be loaded; inspect it manually before closeout", number)) + continue + } + plan.SourceBranches = append(plan.SourceBranches, closeoutBranchPlan{ + Name: pr.HeadRefName, + PR: &pr, + }) + for _, ticket := range extractTicketRefs(pr.HeadRefName) { + ticketSet[ticket] = true + } + } + } + + tickets := mapKeys(ticketSet) + sort.Strings(tickets) + + if plan.LandingPR == nil { + plan.FollowUps = append(plan.FollowUps, fmt.Sprintf("open or relink the landing PR for %s before treating source PRs as superseded", landingBranch)) + } else if plan.LandingPR.State != "MERGED" { + plan.FollowUps = append(plan.FollowUps, fmt.Sprintf("wait for landing PR #%d to merge before closing source PRs as superseded", plan.LandingPR.Number)) + } + if len(plan.LandingPRAmbiguous) > 0 { + numbers := make([]string, 0, len(plan.LandingPRAmbiguous)) + for _, pr := range plan.LandingPRAmbiguous { + numbers = append(numbers, fmt.Sprintf("#%d %s", pr.Number, strings.ToLower(pr.State))) + } + sort.Strings(numbers) + plan.FollowUps = append(plan.FollowUps, fmt.Sprintf("resolve ambiguous landing PR ownership for %s: %s", landingBranch, strings.Join(numbers, ", "))) + } + + deployReady, deployFollowUp := closeoutDeployState(state.Verifications[landingBranch], resolveOID(runtime, landingBranch)) + if deployFollowUp != "" { + plan.FollowUps = append(plan.FollowUps, deployFollowUp) + } + + for _, branch := range plan.SourceBranches { + if branch.PR == nil || branch.PR.Number == 0 { + continue + } + label := fmt.Sprintf("#%d %s", branch.PR.Number, branch.Name) + if plan.LandingPR != nil && plan.LandingPR.State == "MERGED" && branch.PR.State == "OPEN" { + plan.SupersededNow = append(plan.SupersededNow, label) + continue + } + if branch.PR.State == "OPEN" { + plan.SupersededPending = append(plan.SupersededPending, label) + } + } + + if deployReady { + plan.TicketsSafeToClose = append(plan.TicketsSafeToClose, tickets...) + } else { + plan.TicketsPendingPostDeploy = append(plan.TicketsPendingPostDeploy, tickets...) + } + + sort.Strings(plan.SupersededNow) + sort.Strings(plan.SupersededPending) + sort.Strings(plan.TicketsSafeToClose) + sort.Strings(plan.TicketsPendingPostDeploy) + sort.Strings(plan.FollowUps) + + return plan, nil +} + +func buildSupersedePlan(runtime *stackruntime.Runtime, state store.RepoState, landingBranch string, prNumbers []int) (supersedePlan, error) { + landing, ok := state.Landings[landingBranch] + if !ok { + return supersedePlan{}, fmt.Errorf("landing branch %q is not recorded in local compose metadata; compose it first or repair the landing record", landingBranch) + } + if len(landing.SourceBranches) == 0 { + return supersedePlan{}, fmt.Errorf("landing branch %q has no recorded source branches to supersede", landingBranch) + } + + landingPRs, err := runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) + if err != nil { + return supersedePlan{}, err + } + if len(landingPRs) == 0 { + return supersedePlan{}, fmt.Errorf("landing branch %q has no pull request yet; open the landing PR before superseding original PRs", landingBranch) + } + if len(landingPRs) > 1 { + numbers := make([]string, 0, len(landingPRs)) + for _, pr := range landingPRs { + numbers = append(numbers, fmt.Sprintf("#%d %s", pr.Number, strings.ToLower(pr.State))) + } + sort.Strings(numbers) + return supersedePlan{}, fmt.Errorf("landing branch %q matches multiple PRs: %s; resolve the ambiguity before superseding originals", landingBranch, strings.Join(numbers, ", ")) + } + + plan := supersedePlan{ + LandingBranch: landingBranch, + LandingPR: landingPRs[0], + SupersededPRs: make([]store.PullRequest, 0, len(prNumbers)), + } + + for _, number := range prNumbers { + if number == plan.LandingPR.Number { + return supersedePlan{}, fmt.Errorf("landing PR #%d cannot supersede itself", number) + } + pr, err := runtime.GitHub.ViewPR(runtime.Context, number) + if err != nil { + return supersedePlan{}, err + } + plan.SupersededPRs = append(plan.SupersededPRs, pr) + } + + return plan, nil +} + +func closeoutDeployState(records []store.VerificationRecord, currentHead string) (bool, string) { + if currentHead == "" { + return false, "landing branch is missing locally; cannot judge deploy closeout safely" + } + + for index := len(records) - 1; index >= 0; index-- { + record := records[index] + if !isDeployVerification(record.CheckType) { + continue + } + if record.HeadOID != "" && record.HeadOID != currentHead { + continue + } + if record.Passed { + return true, "" + } + return false, fmt.Sprintf("latest %s verification for the current landing head failed; resolve it before closing deploy-gated tickets", record.CheckType) + } + + return false, "record a passed deploy or smoke verification for the current landing head before closing deploy-gated tickets" +} + +func renderCloseoutPlan(plan closeoutPlan) []string { + lines := []string{ + fmt.Sprintf("landing branch: %s", plan.LandingBranch), + fmt.Sprintf("base: %s", plan.BaseBranch), + } + + switch { + case plan.LandingPR != nil: + lines = append(lines, fmt.Sprintf("landing PR: #%d %s", plan.LandingPR.Number, plan.LandingPR.State)) + case len(plan.LandingPRAmbiguous) > 0: + numbers := make([]string, 0, len(plan.LandingPRAmbiguous)) + for _, pr := range plan.LandingPRAmbiguous { + numbers = append(numbers, fmt.Sprintf("#%d %s", pr.Number, strings.ToLower(pr.State))) + } + sort.Strings(numbers) + lines = append(lines, fmt.Sprintf("landing PR: ambiguous (%s)", strings.Join(numbers, ", "))) + default: + lines = append(lines, "landing PR: not found") + } + + lines = append(lines, "source branches:") + for _, branch := range plan.SourceBranches { + line := fmt.Sprintf(" %s", branch.Name) + if branch.PR != nil { + line += fmt.Sprintf(" PR #%d %s", branch.PR.Number, branch.PR.State) + } + lines = append(lines, line) + } - cmd.Flags().BoolVar(&apply, "apply", false, "Apply clean merged-parent repairs") - return cmd -} + lines = append(lines, "superseded PRs safe to close now:") + lines = append(lines, renderCloseoutList(plan.SupersededNow)...) -func newQueueCommand(runtime *stackruntime.Runtime) *cobra.Command { - var yes bool - var strategy string + lines = append(lines, "superseded PRs pending landing merge:") + lines = append(lines, renderCloseoutList(plan.SupersededPending)...) - cmd := &cobra.Command{ - Use: "queue ", - Short: "Hand one healthy bottom-of-stack PR to GitHub auto-merge or merge queue", - Long: "Validate that one tracked branch is ready for trunk handoff, then ask GitHub to auto-merge or enqueue the PR using the current head commit.", - Example: strings.TrimSpace(` -stack queue feature/a -stack queue feature/a --strategy squash -stack queue feature/a --yes - `), - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if !isValidQueueStrategy(strategy) { - return fmt.Errorf("invalid queue strategy %q; expected merge, squash, or rebase", strategy) - } + lines = append(lines, "tickets safe to close now:") + lines = append(lines, renderCloseoutList(plan.TicketsSafeToClose)...) - state, err := runtime.Store.ReadState(runtime.Context) - if err != nil { - return err - } - if err := ensureStateWritable(state); err != nil { - return err - } - if err := runtime.Git.FetchPrune(runtime.Context, state.DefaultRemote); err != nil { - return err - } + lines = append(lines, "tickets pending post-deploy check:") + lines = append(lines, renderCloseoutList(plan.TicketsPendingPostDeploy)...) - branch := args[0] - record, ok := state.Branches[branch] - if !ok { - return fmt.Errorf("branch %q is not tracked", branch) - } - if record.ParentBranch != state.Trunk { - return fmt.Errorf("branch %q must target trunk before queue handoff", branch) - } - if record.PR.Number == 0 { - return fmt.Errorf("branch %q has no tracked PR", branch) - } + lines = append(lines, "required follow-up checks:") + lines = append(lines, renderCloseoutList(plan.FollowUps)...) - headOID, err := runtime.Git.ResolveRef(runtime.Context, branch) - if err != nil { - return err - } - remoteHeadOID, remoteExists, err := runtime.Git.RemoteBranchOID(runtime.Context, state.DefaultRemote, branch) - if err != nil { - return err - } - if !remoteExists { - return fmt.Errorf("branch %q has not been pushed to %s", branch, state.DefaultRemote) - } - if remoteHeadOID != headOID { - return fmt.Errorf("remote branch %q is stale; run `stack submit %s` before queue handoff", branch, branch) - } + return lines +} - record, err = refreshTrackedPR(runtime, state, branch, record) - if err != nil { - return err - } - if err := validateTrackedPR(branch, record); err != nil { - return err - } - if record.PR.BaseRefName != "" && record.PR.BaseRefName != state.Trunk { - return fmt.Errorf("PR for %q currently targets %q; run `stack submit %s` before queue handoff", branch, record.PR.BaseRefName, branch) - } - if record.PR.IsDraft { - return fmt.Errorf("branch %q is still a draft PR", branch) - } - if record.PR.LastSeenHeadOID != "" && record.PR.LastSeenHeadOID != headOID { - return fmt.Errorf("PR head for %q is stale; run `stack submit %s` before queue handoff", branch, branch) - } +func renderCloseoutList(values []string) []string { + if len(values) == 0 { + return []string{" none"} + } + lines := make([]string, 0, len(values)) + for _, value := range values { + lines = append(lines, fmt.Sprintf(" %s", value)) + } + return lines +} - _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Queue handoff", []string{ - fmt.Sprintf("branch: %s", branch), - fmt.Sprintf("pr: #%d", record.PR.Number), - fmt.Sprintf("strategy: %s", strategy), - fmt.Sprintf("head: %s", headOID), - })) +func isDeployVerification(checkType string) bool { + normalized := strings.ToLower(strings.TrimSpace(checkType)) + return normalized == "deploy" || normalized == "smoke" +} - if !yes { - confirmed, err := forms.Confirm("Queue branch", "This hands the current PR head to GitHub auto-merge or merge queue.") - if err != nil { - return err - } - if !confirmed { - return fmt.Errorf("queue cancelled") - } - } +var ticketRefPattern = regexp.MustCompile(`(?i)\b[a-z][a-z0-9]+-\d+\b`) - if err := runtime.GitHub.MergePR(runtime.Context, record.PR.Number, headOID, strategy); err != nil { - return err - } +func extractTicketRefs(value string) []string { + matches := ticketRefPattern.FindAllString(value, -1) + if len(matches) == 0 { + return nil + } + + seen := map[string]bool{} + result := make([]string, 0, len(matches)) + for _, match := range matches { + normalized := strings.ToUpper(strings.TrimSpace(match)) + if normalized == "" || seen[normalized] { + continue + } + seen[normalized] = true + result = append(result, normalized) + } + sort.Strings(result) + return result +} + +func mapKeys(values map[string]bool) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} - nextSteps := []string{ - fmt.Sprintf("wait for GitHub to merge PR #%d", record.PR.Number), - "then run: stack sync", +func parsePRNumbers(values []string) ([]int, error) { + seen := map[int]bool{} + numbers := make([]int, 0) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue } - children := stack.Children(state, branch) - if len(children) > 0 { - nextSteps = append(nextSteps, fmt.Sprintf("then run: stack submit %s", children[0])) - nextSteps = append(nextSteps, fmt.Sprintf("then run: stack queue %s", children[0])) + number, err := strconv.Atoi(trimmed) + if err != nil || number <= 0 { + return nil, fmt.Errorf("invalid PR number %q", trimmed) } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Next steps", nextSteps)) - return nil - }, + if seen[number] { + continue + } + seen[number] = true + numbers = append(numbers, number) + } } + sort.Ints(numbers) + return numbers, nil +} - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") - cmd.Flags().StringVar(&strategy, "strategy", "merge", "Merge strategy: merge, squash, or rebase") - return cmd +func joinPRNumbers(numbers []int) string { + parts := make([]string, 0, len(numbers)) + for _, number := range numbers { + parts = append(parts, fmt.Sprintf("#%d", number)) + } + return strings.Join(parts, ", ") } -func isValidQueueStrategy(strategy string) bool { - switch strategy { - case "merge", "squash", "rebase": - return true - default: - return false +func buildQueuePlan(runtime *stackruntime.Runtime, state store.RepoState, branch string) (queuePlan, error) { + if _, ok := state.Landings[branch]; ok { + return buildLandingQueuePlan(runtime, state, branch) } + return buildTrackedQueuePlan(runtime, state, branch) } -func runStatus(runtime *stackruntime.Runtime, asJSON bool) error { - state, err := runtime.Store.ReadState(runtime.Context) - if err != nil { - return err +func buildTrackedQueuePlan(runtime *stackruntime.Runtime, state store.RepoState, branch string) (queuePlan, error) { + record, ok := state.Branches[branch] + if !ok { + if _, landing := state.Landings[branch]; landing { + return queuePlan{}, fmt.Errorf("landing branch %q could not be resolved for queue handoff", branch) + } + return queuePlan{}, fmt.Errorf("branch %q is neither tracked nor recorded as a landing branch", branch) + } + if record.ParentBranch != state.Trunk { + return queuePlan{}, fmt.Errorf("branch %q must target trunk before queue handoff", branch) + } + if record.PR.Number == 0 { + return queuePlan{}, fmt.Errorf("branch %q has no tracked PR", branch) } - summary, err := stack.BuildSummary(runtime.Context, runtime.Git, state) + if landingPlan, ok, err := buildSourceLandingQueuePlan(runtime, state, branch); err != nil { + return queuePlan{}, err + } else if ok { + return landingPlan, nil + } + + headOID, err := runtime.Git.ResolveRef(runtime.Context, branch) if err != nil { - return err + return queuePlan{}, err + } + remoteHeadOID, remoteExists, err := runtime.Git.RemoteBranchOID(runtime.Context, state.DefaultRemote, branch) + if err != nil { + return queuePlan{}, err + } + if !remoteExists { + return queuePlan{}, fmt.Errorf("branch %q has not been pushed to %s", branch, state.DefaultRemote) + } + if remoteHeadOID != headOID { + return queuePlan{}, fmt.Errorf("remote branch %q is stale; run `stack submit %s` before queue handoff", branch, branch) } - if asJSON { - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - return encoder.Encode(summary) + record, err = refreshTrackedPR(runtime, state, branch, record) + if err != nil { + return queuePlan{}, err + } + if err := validateTrackedPR(branch, record); err != nil { + return queuePlan{}, err + } + if record.PR.BaseRefName != "" && record.PR.BaseRefName != state.Trunk { + return queuePlan{}, fmt.Errorf("PR for %q currently targets %q; run `stack submit %s` before queue handoff", branch, record.PR.BaseRefName, branch) + } + if record.PR.IsDraft { + return queuePlan{}, fmt.Errorf("branch %q is still a draft PR", branch) + } + if record.PR.LastSeenHeadOID != "" && record.PR.LastSeenHeadOID != headOID { + return queuePlan{}, fmt.Errorf("PR head for %q is stale; run `stack submit %s` before queue handoff", branch, branch) } - _, _ = fmt.Fprintln(os.Stdout, ui.RenderStatus(summary)) - return nil -} + verification, err := validateQueueVerification(branch, state.Verifications[branch], headOID) + if err != nil { + return queuePlan{}, err + } -type submitPlan struct { - Branch string - LocalHead string - RemoteHead string - RemoteExists bool - Metadata submitPRMetadata - Record store.BranchRecord - Preview []string -} + nextSteps := []string{ + fmt.Sprintf("wait for GitHub to merge PR #%d", record.PR.Number), + "then run: stack sync", + } + children := stack.Children(state, branch) + if len(children) > 0 { + nextSteps = append(nextSteps, fmt.Sprintf("then run: stack submit %s", children[0])) + nextSteps = append(nextSteps, fmt.Sprintf("then run: stack queue %s", children[0])) + } -type submitPRMetadata struct { - Title string - Body string - TitleSource string - BodySource string + return queuePlan{ + Branch: branch, + PR: record.PR, + HeadOID: headOID, + Verification: verification, + NextSteps: nextSteps, + }, nil } -func buildSubmitPlan(runtime *stackruntime.Runtime, state store.RepoState, branch string) (submitPlan, error) { - record, ok := state.Branches[branch] +func buildLandingQueuePlan(runtime *stackruntime.Runtime, state store.RepoState, branch string) (queuePlan, error) { + landing, ok := state.Landings[branch] if !ok { - return submitPlan{}, fmt.Errorf("branch %q is no longer tracked", branch) + return queuePlan{}, fmt.Errorf("landing branch %q is not recorded in local compose metadata", branch) + } + if landing.BaseBranch != "" && landing.BaseBranch != state.Trunk { + return queuePlan{}, fmt.Errorf("landing branch %q targets %q; only trunk-bound landing branches can enter queue", branch, landing.BaseBranch) + } + if !runtime.Git.BranchExists(runtime.Context, branch) { + return queuePlan{}, fmt.Errorf("landing branch %q does not exist locally", branch) } - localHeadOID, err := runtime.Git.ResolveRef(runtime.Context, branch) + headOID, err := runtime.Git.ResolveRef(runtime.Context, branch) if err != nil { - return submitPlan{}, err + return queuePlan{}, err } - - record, err = refreshTrackedPR(runtime, state, branch, record) + remoteHeadOID, remoteExists, err := runtime.Git.RemoteBranchOID(runtime.Context, state.DefaultRemote, branch) if err != nil { - return submitPlan{}, err + return queuePlan{}, err + } + if !remoteExists { + return queuePlan{}, fmt.Errorf("landing branch %q has not been pushed to %s", branch, state.DefaultRemote) + } + if remoteHeadOID != headOID { + return queuePlan{}, fmt.Errorf("remote landing branch %q is stale; push or refresh it before queue handoff", branch) } - remoteHeadOID, remoteExists, err := runtime.Git.RemoteBranchOID(runtime.Context, state.DefaultRemote, branch) + prs, err := runtime.GitHub.ListPRsByHead(runtime.Context, branch) if err != nil { - return submitPlan{}, err + return queuePlan{}, err } - if err := validateTrackedPR(branch, record); err != nil && record.PR.Number > 0 { - return submitPlan{}, err + if len(prs) == 0 { + return queuePlan{}, fmt.Errorf("landing branch %q has no pull request yet; open the landing PR before queue handoff", branch) } - if err := validateRemoteAgreement(branch, record, localHeadOID, remoteHeadOID, remoteExists); err != nil { - return submitPlan{}, err + if len(prs) > 1 { + numbers := describePRs(prs) + return queuePlan{}, fmt.Errorf("landing branch %q matches multiple PRs: %s; resolve the ambiguity before queue handoff", branch, strings.Join(numbers, ", ")) } - preview := []string{fmt.Sprintf("%s -> %s", branch, record.ParentBranch)} - metadata := submitPRMetadata{} - if !remoteExists { - preview = append(preview, "push new remote branch") - } else if remoteHeadOID != localHeadOID { - preview = append(preview, "force-push remote branch with explicit lease") - } else { - preview = append(preview, "remote branch already matches local head") + pr := prs[0] + if pr.State == "CLOSED" { + return queuePlan{}, fmt.Errorf("landing PR for %q is closed; repair or reopen it before queue handoff", branch) + } + if pr.State == "MERGED" { + return queuePlan{}, fmt.Errorf("landing PR for %q is already merged; run `stack closeout %s` instead", branch, branch) + } + if pr.IsDraft { + return queuePlan{}, fmt.Errorf("landing branch %q is still a draft PR", branch) + } + if pr.BaseRefName != "" && pr.BaseRefName != state.Trunk { + return queuePlan{}, fmt.Errorf("landing PR for %q currently targets %q; retarget it to %s before queue handoff", branch, pr.BaseRefName, state.Trunk) + } + if pr.LastSeenHeadOID != "" && pr.LastSeenHeadOID != headOID { + return queuePlan{}, fmt.Errorf("landing PR head for %q is stale; push the current landing head before queue handoff", branch) } - if record.PR.Number == 0 { - metadata, err = resolveSubmitPRMetadata(runtime, branch, record.ParentBranch) - if err != nil { - return submitPlan{}, err - } - preview = append(preview, "create GitHub pull request") - preview = append(preview, fmt.Sprintf("PR title: %q (%s)", metadata.Title, metadata.TitleSource)) - preview = append(preview, fmt.Sprintf("PR body: %s", metadata.BodySource)) - } else if record.PR.BaseRefName != "" && record.PR.BaseRefName != record.ParentBranch { - preview = append(preview, fmt.Sprintf("retarget PR #%d base to %s", record.PR.Number, record.ParentBranch)) - } else { - preview = append(preview, fmt.Sprintf("refresh tracked PR #%d", record.PR.Number)) + verification, err := validateQueueVerification(branch, state.Verifications[branch], headOID) + if err != nil { + return queuePlan{}, err } - return submitPlan{ + return queuePlan{ Branch: branch, - LocalHead: localHeadOID, - RemoteHead: remoteHeadOID, - RemoteExists: remoteExists, - Metadata: metadata, - Record: record, - Preview: preview, + PR: pr, + HeadOID: headOID, + IsLanding: true, + Verification: verification, + ExcludedPRs: landingExcludedPRs(state, landing), + NextSteps: []string{ + fmt.Sprintf("wait for GitHub to merge PR #%d", pr.Number), + fmt.Sprintf("then run: stack closeout %s", branch), + }, }, nil } -func resolveSubmitPRMetadata(runtime *stackruntime.Runtime, branch string, parent string) (submitPRMetadata, error) { - title, body, err := runtime.Git.CommitMessage(runtime.Context, branch) +func buildSourceLandingQueuePlan(runtime *stackruntime.Runtime, state store.RepoState, branch string) (queuePlan, bool, error) { + landingBranches := landingBranchesForSourceBranch(state, branch) + if len(landingBranches) == 0 { + return queuePlan{}, false, nil + } + if len(landingBranches) > 1 { + return queuePlan{}, false, fmt.Errorf("branch %q appears in multiple landing batches (%s); repair the landing metadata before queue handoff", branch, strings.Join(landingBranches, ", ")) + } + + landingBranch := landingBranches[0] + prs, err := runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) if err != nil { - return submitPRMetadata{}, err + return queuePlan{}, false, err + } + if len(prs) == 0 { + return queuePlan{}, false, fmt.Errorf("branch %q is part of landing batch %q; open or relink the landing PR before queue handoff, and keep source PRs out of the merge queue", branch, landingBranch) + } + if len(prs) > 1 { + numbers := describePRs(prs) + return queuePlan{}, false, fmt.Errorf("branch %q is part of landing batch %q; resolve ambiguous landing PR ownership (%s) before queue handoff, and keep source PRs out of the merge queue", branch, landingBranch, strings.Join(numbers, ", ")) } - metadata := submitPRMetadata{ - Title: strings.TrimSpace(title), - Body: strings.TrimSpace(body), - TitleSource: "commit subject", - BodySource: "commit body", + pr := prs[0] + return queuePlan{}, false, fmt.Errorf("branch %q is part of landing batch %q; queue landing PR #%d instead and keep source PRs out of the merge queue", branch, landingBranch, pr.Number) +} + +func validateQueueVerification(branch string, records []store.VerificationRecord, currentHeadOID string) (*queueVerification, error) { + if len(records) == 0 { + return nil, nil } - if metadata.Title == "" { - metadata.Title = branch - metadata.TitleSource = "branch name fallback" + latest := records[len(records)-1] + verification := &queueVerification{ + Latest: latest, + HeadMatchesCurrent: latest.HeadOID != "" && currentHeadOID != "" && latest.HeadOID == currentHeadOID, } - if metadata.Body == "" { - metadata.Body = fmt.Sprintf("Stack branch `%s` targeting `%s`.\n\nGenerated by `stack submit` because the tip commit body was empty.", branch, parent) - metadata.BodySource = "generated default" + if !latest.Passed { + return nil, fmt.Errorf("latest %s verification for %q failed; inspect `stack verify list %s` before queue handoff", latest.CheckType, branch, branch) + } + if !verification.HeadMatchesCurrent { + return nil, fmt.Errorf("branch %q head moved since the latest recorded verification; rerun or record fresh verification before queue handoff", branch) } + return verification, nil +} - return metadata, nil +func renderQueueVerification(verification queueVerification) string { + parts := []string{ + strings.TrimSpace(verification.Latest.CheckType), + verificationResult(verification.Latest), + } + if verification.Latest.Identifier != "" { + parts = append(parts, verification.Latest.Identifier) + } + return strings.Join(parts, " ") +} + +func landingBranchesForSourceBranch(state store.RepoState, branch string) []string { + names := make([]string, 0) + for landingBranch, landing := range state.Landings { + for _, sourceBranch := range landing.SourceBranches { + if sourceBranch == branch { + names = append(names, landingBranch) + break + } + } + } + sort.Strings(names) + return names +} + +func landingExcludedPRs(state store.RepoState, landing store.LandingRecord) []int { + if len(landing.SupersededPRs) > 0 { + return append([]int(nil), landing.SupersededPRs...) + } + + seen := map[int]bool{} + numbers := make([]int, 0, len(landing.SourceBranches)) + for _, branch := range landing.SourceBranches { + record, ok := state.Branches[branch] + if !ok || record.PR.Number == 0 || seen[record.PR.Number] { + continue + } + seen[record.PR.Number] = true + numbers = append(numbers, record.PR.Number) + } + sort.Ints(numbers) + return numbers +} + +func describePRs(prs []store.PullRequest) []string { + values := make([]string, 0, len(prs)) + for _, pr := range prs { + values = append(values, fmt.Sprintf("#%d %s", pr.Number, strings.ToLower(pr.State))) + } + sort.Strings(values) + return values } func restackSteps(runtime *stackruntime.Runtime, state store.RepoState, args []string, all bool) ([]store.RestackStep, error) { @@ -1201,6 +2488,20 @@ func cloneRepoState(state store.RepoState) store.RepoState { for branch, record := range state.Branches { cloned.Branches[branch] = record } + cloned.Landings = make(map[string]store.LandingRecord, len(state.Landings)) + for branch, record := range state.Landings { + cloned.Landings[branch] = store.LandingRecord{ + BaseBranch: record.BaseBranch, + SourceBranches: append([]string(nil), record.SourceBranches...), + SupersededPRs: append([]int(nil), record.SupersededPRs...), + SupersededAt: record.SupersededAt, + CreatedAt: record.CreatedAt, + } + } + cloned.Verifications = make(map[string][]store.VerificationRecord, len(state.Verifications)) + for branch, records := range state.Verifications { + cloned.Verifications[branch] = append([]store.VerificationRecord(nil), records...) + } return cloned } @@ -1389,28 +2690,33 @@ func ensureTrackedParentAllowed(state store.RepoState, parent string) error { } func trackRestackAnchor(runtime *stackruntime.Runtime, branch string, parent string) (string, error) { + anchor, _, err := trackRestackAnchorDetail(runtime, branch, parent) + return anchor, err +} + +func trackRestackAnchorDetail(runtime *stackruntime.Runtime, branch string, parent string) (string, bool, error) { parentOID, err := runtime.Git.ResolveRef(runtime.Context, parent) if err != nil { - return "", err + return "", false, err } validAnchor, err := runtime.Git.IsAncestor(runtime.Context, parentOID, branch) if err != nil { - return "", err + return "", false, err } if validAnchor { - return parentOID, nil + return parentOID, false, nil } mergeBase, ok, err := runtime.Git.MergeBase(runtime.Context, parent, branch) if err != nil { - return "", err + return "", false, err } if !ok || mergeBase == "" { - return "", fmt.Errorf("branch %q does not share a repairable merge base with parent %q; rebase it first or choose a different parent", branch, parent) + return "", false, fmt.Errorf("branch %q does not share a repairable merge base with parent %q; rebase it first or choose a different parent", branch, parent) } - return mergeBase, nil + return mergeBase, true, nil } func resolveOID(runtime *stackruntime.Runtime, ref string) string { @@ -1421,6 +2727,20 @@ func resolveOID(runtime *stackruntime.Runtime, ref string) string { return oid } +func shortOID(oid string) string { + if len(oid) <= 12 { + return oid + } + return oid[:12] +} + +func verificationResult(record store.VerificationRecord) string { + if record.Passed { + return "passed" + } + return "failed" +} + func init() { pflag.CommandLine.SortFlags = false } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index b00c525..791b5f2 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -1062,6 +1062,830 @@ func TestTrackUsesMergeBaseAnchorForStaleAdoption(t *testing.T) { } } +func TestAdoptPRFetchesMissingLocalBranchAndTracksPR(t *testing.T) { + repo := testutil.SetupGitRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + testutil.Run(t, repo, "git", "init", "--bare", remote) + testutil.Run(t, repo, "git", "remote", "add", "origin", remote) + testutil.Run(t, repo, "git", "push", "-u", "origin", "main") + + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature a") + testutil.Run(t, repo, "git", "push", "-u", "origin", "feature/a") + featureAHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "feature/a")) + + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "branch", "-D", "feature/a") + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + 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) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "7": { + "id": "PR_7", + "number": 7, + "url": "https://example.com/hack-dance/stack/pull/7", + "repo": "hack-dance/stack", + "headRefName": "feature/a", + "baseRefName": "main", + "headRefOid": "`+featureAHead+`", + "baseRefOid": "`+mainHead+`", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 8 +}`) + + output := executeCommand(t, runtime, "adopt", "pr", "7", "--parent", "main", "--yes") + if !strings.Contains(output, "fetched: origin/feature/a") { + t.Fatalf("expected fetch output, got %q", output) + } + + state, err = runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state: %v", err) + } + record := state.Branches["feature/a"] + if record.ParentBranch != "main" { + t.Fatalf("expected feature/a parent main, got %q", record.ParentBranch) + } + if record.PR.Number != 7 { + t.Fatalf("expected tracked PR #7, got %+v", record.PR) + } + if record.Restack.LastParentHeadOID != mainHead { + t.Fatalf("expected restack anchor %q, got %q", mainHead, record.Restack.LastParentHeadOID) + } + if !runtime.Git.BranchExists(runtime.Context, "feature/a") { + t.Fatalf("expected feature/a to exist locally after adopt") + } +} + +func TestAdoptPRUsesMergeBaseAnchorForStaleLocalBranch(t *testing.T) { + repo := testutil.SetupGitRepo(t) + + testutil.Run(t, repo, "git", "switch", "-c", "feature/base") + testutil.WriteFile(t, filepath.Join(repo, "feature-base.txt"), "feature base\n") + testutil.Run(t, repo, "git", "add", "feature-base.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature base") + originalFeatureBaseHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + testutil.Run(t, repo, "git", "switch", "-c", "feature/child") + testutil.WriteFile(t, filepath.Join(repo, "feature-child.txt"), "feature child\n") + testutil.Run(t, repo, "git", "add", "feature-child.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature child") + + testutil.Run(t, repo, "git", "switch", "feature/base") + testutil.WriteFile(t, filepath.Join(repo, "feature-base.txt"), "feature base advanced\n") + testutil.Run(t, repo, "git", "add", "feature-base.txt") + testutil.Run(t, repo, "git", "commit", "-m", "advance feature base") + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/base": { + ParentBranch: "main", + RemoteName: "origin", + Restack: store.RestackMetadata{ + LastParentHeadOID: mainHead, + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "feature/child", + "baseRefName": "feature/base", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 10 +}`) + + output := executeCommand(t, runtime, "adopt", "pr", "9", "--parent", "feature/base", "--yes") + if !strings.Contains(output, "restack anchor: merge-base") { + t.Fatalf("expected merge-base note, got %q", output) + } + + state, err = runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state: %v", err) + } + if got := state.Branches["feature/child"].Restack.LastParentHeadOID; got != originalFeatureBaseHead { + t.Fatalf("expected feature/child anchor %q, got %q", originalFeatureBaseHead, got) + } +} + +func TestComposeCreatesLandingBranchFromTrackedRange(t *testing.T) { + repo := testutil.SetupGitRepo(t) + + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature a") + featureAHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "feature/a")) + + testutil.Run(t, repo, "git", "switch", "-c", "feature/b") + testutil.WriteFile(t, filepath.Join(repo, "feature-b.txt"), "feature b\n") + testutil.Run(t, repo, "git", "add", "feature-b.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature b") + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/a": { + ParentBranch: "main", + RemoteName: "origin", + Restack: store.RestackMetadata{ + LastParentHeadOID: mainHead, + }, + }, + "feature/b": { + ParentBranch: "feature/a", + RemoteName: "origin", + Restack: store.RestackMetadata{ + LastParentHeadOID: featureAHead, + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + output := executeCommand(t, runtime, "compose", "discovery-core", "--from", "feature/a", "--to", "feature/b", "--yes") + if !strings.Contains(output, "branch: stack/discovery-core") { + t.Fatalf("expected compose output branch name, got %q", output) + } + + if !runtime.Git.BranchExists(runtime.Context, "stack/discovery-core") { + t.Fatalf("expected composed branch to exist") + } + state, err = runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state after compose: %v", err) + } + landing, ok := state.Landings["stack/discovery-core"] + if !ok { + t.Fatalf("expected landing metadata to be recorded") + } + if got := strings.Join(landing.SourceBranches, ","); got != "feature/a,feature/b" { + t.Fatalf("unexpected landing source branches %q", got) + } + currentBranch, err := runtime.Git.CurrentBranch(runtime.Context) + if err != nil { + t.Fatalf("current branch: %v", err) + } + if strings.TrimSpace(currentBranch) != "stack/discovery-core" { + t.Fatalf("expected to end on stack/discovery-core, got %q", currentBranch) + } + + logOutput := strings.TrimSpace(testutil.Run(t, repo, "git", "log", "--format=%s", "--reverse", "main..stack/discovery-core")) + if logOutput != "add feature a\nadd feature b" { + t.Fatalf("unexpected composed commit order: %q", logOutput) + } +} + +func TestComposeRejectsNonContiguousExplicitBranches(t *testing.T) { + repo := testutil.SetupGitRepo(t) + + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature a") + + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "switch", "-c", "feature/c") + testutil.WriteFile(t, filepath.Join(repo, "feature-c.txt"), "feature c\n") + testutil.Run(t, repo, "git", "add", "feature-c.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature c") + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/a": { + ParentBranch: "main", + Restack: store.RestackMetadata{ + LastParentHeadOID: mainHead, + }, + }, + "feature/c": { + ParentBranch: "main", + Restack: store.RestackMetadata{ + LastParentHeadOID: mainHead, + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + err = executeCommandExpectError(runtime, "compose", "discovery-core", "--branches", "feature/a", "--branches", "feature/c", "--yes") + if err == nil || !strings.Contains(err.Error(), "contiguous parent chain") { + t.Fatalf("expected contiguous-chain error, got %v", err) + } +} + +func TestCloseoutClassifiesSupersededPRsAndDeployGatedTickets(t *testing.T) { + repo := testutil.SetupGitRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + testutil.Run(t, repo, "git", "init", "--bare", remote) + testutil.Run(t, repo, "git", "remote", "add", "origin", remote) + testutil.Run(t, repo, "git", "push", "-u", "origin", "main") + + testutil.Run(t, repo, "git", "switch", "-c", "hack-agent/lnhack-66-feature-a") + testutil.WriteFile(t, filepath.Join(repo, "a.txt"), "a\n") + testutil.Run(t, repo, "git", "add", "a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add a") + testutil.Run(t, repo, "git", "push", "-u", "origin", "hack-agent/lnhack-66-feature-a") + featureAHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + testutil.Run(t, repo, "git", "switch", "-c", "hack-agent/lnhack-67-feature-b") + testutil.WriteFile(t, filepath.Join(repo, "b.txt"), "b\n") + testutil.Run(t, repo, "git", "add", "b.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add b") + testutil.Run(t, repo, "git", "push", "-u", "origin", "hack-agent/lnhack-67-feature-b") + featureBHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "hack-agent/lnhack-66-feature-a": { + ParentBranch: "main", + PR: store.PullRequest{ + Number: 1, + HeadRefName: "hack-agent/lnhack-66-feature-a", + BaseRefName: "main", + LastSeenHeadOID: featureAHead, + State: "OPEN", + }, + Restack: store.RestackMetadata{LastParentHeadOID: mainHead}, + }, + "hack-agent/lnhack-67-feature-b": { + ParentBranch: "hack-agent/lnhack-66-feature-a", + PR: store.PullRequest{ + Number: 2, + HeadRefName: "hack-agent/lnhack-67-feature-b", + BaseRefName: "hack-agent/lnhack-66-feature-a", + LastSeenHeadOID: featureBHead, + State: "OPEN", + }, + Restack: store.RestackMetadata{LastParentHeadOID: featureAHead}, + }, + }, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{"hack-agent/lnhack-66-feature-a", "hack-agent/lnhack-67-feature-b"}, + CreatedAt: "2026-03-26T18:30:00Z", + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "deploy", + Identifier: "deploy-1", + Passed: true, + HeadOID: landingHead, + RecordedAt: "2026-03-26T18:31:00Z", + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "1": { + "id": "PR_1", + "number": 1, + "url": "https://example.com/hack-dance/stack/pull/1", + "repo": "hack-dance/stack", + "headRefName": "hack-agent/lnhack-66-feature-a", + "baseRefName": "main", + "headRefOid": "`+featureAHead+`", + "state": "OPEN", + "isDraft": false + }, + "2": { + "id": "PR_2", + "number": 2, + "url": "https://example.com/hack-dance/stack/pull/2", + "repo": "hack-dance/stack", + "headRefName": "hack-agent/lnhack-67-feature-b", + "baseRefName": "hack-agent/lnhack-66-feature-a", + "headRefOid": "`+featureBHead+`", + "state": "OPEN", + "isDraft": false + }, + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "MERGED", + "isDraft": false + } + }, + "next_number": 10 +}`) + + output := executeCommand(t, runtime, "closeout", "stack/discovery-core") + if !strings.Contains(output, "landing PR: #9 MERGED") { + t.Fatalf("expected merged landing PR in closeout output, got %q", output) + } + if !strings.Contains(output, "#1 hack-agent/lnhack-66-feature-a") || !strings.Contains(output, "#2 hack-agent/lnhack-67-feature-b") { + t.Fatalf("expected superseded PRs in closeout output, got %q", output) + } + if !strings.Contains(output, "LNHACK-66") || !strings.Contains(output, "LNHACK-67") { + t.Fatalf("expected inferred ticket refs in closeout output, got %q", output) + } + if strings.Contains(output, "record a passed deploy or smoke verification") { + t.Fatalf("expected deploy follow-up to be cleared, got %q", output) + } +} + +func TestCloseoutRequiresExplicitResolutionForAmbiguousLandingPRs(t *testing.T) { + repo := testutil.SetupGitRepo(t) + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + runtime := newTestRuntime(repo) + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{}, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{}, + CreatedAt: "2026-03-26T18:40:00Z", + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "manual", + Identifier: "check", + Passed: true, + HeadOID: landingHead, + RecordedAt: "2026-03-26T18:41:00Z", + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "OPEN", + "isDraft": false + }, + "10": { + "id": "PR_10", + "number": 10, + "url": "https://example.com/hack-dance/stack/pull/10", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "MERGED", + "isDraft": false + } + }, + "next_number": 11 +}`) + + output := executeCommand(t, runtime, "closeout", "stack/discovery-core") + if !strings.Contains(output, "landing PR: ambiguous") || !strings.Contains(output, "resolve ambiguous landing PR ownership") { + t.Fatalf("expected ambiguous landing PR guidance, got %q", output) + } +} + +func TestSupersedeRecordsMetadataAndCommentsOriginalPRs(t *testing.T) { + repo := testutil.SetupGitRepo(t) + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + runtime := newTestRuntime(repo) + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{}, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{"hack-agent/lnhack-66-feature-a", "hack-agent/lnhack-67-feature-b"}, + CreatedAt: "2026-03-26T19:00:00Z", + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "1": { + "id": "PR_1", + "number": 1, + "url": "https://example.com/hack-dance/stack/pull/1", + "repo": "hack-dance/stack", + "headRefName": "hack-agent/lnhack-66-feature-a", + "baseRefName": "main", + "headRefOid": "", + "state": "OPEN", + "isDraft": false + }, + "2": { + "id": "PR_2", + "number": 2, + "url": "https://example.com/hack-dance/stack/pull/2", + "repo": "hack-dance/stack", + "headRefName": "hack-agent/lnhack-67-feature-b", + "baseRefName": "hack-agent/lnhack-66-feature-a", + "headRefOid": "", + "state": "OPEN", + "isDraft": false + }, + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 10 +}`) + + output := executeCommand(t, runtime, "supersede", "--landing", "stack/discovery-core", "--prs", "1,2", "--yes") + if !strings.Contains(output, "github comments: posted") { + t.Fatalf("expected supersede output to mention posted comments, got %q", output) + } + + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state: %v", err) + } + landing, ok := state.Landings["stack/discovery-core"] + if !ok { + t.Fatalf("expected landing metadata to remain present") + } + if got := strings.TrimSpace(joinPRNumbers(landing.SupersededPRs)); got != "#1, #2" { + t.Fatalf("expected superseded PR metadata to be recorded, got %q", got) + } + + log := readFile(t, ghStub.LogPath) + if !strings.Contains(log, "pr comment 1 --body") || !strings.Contains(log, "pr comment 2 --body") { + t.Fatalf("expected GitHub comments on both original PRs, got %q", log) + } + + ghState := readFile(t, ghStub.StatePath) + if !strings.Contains(ghState, "superseded by landing PR #9") { + t.Fatalf("expected landing PR reference in gh comment state, got %q", ghState) + } +} + +func TestVerifyAddAndListForTrackedBranch(t *testing.T) { + repo := testutil.SetupGitRepo(t) + + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature a") + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/a": { + ParentBranch: "main", + Restack: store.RestackMetadata{ + LastParentHeadOID: mainHead, + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + output := executeCommand(t, runtime, "verify", "add", "feature/a", "--type", "sim", "--run-id", "run-123", "--passed", "--score", "100", "--note", "festival smoke") + if !strings.Contains(output, "Verification recorded") { + t.Fatalf("expected verification add output, got %q", output) + } + + state, err = runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state: %v", err) + } + records := state.Verifications["feature/a"] + if len(records) != 1 { + t.Fatalf("expected 1 verification record, got %+v", records) + } + if records[0].CheckType != "sim" { + t.Fatalf("expected sim verification type, got %+v", records[0]) + } + if records[0].Identifier != "run-123" { + t.Fatalf("expected run-123 identifier, got %+v", records[0]) + } + if !records[0].Passed { + t.Fatalf("expected verification to be marked passed") + } + if records[0].Score == nil || *records[0].Score != 100 { + t.Fatalf("expected score 100, got %+v", records[0].Score) + } + + listOutput := executeCommand(t, runtime, "verify", "list", "feature/a") + if !strings.Contains(listOutput, "run-123") || !strings.Contains(listOutput, "festival smoke") { + t.Fatalf("expected verification list output, got %q", listOutput) + } +} + +func TestVerifyAddWorksForUntrackedLandingBranch(t *testing.T) { + repo := testutil.SetupGitRepo(t) + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing branch\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing branch") + + 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) + } + + executeCommand(t, runtime, "verify", "add", "stack/discovery-core", "--type", "manual", "--identifier", "deploy-check", "--failed", "--note", "safe to close after deploy") + + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state: %v", err) + } + records := state.Verifications["stack/discovery-core"] + if len(records) != 1 { + t.Fatalf("expected 1 landing verification record, got %+v", records) + } + if records[0].Passed { + t.Fatalf("expected landing verification to be marked failed") + } + if records[0].Identifier != "deploy-check" { + t.Fatalf("expected identifier deploy-check, got %+v", records[0]) + } +} + +func TestVerifyAddRejectsConflictingOutcomeFlags(t *testing.T) { + repo := testutil.SetupGitRepo(t) + 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, "verify", "add", "main", "--type", "manual", "--passed", "--failed") + if err == nil || !strings.Contains(err.Error(), "exactly one of --passed or --failed") { + t.Fatalf("expected conflicting verification flag error, got %v", err) + } +} + +func TestStatusShowsVerificationAndLandingBranchSections(t *testing.T) { + repo := testutil.SetupGitRepo(t) + + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature a") + verifiedHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a changed\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "advance feature a") + + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing branch") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + score := 100 + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/a": { + ParentBranch: "main", + Restack: store.RestackMetadata{ + LastParentHeadOID: mainHead, + }, + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "feature/a": { + { + CheckType: "sim", + Identifier: "run-123", + Passed: true, + HeadOID: verifiedHead, + RecordedAt: "2026-03-26T18:00:00Z", + Score: &score, + }, + }, + "stack/discovery-core": { + { + CheckType: "manual", + Identifier: "deploy-check", + Passed: false, + HeadOID: landingHead, + RecordedAt: "2026-03-26T18:05:00Z", + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + output := executeCommand(t, runtime, "status") + if !strings.Contains(output, "Verify: sim") || !strings.Contains(output, "run-123") || !strings.Contains(output, "stale") { + t.Fatalf("expected tracked verification details in status output, got %q", output) + } + if !strings.Contains(output, "Landing Branches") || !strings.Contains(output, "stack/discovery-core") || !strings.Contains(output, "deploy-check") { + t.Fatalf("expected landing branch section in status output, got %q", output) + } +} + func TestVersionCommandPrintsBuildInfo(t *testing.T) { repo := testutil.SetupGitRepo(t) runtime := newTestRuntime(repo) @@ -1317,6 +2141,323 @@ func TestQueuePrintsNextStepsForDownstackBranch(t *testing.T) { _ = repo } +func TestQueueRejectsSourceBranchWhenLandingPRExists(t *testing.T) { + repo, runtime, featureAHead := setupTrackedStackRepo(t) + + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + testutil.Run(t, repo, "git", "push", "-u", "origin", "stack/discovery-core") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state: %v", err) + } + state.Landings = map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{"feature/a", "feature/b"}, + CreatedAt: "2026-03-26T20:00:00Z", + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "1": { + "id": "PR_1", + "number": 1, + "url": "https://example.com/hack-dance/stack/pull/1", + "repo": "hack-dance/stack", + "headRefName": "feature/a", + "baseRefName": "main", + "headRefOid": "`+featureAHead+`", + "baseRefOid": "", + "state": "OPEN", + "isDraft": false + }, + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "baseRefOid": "", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 10 +}`) + + err = executeCommandExpectError(runtime, "queue", "feature/a", "--yes") + if err == nil || !strings.Contains(err.Error(), "queue landing PR #9 instead") || !strings.Contains(err.Error(), "keep source PRs out of the merge queue") { + t.Fatalf("expected landing queue guidance error, got %v", err) + } +} + +func TestQueueAllowsLandingBranchAndPrintsCloseoutGuidance(t *testing.T) { + repo, runtime, _ := setupTrackedStackRepo(t) + + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + testutil.Run(t, repo, "git", "push", "-u", "origin", "stack/discovery-core") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + state, err := runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state: %v", err) + } + state.Landings = map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{"feature/a", "feature/b"}, + SupersededPRs: []int{1, 2}, + CreatedAt: "2026-03-26T20:10:00Z", + }, + } + state.Verifications = map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "manual", + Identifier: "discovery-batch", + Passed: true, + HeadOID: landingHead, + RecordedAt: "2026-03-26T20:11:00Z", + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "baseRefOid": "", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 10 +}`) + + output := executeCommand(t, runtime, "queue", "stack/discovery-core", "--yes") + if !strings.Contains(output, "keep out of queue: #1, #2") { + t.Fatalf("expected source PR exclusion guidance, got %q", output) + } + if !strings.Contains(output, "verification: manual passed discovery-batch") { + t.Fatalf("expected verification summary in queue output, got %q", output) + } + if !strings.Contains(output, "then run: stack closeout stack/discovery-core") { + t.Fatalf("expected closeout next-step guidance, got %q", output) + } + + log := readFile(t, ghStub.LogPath) + expected := "pr merge 9 --auto --merge --match-head-commit " + landingHead + if !strings.Contains(log, expected) { + t.Fatalf("expected gh merge log %q, got %q", expected, log) + } +} + +func TestQueueRejectsLandingBranchWithStaleVerification(t *testing.T) { + repo := testutil.SetupGitRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + testutil.Run(t, repo, "git", "init", "--bare", remote) + testutil.Run(t, repo, "git", "remote", "add", "origin", remote) + testutil.Run(t, repo, "git", "push", "-u", "origin", "main") + + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing one\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing one") + verifiedHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing two\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing two") + testutil.Run(t, repo, "git", "push", "-u", "origin", "stack/discovery-core") + currentHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + runtime := newTestRuntime(repo) + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{}, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{"feature/a"}, + CreatedAt: "2026-03-26T20:20:00Z", + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "sim", + Identifier: "run-123", + Passed: true, + HeadOID: verifiedHead, + RecordedAt: "2026-03-26T20:21:00Z", + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+currentHead+`", + "baseRefOid": "", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 10 +}`) + + err := executeCommandExpectError(runtime, "queue", "stack/discovery-core", "--yes") + if err == nil || !strings.Contains(err.Error(), "head moved since the latest recorded verification") { + t.Fatalf("expected stale verification error, got %v", err) + } +} + +func TestQueueRejectsLandingBranchWithFailedVerification(t *testing.T) { + repo := testutil.SetupGitRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + testutil.Run(t, repo, "git", "init", "--bare", remote) + testutil.Run(t, repo, "git", "remote", "add", "origin", remote) + testutil.Run(t, repo, "git", "push", "-u", "origin", "main") + + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + testutil.Run(t, repo, "git", "push", "-u", "origin", "stack/discovery-core") + currentHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + runtime := newTestRuntime(repo) + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{}, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{"feature/a"}, + CreatedAt: "2026-03-26T20:30:00Z", + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "sim", + Identifier: "run-456", + Passed: false, + HeadOID: currentHead, + RecordedAt: "2026-03-26T20:31:00Z", + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+currentHead+`", + "baseRefOid": "", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 10 +}`) + + err := executeCommandExpectError(runtime, "queue", "stack/discovery-core", "--yes") + if err == nil || !strings.Contains(err.Error(), "latest sim verification") { + t.Fatalf("expected failed verification error, got %v", err) + } +} + func executeCommand(t *testing.T, runtime *stackruntime.Runtime, args ...string) string { t.Helper() diff --git a/internal/git/client.go b/internal/git/client.go index 18b3f6f..fed08e5 100644 --- a/internal/git/client.go +++ b/internal/git/client.go @@ -16,6 +16,11 @@ type Client struct { cwd string } +type Commit struct { + OID string + Subject string +} + type RepoPaths struct { Root string GitDir string @@ -149,11 +154,22 @@ func (c *Client) SwitchCreate(ctx context.Context, branch string) error { return err } +func (c *Client) SwitchCreateFrom(ctx context.Context, branch string, startPoint string) error { + _, err := c.run(ctx, "switch", "-c", branch, startPoint) + return err +} + func (c *Client) FetchPrune(ctx context.Context, remote string) error { _, err := c.run(ctx, "fetch", "--prune", remote) return err } +func (c *Client) FetchBranch(ctx context.Context, remote string, sourceBranch string, localBranch string) error { + refspec := fmt.Sprintf("refs/heads/%s:refs/heads/%s", sourceBranch, localBranch) + _, err := c.run(ctx, "fetch", remote, refspec) + return err +} + func (c *Client) PushBranch(ctx context.Context, remote string, branch string, expectedRemoteOID string) error { args := []string{"push"} if expectedRemoteOID != "" { @@ -235,6 +251,38 @@ func (c *Client) RangeDiff(ctx context.Context, oldBase string, newBranch string return c.output(ctx, "range-diff", fmt.Sprintf("%s...%s", oldBase, newBranch)) } +func (c *Client) RangeCommits(ctx context.Context, base string, head string) ([]Commit, error) { + output, err := c.output(ctx, "log", "--reverse", "--format=%H%x1f%s", fmt.Sprintf("%s..%s", base, head)) + if err != nil { + return nil, err + } + if strings.TrimSpace(output) == "" { + return nil, nil + } + + lines := strings.Split(output, "\n") + commits := make([]Commit, 0, len(lines)) + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + + parts := strings.SplitN(line, "\x1f", 2) + commit := Commit{OID: strings.TrimSpace(parts[0])} + if len(parts) == 2 { + commit.Subject = strings.TrimSpace(parts[1]) + } + commits = append(commits, commit) + } + + return commits, nil +} + +func (c *Client) CherryPick(ctx context.Context, commit string) error { + _, err := c.run(ctx, "cherry-pick", commit) + return err +} + func (c *Client) output(ctx context.Context, args ...string) (string, error) { stdout, err := c.run(ctx, args...) if err != nil { diff --git a/internal/github/client.go b/internal/github/client.go index aa30e7c..e1191dd 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -70,6 +70,33 @@ func (c *Client) ViewPR(ctx context.Context, number int) (store.PullRequest, err } func (c *Client) FindPRByHead(ctx context.Context, branch string) (store.PullRequest, error) { + prs, err := c.ListPRsByHead(ctx, branch) + if err != nil { + return store.PullRequest{}, err + } + + open := make([]store.PullRequest, 0, len(prs)) + for _, pr := range prs { + if pr.State == "OPEN" { + open = append(open, pr) + } + } + + switch len(open) { + case 0: + return store.PullRequest{}, nil + case 1: + return open[0], 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; close or retarget the duplicate until one open PR remains, then rerun `stack submit %s`", branch, strings.Join(numbers, ", "), branch) + } +} + +func (c *Client) ListPRsByHead(ctx context.Context, branch string) ([]store.PullRequest, error) { output, err := c.run( ctx, "pr", @@ -82,37 +109,20 @@ func (c *Client) FindPRByHead(ctx context.Context, branch string) (store.PullReq "id,number,url,baseRefName,baseRefOid,headRefName,headRefOid,state,isDraft", ) if err != nil { - return store.PullRequest{}, err + return nil, err } var payload []pullRequestPayload if err := json.Unmarshal([]byte(output), &payload); err != nil { - return store.PullRequest{}, err - } - - if len(payload) == 0 { - return store.PullRequest{}, nil + return nil, err } - open := make([]pullRequestPayload, 0, len(payload)) + prs := make([]store.PullRequest, 0, len(payload)) for _, pr := range payload { - if pr.State == "OPEN" { - open = append(open, pr) - } + prs = append(prs, pr.toStorePullRequest()) } - 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; close or retarget the duplicate until one open PR remains, then rerun `stack submit %s`", branch, strings.Join(numbers, ", "), branch) - } + return prs, nil } func (c *Client) CreatePR(ctx context.Context, base string, head string, title string, body string, draft bool) (store.PullRequest, error) { @@ -168,6 +178,11 @@ func (c *Client) MergePR(ctx context.Context, number int, headOID string, strate return err } +func (c *Client) CommentPR(ctx context.Context, number int, body string) error { + _, err := c.run(ctx, "pr", "comment", fmt.Sprintf("%d", number), "--body", body) + return err +} + func decodePR(raw string) (store.PullRequest, error) { var payload pullRequestPayload if err := json.Unmarshal([]byte(raw), &payload); err != nil { diff --git a/internal/stack/summary.go b/internal/stack/summary.go index eece7bf..a552794 100644 --- a/internal/stack/summary.go +++ b/internal/stack/summary.go @@ -24,28 +24,46 @@ type HealthIssue struct { } type BranchSummary struct { - Name string `json:"name"` - Parent string `json:"parent"` - Depth int `json:"depth"` - CurrentHeadOID string `json:"currentHeadOid,omitempty"` - ParentHeadOID string `json:"parentHeadOid,omitempty"` - RemoteExists bool `json:"remoteExists"` - ParentAncestor bool `json:"parentAncestor"` - LocalExists bool `json:"localExists"` - ParentExists bool `json:"parentExists"` - IsCurrentBranch bool `json:"isCurrentBranch"` - Issues []HealthIssue `json:"issues"` - Record store.BranchRecord `json:"record"` + Name string `json:"name"` + Parent string `json:"parent"` + Depth int `json:"depth"` + CurrentHeadOID string `json:"currentHeadOid,omitempty"` + ParentHeadOID string `json:"parentHeadOid,omitempty"` + RemoteExists bool `json:"remoteExists"` + ParentAncestor bool `json:"parentAncestor"` + LocalExists bool `json:"localExists"` + ParentExists bool `json:"parentExists"` + IsCurrentBranch bool `json:"isCurrentBranch"` + Verification *VerificationSummary `json:"verification,omitempty"` + Issues []HealthIssue `json:"issues"` + Record store.BranchRecord `json:"record"` +} + +type LandingBranchSummary struct { + Name string `json:"name"` + CurrentHeadOID string `json:"currentHeadOid,omitempty"` + RemoteExists bool `json:"remoteExists"` + LocalExists bool `json:"localExists"` + IsCurrentBranch bool `json:"isCurrentBranch"` + Verification *VerificationSummary `json:"verification,omitempty"` + Issues []HealthIssue `json:"issues"` +} + +type VerificationSummary struct { + Count int `json:"count"` + Latest store.VerificationRecord `json:"latest"` + HeadMatchesCurrent bool `json:"headMatchesCurrent"` } type Summary struct { - RepoRoot string `json:"repoRoot"` - Trunk string `json:"trunk"` - DefaultRemote string `json:"defaultRemote"` - CurrentBranch string `json:"currentBranch,omitempty"` - RepoIssues []HealthIssue `json:"repoIssues,omitempty"` - UntrackedHeads []string `json:"untrackedHeads,omitempty"` - Branches []BranchSummary `json:"branches"` + RepoRoot string `json:"repoRoot"` + Trunk string `json:"trunk"` + DefaultRemote string `json:"defaultRemote"` + CurrentBranch string `json:"currentBranch,omitempty"` + RepoIssues []HealthIssue `json:"repoIssues,omitempty"` + UntrackedHeads []string `json:"untrackedHeads,omitempty"` + Branches []BranchSummary `json:"branches"` + LandingBranches []LandingBranchSummary `json:"landingBranches,omitempty"` } func BuildSummary(ctx context.Context, git *stackgit.Client, state store.RepoState) (Summary, error) { @@ -77,6 +95,7 @@ func BuildSummary(ctx context.Context, git *stackgit.Client, state store.RepoSta summary.CurrentHeadOID = headOID } } + summary.Verification = buildVerificationSummary(state.Verifications[branchName], summary.CurrentHeadOID) if record.ParentBranch == state.Trunk || summary.ParentExists { parentOID, err := git.ResolveRef(ctx, record.ParentBranch) @@ -170,20 +189,99 @@ func BuildSummary(ctx context.Context, git *stackgit.Client, state store.RepoSta }) } } + appendVerificationIssues(branchName, &summary.Issues, summary.Verification) branches = append(branches, summary) } + landingBranches := buildLandingSummaries(ctx, git, state, strings.TrimSpace(currentBranch)) + return Summary{ - RepoRoot: paths.Root, - Trunk: state.Trunk, - DefaultRemote: state.DefaultRemote, - CurrentBranch: strings.TrimSpace(currentBranch), - RepoIssues: validation.RepoIssues, - Branches: branches, + RepoRoot: paths.Root, + Trunk: state.Trunk, + DefaultRemote: state.DefaultRemote, + CurrentBranch: strings.TrimSpace(currentBranch), + RepoIssues: validation.RepoIssues, + Branches: branches, + LandingBranches: landingBranches, }, nil } +func buildLandingSummaries(ctx context.Context, git *stackgit.Client, state store.RepoState, currentBranch string) []LandingBranchSummary { + names := make([]string, 0) + for branchName := range state.Verifications { + if _, tracked := state.Branches[branchName]; tracked { + continue + } + names = append(names, branchName) + } + sort.Strings(names) + + landingBranches := make([]LandingBranchSummary, 0, len(names)) + for _, branchName := range names { + summary := LandingBranchSummary{ + Name: branchName, + LocalExists: git.BranchExists(ctx, branchName), + RemoteExists: git.RemoteBranchExists(ctx, state.DefaultRemote, branchName), + IsCurrentBranch: currentBranch == branchName, + } + if summary.LocalExists { + headOID, err := git.ResolveRef(ctx, branchName) + if err == nil { + summary.CurrentHeadOID = headOID + } + } + summary.Verification = buildVerificationSummary(state.Verifications[branchName], summary.CurrentHeadOID) + + if !summary.LocalExists { + summary.Issues = append(summary.Issues, HealthIssue{ + Severity: SeverityWarn, + Message: "landing branch is missing locally; recreate or repair it before relying on stored verification", + }) + } + if summary.LocalExists && !summary.RemoteExists { + summary.Issues = append(summary.Issues, HealthIssue{ + Severity: SeverityInfo, + Message: "landing branch has not been pushed yet; push it when you are ready to open or update the landing PR", + }) + } + appendVerificationIssues(branchName, &summary.Issues, summary.Verification) + landingBranches = append(landingBranches, summary) + } + + return landingBranches +} + +func buildVerificationSummary(records []store.VerificationRecord, currentHeadOID string) *VerificationSummary { + if len(records) == 0 { + return nil + } + latest := records[len(records)-1] + return &VerificationSummary{ + Count: len(records), + Latest: latest, + HeadMatchesCurrent: latest.HeadOID != "" && currentHeadOID != "" && latest.HeadOID == currentHeadOID, + } +} + +func appendVerificationIssues(branchName string, issues *[]HealthIssue, verification *VerificationSummary) { + if verification == nil { + return + } + if !verification.Latest.Passed { + *issues = append(*issues, HealthIssue{ + Severity: SeverityWarn, + Message: fmt.Sprintf("latest %s verification failed; inspect `stack verify list %s` before landing", verification.Latest.CheckType, branchName), + }) + } + if !verification.HeadMatchesCurrent { + *issues = append(*issues, HealthIssue{ + Severity: SeverityWarn, + Message: "branch head moved since the latest recorded verification; rerun or record fresh verification before landing", + }) + } +} + func topoOrder(state store.RepoState) []string { children := map[string][]string{} roots := make([]string, 0) diff --git a/internal/stack/summary_test.go b/internal/stack/summary_test.go index 0e648ac..fb163a8 100644 --- a/internal/stack/summary_test.go +++ b/internal/stack/summary_test.go @@ -106,6 +106,143 @@ func TestBuildSummaryFlagsUnlinkedRemoteBranchWithSubmitGuidance(t *testing.T) { } } +func TestBuildSummaryIncludesLatestVerificationAndStaleWarning(t *testing.T) { + t.Parallel() + + repo := setupRepo(t) + client := stackgit.NewClient(repo) + + run(t, repo, "git", "switch", "-c", "feature/a") + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("a\n"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + run(t, repo, "git", "add", "a.txt") + run(t, repo, "git", "commit", "-m", "add a") + verifiedHead := strings.TrimSpace(runOutput(t, repo, "git", "rev-parse", "HEAD")) + + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("a changed\n"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + run(t, repo, "git", "add", "a.txt") + run(t, repo, "git", "commit", "-m", "advance a") + + mainHead := strings.TrimSpace(runOutput(t, repo, "git", "rev-parse", "main")) + score := 100 + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/a": { + ParentBranch: "main", + Restack: store.RestackMetadata{ + LastParentHeadOID: mainHead, + }, + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "feature/a": { + { + CheckType: "sim", + Identifier: "run-123", + Passed: true, + HeadOID: verifiedHead, + RecordedAt: "2026-03-26T17:30:00Z", + Score: &score, + }, + }, + }, + } + + summary, err := stack.BuildSummary(context.Background(), client, state) + if err != nil { + t.Fatalf("build summary: %v", err) + } + + if summary.Branches[0].Verification == nil { + t.Fatalf("expected verification summary") + } + if summary.Branches[0].Verification.Latest.Identifier != "run-123" { + t.Fatalf("expected latest verification identifier run-123, got %+v", summary.Branches[0].Verification) + } + if summary.Branches[0].Verification.HeadMatchesCurrent { + t.Fatalf("expected verification to be stale after branch head moved") + } + + found := false + for _, issue := range summary.Branches[0].Issues { + if strings.Contains(issue.Message, "branch head moved since the latest recorded verification") { + found = true + break + } + } + if !found { + t.Fatalf("expected stale verification issue, got %+v", summary.Branches[0].Issues) + } +} + +func TestBuildSummaryIncludesLandingBranchesFromVerificationRecords(t *testing.T) { + t.Parallel() + + repo := setupRepo(t) + client := stackgit.NewClient(repo) + + run(t, repo, "git", "switch", "-c", "stack/discovery-core") + if err := os.WriteFile(filepath.Join(repo, "landing.txt"), []byte("landing\n"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + run(t, repo, "git", "add", "landing.txt") + run(t, repo, "git", "commit", "-m", "landing") + landingHead := strings.TrimSpace(runOutput(t, repo, "git", "rev-parse", "HEAD")) + + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{}, + Verifications: map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "manual", + Identifier: "deploy-check", + Passed: false, + HeadOID: landingHead, + RecordedAt: "2026-03-26T17:45:00Z", + Note: "post-deploy pending", + }, + }, + }, + } + + summary, err := stack.BuildSummary(context.Background(), client, state) + if err != nil { + t.Fatalf("build summary: %v", err) + } + + if len(summary.LandingBranches) != 1 { + t.Fatalf("expected 1 landing branch summary, got %+v", summary.LandingBranches) + } + landing := summary.LandingBranches[0] + if landing.Name != "stack/discovery-core" { + t.Fatalf("unexpected landing branch summary %+v", landing) + } + if landing.Verification == nil || landing.Verification.Latest.Identifier != "deploy-check" { + t.Fatalf("expected landing verification summary, got %+v", landing.Verification) + } + found := false + for _, issue := range landing.Issues { + if strings.Contains(issue.Message, "latest manual verification failed") { + found = true + break + } + } + if !found { + t.Fatalf("expected failed verification guidance on landing branch, got %+v", landing.Issues) + } +} + func setupRepo(t *testing.T) string { t.Helper() diff --git a/internal/store/store.go b/internal/store/store.go index b7e69b7..0fefc0b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -64,6 +64,8 @@ func (s *Store) InitState(ctx context.Context, trunk string, remote string, repo Trunk: trunk, UpdatedAt: time.Now().UTC().Format(time.RFC3339), Branches: map[string]BranchRecord{}, + Landings: map[string]LandingRecord{}, + Verifications: map[string][]VerificationRecord{}, } if err := s.writeJSON(paths.StateFile, state); err != nil { @@ -95,6 +97,12 @@ func (s *Store) ReadState(ctx context.Context) (RepoState, error) { if state.Branches == nil { state.Branches = map[string]BranchRecord{} } + if state.Landings == nil { + state.Landings = map[string]LandingRecord{} + } + if state.Verifications == nil { + state.Verifications = map[string][]VerificationRecord{} + } return state, nil } diff --git a/internal/store/types.go b/internal/store/types.go index 0e4cf2a..2b2f60b 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -1,12 +1,14 @@ package store type RepoState struct { - Version int `json:"version"` - Repo string `json:"repo,omitempty"` - DefaultRemote string `json:"defaultRemote"` - Trunk string `json:"trunk"` - UpdatedAt string `json:"updatedAt"` - Branches map[string]BranchRecord `json:"branches"` + Version int `json:"version"` + Repo string `json:"repo,omitempty"` + DefaultRemote string `json:"defaultRemote"` + Trunk string `json:"trunk"` + UpdatedAt string `json:"updatedAt"` + Branches map[string]BranchRecord `json:"branches"` + Landings map[string]LandingRecord `json:"landings,omitempty"` + Verifications map[string][]VerificationRecord `json:"verifications,omitempty"` } type BranchRecord struct { @@ -34,6 +36,24 @@ type RestackMetadata struct { LastRestackedAt string `json:"lastRestackedAt,omitempty"` } +type VerificationRecord struct { + CheckType string `json:"checkType"` + Identifier string `json:"identifier,omitempty"` + Passed bool `json:"passed"` + HeadOID string `json:"headOid,omitempty"` + RecordedAt string `json:"recordedAt"` + Note string `json:"note,omitempty"` + Score *int `json:"score,omitempty"` +} + +type LandingRecord struct { + BaseBranch string `json:"baseBranch"` + SourceBranches []string `json:"sourceBranches"` + SupersededPRs []int `json:"supersededPrs,omitempty"` + SupersededAt string `json:"supersededAt,omitempty"` + CreatedAt string `json:"createdAt"` +} + type OperationState struct { Version int `json:"version"` Type string `json:"type"` diff --git a/internal/testutil/gh_stub.go b/internal/testutil/gh_stub.go index ba39931..4f22f9d 100644 --- a/internal/testutil/gh_stub.go +++ b/internal/testutil/gh_stub.go @@ -121,6 +121,20 @@ if args[:2] == ["pr", "merge"]: save() sys.exit(0) +if args[:2] == ["pr", "comment"]: + number = args[2] + pr = state.get("prs", {}).get(str(number)) + if pr is None: + print(f"unknown pr {number}", file=sys.stderr) + sys.exit(1) + body = find_flag(args, "--body", "") + comments = pr.get("comments", []) + comments.append(body) + pr["comments"] = comments + state["prs"][str(number)] = pr + save() + sys.exit(0) + print("unsupported gh invocation", " ".join(args), file=sys.stderr) sys.exit(1) ` diff --git a/internal/ui/render.go b/internal/ui/render.go index 9db975d..2a4f7ea 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -60,6 +60,9 @@ func RenderStatus(summary stack.Summary) string { ), ) } + if branch.Verification != nil { + lines = append(lines, MutedStyle.Render(fmt.Sprintf("%sVerify: %s", prefix+" ", renderVerificationSummary(*branch.Verification)))) + } if len(branch.Issues) == 0 { lines = append(lines, MutedStyle.Render(fmt.Sprintf("%sNo issues detected.", prefix+" "))) @@ -71,6 +74,36 @@ func RenderStatus(summary stack.Summary) string { } } + if len(summary.LandingBranches) > 0 { + lines = append(lines, + "", + SectionStyle.Render("Landing Branches"), + ) + for _, branch := range summary.LandingBranches { + status := HealthyBadgeStyle.Render("healthy") + if len(branch.Issues) > 0 { + status = renderSeverity(branch.Issues[0].Severity) + } + + currentMarker := "" + if branch.IsCurrentBranch { + currentMarker = " " + CodeStyle.Render("(current)") + } + + lines = append(lines, fmt.Sprintf("%s %s%s", status, CodeStyle.Render(branch.Name), currentMarker)) + if branch.Verification != nil { + lines = append(lines, MutedStyle.Render(fmt.Sprintf(" Verify: %s", renderVerificationSummary(*branch.Verification)))) + } + if len(branch.Issues) == 0 { + lines = append(lines, MutedStyle.Render(" No issues detected.")) + continue + } + for _, issue := range branch.Issues { + lines = append(lines, fmt.Sprintf(" - %s", renderIssue(issue))) + } + } + } + return AppFrameStyle.Render(strings.Join(lines, "\n")) } @@ -100,3 +133,33 @@ func renderIssue(issue stack.HealthIssue) string { issue.Message, ) } + +func renderVerificationSummary(summary stack.VerificationSummary) string { + result := verificationResultStyle(summary.Latest.Passed).Render(verificationResultLabel(summary.Latest.Passed)) + line := fmt.Sprintf("%s %s", summary.Latest.CheckType, result) + if summary.Latest.Identifier != "" { + line += fmt.Sprintf(" %s", summary.Latest.Identifier) + } + if summary.Latest.Score != nil { + line += fmt.Sprintf(" score=%d", *summary.Latest.Score) + } + if !summary.HeadMatchesCurrent { + line += " stale" + } + line += fmt.Sprintf(" (%d total)", summary.Count) + return line +} + +func verificationResultStyle(passed bool) lipgloss.Style { + if passed { + return HealthyBadgeStyle + } + return WarnBadgeStyle +} + +func verificationResultLabel(passed bool) string { + if passed { + return "passed" + } + return "failed" +} From 1867aebd3c06c156fe77b99297f2e23a638364e7 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 26 Mar 2026 14:07:36 -0400 Subject: [PATCH 2/9] feat: extend landing pr orchestration --- docs/cli/stack_closeout.md | 7 +- docs/cli/stack_compose.md | 6 + docs/cli/stack_supersede.md | 11 +- internal/cmd/root.go | 310 ++++++++++++++++++++++++++++++++--- internal/cmd/root_test.go | 219 ++++++++++++++++++++++++- internal/github/client.go | 27 +++ internal/store/types.go | 13 +- internal/testutil/gh_stub.go | 26 ++- 8 files changed, 581 insertions(+), 38 deletions(-) diff --git a/docs/cli/stack_closeout.md b/docs/cli/stack_closeout.md index 6caaea0..f1431e9 100644 --- a/docs/cli/stack_closeout.md +++ b/docs/cli/stack_closeout.md @@ -8,7 +8,7 @@ Plan read-only post-merge closeout for a landing branch ### Synopsis -Use recorded landing composition, pull request state, and verification records to show which original PRs and inferred tickets are safe to close now versus still blocked on deploy checks. +Use recorded landing composition, pull request state, and verification records to show which original PRs and explicit tickets are safe to close now versus still blocked on deploy checks. ``` stack closeout [flags] @@ -18,12 +18,15 @@ stack closeout [flags] ``` stack closeout stack/discovery-core +stack closeout stack/discovery-core --apply --yes ``` ### Options ``` - -h, --help help for closeout + --apply Close superseded PRs that are explicitly marked safe to close after landing merge + -h, --help help for closeout + --yes Skip confirmation when used with --apply ``` ### SEE ALSO diff --git a/docs/cli/stack_compose.md b/docs/cli/stack_compose.md index b1e27d1..11b2870 100644 --- a/docs/cli/stack_compose.md +++ b/docs/cli/stack_compose.md @@ -18,15 +18,21 @@ stack compose [flags] ``` stack compose discovery-core --from feature/a --to feature/c +stack compose discovery-core --from feature/a --to feature/c --ticket LNHACK-66 --ticket LNHACK-67 --open-pr stack compose discovery-core --branches feature/a --branches feature/b --yes ``` ### Options ``` + --body string Explicit landing PR body when used with --open-pr --branches stringArray Tracked branches to include in explicit order + --draft Create the landing PR as a draft when used with --open-pr --from string Bottom branch of a contiguous tracked path -h, --help help for compose + --open-pr Push the landing branch and create or refresh its GitHub pull request + --ticket stringArray Ticket references to attach to the landing branch, comma-separated or repeated + --title string Explicit landing PR title when used with --open-pr --to string Top branch of a contiguous tracked path --yes Skip confirmation ``` diff --git a/docs/cli/stack_supersede.md b/docs/cli/stack_supersede.md index 73b47ec..0be8ede 100644 --- a/docs/cli/stack_supersede.md +++ b/docs/cli/stack_supersede.md @@ -24,11 +24,12 @@ stack supersede --landing stack/discovery-core --prs 353 --prs 354 --no-comment ### Options ``` - -h, --help help for supersede - --landing string Landing branch to use as the superseding target - --no-comment Record superseded linkage locally without posting GitHub comments - --prs stringArray Original PR numbers, comma-separated or repeated - --yes Skip confirmation + --close-after-merge stack closeout --apply Mark superseded PRs to be closed by stack closeout --apply after the landing PR merges + -h, --help help for supersede + --landing string Landing branch to use as the superseding target + --no-comment Record superseded linkage locally without posting GitHub comments + --prs stringArray Original PR numbers, comma-separated or repeated + --yes Skip confirmation ``` ### SEE ALSO diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 19872db..bcebbde 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -410,6 +410,11 @@ func newComposeCommand(runtime *stackruntime.Runtime) *cobra.Command { var branches []string var from string var to string + var ticketValues []string + var openPR bool + var draft bool + var title string + var body string var yes bool cmd := &cobra.Command{ @@ -418,6 +423,7 @@ func newComposeCommand(runtime *stackruntime.Runtime) *cobra.Command { Long: "Create one ordinary local landing branch from an explicit linear branch selection and replay only the selected commits in order.", Example: strings.TrimSpace(` stack compose discovery-core --from feature/a --to feature/c +stack compose discovery-core --from feature/a --to feature/c --ticket LNHACK-66 --ticket LNHACK-67 --open-pr stack compose discovery-core --branches feature/a --branches feature/b --yes `), Args: cobra.ExactArgs(1), @@ -437,16 +443,40 @@ stack compose discovery-core --branches feature/a --branches feature/b --yes if err != nil { return err } + tickets, err := parseTicketRefs(ticketValues) + if err != nil { + return err + } plan, err := buildComposePlan(runtime, state, composeDestinationBranch(args[0]), selectedBranches) if err != nil { return err } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Compose preview", renderComposePreview(plan))) + preview := renderComposePreview(plan) + if len(tickets) > 0 { + preview = append(preview, fmt.Sprintf("tickets: %s", strings.Join(tickets, ", "))) + } + if openPR { + preview = append(preview, "push landing branch and open or refresh landing PR") + if strings.TrimSpace(title) != "" { + preview = append(preview, fmt.Sprintf("pr title: %q", strings.TrimSpace(title))) + } + if strings.TrimSpace(body) != "" { + preview = append(preview, "pr body: explicit command value") + } + if draft { + preview = append(preview, "pr draft: true") + } + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Compose preview", preview)) if !yes { - confirmed, err := forms.Confirm("Compose landing branch", "This creates a new local branch and cherry-picks the selected commits onto trunk.") + confirmText := "This creates a new local branch and cherry-picks the selected commits onto trunk." + if openPR { + confirmText = "This creates a new local branch, cherry-picks the selected commits onto trunk, pushes the landing branch, and may create or edit a GitHub PR." + } + confirmed, err := forms.Confirm("Compose landing branch", confirmText) if err != nil { return err } @@ -467,11 +497,57 @@ stack compose discovery-core --branches feature/a --branches feature/b --yes } } - state.Landings[plan.Destination] = store.LandingRecord{ + landing := store.LandingRecord{ BaseBranch: plan.Base, SourceBranches: append([]string(nil), selectedBranches...), + Tickets: tickets, CreatedAt: time.Now().UTC().Format(time.RFC3339), } + state.Landings[plan.Destination] = landing + + if openPR { + metadata := resolveComposePRMetadata(plan, tickets, title, body) + remoteHeadOID, remoteExists, err := runtime.Git.RemoteBranchOID(runtime.Context, state.DefaultRemote, plan.Destination) + if err != nil { + return err + } + if err := runtime.Git.PushBranch(runtime.Context, state.DefaultRemote, plan.Destination, remoteHeadOID); err != nil { + return err + } + + pr, created, err := openOrUpdateLandingPR(runtime, state, plan.Destination, metadata.Title, metadata.Body, draft) + if err != nil { + return err + } + landing.LandingPRNumber = pr.Number + state.Landings[plan.Destination] = landing + + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + return err + } + + lines := []string{ + fmt.Sprintf("branch: %s", plan.Destination), + fmt.Sprintf("base: %s", plan.Base), + fmt.Sprintf("replayed commits: %d", plan.CommitCount()), + fmt.Sprintf("tickets: %s", chooseComposeTicketSummary(tickets)), + fmt.Sprintf("landing PR: #%d", pr.Number), + } + if remoteExists { + lines = append(lines, fmt.Sprintf("pushed: updated %s/%s", state.DefaultRemote, plan.Destination)) + } else { + lines = append(lines, fmt.Sprintf("pushed: created %s/%s", state.DefaultRemote, plan.Destination)) + } + if created { + lines = append(lines, fmt.Sprintf("landing PR url: %s", pr.URL)) + } else { + lines = append(lines, "landing PR: refreshed existing GitHub PR") + } + lines = append(lines, plan.Warnings...) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Composed landing branch", lines)) + return nil + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { return err } @@ -480,6 +556,7 @@ stack compose discovery-core --branches feature/a --branches feature/b --yes fmt.Sprintf("branch: %s", plan.Destination), fmt.Sprintf("base: %s", plan.Base), fmt.Sprintf("replayed commits: %d", plan.CommitCount()), + fmt.Sprintf("tickets: %s", chooseComposeTicketSummary(tickets)), } lines = append(lines, plan.Warnings...) _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Composed landing branch", lines)) @@ -490,6 +567,11 @@ stack compose discovery-core --branches feature/a --branches feature/b --yes cmd.Flags().StringArrayVar(&branches, "branches", nil, "Tracked branches to include in explicit order") cmd.Flags().StringVar(&from, "from", "", "Bottom branch of a contiguous tracked path") cmd.Flags().StringVar(&to, "to", "", "Top branch of a contiguous tracked path") + cmd.Flags().StringArrayVar(&ticketValues, "ticket", nil, "Ticket references to attach to the landing branch, comma-separated or repeated") + cmd.Flags().BoolVar(&openPR, "open-pr", false, "Push the landing branch and create or refresh its GitHub pull request") + cmd.Flags().BoolVar(&draft, "draft", false, "Create the landing PR as a draft when used with --open-pr") + cmd.Flags().StringVar(&title, "title", "", "Explicit landing PR title when used with --open-pr") + cmd.Flags().StringVar(&body, "body", "", "Explicit landing PR body when used with --open-pr") cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") return cmd } @@ -654,12 +736,16 @@ func newVerifyListCommand(runtime *stackruntime.Runtime) *cobra.Command { } func newCloseoutCommand(runtime *stackruntime.Runtime) *cobra.Command { + var apply bool + var yes bool + cmd := &cobra.Command{ Use: "closeout ", Short: "Plan read-only post-merge closeout for a landing branch", - Long: "Use recorded landing composition, pull request state, and verification records to show which original PRs and inferred tickets are safe to close now versus still blocked on deploy checks.", + Long: "Use recorded landing composition, pull request state, and verification records to show which original PRs and explicit tickets are safe to close now versus still blocked on deploy checks.", Example: strings.TrimSpace(` stack closeout stack/discovery-core +stack closeout stack/discovery-core --apply --yes `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -673,11 +759,36 @@ stack closeout stack/discovery-core return err } + if apply { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Closeout plan", renderCloseoutPlan(plan))) + if !yes { + confirmed, err := forms.Confirm("Apply closeout", "This may close superseded GitHub pull requests that are marked safe to close.") + if err != nil { + return err + } + if !confirmed { + return fmt.Errorf("closeout cancelled") + } + } + + applied, nextState, err := applyCloseoutPlan(runtime, state, plan) + if err != nil { + return err + } + if err := runtime.Store.WriteState(runtime.Context, nextState); err != nil { + return err + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Closeout applied", applied)) + return nil + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Closeout plan", renderCloseoutPlan(plan))) return nil }, } + cmd.Flags().BoolVar(&apply, "apply", false, "Close superseded PRs that are explicitly marked safe to close after landing merge") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation when used with --apply") return cmd } @@ -685,6 +796,7 @@ func newSupersedeCommand(runtime *stackruntime.Runtime) *cobra.Command { var landingBranch string var prValues []string var noComment bool + var closeAfterMerge bool var yes bool cmd := &cobra.Command{ @@ -730,6 +842,9 @@ stack supersede --landing stack/discovery-core --prs 353 --prs 354 --no-comment } else { lines = append(lines, fmt.Sprintf("comments: %d GitHub PR comments will be posted", len(plan.SupersededPRs))) } + if closeAfterMerge { + lines = append(lines, "close originals: after landing merge via `stack closeout --apply`") + } _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Supersede preview", lines)) if !yes { @@ -744,6 +859,8 @@ stack supersede --landing stack/discovery-core --prs 353 --prs 354 --no-comment landing := state.Landings[plan.LandingBranch] landing.SupersededPRs = append([]int(nil), prNumbers...) + landing.LandingPRNumber = plan.LandingPR.Number + landing.CloseSupersededAfterMerge = closeAfterMerge landing.SupersededAt = time.Now().UTC().Format(time.RFC3339) state.Landings[plan.LandingBranch] = landing if err := runtime.Store.WriteState(runtime.Context, state); err != nil { @@ -764,6 +881,9 @@ stack supersede --landing stack/discovery-core --prs 353 --prs 354 --no-comment fmt.Sprintf("landing PR: #%d", plan.LandingPR.Number), fmt.Sprintf("superseded PRs: %s", joinPRNumbers(prNumbers)), } + if closeAfterMerge { + result = append(result, "close originals: enabled for post-merge closeout") + } if noComment { result = append(result, "github comments: skipped") } else { @@ -777,6 +897,7 @@ stack supersede --landing stack/discovery-core --prs 353 --prs 354 --no-comment cmd.Flags().StringVar(&landingBranch, "landing", "", "Landing branch to use as the superseding target") cmd.Flags().StringArrayVar(&prValues, "prs", nil, "Original PR numbers, comma-separated or repeated") cmd.Flags().BoolVar(&noComment, "no-comment", false, "Record superseded linkage locally without posting GitHub comments") + cmd.Flags().BoolVar(&closeAfterMerge, "close-after-merge", false, "Mark superseded PRs to be closed by `stack closeout --apply` after the landing PR merges") cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") return cmd } @@ -1427,6 +1548,11 @@ type composeCommitPlan struct { Subject string } +type composePRMetadata struct { + Title string + Body string +} + type closeoutPlan struct { LandingBranch string BaseBranch string @@ -1651,6 +1777,65 @@ func renderComposePreview(plan composePlan) []string { return lines } +func resolveComposePRMetadata(plan composePlan, tickets []string, explicitTitle string, explicitBody string) composePRMetadata { + title := strings.TrimSpace(explicitTitle) + if title == "" { + if len(tickets) > 0 { + title = fmt.Sprintf("Landing: %s", strings.Join(tickets, ", ")) + } else { + title = fmt.Sprintf("Landing: %s", strings.TrimPrefix(plan.Destination, "stack/")) + } + } + + body := strings.TrimSpace(explicitBody) + if body == "" { + lines := []string{ + fmt.Sprintf("Composed landing branch `%s` targeting `%s`.", plan.Destination, plan.Base), + "", + "Included branches:", + } + for _, branchPlan := range plan.Branches { + lines = append(lines, fmt.Sprintf("- `%s`", branchPlan.Name)) + } + if len(tickets) > 0 { + lines = append(lines, "", "Tickets:") + for _, ticket := range tickets { + lines = append(lines, fmt.Sprintf("- `%s`", ticket)) + } + } + body = strings.Join(lines, "\n") + } + + return composePRMetadata{ + Title: title, + Body: body, + } +} + +func chooseComposeTicketSummary(tickets []string) string { + if len(tickets) == 0 { + return "none recorded" + } + return strings.Join(tickets, ", ") +} + +func openOrUpdateLandingPR(runtime *stackruntime.Runtime, state store.RepoState, branch string, title string, body string, draft bool) (store.PullRequest, bool, error) { + pr, err := runtime.GitHub.FindPRByHead(runtime.Context, branch) + if err != nil { + return store.PullRequest{}, false, err + } + if pr.Number == 0 { + created, err := runtime.GitHub.CreatePR(runtime.Context, state.Trunk, branch, title, body, draft) + return created, true, err + } + + if err := runtime.GitHub.EditPR(runtime.Context, pr.Number, state.Trunk, title, body); err != nil { + return store.PullRequest{}, false, err + } + updated, err := runtime.GitHub.ViewPR(runtime.Context, pr.Number) + return updated, false, err +} + func resolveComposeBranches(state store.RepoState, explicit []string, from string, to string) ([]string, error) { if len(explicit) > 0 { if from != "" || to != "" { @@ -1738,7 +1923,7 @@ func buildCloseoutPlan(runtime *stackruntime.Runtime, state store.RepoState, lan SourceBranches: make([]closeoutBranchPlan, 0, len(landing.SourceBranches)), } - landingPRs, err := runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) + landingPRs, err := resolveLandingPRs(runtime, landingBranch, landing) if err != nil { return closeoutPlan{}, err } @@ -1746,12 +1931,12 @@ func buildCloseoutPlan(runtime *stackruntime.Runtime, state store.RepoState, lan case 0: case 1: pr := landingPRs[0] + landing.LandingPRNumber = pr.Number plan.LandingPR = &pr default: plan.LandingPRAmbiguous = append(plan.LandingPRAmbiguous, landingPRs...) } - ticketSet := map[string]bool{} for _, branch := range landing.SourceBranches { branchPlan := closeoutBranchPlan{Name: branch} record, tracked := state.Branches[branch] @@ -1763,10 +1948,6 @@ func buildCloseoutPlan(runtime *stackruntime.Runtime, state store.RepoState, lan } } plan.SourceBranches = append(plan.SourceBranches, branchPlan) - - for _, ticket := range extractTicketRefs(branch) { - ticketSet[ticket] = true - } } if len(landing.SupersededPRs) > 0 { plan.SourceBranches = plan.SourceBranches[:0] @@ -1780,13 +1961,10 @@ func buildCloseoutPlan(runtime *stackruntime.Runtime, state store.RepoState, lan Name: pr.HeadRefName, PR: &pr, }) - for _, ticket := range extractTicketRefs(pr.HeadRefName) { - ticketSet[ticket] = true - } } } - tickets := mapKeys(ticketSet) + tickets := append([]string(nil), landing.Tickets...) sort.Strings(tickets) if plan.LandingPR == nil { @@ -1807,6 +1985,9 @@ func buildCloseoutPlan(runtime *stackruntime.Runtime, state store.RepoState, lan if deployFollowUp != "" { plan.FollowUps = append(plan.FollowUps, deployFollowUp) } + if len(tickets) == 0 { + plan.FollowUps = append(plan.FollowUps, fmt.Sprintf("no explicit tickets are recorded for %s; compose or repair the landing metadata with ticket refs before using closeout for ticket closure", landingBranch)) + } for _, branch := range plan.SourceBranches { if branch.PR == nil || branch.PR.Number == 0 { @@ -1846,7 +2027,7 @@ func buildSupersedePlan(runtime *stackruntime.Runtime, state store.RepoState, la return supersedePlan{}, fmt.Errorf("landing branch %q has no recorded source branches to supersede", landingBranch) } - landingPRs, err := runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) + landingPRs, err := resolveLandingPRs(runtime, landingBranch, landing) if err != nil { return supersedePlan{}, err } @@ -1962,6 +2143,53 @@ func renderCloseoutList(values []string) []string { return lines } +func applyCloseoutPlan(runtime *stackruntime.Runtime, state store.RepoState, plan closeoutPlan) ([]string, store.RepoState, error) { + nextState := cloneRepoState(state) + landing, ok := nextState.Landings[plan.LandingBranch] + if !ok { + return nil, state, fmt.Errorf("landing branch %q is no longer recorded in local metadata", plan.LandingBranch) + } + if !landing.CloseSupersededAfterMerge { + return nil, state, fmt.Errorf("landing branch %q is not marked for post-merge superseded PR closure; rerun `stack supersede --landing %s --prs ... --close-after-merge` or close the PRs manually", plan.LandingBranch, plan.LandingBranch) + } + if plan.LandingPR == nil || plan.LandingPR.State != "MERGED" { + return nil, state, fmt.Errorf("landing PR for %q must be merged before closeout can apply superseded PR closure", plan.LandingBranch) + } + + closed := make([]string, 0, len(plan.SourceBranches)) + for _, branch := range plan.SourceBranches { + if branch.PR == nil || branch.PR.Number == 0 || branch.PR.State != "OPEN" { + continue + } + comment := fmt.Sprintf("Closing as superseded by merged landing PR #%d.", plan.LandingPR.Number) + if err := runtime.GitHub.ClosePR(runtime.Context, branch.PR.Number, comment); err != nil { + return nil, state, err + } + closed = append(closed, fmt.Sprintf("#%d %s", branch.PR.Number, branch.Name)) + for trackedBranch, record := range nextState.Branches { + if record.PR.Number != branch.PR.Number { + continue + } + record.PR.State = "CLOSED" + nextState.Branches[trackedBranch] = record + } + } + + lines := []string{ + fmt.Sprintf("landing branch: %s", plan.LandingBranch), + fmt.Sprintf("landing PR: #%d", plan.LandingPR.Number), + } + if len(closed) == 0 { + lines = append(lines, "closed superseded PRs: none") + } else { + lines = append(lines, fmt.Sprintf("closed superseded PRs: %s", strings.Join(closed, ", "))) + } + if len(plan.TicketsSafeToClose) > 0 { + lines = append(lines, fmt.Sprintf("tickets safe to close now: %s", strings.Join(plan.TicketsSafeToClose, ", "))) + } + return lines, nextState, nil +} + func isDeployVerification(checkType string) bool { normalized := strings.ToLower(strings.TrimSpace(checkType)) return normalized == "deploy" || normalized == "smoke" @@ -1997,6 +2225,17 @@ func mapKeys(values map[string]bool) []string { return keys } +func resolveLandingPRs(runtime *stackruntime.Runtime, landingBranch string, landing store.LandingRecord) ([]store.PullRequest, error) { + if landing.LandingPRNumber > 0 { + pr, err := runtime.GitHub.ViewPR(runtime.Context, landing.LandingPRNumber) + if err != nil { + return nil, err + } + return []store.PullRequest{pr}, nil + } + return runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) +} + func parsePRNumbers(values []string) ([]int, error) { seen := map[int]bool{} numbers := make([]int, 0) @@ -2021,6 +2260,30 @@ func parsePRNumbers(values []string) ([]int, error) { return numbers, nil } +func parseTicketRefs(values []string) ([]string, error) { + seen := map[string]bool{} + tickets := make([]string, 0) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + if !ticketRefPattern.MatchString(trimmed) { + return nil, fmt.Errorf("invalid ticket reference %q", trimmed) + } + normalized := strings.ToUpper(trimmed) + if seen[normalized] { + continue + } + seen[normalized] = true + tickets = append(tickets, normalized) + } + } + sort.Strings(tickets) + return tickets, nil +} + func joinPRNumbers(numbers []int) string { parts := make([]string, 0, len(numbers)) for _, number := range numbers { @@ -2140,7 +2403,7 @@ func buildLandingQueuePlan(runtime *stackruntime.Runtime, state store.RepoState, return queuePlan{}, fmt.Errorf("remote landing branch %q is stale; push or refresh it before queue handoff", branch) } - prs, err := runtime.GitHub.ListPRsByHead(runtime.Context, branch) + prs, err := resolveLandingPRs(runtime, branch, landing) if err != nil { return queuePlan{}, err } @@ -2198,7 +2461,7 @@ func buildSourceLandingQueuePlan(runtime *stackruntime.Runtime, state store.Repo } landingBranch := landingBranches[0] - prs, err := runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) + prs, err := resolveLandingPRs(runtime, landingBranch, state.Landings[landingBranch]) if err != nil { return queuePlan{}, false, err } @@ -2491,11 +2754,14 @@ func cloneRepoState(state store.RepoState) store.RepoState { cloned.Landings = make(map[string]store.LandingRecord, len(state.Landings)) for branch, record := range state.Landings { cloned.Landings[branch] = store.LandingRecord{ - BaseBranch: record.BaseBranch, - SourceBranches: append([]string(nil), record.SourceBranches...), - SupersededPRs: append([]int(nil), record.SupersededPRs...), - SupersededAt: record.SupersededAt, - CreatedAt: record.CreatedAt, + BaseBranch: record.BaseBranch, + SourceBranches: append([]string(nil), record.SourceBranches...), + Tickets: append([]string(nil), record.Tickets...), + LandingPRNumber: record.LandingPRNumber, + SupersededPRs: append([]int(nil), record.SupersededPRs...), + CloseSupersededAfterMerge: record.CloseSupersededAfterMerge, + SupersededAt: record.SupersededAt, + CreatedAt: record.CreatedAt, } } cloned.Verifications = make(map[string][]store.VerificationRecord, len(state.Verifications)) diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 791b5f2..111ee94 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -1357,6 +1357,92 @@ func TestComposeRejectsNonContiguousExplicitBranches(t *testing.T) { } } +func TestComposeOpenPRCreatesLandingPRAndStoresTickets(t *testing.T) { + repo := testutil.SetupGitRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + testutil.Run(t, repo, "git", "init", "--bare", remote) + testutil.Run(t, repo, "git", "remote", "add", "origin", remote) + testutil.Run(t, repo, "git", "push", "-u", "origin", "main") + + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature a") + featureAHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "feature/a")) + + testutil.Run(t, repo, "git", "switch", "-c", "feature/b") + testutil.WriteFile(t, filepath.Join(repo, "feature-b.txt"), "feature b\n") + testutil.Run(t, repo, "git", "add", "feature-b.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature b") + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/a": { + ParentBranch: "main", + RemoteName: "origin", + Restack: store.RestackMetadata{ + LastParentHeadOID: mainHead, + }, + }, + "feature/b": { + ParentBranch: "feature/a", + RemoteName: "origin", + Restack: store.RestackMetadata{ + LastParentHeadOID: featureAHead, + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + output := executeCommand(t, runtime, "compose", "discovery-core", "--from", "feature/a", "--to", "feature/b", "--ticket", "lnhack-66", "--ticket", "LNHACK-67", "--open-pr", "--yes") + if !strings.Contains(output, "landing PR: #1") { + t.Fatalf("expected compose output to mention landing PR, got %q", output) + } + if !strings.Contains(output, "tickets: LNHACK-66, LNHACK-67") { + t.Fatalf("expected compose output to mention tickets, got %q", output) + } + + state, err = runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state after compose: %v", err) + } + landing, ok := state.Landings["stack/discovery-core"] + if !ok { + t.Fatalf("expected landing metadata to be recorded") + } + if got := strings.Join(landing.Tickets, ","); got != "LNHACK-66,LNHACK-67" { + t.Fatalf("unexpected landing tickets %q", got) + } + if landing.LandingPRNumber != 1 { + t.Fatalf("expected landing PR number 1, got %+v", landing) + } + if !runtime.Git.RemoteBranchExists(runtime.Context, "origin", "stack/discovery-core") { + t.Fatalf("expected landing branch to be pushed to origin") + } + + ghState := readFile(t, ghStub.StatePath) + if !strings.Contains(ghState, `"headRefName": "stack/discovery-core"`) || !strings.Contains(ghState, `"title": "Landing: LNHACK-66, LNHACK-67"`) { + t.Fatalf("expected landing PR to be created in gh state, got %q", ghState) + } +} + func TestCloseoutClassifiesSupersededPRsAndDeployGatedTickets(t *testing.T) { repo := testutil.SetupGitRepo(t) remote := filepath.Join(t.TempDir(), "remote.git") @@ -1423,6 +1509,7 @@ func TestCloseoutClassifiesSupersededPRsAndDeployGatedTickets(t *testing.T) { "stack/discovery-core": { BaseBranch: "main", SourceBranches: []string{"hack-agent/lnhack-66-feature-a", "hack-agent/lnhack-67-feature-b"}, + Tickets: []string{"LNHACK-66", "LNHACK-67"}, CreatedAt: "2026-03-26T18:30:00Z", }, }, @@ -1499,13 +1586,135 @@ func TestCloseoutClassifiesSupersededPRsAndDeployGatedTickets(t *testing.T) { t.Fatalf("expected superseded PRs in closeout output, got %q", output) } if !strings.Contains(output, "LNHACK-66") || !strings.Contains(output, "LNHACK-67") { - t.Fatalf("expected inferred ticket refs in closeout output, got %q", output) + t.Fatalf("expected explicit ticket refs in closeout output, got %q", output) } if strings.Contains(output, "record a passed deploy or smoke verification") { t.Fatalf("expected deploy follow-up to be cleared, got %q", output) } } +func TestCloseoutApplyClosesSupersededPRsWhenOptedIn(t *testing.T) { + repo := testutil.SetupGitRepo(t) + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "a.txt"), "a\n") + testutil.Run(t, repo, "git", "add", "a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add a") + featureAHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/a": { + ParentBranch: "main", + PR: store.PullRequest{ + Number: 1, + HeadRefName: "feature/a", + BaseRefName: "main", + LastSeenHeadOID: featureAHead, + State: "OPEN", + }, + Restack: store.RestackMetadata{LastParentHeadOID: mainHead}, + }, + }, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{"feature/a"}, + Tickets: []string{"LNHACK-66"}, + LandingPRNumber: 9, + SupersededPRs: []int{1}, + CloseSupersededAfterMerge: true, + CreatedAt: "2026-03-26T18:35:00Z", + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "deploy", + Identifier: "deploy-1", + Passed: true, + HeadOID: landingHead, + RecordedAt: "2026-03-26T18:36:00Z", + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "1": { + "id": "PR_1", + "number": 1, + "url": "https://example.com/hack-dance/stack/pull/1", + "repo": "hack-dance/stack", + "headRefName": "feature/a", + "baseRefName": "main", + "headRefOid": "`+featureAHead+`", + "state": "OPEN", + "isDraft": false + }, + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "MERGED", + "isDraft": false + } + }, + "next_number": 10 +}`) + + output := executeCommand(t, runtime, "closeout", "stack/discovery-core", "--apply", "--yes") + if !strings.Contains(output, "closed superseded PRs: #1 feature/a") { + t.Fatalf("expected closeout apply output to mention closed PR, got %q", output) + } + + log := readFile(t, ghStub.LogPath) + if !strings.Contains(log, "pr close 1 --comment Closing as superseded by merged landing PR #9.") { + t.Fatalf("expected GitHub close log, got %q", log) + } + + state, err = runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state after closeout apply: %v", err) + } + if state.Branches["feature/a"].PR.State != "CLOSED" { + t.Fatalf("expected tracked PR state to be updated locally, got %+v", state.Branches["feature/a"].PR) + } +} + func TestCloseoutRequiresExplicitResolutionForAmbiguousLandingPRs(t *testing.T) { repo := testutil.SetupGitRepo(t) testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") @@ -1664,7 +1873,7 @@ func TestSupersedeRecordsMetadataAndCommentsOriginalPRs(t *testing.T) { "next_number": 10 }`) - output := executeCommand(t, runtime, "supersede", "--landing", "stack/discovery-core", "--prs", "1,2", "--yes") + output := executeCommand(t, runtime, "supersede", "--landing", "stack/discovery-core", "--prs", "1,2", "--close-after-merge", "--yes") if !strings.Contains(output, "github comments: posted") { t.Fatalf("expected supersede output to mention posted comments, got %q", output) } @@ -1680,6 +1889,12 @@ func TestSupersedeRecordsMetadataAndCommentsOriginalPRs(t *testing.T) { if got := strings.TrimSpace(joinPRNumbers(landing.SupersededPRs)); got != "#1, #2" { t.Fatalf("expected superseded PR metadata to be recorded, got %q", got) } + if !landing.CloseSupersededAfterMerge { + t.Fatalf("expected close-after-merge metadata to be recorded") + } + if landing.LandingPRNumber != 9 { + t.Fatalf("expected landing PR number to be recorded, got %+v", landing) + } log := readFile(t, ghStub.LogPath) if !strings.Contains(log, "pr comment 1 --body") || !strings.Contains(log, "pr comment 2 --body") { diff --git a/internal/github/client.go b/internal/github/client.go index e1191dd..97ec80c 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -154,6 +154,24 @@ func (c *Client) EditPRBase(ctx context.Context, number int, base string) error return err } +func (c *Client) EditPR(ctx context.Context, number int, base string, title string, body string) error { + args := []string{"pr", "edit", fmt.Sprintf("%d", number)} + if strings.TrimSpace(base) != "" { + args = append(args, "--base", base) + } + if strings.TrimSpace(title) != "" { + args = append(args, "--title", title) + } + if strings.TrimSpace(body) != "" { + args = append(args, "--body", body) + } + if len(args) == 3 { + return nil + } + _, err := c.run(ctx, args...) + return err +} + func (c *Client) MergePR(ctx context.Context, number int, headOID string, strategy string) error { strategyFlag := "--merge" switch strategy { @@ -183,6 +201,15 @@ func (c *Client) CommentPR(ctx context.Context, number int, body string) error { return err } +func (c *Client) ClosePR(ctx context.Context, number int, comment string) error { + args := []string{"pr", "close", fmt.Sprintf("%d", number)} + if strings.TrimSpace(comment) != "" { + args = append(args, "--comment", comment) + } + _, err := c.run(ctx, args...) + return err +} + func decodePR(raw string) (store.PullRequest, error) { var payload pullRequestPayload if err := json.Unmarshal([]byte(raw), &payload); err != nil { diff --git a/internal/store/types.go b/internal/store/types.go index 2b2f60b..e774727 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -47,11 +47,14 @@ type VerificationRecord struct { } type LandingRecord struct { - BaseBranch string `json:"baseBranch"` - SourceBranches []string `json:"sourceBranches"` - SupersededPRs []int `json:"supersededPrs,omitempty"` - SupersededAt string `json:"supersededAt,omitempty"` - CreatedAt string `json:"createdAt"` + BaseBranch string `json:"baseBranch"` + SourceBranches []string `json:"sourceBranches"` + Tickets []string `json:"tickets,omitempty"` + LandingPRNumber int `json:"landingPrNumber,omitempty"` + SupersededPRs []int `json:"supersededPrs,omitempty"` + CloseSupersededAfterMerge bool `json:"closeSupersededAfterMerge,omitempty"` + SupersededAt string `json:"supersededAt,omitempty"` + CreatedAt string `json:"createdAt"` } type OperationState struct { diff --git a/internal/testutil/gh_stub.go b/internal/testutil/gh_stub.go index 4f22f9d..8199bbf 100644 --- a/internal/testutil/gh_stub.go +++ b/internal/testutil/gh_stub.go @@ -104,10 +104,32 @@ if args[:2] == ["pr", "edit"]: print(f"unknown pr {number}", file=sys.stderr) sys.exit(1) base = find_flag(args, "--base", "") + title = find_flag(args, "--title", "") + body = find_flag(args, "--body", "") if base: pr["baseRefName"] = base - state["prs"][str(number)] = pr - save() + if title: + pr["title"] = title + if body: + pr["body"] = body + state["prs"][str(number)] = pr + save() + sys.exit(0) + +if args[:2] == ["pr", "close"]: + number = args[2] + pr = state.get("prs", {}).get(str(number)) + if pr is None: + print(f"unknown pr {number}", file=sys.stderr) + sys.exit(1) + pr["state"] = "CLOSED" + comment = find_flag(args, "--comment", "") + if comment: + comments = pr.get("comments", []) + comments.append(comment) + pr["comments"] = comments + state["prs"][str(number)] = pr + save() sys.exit(0) if args[:2] == ["pr", "merge"]: From acf636a6f861565e2b80957f8897584f9fcdbe8e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 26 Mar 2026 14:45:15 -0400 Subject: [PATCH 3/9] docs: refresh landing workflow guides --- README.md | 143 +++++++++-------- docs/README.md | 26 +++- docs/adopting-existing-prs.md | 160 ++++++++----------- docs/cli/stack_supersede.md | 12 +- docs/how-it-works.md | 118 +++++++++----- docs/landing-workflow-followups.md | 7 +- docs/landing-workflow.md | 237 +++++++++++++++++++++++++++++ docs/testing.md | 103 +++++++++---- docs/troubleshooting.md | 99 +++++++++--- docs/usage.md | 85 +++++++---- internal/cmd/root.go | 2 +- 11 files changed, 712 insertions(+), 280 deletions(-) create mode 100644 docs/landing-workflow.md diff --git a/README.md b/README.md index c84a7d3..f2078bd 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # stack -`stack` is a CLI for stacked pull requests on GitHub. +`stack` is a CLI for stacked pull requests and landing orchestration on GitHub. -It helps you split one large change into a chain of smaller dependent PRs, keep -their branch relationships straight, update PR bases when parents move, and hand -the ready bottom PR to GitHub merge queue without turning your repo into -something only one tool understands. +It helps you do two related jobs without inventing a hosted control plane: + +- keep an explicit parent graph for ordinary stacked PRs +- turn a verified set of existing PRs into one explicit landing branch and landing PR ![Status demo](docs/demo/status.gif) @@ -13,102 +13,125 @@ something only one tool understands. ## What stacked PRs are -A stacked PR flow takes one bigger feature and breaks it into a sequence: +A stacked PR flow splits one larger change into a chain: - branch A targets `main` - branch B builds on A and its PR targets A - branch C builds on B and its PR targets B -That makes review smaller and landing order clearer, but it also creates work: -when A changes or merges, B and C need to move with it. +That gives reviewers smaller PRs. It also makes landing order explicit. The +cost is that branch heads and PR bases need to move together when something +lower in the stack changes or merges. ## What `stack` does `stack` keeps that workflow explicit and repairable: -- you create or track a branch inside a stack -- you restack branches when parents move -- you submit one normal GitHub PR per branch -- you sync local stack state after merges or GitHub-side changes -- you queue the bottom PR when it is ready +- create or track branches inside a stack +- restack branches when parents move +- submit one normal GitHub PR per tracked branch +- compose one landing branch from a selected verified subset +- attach verification and ticket metadata to the landing branch +- mark original PRs as superseded and close them out after landing +- hand the real merge target to GitHub auto-merge or merge queue The branches stay ordinary Git branches. The PRs stay ordinary GitHub PRs. If -you stop using `stack`, the repository still looks like a normal repository. - -## Merge queue - -`stack queue` is for the bottom branch in a healthy stack. It verifies the PR -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, -but it is deliberately simple: - -- it works with ordinary branches and ordinary GitHub PRs -- it keeps stack intent locally, not in a hosted control plane -- it favors previews, confirmations, and repair loops over hidden automation -- it stays legible even if someone on the team never installs the tool - -That makes it a good fit for teams that want stacked PRs on GitHub without -adopting a more opinionated end-to-end workflow. +you stop using `stack`, the repo still looks like a normal repo. -## Install +## Two landing paths -```bash -brew tap hack-dance/homebrew-tap -brew install hack-dance/tap/stack -``` +Use the basic stacked-PR flow when each branch should land in order as its own +PR. -More install and source-build options live in [docs/install.md](docs/install.md). +Use the landing workflow when the graph is useful for organization and repair, +but the real merge target should be one combined landing PR. That is the right +path when you already have a pile of open PRs, want to verify a strict subset, +and need the originals to become traceability-only. ## Quick start +Clean stack: + ```bash stack init --trunk main --remote origin stack create feature/base stack create feature/child -stack status stack submit --all stack queue feature/base ``` -Before using `stack queue`, make sure the GitHub repository has auto-merge -enabled. +Landing workflow from an existing PR pile: + +```bash +stack init --trunk main --remote origin +stack adopt pr 353 --parent main +stack adopt pr 354 --parent pr/353 +stack compose discovery-core --from pr/353 --to pr/354 --ticket LNHACK-66 --open-pr +stack verify add stack/discovery-core --type sim --run-id run-123 --passed +stack supersede --landing stack/discovery-core --prs 353,354 --close-after-merge +stack queue stack/discovery-core +stack closeout stack/discovery-core --apply +``` For the full daily workflow, start with [docs/usage.md](docs/usage.md). +For the real operator workflow around one combined landing PR, start with +[docs/landing-workflow.md](docs/landing-workflow.md). + +## Merge queue + +`stack` does not reimplement merge queue. It only hands off the chosen PR +through `gh pr merge --auto`. + +Sometimes that PR is the bottom tracked branch. Sometimes it is the landing PR. +GitHub decides whether the handoff becomes auto-merge or queue entry. + +The repo must have auto-merge enabled before `stack queue` can work. + ## Starting from existing PRs You do not need to start with a clean stack on day one. -If you already have a pile of open PRs, `stack` can still help you turn them -into an explicit stack so you can test them as a composed set, land them in a -clean order, and handle conflicts with less guesswork. +If you already have a larger PR pile, `stack` can help you: + +- adopt the existing heads into an explicit graph +- repair parent order and base drift +- compose one strict landing branch from a verified subset +- keep the original PRs for traceability instead of queueing them directly + +Use [docs/adopting-existing-prs.md](docs/adopting-existing-prs.md) to make the +graph explicit, then [docs/landing-workflow.md](docs/landing-workflow.md) to +turn that graph into one landing PR. + +## How it differs from Graphite and similar tools + +`stack` stays deliberately simple: + +- ordinary branches +- ordinary GitHub PRs +- explicit local metadata +- previews and repair loops instead of hidden automation -The practical path is: +That makes it a good fit for teams that want stacked PRs and landing batches on +GitHub without committing the repo to a hosted workflow layer. + +## Install -1. check out the repo locally and make sure you have local branches for the PR heads you care about -2. decide the intended parent chain or grouping -3. run `stack track --parent ` for each branch -4. run `stack status` and `stack sync` to see what does not match yet -5. use `stack move`, `stack restack`, and `stack submit` to bring the stack into shape +```bash +brew tap hack-dance/homebrew-tap +brew install hack-dance/tap/stack +``` -That adoption flow is documented in [docs/adopting-existing-prs.md](docs/adopting-existing-prs.md). +More install and source-build options live in [docs/install.md](docs/install.md). ## Documentation - [docs/README.md](docs/README.md) for the full docs index -- [docs/adopting-existing-prs.md](docs/adopting-existing-prs.md) for grouping and ordering an existing PR set +- [docs/usage.md](docs/usage.md) for the standard stacked-PR loop +- [docs/landing-workflow.md](docs/landing-workflow.md) for the landing-orchestration path +- [docs/adopting-existing-prs.md](docs/adopting-existing-prs.md) for making an existing PR graph explicit - [docs/how-it-works.md](docs/how-it-works.md) for the model and workflow -- [docs/usage.md](docs/usage.md) for everyday commands and repair loops -- [docs/troubleshooting.md](docs/troubleshooting.md) for common failure modes +- [docs/troubleshooting.md](docs/troubleshooting.md) for failure modes and repair paths - [docs/cli/stack.md](docs/cli/stack.md) for generated command reference Contributor docs live in [docs/testing.md](docs/testing.md) and diff --git a/docs/README.md b/docs/README.md index 6517ce1..2f1b1aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,18 +1,34 @@ # Docs -Start here if you want the public documentation for `stack`. +Start here if you want the public docs for `stack`. + +## Choose your path + +If you are starting a clean stack and want the normal branch-by-branch flow: + +- read [usage.md](usage.md) + +If you already have a PR pile and want one explicit landing PR: + +- read [landing-workflow.md](landing-workflow.md) + +If you first need to make an existing PR graph explicit before either path: + +- read [adopting-existing-prs.md](adopting-existing-prs.md) ## Product docs - [install.md](install.md) for Homebrew, release artifacts, and source builds -- [adopting-existing-prs.md](adopting-existing-prs.md) for turning an existing PR set into an explicit stack -- [how-it-works.md](how-it-works.md) for stacked PR concepts, merge queue, and workflow shape -- [usage.md](usage.md) for the everyday command flow +- [usage.md](usage.md) for the standard stacked-PR loop +- [landing-workflow.md](landing-workflow.md) for landing-branch composition, verification, superseded PRs, queue handoff, and closeout +- [adopting-existing-prs.md](adopting-existing-prs.md) for turning an existing PR set into an explicit graph +- [how-it-works.md](how-it-works.md) for the core model and workflow shape - [troubleshooting.md](troubleshooting.md) for repair paths when state drifts - [cli/stack.md](cli/stack.md) for generated command reference -## More docs +## Contributor docs - [testing.md](testing.md) for unit, fixture, and live sandbox verification - [releasing.md](releasing.md) for release automation and Homebrew publishing - [demo/README.md](demo/README.md) if you need to regenerate the README demos +- [landing-workflow-followups.md](landing-workflow-followups.md) for the historical feature request that drove the landing-orchestration work diff --git a/docs/adopting-existing-prs.md b/docs/adopting-existing-prs.md index f5f4201..e5398cd 100644 --- a/docs/adopting-existing-prs.md +++ b/docs/adopting-existing-prs.md @@ -1,19 +1,15 @@ # Adopting existing PRs -`stack` is easiest when you start with a stack from the beginning, but that is -not the only useful case. +Use this guide when the repo already has open PRs and you need to turn their +heads into an explicit branch graph. -If your repo already has a large set of open PRs, you can use `stack` to turn -that pile into an explicit branch graph so it becomes easier to: +That graph is the prerequisite for both supported landing paths: -- test a composed set of changes locally -- see the intended landing order -- move related PRs into a dependency chain -- restack and repair larger sets when conflicts show up +- ordinary branch-by-branch stacked landing +- one composed landing branch and landing PR -This is especially useful when a team or agent workflow is producing many PRs in -parallel and you need to turn that output into something reviewable and -mergeable. +This guide is about making the graph explicit. For the full combined-landing +operator flow, continue with [landing-workflow.md](landing-workflow.md). ## What `stack` can do today @@ -21,28 +17,24 @@ mergeable. dependency graph for you. You still choose the parent chain. The tool then helps you keep that chain -consistent and repairable. +repairable. -That means V1 is good for: +That makes it good for: -- grouping a set of related PRs under a chosen base branch -- reordering a chain once you know the intended dependency structure -- moving branches to new parents and retargeting their PR bases +- grouping a related PR set under a chosen base +- reordering the chain once you know the intended dependency structure - restacking large dependent sets after lower branches change or merge - -It is not trying to guess the stack structure from commit history alone. +- deciding later whether the graph should land branch-by-branch or as one landing batch ## Before you start Make sure you have: - a local clone of the repo -- local branches for the PR heads you want to organize - a rough view of which PRs depend on which others +- permission to fetch PR heads if they do not exist locally yet -If the branches only exist on GitHub, fetch them first. - -## A practical adoption workflow +## Practical adoption flow ### 1. Pick the scope @@ -50,11 +42,12 @@ Do not start with all 30 PRs unless they really belong together. Usually the better move is: -- make one stack per feature area or dependency chain +- one graph per feature area or dependency chain - keep independent PR groups separate - pick a clear bottom branch for each group -You are trying to create legible review and merge order, not one giant tower. +You are trying to create one legible review and merge shape, not one giant +tower. ### 2. Initialize the repo @@ -62,24 +55,28 @@ You are trying to create legible review and merge order, not one giant tower. stack init --trunk main --remote origin ``` -### 3. Track the branches you want in the stack +### 3. Adopt the PR heads + +Use `stack adopt pr` when the unit you care about is the PR itself: + +```bash +stack adopt pr 353 --parent main +stack adopt pr 354 --parent pr/353 +stack adopt pr 363 --parent pr/354 +stack adopt pr 364 --parent pr/363 +``` -Start at the bottom and work upward: +Use `stack track` when the local branches already exist and you do not need the +PR lookup or fetch path: ```bash stack track feature/base --parent main stack track feature/parser --parent feature/base -stack track feature/runtime --parent feature/parser -stack track feature/ui --parent feature/runtime ``` -When the parent branch has moved since a child branch was originally cut, -`stack track` records a merge-base-style restack anchor instead of blindly -assuming the current parent tip. That makes stale existing PR heads adoptable -without breaking the first `stack restack`. - -If the first parent chain is wrong, that is fine. Get a draft graph in place -first. +When the parent branch moved since a child branch was originally cut, adoption +records a merge-base-style restack anchor instead of assuming the current parent +tip. That makes stale PR heads repairable without poisoning the first restack. ### 4. Inspect drift and PR linkage @@ -92,98 +89,73 @@ This tells you: - which tracked branches are healthy - which branches already have linked PRs -- which PR bases or heads disagree with your intended stack -- whether a branch needs `stack submit`, `stack restack`, or manual metadata repair next +- which PR bases or heads disagree with your intended graph +- whether the next step is `submit`, `restack`, or manual repair ### 5. Fix the shape -If a branch belongs elsewhere, move it: +If a branch belongs elsewhere: ```bash -stack move feature/ui --parent feature/base +stack move pr/364 --parent pr/354 ``` -If lower branches have already moved, restack: +If lower branches already moved: ```bash stack restack --all ``` -When the local stack shape looks right, update GitHub: +When the local graph looks right, update GitHub: ```bash stack submit --all ``` -That is the step that turns your intended parent graph into updated PR bases and -branch tips on GitHub. +That is the step that makes GitHub match your intended parent graph. -When `stack submit` creates a PR during adoption, it uses the tip commit subject -and body by default. If the branch tip has no commit body yet, `stack` uses a -deterministic fallback body so the first adoption submit stays non-interactive -and reviewable. +## When to compose instead of submitting directly -## Large sets: how to keep them manageable +Do not force every adopted PR set into a strict chain if the real goal is one +combined landing PR. -For a larger PR set, the safest pattern is: +Compose a landing branch when: -1. group PRs into smaller dependency chains -2. adopt one chain at a time -3. run `stack status` after each batch -4. restack and submit the chain before moving to the next one +- the originals should remain traceability-only +- you want to verify one strict subset before merge +- later follow-up commits should stay out of the first landing +- queueing the original PRs directly would create noise or ambiguity -That gives you tighter feedback loops and makes conflicts easier to localize. +That is the normal move for a larger existing PR pile. -If you try to adopt everything at once, you can still do it, but debugging -parent mistakes gets slower and noisier. +## After adoption -If `stack submit` reports multiple open PRs for one head branch, stop and clean -that up before continuing. `stack` refuses to guess which open PR owns a reused -head name. +From here you have two choices: -## Testing a composed set +If each tracked branch should land as its own PR: -Once the branches are tracked, the stack graph gives you a clearer local test -surface. +- continue with [usage.md](usage.md) -You can: +If the graph should end in one landing PR: -- check out the top branch in a chain and test the full composed set -- move or split branches when the integration shape is wrong -- restack after conflict resolution without manually retargeting every PR again +- continue with [landing-workflow.md](landing-workflow.md) -That is often the difference between “30 unrelated PRs” and “5 understandable -stacks.” +## Keep larger sets manageable -## Merge order - -After adoption, the normal landing flow is: - -1. make sure the bottom branch is healthy -2. run `stack submit` so the pushed heads and PR bases are current -3. run `stack queue ` -4. after merge, run `stack sync` -5. repeat for the next bottom branch - -That gives you a controlled path for landing larger change sets in order. - -## Conflict handling on larger stacks - -The main reason to adopt an explicit stack is conflict handling. - -When a lower branch changes: +For a larger PR set, the safest pattern is: -- `stack restack` moves the dependent branches in order -- `stack continue` and `stack abort` give you a recovery loop if a rebase stops -- `stack sync` helps after merges when PR bases or local metadata drift +1. group PRs into smaller dependency chains +2. adopt one graph at a time +3. run `stack status` after each batch +4. restack and submit before moving to the next graph -You still resolve the Git conflicts, but the tool keeps the branch order and PR -bases aligned while you do it. +That gives tighter feedback loops and makes conflicts easier to localize. -## A realistic expectation +## Realistic expectation -For existing PRs, `stack` is best thought of as a repair and organization layer, -not a magic importer. +For existing PRs, `stack` is a repair and organization layer, not a magic +importer. You choose the intended structure. Once that structure is explicit, `stack` -helps you keep it stable. +helps you keep it stable and use it as the basis for either ordinary stacked +landing or one explicit landing batch. diff --git a/docs/cli/stack_supersede.md b/docs/cli/stack_supersede.md index 0be8ede..f0c782d 100644 --- a/docs/cli/stack_supersede.md +++ b/docs/cli/stack_supersede.md @@ -24,12 +24,12 @@ stack supersede --landing stack/discovery-core --prs 353 --prs 354 --no-comment ### Options ``` - --close-after-merge stack closeout --apply Mark superseded PRs to be closed by stack closeout --apply after the landing PR merges - -h, --help help for supersede - --landing string Landing branch to use as the superseding target - --no-comment Record superseded linkage locally without posting GitHub comments - --prs stringArray Original PR numbers, comma-separated or repeated - --yes Skip confirmation + --close-after-merge Mark superseded PRs for later closure during closeout apply after the landing PR merges + -h, --help help for supersede + --landing string Landing branch to use as the superseding target + --no-comment Record superseded linkage locally without posting GitHub comments + --prs stringArray Original PR numbers, comma-separated or repeated + --yes Skip confirmation ``` ### SEE ALSO diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 5be44a2..25b227e 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -1,56 +1,102 @@ # How stack works -## Stacked PRs in plain language +## The core idea -A stacked PR flow splits one larger change into a chain of smaller pull -requests. +`stack` keeps the graph explicit first. Then you choose how it lands. -Example: +That gives you two valid outcomes: -- `feature/base` targets `main` -- `feature/child` targets `feature/base` -- `feature/grandchild` targets `feature/child` - -That gives reviewers smaller PRs and clearer landing order. The tradeoff is that -the branches and PR bases need to move together when something lower in the -stack changes or merges. +- a normal stacked PR flow where each tracked branch lands in order +- a landing workflow where the graph stays useful for organization and repair, but one composed landing PR becomes the real merge target ## The model -`stack` treats branches as the stack unit. +`stack` uses a small number of explicit objects: + +- tracked branches + - each tracked branch records one parent + - each tracked branch can have one ordinary GitHub PR +- landing branches + - an ordinary local branch such as `stack/discovery-core` + - built from a selected portion of the tracked graph + - may carry explicit tickets, verification records, and landing PR linkage +- verification records + - attached to a tracked branch or landing branch + - used by status, queue, and closeout +- superseded PR metadata + - records which original PRs were replaced by a landing PR + - used to keep traceability clear and to drive closeout + +The repo still uses ordinary Git branches and ordinary GitHub PRs. `stack` +adds local intent and repair metadata on top. + +## The standard stacked-PR loop + +Use this when each tracked branch should land as its own PR: -- each tracked branch records an explicit parent -- each tracked branch can have one ordinary GitHub PR -- restacks move branches to match their recorded parents -- sync refreshes PR state and proposes repairs when GitHub no longer matches local intent +1. create or track branches in a stack +2. inspect health with `stack status` +3. restack when a parent moved +4. submit the branches with `stack submit` +5. queue the healthy trunk-bound PR with `stack queue` +6. sync after merges with `stack sync` -The repository still uses normal Git and GitHub objects. The tool adds a local -layer of stack intent and recovery logic on top. +## The landing workflow loop -## The normal loop +Use this when the graph should end in one combined landing PR: -1. Create or track branches in a stack. -2. Use `stack status` to inspect the hierarchy and current health. -3. Use `stack restack` when a parent branch moved. -4. Use `stack submit` to push branches and create or update their PRs. -5. Use `stack queue` when the bottom PR is healthy and ready to land. -6. Use `stack sync` after merges or GitHub-side changes. +1. adopt or repair the graph +2. compose a strict landing branch with `stack compose` +3. attach verification with `stack verify add` +4. mark original PRs as superseded with `stack supersede` +5. queue the landing PR with `stack queue` +6. close out superseded PRs and tickets with `stack closeout` + +The important distinction is operational, not theoretical: + +- source PRs remain useful for traceability and review history +- the landing PR becomes the real merge target ## Merge queue -Merge queue matters most at the bottom of the stack. +Merge queue only matters for the real merge target. + +Sometimes that target is the bottom tracked PR. Sometimes it is a landing PR. + +`stack queue` checks that: -When the bottom PR is ready, `stack queue` checks that the local branch, pushed -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. +- the PR targets trunk +- the local head matches the pushed head +- the PR head matches the current branch head +- the latest verification passed and still matches the current head when verification exists -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. +Then it hands off through `gh pr merge --auto`. + +`stack` does not decide whether the PR becomes auto-merge or merge queue entry. +GitHub does. + +## Why the landing workflow exists + +Real repos often produce a pile of already-open PRs before anyone has turned the +shape into a clean dependency chain. + +In that situation, the graph is still useful. It tells you: + +- what depends on what +- what to restack when lower branches move +- how to build one strict verified landing branch from the exact subset you want to ship + +That is the move from “branch graph management” to “landing orchestration.” ## How this differs from Graphite and similar tools -The important difference is workflow shape: `stack` keeps branches and PRs -ordinary, stores stack intent locally in the repo, and prefers explicit repair -when state drifts instead of hiding that drift. +The main difference is workflow shape, not command count: + +- ordinary branches +- ordinary GitHub PRs +- explicit local metadata +- visible previews and repair loops +- no hosted control plane + +That makes `stack` easier to adopt in repos where not everyone wants to depend +on the same end-to-end workflow tool. diff --git a/docs/landing-workflow-followups.md b/docs/landing-workflow-followups.md index d6a0d63..cd31c6a 100644 --- a/docs/landing-workflow-followups.md +++ b/docs/landing-workflow-followups.md @@ -1,5 +1,11 @@ # Landing workflow follow-ups +This is a historical design note from before the landing-orchestration work was +implemented. Many of the follow-ups in this document now exist in the CLI. + +For the current operator-facing workflow, use +[landing-workflow.md](landing-workflow.md). + This document captures the next layer of work that became obvious while using `stack` to turn a pile of existing PRs into one verified landing PR in `TeamSidewinder/event-agent`. @@ -258,4 +264,3 @@ The tool should help an operator say: - “these are the tickets safe to close after deploy” without requiring a separate spreadsheet or a memory-heavy manual ritual. - diff --git a/docs/landing-workflow.md b/docs/landing-workflow.md new file mode 100644 index 0000000..a47a73a --- /dev/null +++ b/docs/landing-workflow.md @@ -0,0 +1,237 @@ +# Landing workflow + +Use this path when the graph is useful for organization, repair, and review, +but the real merge target should be one combined landing PR. + +That is the common case when you already have a pile of open PRs, want to land +only a verified subset, and need the original PRs to become traceability-only +instead of queue targets. + +## What this guide is for + +This guide covers one continuous operator flow: + +1. adopt or repair the existing PR graph +2. compose one strict landing branch +3. open or refresh the landing PR +4. attach verification to the landing branch +5. mark the original PRs as superseded +6. queue the landing PR +7. close out the original PRs and tickets after merge + +If you are starting a clean stack and want each branch to land as its own PR, +read [usage.md](usage.md) instead. + +## A concrete situation + +Suppose you have four already-open PRs: + +- `#353` +- `#354` +- `#363` +- `#364` + +You want one verified landing PR. + +You also know the combined working branch later picked up a follow-up commit +that should not ship yet. That is exactly why this flow exists. You keep the +graph explicit, but you choose one strict merge target instead of queueing the +original PRs directly. + +## Step 1: make the graph explicit + +If the PRs are already checked out locally, you can track the branches directly. +If not, use `stack adopt pr` and let the CLI fetch the PR head if needed. + +Example: + +```bash +stack init --trunk main --remote origin + +stack adopt pr 353 --parent main +stack adopt pr 354 --parent pr/353 +stack adopt pr 363 --parent pr/354 +stack adopt pr 364 --parent pr/363 +``` + +Then inspect drift: + +```bash +stack status +stack sync +``` + +If the shape is wrong, fix it before you think about landing: + +```bash +stack move pr/364 --parent pr/354 +stack restack --all +stack submit --all +``` + +The point here is simple: make the intended graph explicit first. Do not try to +compose or queue while the parent chain is still ambiguous. + +For more detail on adoption and repair, read +[adopting-existing-prs.md](adopting-existing-prs.md). + +## Step 2: compose the strict landing branch + +Once the graph is right, create a landing branch from the exact subset you want +to ship. + +If you already know the contiguous bottom and top branches: + +```bash +stack compose discovery-core \ + --from pr/353 \ + --to pr/364 \ + --ticket LNHACK-66 \ + --ticket LNHACK-74 \ + --open-pr +``` + +If you want an explicit ordered set instead: + +```bash +stack compose discovery-core \ + --branches pr/353 \ + --branches pr/354 \ + --branches pr/363 \ + --branches pr/364 \ + --ticket LNHACK-66 \ + --ticket LNHACK-74 \ + --open-pr +``` + +What this does: + +- creates an ordinary landing branch such as `stack/discovery-core` +- replays only the selected commits onto trunk +- stores explicit ticket metadata on the landing branch +- pushes the landing branch +- creates or refreshes the landing PR + +That is the step that keeps later follow-up commits out of the first merge. If +the source graph moved, you can recompute the landing branch from the exact +range again instead of manually rebuilding a composed branch. + +## Step 3: verify the landing branch + +Verification belongs on the landing branch, not in chat or in a PR body that +people have to interpret from memory. + +Examples: + +```bash +stack verify add stack/discovery-core \ + --type sim \ + --run-id run-123 \ + --score 100 \ + --passed + +stack verify add stack/discovery-core \ + --type deploy \ + --identifier deploy-42 \ + --passed \ + --note "safe to close after deploy" +``` + +Use `stack status` to see the latest verification and whether the branch head +has moved since that record: + +```bash +stack status +``` + +If the head moved, `stack queue` will refuse the handoff until verification is +fresh again. + +## Step 4: mark the originals as superseded + +Once the landing PR exists, make it explicit that the originals are no longer +queue candidates. + +```bash +stack supersede \ + --landing stack/discovery-core \ + --prs 353,354,363,364 \ + --close-after-merge +``` + +This does two things: + +- comments on the original PRs so reviewers know they were replaced by the landing PR +- stores explicit superseded-PR metadata locally so `closeout` can act on it later + +The important operator rule is plain: + +- queue the landing PR +- do not queue the original PRs once they are traceability-only + +## Step 5: queue the real merge target + +Queue the landing PR, not the original source PRs: + +```bash +stack queue stack/discovery-core +``` + +`stack queue` verifies: + +- the landing branch targets trunk +- the local head matches the pushed head +- the landing PR head matches the current branch head +- the latest recorded verification passed and still matches the current head + +If you accidentally try to queue one of the source PRs after a landing batch +exists, `stack` will stop and tell you to queue the landing PR instead. + +`stack` only performs the handoff. GitHub decides whether that becomes +auto-merge or queue entry. + +## Step 6: close out after merge + +After the landing PR merges: + +```bash +stack closeout stack/discovery-core +``` + +This shows: + +- the landing PR +- the superseded original PRs +- tickets safe to close now +- tickets still blocked on deploy verification +- any remaining follow-up checks + +If you opted into post-merge superseded PR closure: + +```bash +stack closeout stack/discovery-core --apply +``` + +That closes the superseded original PRs after the landing PR is merged. + +## Ticket handling + +Ticket closure is now explicit. + +Use repeated `--ticket` flags when composing the landing branch. `closeout` +uses those stored ticket refs instead of guessing from branch names. + +That is deliberate. Ticket closure is operational state, not something the CLI +should infer from a branch naming convention and silently get wrong. + +## When to use this flow + +Use the landing workflow when: + +- you already have a larger PR pile +- you want one verified landing PR +- you need strict control over which commits ship first +- the original PRs should remain as traceability, not queue targets + +Do not use it when each branch should still land individually in order. In that +case, use the standard stacked-PR loop from [usage.md](usage.md). diff --git a/docs/testing.md b/docs/testing.md index 70e30b6..f059ce3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,10 +1,10 @@ # Testing -`stack` now has three verification layers: +`stack` now has three verification layers. -## 1. Unit and Fixture Coverage +## 1. Unit and fixture coverage -Run the normal test suite: +Run the normal suite: ```bash mise exec -- go test ./... @@ -17,13 +17,19 @@ This covers: - stack validation rules, including cycles and duplicate PR linkage - git ancestor checks and explicit `--force-with-lease` behavior - command-level flows for: - - `stack sync --apply` + - `stack adopt pr` + - `stack compose` + - `stack verify add` + - `stack verify list` + - `stack supersede` + - `stack closeout` - `stack submit` + - `stack sync --apply` - `stack queue` Those command tests use real temporary git repositories and a fake `gh` binary. -## 2. Local Smoke Checks +## 2. Local smoke checks For a quick local alpha check: @@ -46,29 +52,44 @@ mise exec -- go build -o "$tmpdir/stack" ./cmd/stack ) ``` -## 3. Real GitHub Sandbox Checks +## 3. Real GitHub sandbox checks These are opt-in and non-destructive by default. -### Seed Real Sandbox PR Fixtures +### Pin the GitHub identity + +If multiple `gh` accounts exist on the machine, do not rely on active-account +state alone for live sandbox runs. Pin `GH_TOKEN` to the intended account: + +```bash +gh auth switch -u roodboi +TOKEN="$(gh auth token)" +GH_TOKEN="$TOKEN" scripts/sandbox/seed-fixtures.sh +``` + +That avoids account drift during long-running scripts and makes auth failures +easier to interpret. + +### Seed real sandbox PR fixtures Create or refresh the deterministic sandbox PR set in `hack-dance/stack`: ```bash -scripts/sandbox/seed-fixtures.sh -scripts/sandbox/report-fixtures.sh +GH_TOKEN="$TOKEN" scripts/sandbox/seed-fixtures.sh +GH_TOKEN="$TOKEN" scripts/sandbox/report-fixtures.sh ``` -Advance only the trunk-drift branch when you want to trigger a real rebase conflict later: +Advance only the trunk-drift branch when you want to trigger a real rebase +conflict later: ```bash -scripts/sandbox/advance-conflict.sh +GH_TOKEN="$TOKEN" scripts/sandbox/advance-conflict.sh ``` Clean everything back up: ```bash -scripts/sandbox/cleanup-fixtures.sh +GH_TOKEN="$TOKEN" scripts/sandbox/cleanup-fixtures.sh ``` Verify the live sandbox repo: @@ -84,20 +105,21 @@ STACK_GITHUB_SANDBOX_PR_NUMBER= \ mise exec -- go test ./internal/github -run TestSandboxViewExistingPR -v ``` -Run the destructive end-to-end scenarios in a temp clone: +Run the end-to-end scenarios in a temp clone: ```bash -scripts/sandbox/run-live-queue.sh -scripts/sandbox/run-live-sync.sh -scripts/sandbox/run-live-conflict.sh +GH_TOKEN="$TOKEN" scripts/sandbox/run-live-queue.sh +GH_TOKEN="$TOKEN" scripts/sandbox/run-live-sync.sh +GH_TOKEN="$TOKEN" scripts/sandbox/run-live-conflict.sh ``` -These scripts intentionally mutate the live sandbox repo, then reseed the consumed fixtures so the next run starts from a known state. +These scripts intentionally mutate the live sandbox repo, then reseed the +consumed fixtures so the next run starts from a known state. - `run-live-queue.sh` - verifies `stack submit` can refresh a real PR - - verifies `stack queue` works against GitHub with `gh pr merge --auto --merge --match-head-commit` - - verifies the queue fixture can be reseeded after the PR is merged + - verifies `stack queue` can hand off a real PR through `gh pr merge --auto --merge --match-head-commit` + - a final GitHub `mergeStateStatus` of `BLOCKED` can still count as a successful handoff when branch protection or queue policy takes over after the auto-merge request - `run-live-sync.sh` - verifies `stack sync --apply` reparents a clean child after its parent PR is merged - verifies a clean two-hop advancement from parent to child to grandchild @@ -114,28 +136,47 @@ Optional environment: - `STACK_GITHUB_SANDBOX_REPO_ROOT` - overrides the repo root used for the sandbox tests -## Current Boundary +## Landing-workflow verification + +When you change landing-orchestration behavior, do not stop at the generic +queue/restack loop. Make sure the local command suite covers: + +- `compose` +- `verify` +- `supersede` +- `closeout` +- landing-aware `queue` + +If the change touches GitHub mutation paths for landing PR creation, edit, or +closeout, pair the local suite with a pinned-token sandbox run. + +## Current boundary -The normal suite now exercises most risky local behaviors and GitHub command wiring, but it still does not fully prove: +The normal suite now exercises most risky local behaviors and GitHub command +wiring, but it still does not fully prove: -- real `gh pr create/edit/merge` behavior against GitHub +- real `gh pr create/edit/close/merge` behavior against GitHub in every account configuration - merge queue branch protection behavior -- end-to-end submit/sync/queue flows against the live `hack-dance/stack` sandbox +- every end-to-end landing-orchestration scenario against the live sandbox repo -Those still need deliberate sandbox runs before calling V1 feature complete. +Those still need deliberate sandbox runs before calling the feature set fully +proven. -## Strongest Current Loop +## Strongest current loop -For work that changes queue, restack, submit, or crash-recovery behavior, use this loop: +For work that changes queue, restack, submit, landing orchestration, or +crash-recovery behavior, use this loop: ```bash mise exec -- go test ./... mise exec -- go build ./... -scripts/sandbox/seed-fixtures.sh -scripts/sandbox/run-live-queue.sh -scripts/sandbox/run-live-sync.sh -scripts/sandbox/run-live-conflict.sh -scripts/sandbox/report-fixtures.sh +gh auth switch -u roodboi +TOKEN="$(gh auth token)" +GH_TOKEN="$TOKEN" scripts/sandbox/seed-fixtures.sh +GH_TOKEN="$TOKEN" scripts/sandbox/run-live-queue.sh +GH_TOKEN="$TOKEN" scripts/sandbox/run-live-sync.sh +GH_TOKEN="$TOKEN" scripts/sandbox/run-live-conflict.sh +GH_TOKEN="$TOKEN" scripts/sandbox/report-fixtures.sh ``` That combination gives: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 38f3fc6..7da76cb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -12,9 +12,10 @@ Look for: - untracked parents - duplicate PR linkage -- cycles in the stack graph +- cycles in the graph -Repair the graph before trying `restack`, `move`, `submit`, or `queue`. +Repair the graph before trying `restack`, `move`, `submit`, `compose`, or +`queue`. ## `stack move` rejects the new parent @@ -29,6 +30,12 @@ If the local branch exists but is not tracked yet, adopt it first: stack track feature/base --parent main ``` +Or, if the real unit is an open PR: + +```bash +stack adopt pr 353 --parent main +``` + ## `stack continue` or `stack abort` is required The CLI found an interrupted rebase or a recorded operation journal. @@ -53,7 +60,7 @@ That is intentional. The CLI only auto-applies clean merged-parent repairs. Manual review is expected when: -- the merged PR metadata drifted +- merged PR metadata drifted - a parent was squash-merged or otherwise rewritten ambiguously - the remote branch disappeared unexpectedly - the tracked PR head or base disagrees with local intent @@ -67,21 +74,80 @@ stack sync Then choose a repair path deliberately. -Common outcomes: +## `stack compose --open-pr` fails against GitHub + +Check: + +- `gh auth status` +- the branch was pushed to the expected remote +- the repo has GitHub permissions to create or edit PRs +- the landing branch does not already match multiple open PRs + +If multiple `gh` accounts exist on the machine, pin `GH_TOKEN` to the intended +account before live checks instead of trusting active-account state alone. + +## `stack queue` tells me to queue the landing PR instead + +That is correct. + +Once a landing batch exists, the original source PRs are traceability-only. +They are no longer the real merge targets. + +Queue the landing branch instead: + +```bash +stack queue stack/discovery-core +``` + +If `stack queue` stops, read the message literally. The most common causes are: + +- you tried to queue a source PR after a landing PR already exists +- the landing PR head is stale +- the latest verification failed +- the latest verification does not match the current landing head -- `run \`stack submit \`` when the remote branch or PR base/head is simply stale -- relink or clear local PR metadata when the tracked PR was closed, deleted, or points at the wrong head -- inspect the merged parent manually when GitHub-side history drift means `sync --apply` would have to guess +## `stack closeout` says no explicit tickets are recorded -## `stack submit` or `stack queue` fails against GitHub +That is a deliberate stop. + +`stack` no longer guesses tickets from branch names during closeout. Record +explicit tickets when you compose the landing branch: + +```bash +stack compose discovery-core --from pr/353 --to pr/364 --ticket LNHACK-66 --ticket LNHACK-74 +``` + +If the landing branch already exists, repair the local landing metadata before +using closeout for ticket closure. + +## `stack closeout --apply` will not close superseded PRs 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 +- the landing PR is merged +- `stack supersede` was run with `--close-after-merge` +- the superseded PRs are still open + +`closeout --apply` only closes original PRs when that post-merge closure was +made explicit earlier. + +## GitHub operations fail even though `gh auth status` looks fine + +If you see account-specific GraphQL failures, especially on live sandbox runs, +assume auth drift first. + +Recommended check: + +```bash +gh auth switch -u roodboi +TOKEN="$(gh auth token)" +GH_TOKEN="$TOKEN" scripts/sandbox/seed-fixtures.sh +``` + +Pinned `GH_TOKEN` is more reliable than relying on active-account state during +long-running scripts. + +## `stack submit` or `stack queue` reports stale state When queue handoff reports stale state, resubmit first: @@ -94,10 +160,6 @@ 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`. -When `stack submit` creates a new PR, it uses the tip commit subject and body by -default. If the commit body is empty, the preview will show that `stack` is -using its generated fallback body instead. - ## Release automation does not update the tap The release workflow needs: @@ -105,4 +167,5 @@ The release workflow needs: - `RELEASE_PLEASE_TOKEN` if you want release PRs to trigger normal follow-on checks - `HOMEBREW_TAP_GITHUB_TOKEN` with write access to `hack-dance/homebrew-tap` -If the GitHub release succeeds but the tap update does not, inspect the `release` workflow log and verify the token can push to the tap repo. +If the GitHub release succeeds but the tap update does not, inspect the +`release` workflow log and verify the token can push to the tap repo. diff --git a/docs/usage.md b/docs/usage.md index f3931d7..aa98827 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,5 +1,10 @@ # Usage Guide +Use this guide for the standard stacked-PR loop. + +If your real merge target should be one combined landing PR, read +[landing-workflow.md](landing-workflow.md) instead. + ## Start a repo Initialize the repo once: @@ -15,19 +20,23 @@ stack create feature/base stack create feature/child ``` -Or adopt an existing branch and make the parent explicit: +Or make an existing branch explicit: ```bash stack track feature/child --parent feature/base ``` -If the branch is stale and `feature/base` has moved since it was cut, `track` -records a repairable restack anchor from shared history instead of the current -parent tip. +If you already have open PRs and want to adopt their heads directly, use +`stack adopt pr`: -If you already have a larger set of open PRs and want to turn them into an -explicit stack after the fact, use -[adopting-existing-prs.md](adopting-existing-prs.md). +```bash +stack adopt pr 353 --parent main +stack adopt pr 354 --parent pr/353 +``` + +If the branch is stale and the parent moved since it was cut, `stack` records a +repairable restack anchor from shared history instead of blindly anchoring on +the current parent tip. ## Inspect first @@ -38,36 +47,55 @@ stack status stack tui ``` -`stack tui` is a read-only dashboard for browsing the stack tree, branch health, -and cached PR state. +`stack tui` is a read-only dashboard for the tree, branch health, PR linkage, +and verification summaries. -## The normal loop +## The standard loop -1. Run `stack status`. -2. Run `stack restack` if a parent branch moved. -3. Run `stack submit ` or `stack submit --all` to push and create or update PRs. -4. Run `stack queue ` only when the bottom branch targets trunk and is healthy. -5. Run `stack sync` after merges or GitHub-side base changes. +1. run `stack status` +2. run `stack restack` if a parent branch moved +3. run `stack submit ` or `stack submit --all` +4. run `stack queue ` only when the real merge target is a healthy trunk-bound PR +5. run `stack sync` after merges or GitHub-side base changes When `stack submit` creates a new PR, it stays non-interactive by default: - the PR title comes from the tip commit subject - the PR body comes from the tip commit body -- if the tip commit body is empty, `stack` generates a deterministic fallback body that records the branch and parent +- if the tip commit body is empty, `stack` generates a deterministic fallback body - if the tip commit subject is empty, `stack` falls back to the branch name -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. +## Choose the right landing path + +Use the standard loop in this file when each tracked branch should land as its +own PR. + +Switch to [landing-workflow.md](landing-workflow.md) when: + +- you already have a PR pile +- you want one combined landing PR +- the original PRs should remain traceability-only +- you need explicit verification and closeout on the landing branch + +That path uses: + +- `stack compose --ticket ... --open-pr` +- `stack verify add` +- `stack supersede --close-after-merge` +- `stack queue stack/...` +- `stack closeout --apply` ## Repair loop -Use `stack sync` first when local metadata and GitHub disagree. +Use `stack sync` first when local metadata and GitHub disagree: + +```bash +stack sync +stack sync --apply +``` -Use `stack sync --apply` only for clean repairs. If the CLI reports a -manual-review case, keep it manual. `stack status` and `stack sync` should tell -you whether the next step is `submit`, metadata repair, or deliberate manual -inspection. +`stack sync --apply` only handles clean repairs. If the CLI reports a +manual-review case, keep it manual. If a rebase or restack stops for conflicts: @@ -77,12 +105,13 @@ stack abort ``` `stack abort` clears the recorded operation and leaves stack metadata on the -clean recovery point. +last clean recovery point. ## Guardrails - parents must be trunk or another tracked branch -- `move`, `restack`, `submit`, and `queue` preview before destructive work unless you pass `--yes` +- `move`, `restack`, `submit`, `compose`, `supersede`, `closeout --apply`, 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 +- `queue` is only for a healthy trunk-bound PR or recorded landing PR +- when verification exists, `queue` requires the latest verification to pass and still match the current head +- the repo must have GitHub auto-merge enabled before `stack queue` can hand off successfully diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bcebbde..a6c2769 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -897,7 +897,7 @@ stack supersede --landing stack/discovery-core --prs 353 --prs 354 --no-comment cmd.Flags().StringVar(&landingBranch, "landing", "", "Landing branch to use as the superseding target") cmd.Flags().StringArrayVar(&prValues, "prs", nil, "Original PR numbers, comma-separated or repeated") cmd.Flags().BoolVar(&noComment, "no-comment", false, "Record superseded linkage locally without posting GitHub comments") - cmd.Flags().BoolVar(&closeAfterMerge, "close-after-merge", false, "Mark superseded PRs to be closed by `stack closeout --apply` after the landing PR merges") + cmd.Flags().BoolVar(&closeAfterMerge, "close-after-merge", false, "Mark superseded PRs for later closure during closeout apply after the landing PR merges") cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation") return cmd } From 79b517c2b78745c4be85086115f26f5228d948fd Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 26 Mar 2026 14:49:14 -0400 Subject: [PATCH 4/9] docs: refresh stack skill guidance --- skills/stack-cli/SKILL.md | 158 ++++++++++++++++++++-------- skills/stack-cli/agents/openai.yaml | 4 +- 2 files changed, 117 insertions(+), 45 deletions(-) diff --git a/skills/stack-cli/SKILL.md b/skills/stack-cli/SKILL.md index 280eb34..aa30e72 100644 --- a/skills/stack-cli/SKILL.md +++ b/skills/stack-cli/SKILL.md @@ -1,65 +1,137 @@ --- name: stack-cli -description: Use for the `stack` GitHub stacked-PR CLI. Covers safe init, track, create, status, restack, submit, sync, move, and queue workflows with preview-first, repairable operations on ordinary branches and PRs. +description: Use for the `stack` GitHub CLI for stacked PRs and landing orchestration. Covers graph setup, stacked-PR workflows, landing branches, verification, superseded PR handling, closeout, and queue handoff for the real merge target. --- # stack CLI -Use this skill when helping someone operate the `stack` CLI safely. +Use this skill to operate `stack` safely: make the graph explicit, then choose how it lands. + +## Decide the path first + +There are two supported paths: + +- standard stacked PR flow: each tracked branch lands as its own PR +- landing workflow: one composed landing branch and landing PR become the real merge target + +If you are starting from existing PRs, first make the graph explicit, then continue with one of those two paths. ## Operating rules -- Prefer `stack status` first when the repo state is unclear. +- Prefer `stack status` first when repo state is unclear. - Treat stack metadata as the source of truth; use `sync` to refresh GitHub-backed state. -- Keep write flows previewable and repairable. If a command can be checked before mutation, do that. -- Use `git` and `gh` for the repository and PR operations around `stack`. -- When a command offers confirmation or `--yes`, assume the safer preview path unless the user explicitly wants unattended execution. -- Stop on ambiguity. Do not guess parentage, merge bases, or repair actions when the CLI surfaces a manual-review case. - -## Core flow map - -- `init`: initialize repo-level stack metadata. -- `create `: create a new tracked branch on top of the current branch. -- `track --parent `: adopt an existing branch into the stack graph. -- `status`: inspect stack health, hierarchy, and cached PR state. -- `restack [branch] | --all`: preview and rebase tracked branches onto their configured parents. -- `continue`: resume an interrupted restack after conflicts are resolved. -- `abort`: abandon an interrupted restack and restore the original branch when possible. -- `submit [branch] | --all`: fetch, optionally restack, preview the push plan, then push and create or update one normal PR per branch. -- `sync [--apply]`: refresh cached PR metadata and report or apply only clean repairs. -- `move --parent `: change a branch parent, preview the rewrite, and restack affected descendants. -- `queue `: hand one healthy bottom-of-stack PR to GitHub auto-merge or merge queue. - -## Safe usage patterns - -- Start with `stack status` or `stack status --json` to understand the graph and current drift. -- Use `stack init` once per repo before tracking branches. -- Use `stack create` for new stack branches and `stack track` for existing ones; always make the parent explicit. -- Prefer `stack submit` before `stack queue`; queue handoff expects a fresh push, matching PR base, and matching head commit. -- Use `stack sync` after merges or PR changes to reconcile local metadata with GitHub. -- Use `stack sync --apply` only for clean repairs. If the tool marks a case as ambiguous or manual review, keep it manual. +- Preview before mutating when possible. +- Stop on ambiguity. Do not guess parentage, merge bases, repair actions, or the real merge target when the CLI surfaces a manual-review case. +- Prefer preview over `--yes` unless the user wants unattended execution. +- Use ordinary `git` and `gh` around `stack`; do not invent a parallel hosted workflow. + +## Core command map + +- Setup +- `init`: initialize repo metadata +- `create `: create a tracked branch +- `track --parent `: adopt an existing local branch +- `adopt pr --parent `: adopt an existing PR head +- Health and repair +- `status`: graph, PR, verification, and landing health +- `sync [--apply]`: refresh PR state and apply clean repairs +- `restack [branch] | --all`: rebase tracked branches onto parents +- `continue`: resume an interrupted restack +- `abort`: abandon an interrupted restack +- `move --parent `: change a branch parent +- Standard PR flow +- `submit [branch] | --all`: push tracked branches and create or update PRs +- Landing workflow +- `compose `: create a landing branch +- `verify add `: attach verification evidence +- `verify list `: inspect verification records +- `supersede --landing --prs ...`: mark original PRs as superseded +- `closeout [--apply]`: plan or apply post-merge closure work +- Queue handoff +- `queue `: hand off the real merge target + +## Standard stacked-PR workflow + +Typical flow: + +1. `stack init --trunk main --remote origin` +2. `stack create feature/base` +3. `stack create feature/child` +4. `stack status` +5. `stack restack` when a parent moved +6. `stack submit --all` +7. `stack queue feature/base` +8. `stack sync` after merges + +## Existing PR pile setup + +Typical flow: + +1. `stack init --trunk main --remote origin` +2. `stack adopt pr 353 --parent main` +3. `stack adopt pr 354 --parent pr/353` +4. `stack status` +5. `stack sync` +6. `stack move`, `stack restack`, and `stack submit` until the graph matches intent + +Do not ask `stack` to infer the dependency graph. The operator still chooses the parent chain. + +Once the graph is explicit, continue with Standard stacked-PR workflow or Landing workflow. + +## Landing workflow + +Typical flow: + +1. make the graph explicit with the setup flow above +2. `stack compose discovery-core --from pr/353 --to pr/364 --ticket LNHACK-66 --ticket LNHACK-74 --open-pr` +3. `stack verify add stack/discovery-core --type sim --run-id run-123 --passed` +4. `stack supersede --landing stack/discovery-core --prs 353,354,363,364 --close-after-merge` +5. `stack queue stack/discovery-core` +6. `stack closeout stack/discovery-core` +7. `stack closeout stack/discovery-core --apply` after the landing PR merges + +Important operator rules: + +- use explicit `--ticket` flags during compose; closeout no longer guesses tickets from branch names +- keep verification on the landing branch, not only in chat or in a PR body + +## Before queue + +- Queue only the real merge target: the bottom tracked PR in standard flow, or the landing PR in landing workflow. +- Prefer `submit` before `queue`. +- If queue reports stale remote or stale PR head state, rerun `submit`. +- If verification exists, queue requires the latest verification to pass and still match the current head. ## Repair loop -When state drifts: +1. inspect with `stack status` +2. refresh with `stack sync` +3. apply only clean repairs if they are obvious and supported +4. restack, resubmit, or recompose only after the graph is consistent again + +If a restack stops mid-flight: + +- use `stack continue` from the same worktree after resolving conflicts +- use `stack abort` if you need to clear the operation journal and recover instead + +## Queue and GitHub guardrails -1. Inspect with `stack status`. -2. Refresh with `stack sync`. -3. Apply only clean repairs if they are obvious and supported. -4. Restack or resubmit only after the graph is consistent again. +- `queue` is for a healthy trunk-bound PR or recorded landing PR, not for any arbitrary branch in the graph +- when a landing batch exists, original source PRs are traceability-only; if queue redirects you to the landing PR, trust that +- if a PR is closed, merged, draft, or on the wrong base, repair that before queue handoff -If a restack stops mid-flight, use `stack continue` from the same worktree after resolving conflicts. Use `stack abort` if you need to clear the operation journal and recover instead. +## Repo-specific sandbox note -## Queue and submit guardrails +On machines with multiple `gh` accounts, pin `GH_TOKEN`; do not rely on the active account: -- `submit` may push branch tips, create PRs, edit PR bases, and refresh PR metadata. Preview first. -- `queue` is only for a tracked branch that already targets trunk, has a PR, has a pushed remote branch, and has a current head that matches local state. -- For queue handoff, prefer the default merge strategy unless the user asked for `squash` or `rebase`. -- If the CLI reports stale remote state, re-run `submit` before `queue`. -- If a PR is closed, merged, draft, or on the wrong base, repair that state before handing it to queue. +```bash +gh auth switch -u roodboi +TOKEN="$(gh auth token)" +GH_TOKEN="$TOKEN" scripts/sandbox/seed-fixtures.sh +``` ## What to avoid - Do not bypass stack metadata with direct remote mutations unless the user explicitly wants a manual repair step. - Do not override the CLI’s manual-review cases with guesses. -- Do not present `stack tui` as an edit surface; it is read-only. +- Do not treat `stack tui` as an edit surface; it is read-only. diff --git a/skills/stack-cli/agents/openai.yaml b/skills/stack-cli/agents/openai.yaml index 788011c..1714202 100644 --- a/skills/stack-cli/agents/openai.yaml +++ b/skills/stack-cli/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "Stack CLI" - short_description: "Safe stacked-PR workflows for stack" - default_prompt: "Use $stack-cli to guide a safe `stack` workflow with previews first, explicit parents, and repairable restacks." + short_description: "Safe stacked-PR and landing-orchestration workflows for stack" + default_prompt: "Use $stack-cli to guide a safe `stack` workflow with explicit graph setup, the right landing path, previews first, and repairable restacks." policy: allow_implicit_invocation: true From 9fba65f3ea93444b376cf4a2b95f88aa0c6816a0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 26 Mar 2026 14:56:30 -0400 Subject: [PATCH 5/9] fix: address review regressions in adopt and compose --- internal/cmd/root.go | 55 ++++++++++-- internal/cmd/root_test.go | 173 ++++++++++++++++++++++++++++++++++++++ internal/git/client.go | 6 ++ 3 files changed, 228 insertions(+), 6 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a6c2769..95970cf 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -328,7 +328,18 @@ stack adopt pr 354 --parent pr/353 --yes return err } - needsFetch := !runtime.Git.BranchExists(runtime.Context, branch) + branchExists := runtime.Git.BranchExists(runtime.Context, branch) + localHeadOID := "" + if branchExists { + localHeadOID, err = runtime.Git.ResolveRef(runtime.Context, branch) + if err != nil { + return err + } + localHeadOID = strings.TrimSpace(localHeadOID) + } + prHeadOID := strings.TrimSpace(pr.LastSeenHeadOID) + needsFetch := !branchExists + refreshHead := branchExists && localHeadOID != "" && prHeadOID != "" && localHeadOID != prHeadOID preview := []string{ fmt.Sprintf("pr: #%d", pr.Number), fmt.Sprintf("branch: %s", branch), @@ -336,6 +347,8 @@ stack adopt pr 354 --parent pr/353 --yes } if needsFetch { preview = append(preview, fmt.Sprintf("fetch: %s/%s -> %s", state.DefaultRemote, branch, branch)) + } else if refreshHead { + preview = append(preview, fmt.Sprintf("refresh: local head %s -> PR head %s", shortOID(localHeadOID), shortOID(prHeadOID))) } if pr.BaseRefName != "" && pr.BaseRefName != parent { preview = append(preview, fmt.Sprintf("note: PR #%d currently targets %s; `stack submit %s` will retarget it later", pr.Number, pr.BaseRefName, branch)) @@ -343,7 +356,7 @@ stack adopt pr 354 --parent pr/353 --yes _, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.RenderPreview("Adopt PR preview", preview)) if !yes { - confirmed, err := forms.Confirm("Adopt pull request", "This may fetch a remote branch head and will update local stack metadata.") + confirmed, err := forms.Confirm("Adopt pull request", "This may fetch or refresh a remote branch head and will update local stack metadata.") if err != nil { return err } @@ -356,6 +369,34 @@ stack adopt pr 354 --parent pr/353 --yes if err := runtime.Git.FetchBranch(runtime.Context, state.DefaultRemote, branch, branch); err != nil { return fmt.Errorf("fetch pull request #%d head %q from %s: %w", pr.Number, branch, state.DefaultRemote, err) } + if prHeadOID != "" { + fetchedHeadOID, err := runtime.Git.ResolveRef(runtime.Context, branch) + if err != nil { + return err + } + if strings.TrimSpace(fetchedHeadOID) != prHeadOID { + return fmt.Errorf("fetched pull request #%d head %q from %s, but local branch %q now points to %s instead of expected PR head %s", pr.Number, branch, state.DefaultRemote, branch, shortOID(strings.TrimSpace(fetchedHeadOID)), shortOID(prHeadOID)) + } + } + } + if refreshHead { + currentBranch, err := runtime.Git.CurrentBranch(runtime.Context) + if err != nil { + return err + } + if strings.TrimSpace(currentBranch) == branch { + return fmt.Errorf("local branch %q points to %s but pull request #%d is at %s; switch away from %s or delete the local branch, then rerun `stack adopt pr %d --parent %s`", branch, shortOID(localHeadOID), pr.Number, shortOID(prHeadOID), branch, pr.Number, parent) + } + if err := runtime.Git.FetchBranchForce(runtime.Context, state.DefaultRemote, branch, branch); err != nil { + return fmt.Errorf("refresh pull request #%d head %q from %s: %w", pr.Number, branch, state.DefaultRemote, err) + } + refreshedHeadOID, err := runtime.Git.ResolveRef(runtime.Context, branch) + if err != nil { + return err + } + if strings.TrimSpace(refreshedHeadOID) != prHeadOID { + return fmt.Errorf("refreshed pull request #%d head %q from %s, but local branch %q now points to %s instead of expected PR head %s", pr.Number, branch, state.DefaultRemote, branch, shortOID(strings.TrimSpace(refreshedHeadOID)), shortOID(prHeadOID)) + } } parentOID, usedMergeBase, err := trackRestackAnchorDetail(runtime, branch, parent) @@ -388,6 +429,9 @@ stack adopt pr 354 --parent pr/353 --yes if needsFetch { lines = append(lines, fmt.Sprintf("fetched: %s/%s", state.DefaultRemote, branch)) } + if refreshHead { + lines = append(lines, fmt.Sprintf("refreshed: %s/%s -> %s", state.DefaultRemote, branch, shortOID(prHeadOID))) + } if usedMergeBase { lines = append(lines, "restack anchor: merge-base with the selected parent") } else { @@ -504,6 +548,9 @@ stack compose discovery-core --branches feature/a --branches feature/b --yes CreatedAt: time.Now().UTC().Format(time.RFC3339), } state.Landings[plan.Destination] = landing + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + return err + } if openPR { metadata := resolveComposePRMetadata(plan, tickets, title, body) @@ -548,10 +595,6 @@ stack compose discovery-core --branches feature/a --branches feature/b --yes return nil } - if err := runtime.Store.WriteState(runtime.Context, state); err != nil { - return err - } - lines := []string{ fmt.Sprintf("branch: %s", plan.Destination), fmt.Sprintf("base: %s", plan.Base), diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 111ee94..a8124be 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -1230,6 +1230,97 @@ func TestAdoptPRUsesMergeBaseAnchorForStaleLocalBranch(t *testing.T) { } } +func TestAdoptPRRefreshesStaleLocalBranchToMatchPRHead(t *testing.T) { + repo := testutil.SetupGitRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + testutil.Run(t, repo, "git", "init", "--bare", remote) + testutil.Run(t, repo, "git", "remote", "add", "origin", remote) + testutil.Run(t, repo, "git", "push", "-u", "origin", "main") + + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature a") + testutil.Run(t, repo, "git", "push", "-u", "origin", "feature/a") + staleHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "feature/a")) + testutil.Run(t, repo, "git", "switch", "main") + + otherClone := filepath.Join(t.TempDir(), "other-clone") + testutil.Run(t, "", "git", "clone", remote, otherClone) + testutil.Run(t, otherClone, "git", "switch", "feature/a") + testutil.WriteFile(t, filepath.Join(otherClone, "feature-a.txt"), "feature a advanced\n") + testutil.Run(t, otherClone, "git", "add", "feature-a.txt") + testutil.Run(t, otherClone, "git", "commit", "-m", "advance feature a") + testutil.Run(t, otherClone, "git", "push", "origin", "feature/a") + freshHead := strings.TrimSpace(testutil.Run(t, otherClone, "git", "rev-parse", "feature/a")) + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + 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) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "7": { + "id": "PR_7", + "number": 7, + "url": "https://example.com/hack-dance/stack/pull/7", + "repo": "hack-dance/stack", + "headRefName": "feature/a", + "baseRefName": "main", + "headRefOid": "`+freshHead+`", + "baseRefOid": "`+mainHead+`", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 8 +}`) + + output := executeCommand(t, runtime, "adopt", "pr", "7", "--parent", "main", "--yes") + if !strings.Contains(output, "refreshed: origin/feature/a") { + t.Fatalf("expected refresh output, got %q", output) + } + + currentHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "feature/a")) + if currentHead != freshHead { + t.Fatalf("expected local feature/a head %q after adopt refresh, got %q (stale was %q)", freshHead, currentHead, staleHead) + } + + state, err = runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state: %v", err) + } + record := state.Branches["feature/a"] + if record.PR.Number != 7 { + t.Fatalf("expected tracked PR #7, got %+v", record.PR) + } + if record.Restack.LastParentHeadOID != mainHead { + t.Fatalf("expected restack anchor %q, got %q", mainHead, record.Restack.LastParentHeadOID) + } +} + func TestComposeCreatesLandingBranchFromTrackedRange(t *testing.T) { repo := testutil.SetupGitRepo(t) @@ -1443,6 +1534,88 @@ func TestComposeOpenPRCreatesLandingPRAndStoresTickets(t *testing.T) { } } +func TestComposeOpenPRPersistsLandingMetadataBeforePROperations(t *testing.T) { + repo := testutil.SetupGitRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + testutil.Run(t, repo, "git", "init", "--bare", remote) + testutil.Run(t, repo, "git", "remote", "add", "origin", remote) + testutil.Run(t, repo, "git", "push", "-u", "origin", "main") + + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature a") + featureAHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "feature/a")) + + testutil.Run(t, repo, "git", "switch", "-c", "feature/b") + testutil.WriteFile(t, filepath.Join(repo, "feature-b.txt"), "feature b\n") + testutil.Run(t, repo, "git", "add", "feature-b.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature b") + + runtime := newTestRuntime(repo) + mainHead, err := runtime.Git.ResolveRef(runtime.Context, "main") + if err != nil { + t.Fatalf("resolve main head: %v", err) + } + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/a": { + ParentBranch: "main", + RemoteName: "origin", + Restack: store.RestackMetadata{ + LastParentHeadOID: mainHead, + }, + }, + "feature/b": { + ParentBranch: "feature/a", + RemoteName: "origin", + Restack: store.RestackMetadata{ + LastParentHeadOID: featureAHead, + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + failDir := t.TempDir() + failGH := filepath.Join(failDir, "gh") + testutil.WriteFile(t, failGH, "#!/bin/sh\necho \"forced gh failure\" >&2\nexit 1\n") + testutil.Run(t, "", "chmod", "+x", failGH) + t.Setenv("PATH", failDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + err = executeCommandExpectError(runtime, "compose", "discovery-core", "--from", "feature/a", "--to", "feature/b", "--ticket", "LNHACK-66", "--open-pr", "--yes") + if err == nil || !strings.Contains(err.Error(), "forced gh failure") { + t.Fatalf("expected gh failure during compose --open-pr, got %v", err) + } + + state, err = runtime.Store.ReadState(runtime.Context) + if err != nil { + t.Fatalf("read state after failed compose: %v", err) + } + landing, ok := state.Landings["stack/discovery-core"] + if !ok { + t.Fatalf("expected landing metadata to be recorded before PR operations fail") + } + if got := strings.Join(landing.Tickets, ","); got != "LNHACK-66" { + t.Fatalf("unexpected landing tickets %q", got) + } + if landing.LandingPRNumber != 0 { + t.Fatalf("expected landing PR number to remain unset after failure, got %+v", landing) + } + if !runtime.Git.BranchExists(runtime.Context, "stack/discovery-core") { + t.Fatalf("expected landing branch to exist locally after failed PR open") + } + if !runtime.Git.RemoteBranchExists(runtime.Context, "origin", "stack/discovery-core") { + t.Fatalf("expected landing branch to be pushed before gh failure") + } +} + func TestCloseoutClassifiesSupersededPRsAndDeployGatedTickets(t *testing.T) { repo := testutil.SetupGitRepo(t) remote := filepath.Join(t.TempDir(), "remote.git") diff --git a/internal/git/client.go b/internal/git/client.go index fed08e5..7082974 100644 --- a/internal/git/client.go +++ b/internal/git/client.go @@ -170,6 +170,12 @@ func (c *Client) FetchBranch(ctx context.Context, remote string, sourceBranch st return err } +func (c *Client) FetchBranchForce(ctx context.Context, remote string, sourceBranch string, localBranch string) error { + refspec := fmt.Sprintf("+refs/heads/%s:refs/heads/%s", sourceBranch, localBranch) + _, err := c.run(ctx, "fetch", remote, refspec) + return err +} + func (c *Client) PushBranch(ctx context.Context, remote string, branch string, expectedRemoteOID string) error { args := []string{"push"} if expectedRemoteOID != "" { From 31b8bbb48abf32b8e15eaf2955860560beeccd87 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 26 Mar 2026 14:59:08 -0400 Subject: [PATCH 6/9] fix: tighten landing pr recovery paths --- internal/cmd/root.go | 24 ++++- internal/cmd/root_test.go | 179 +++++++++++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 95970cf..faca124 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -1985,7 +1985,9 @@ func buildCloseoutPlan(runtime *stackruntime.Runtime, state store.RepoState, lan record, tracked := state.Branches[branch] if tracked { record, err = refreshTrackedPR(runtime, state, branch, record) - if err == nil && record.PR.Number > 0 { + if err != nil { + plan.FollowUps = append(plan.FollowUps, fmt.Sprintf("source PR for %s could not be refreshed: %v", branch, err)) + } else if record.PR.Number > 0 { pr := record.PR branchPlan.PR = &pr } @@ -2276,7 +2278,25 @@ func resolveLandingPRs(runtime *stackruntime.Runtime, landingBranch string, land } return []store.PullRequest{pr}, nil } - return runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) + prs, err := runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) + if err != nil { + return nil, err + } + + open := make([]store.PullRequest, 0, len(prs)) + for _, pr := range prs { + if pr.State == "OPEN" { + open = append(open, pr) + } + } + if len(open) == 1 { + return open, nil + } + if len(open) > 1 { + return open, nil + } + + return prs, nil } func parsePRNumbers(values []string) ([]int, error) { diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index a8124be..2bb7ca9 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -1957,7 +1957,7 @@ func TestCloseoutRequiresExplicitResolutionForAmbiguousLandingPRs(t *testing.T) "headRefName": "stack/discovery-core", "baseRefName": "main", "headRefOid": "`+landingHead+`", - "state": "MERGED", + "state": "OPEN", "isDraft": false } }, @@ -1970,6 +1970,95 @@ func TestCloseoutRequiresExplicitResolutionForAmbiguousLandingPRs(t *testing.T) } } +func TestCloseoutSurfacesTrackedSourcePRRefreshFailures(t *testing.T) { + repo := testutil.SetupGitRepo(t) + testutil.Run(t, repo, "git", "switch", "-c", "feature/a") + testutil.WriteFile(t, filepath.Join(repo, "feature-a.txt"), "feature a\n") + testutil.Run(t, repo, "git", "add", "feature-a.txt") + testutil.Run(t, repo, "git", "commit", "-m", "add feature a") + + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + runtime := newTestRuntime(repo) + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{ + "feature/a": { + ParentBranch: "main", + RemoteName: "origin", + PR: store.PullRequest{ + Number: 1, + HeadRefName: "feature/a", + BaseRefName: "main", + LastSeenHeadOID: strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "feature/a")), + State: "OPEN", + }, + }, + }, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{"feature/a"}, + CreatedAt: "2026-03-26T18:40:00Z", + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "manual", + Identifier: "check", + Passed: true, + HeadOID: landingHead, + RecordedAt: "2026-03-26T18:41:00Z", + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "MERGED", + "isDraft": false + } + }, + "next_number": 10 +}`) + + output := executeCommand(t, runtime, "closeout", "stack/discovery-core") + if !strings.Contains(output, `source PR for feature/a could not be refreshed: tracked PR #1 for "feature/a" could not be loaded`) { + t.Fatalf("expected source PR refresh failure guidance, got %q", output) + } +} + func TestSupersedeRecordsMetadataAndCommentsOriginalPRs(t *testing.T) { repo := testutil.SetupGitRepo(t) testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") @@ -2685,6 +2774,94 @@ func TestQueueAllowsLandingBranchAndPrintsCloseoutGuidance(t *testing.T) { } } +func TestQueueLandingBranchPrefersUniqueOpenPRWhenHistoryExists(t *testing.T) { + repo, runtime, _ := setupTrackedStackRepo(t) + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + testutil.Run(t, repo, "git", "push", "-u", "origin", "stack/discovery-core") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{}, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{}, + CreatedAt: "2026-03-26T18:40:00Z", + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "manual", + Identifier: "check", + Passed: true, + HeadOID: landingHead, + RecordedAt: "2026-03-26T18:41:00Z", + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "OPEN", + "isDraft": false + }, + "10": { + "id": "PR_10", + "number": 10, + "url": "https://example.com/hack-dance/stack/pull/10", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "MERGED", + "isDraft": false + } + }, + "next_number": 11 +}`) + + output := executeCommand(t, runtime, "queue", "stack/discovery-core", "--yes") + if !strings.Contains(output, "PR #9") { + t.Fatalf("expected queue output to use the unique open landing PR, got %q", output) + } + + log := readFile(t, ghStub.LogPath) + if !strings.Contains(log, "pr merge 9 --auto --merge --match-head-commit "+landingHead) { + t.Fatalf("expected queue to merge open landing PR #9, got %q", log) + } +} + func TestQueueRejectsLandingBranchWithStaleVerification(t *testing.T) { repo := testutil.SetupGitRepo(t) remote := filepath.Join(t.TempDir(), "remote.git") From 4d2c86fe6e792805fa517c0caee6199f45cc6bc8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 26 Mar 2026 15:03:55 -0400 Subject: [PATCH 7/9] test: configure git identity in secondary clone --- internal/cmd/root_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 2bb7ca9..b2f9351 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -1247,6 +1247,8 @@ func TestAdoptPRRefreshesStaleLocalBranchToMatchPRHead(t *testing.T) { otherClone := filepath.Join(t.TempDir(), "other-clone") testutil.Run(t, "", "git", "clone", remote, otherClone) + testutil.Run(t, otherClone, "git", "config", "user.email", "stack@example.com") + testutil.Run(t, otherClone, "git", "config", "user.name", "Stack Test") testutil.Run(t, otherClone, "git", "switch", "feature/a") testutil.WriteFile(t, filepath.Join(otherClone, "feature-a.txt"), "feature a advanced\n") testutil.Run(t, otherClone, "git", "add", "feature-a.txt") From 02909ed7328e7b7adcbad4e1becaca0762da1265 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 26 Mar 2026 15:23:17 -0400 Subject: [PATCH 8/9] fix: recover stale landing pr references --- internal/cmd/root.go | 12 ++++- internal/cmd/root_test.go | 103 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index faca124..b2d9b6a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -2241,6 +2241,7 @@ func isDeployVerification(checkType string) bool { } var ticketRefPattern = regexp.MustCompile(`(?i)\b[a-z][a-z0-9]+-\d+\b`) +var fullTicketRefPattern = regexp.MustCompile(`(?i)^[a-z][a-z0-9]+-\d+$`) func extractTicketRefs(value string) []string { matches := ticketRefPattern.FindAllString(value, -1) @@ -2271,12 +2272,16 @@ func mapKeys(values map[string]bool) []string { } func resolveLandingPRs(runtime *stackruntime.Runtime, landingBranch string, landing store.LandingRecord) ([]store.PullRequest, error) { + var recorded *store.PullRequest if landing.LandingPRNumber > 0 { pr, err := runtime.GitHub.ViewPR(runtime.Context, landing.LandingPRNumber) if err != nil { return nil, err } - return []store.PullRequest{pr}, nil + recorded = &pr + if pr.State == "OPEN" { + return []store.PullRequest{pr}, nil + } } prs, err := runtime.GitHub.ListPRsByHead(runtime.Context, landingBranch) if err != nil { @@ -2295,6 +2300,9 @@ func resolveLandingPRs(runtime *stackruntime.Runtime, landingBranch string, land if len(open) > 1 { return open, nil } + if recorded != nil { + return []store.PullRequest{*recorded}, nil + } return prs, nil } @@ -2332,7 +2340,7 @@ func parseTicketRefs(values []string) ([]string, error) { if trimmed == "" { continue } - if !ticketRefPattern.MatchString(trimmed) { + if !fullTicketRefPattern.MatchString(trimmed) { return nil, fmt.Errorf("invalid ticket reference %q", trimmed) } normalized := strings.ToUpper(trimmed) diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index b2f9351..b314601 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -2864,6 +2864,109 @@ func TestQueueLandingBranchPrefersUniqueOpenPRWhenHistoryExists(t *testing.T) { } } +func TestQueueLandingBranchIgnoresStaleRecordedClosedLandingPRWhenReplacementExists(t *testing.T) { + repo, runtime, _ := setupTrackedStackRepo(t) + testutil.Run(t, repo, "git", "switch", "main") + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + testutil.Run(t, repo, "git", "push", "-u", "origin", "stack/discovery-core") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{}, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{}, + LandingPRNumber: 8, + CreatedAt: "2026-03-26T18:40:00Z", + }, + }, + Verifications: map[string][]store.VerificationRecord{ + "stack/discovery-core": { + { + CheckType: "manual", + Identifier: "check", + Passed: true, + HeadOID: landingHead, + RecordedAt: "2026-03-26T18:41:00Z", + }, + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "8": { + "id": "PR_8", + "number": 8, + "url": "https://example.com/hack-dance/stack/pull/8", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "CLOSED", + "isDraft": false + }, + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 10 +}`) + + output := executeCommand(t, runtime, "queue", "stack/discovery-core", "--yes") + if !strings.Contains(output, "PR #9") { + t.Fatalf("expected queue output to use replacement open landing PR, got %q", output) + } + + log := readFile(t, ghStub.LogPath) + if !strings.Contains(log, "pr merge 9 --auto --merge --match-head-commit "+landingHead) { + t.Fatalf("expected queue to merge replacement open landing PR #9, got %q", log) + } +} + +func TestParseTicketRefsRejectsPartialMatches(t *testing.T) { + tests := []string{ + "ABC-123/extra", + "prefix/ABC-1", + "ABC-123 extra", + } + for _, value := range tests { + tickets, err := parseTicketRefs([]string{value}) + if err == nil { + t.Fatalf("expected parseTicketRefs to reject %q, got tickets %+v", value, tickets) + } + } +} + func TestQueueRejectsLandingBranchWithStaleVerification(t *testing.T) { repo := testutil.SetupGitRepo(t) remote := filepath.Join(t.TempDir(), "remote.git") From 2fbc0475df57c4b3497d20ff61bbd479031f6c50 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 26 Mar 2026 15:38:48 -0400 Subject: [PATCH 9/9] fix: validate superseded pr membership --- internal/cmd/root.go | 7 ++++ internal/cmd/root_test.go | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b2d9b6a..4116772 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -2093,6 +2093,10 @@ func buildSupersedePlan(runtime *stackruntime.Runtime, state store.RepoState, la LandingPR: landingPRs[0], SupersededPRs: make([]store.PullRequest, 0, len(prNumbers)), } + sourceBranches := map[string]bool{} + for _, branch := range landing.SourceBranches { + sourceBranches[branch] = true + } for _, number := range prNumbers { if number == plan.LandingPR.Number { @@ -2102,6 +2106,9 @@ func buildSupersedePlan(runtime *stackruntime.Runtime, state store.RepoState, la if err != nil { return supersedePlan{}, err } + if !sourceBranches[pr.HeadRefName] { + return supersedePlan{}, fmt.Errorf("pull request #%d head %q is not part of landing batch %q; expected one of: %s", pr.Number, pr.HeadRefName, landingBranch, strings.Join(landing.SourceBranches, ", ")) + } plan.SupersededPRs = append(plan.SupersededPRs, pr) } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index b314601..48f5b45 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -2171,6 +2171,77 @@ func TestSupersedeRecordsMetadataAndCommentsOriginalPRs(t *testing.T) { } } +func TestSupersedeRejectsPRsOutsideLandingSourceBranches(t *testing.T) { + repo := testutil.SetupGitRepo(t) + testutil.Run(t, repo, "git", "switch", "-c", "stack/discovery-core") + testutil.WriteFile(t, filepath.Join(repo, "landing.txt"), "landing\n") + testutil.Run(t, repo, "git", "add", "landing.txt") + testutil.Run(t, repo, "git", "commit", "-m", "landing") + landingHead := strings.TrimSpace(testutil.Run(t, repo, "git", "rev-parse", "HEAD")) + + runtime := newTestRuntime(repo) + state := store.RepoState{ + Version: 1, + Repo: "hack-dance/stack", + DefaultRemote: "origin", + Trunk: "main", + Branches: map[string]store.BranchRecord{}, + Landings: map[string]store.LandingRecord{ + "stack/discovery-core": { + BaseBranch: "main", + SourceBranches: []string{"hack-agent/lnhack-66-feature-a", "hack-agent/lnhack-67-feature-b"}, + CreatedAt: "2026-03-26T19:00:00Z", + }, + }, + } + if err := runtime.Store.WriteState(runtime.Context, state); err != nil { + t.Fatalf("write state: %v", err) + } + + ghStub := testutil.SetupGHStub(t, "hack-dance/stack", "main") + t.Setenv("STACK_TEST_GH_STATE", ghStub.StatePath) + t.Setenv("STACK_TEST_GH_LOG", ghStub.LogPath) + t.Setenv("PATH", ghStub.Dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeGHState(t, ghStub.StatePath, `{ + "repo": { + "nameWithOwner": "hack-dance/stack", + "url": "https://github.com/hack-dance/stack", + "defaultBranchRef": { "name": "main" } + }, + "prs": { + "3": { + "id": "PR_3", + "number": 3, + "url": "https://example.com/hack-dance/stack/pull/3", + "repo": "hack-dance/stack", + "headRefName": "hack-agent/unrelated-follow-up", + "baseRefName": "main", + "headRefOid": "", + "state": "OPEN", + "isDraft": false + }, + "9": { + "id": "PR_9", + "number": 9, + "url": "https://example.com/hack-dance/stack/pull/9", + "repo": "hack-dance/stack", + "headRefName": "stack/discovery-core", + "baseRefName": "main", + "headRefOid": "`+landingHead+`", + "state": "OPEN", + "isDraft": false + } + }, + "next_number": 10 +}`) + + err := executeCommandExpectError(runtime, "supersede", "--landing", "stack/discovery-core", "--prs", "3", "--no-comment", "--yes") + if err == nil || !strings.Contains(err.Error(), `pull request #3 head "hack-agent/unrelated-follow-up" is not part of landing batch "stack/discovery-core"`) { + t.Fatalf("expected supersede source-branch validation error, got %v", err) + } +} + func TestVerifyAddAndListForTrackedBranch(t *testing.T) { repo := testutil.SetupGitRepo(t)