From d7a79ddf475f00e56402e8b7d0ca9b26e4348c2b Mon Sep 17 00:00:00 2001 From: Gabe Lease Date: Sat, 11 Jul 2026 17:21:17 -0700 Subject: [PATCH 1/2] feat(stack): add configurable readiness modes Keep prior behavior under unmanaged while allowing sync and merge to enforce all-ready or root-ready across GitHub and GitLab. Readiness changes run after repair and description updates, and the undo journal preserves prior states so conversions remain reversible. --- .changeset/calm-stacks-ready.md | 5 + AGENTS.md | 9 +- README.md | 82 ++++- skills/stack/SKILL.md | 124 ++++++- src/cli.ts | 49 ++- src/domain/model.ts | 6 + src/repairPlan.ts | 3 + src/services/CodeHost.ts | 13 +- src/services/Config.ts | 12 + src/services/Stack.ts | 378 +++++++++++++++++---- src/services/code-host/GitHub.ts | 13 +- src/services/code-host/GitLab.ts | 11 +- src/services/code-host/Memory.ts | 55 ++- src/stackResult.ts | 21 +- tests/stack.test.ts | 551 +++++++++++++++++++++++++++++-- 15 files changed, 1191 insertions(+), 141 deletions(-) create mode 100644 .changeset/calm-stacks-ready.md diff --git a/.changeset/calm-stacks-ready.md b/.changeset/calm-stacks-ready.md new file mode 100644 index 0000000..59f5f55 --- /dev/null +++ b/.changeset/calm-stacks-ready.md @@ -0,0 +1,5 @@ +--- +"@kitlangton/stack": patch +--- + +Add configurable, provider-neutral readiness policies, reconciliation, and readiness-aware undo for stack sync and merge workflows. diff --git a/AGENTS.md b/AGENTS.md index b77178f..6093fcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,12 +9,13 @@ ## Safety rules +- Linked Git worktrees share `.git/stack/state.json`, `.git/stack/undo.json`, and repo-local `stack.*` configuration. Treat each connected stack as owned by one worktree, scope mutations with a branch from that stack, and never mutate another worktree's stack or run cross-worktree stack mutations concurrently without explicit coordination. - Bare `stack sync` must stay non-mutating, including scoped and keep-going runs. - Mutating commands need an explicit mode: `--apply`, or `merge --auto` for code-host auto-merge plus descendant repair. - Never mutate configured trunk branches like `dev`, `main`, or `master`. - Before rebasing a branch, create a local backup branch. - Before repair mutates Git or a hosted change, save an undo checkpoint. Merge child retargets use a pre-merge recovery journal; after the root lands, descendant repair starts from a post-merge baseline that never retargets children back onto the landed branch. -- `stack undo` should restore the last applied mutation from the saved journal. +- `stack undo` should restore the last applied mutation from the saved journal, including hosted-change readiness. ## Current commands @@ -24,12 +25,14 @@ - `track` records parentage for an existing branch only when change target branches do not already encode the stack. - `sync [branch]` previews target-branch inference, stale metadata cleanup, and repairs without mutating branches, requests, or stack metadata using the tree summary output. - `sync --apply [branch]` applies the common maintenance workflow: remove stale local links, infer clear target-branch stack links, repair branches, retarget requests, refresh links, and show a concise tree summary. With a branch argument, sync only the stack containing that branch. +- `sync [--readiness-mode unmanaged|all-ready|root-ready]` previews the effective readiness policy; `sync --apply` is the explicit conversion and reconciliation path for existing changes. - `sync` with no branch scopes to the current stack when the current branch is stack-relevant; when off-stack, it keeps the repo-wide behavior. - `sync --apply --continue-on-failure` / `sync --apply --keep-going` processes independent stacks, reports succeeded and failed stacks, preserves per-stack cleanup output, and exits nonzero if any stack failed. - `sync` should not auto-track standalone trunk-root requests; infer a trunk-root request only when another open request is based on it. - `merge` merges the oldest branch in a stack and immediately repairs descendants; when no branch is given, it infers the root from the current branch. It retargets immediate child requests before merge to preserve open work in auto-delete repos. - `merge --auto` retargets immediate child requests, enables code-host auto-merge, waits for merge, then repairs descendants. - `merge --auto --through ` repeats root auto-merge and descendant repair until the target branch or request has landed. +- `merge [--readiness-mode unmanaged|all-ready|root-ready]` applies the effective policy after descendant repair. It must not promote a draft current root immediately before merge; callers should reconcile with `sync --apply` first so readiness-triggered checks can finish. - `history` explains the most recent applied mutation from the undo journal. - `undo` restores the last applied mutation. @@ -37,10 +40,10 @@ - Persist stack metadata in `.git/stack/state.json`. - Persist undo state in `.git/stack/undo.json`. -- User preferences live in `git config stack.*` (read at startup in the CLI `live` layer), not in `state.json`. Current keys: `stack.codeHost`, `stack.trunks`, and `stack.blockLink` (default true; set false to render a plain `### Stack` heading without the attribution link). +- User preferences live in `git config stack.*` (read at startup in the CLI `live` layer), not in `state.json`. Current keys: `stack.codeHost`, `stack.trunks`, `stack.blockLink` (default true; set false to render a plain `### Stack` heading without the attribution link), and `stack.readinessMode` (`unmanaged` by default, or `all-ready` / `root-ready`). - Prefer `Context.Service`-based Effect services and test-first changes. - Use OpenCode-style service modules for deep seams: export `Interface`, `Service`, adapters like `layer`, `live`, or `memory`, and a namespace self-reexport such as `export * as CodeHost from "./CodeHost.ts"`; consumers import that named namespace directly from the module file. -- Keep local Git behavior behind `Git` and pull/merge-request behavior behind `CodeHost`. Concrete backends live in `services/code-host/GitHub.ts` (via `gh`) and `services/code-host/GitLab.ts` (via `glab`), while their in-memory contract behavior is shared through `services/code-host/Memory.ts`; the CLI picks one backend at startup from `STACK_CODE_HOST`, `git config stack.codeHost`, or an unambiguous `origin` host. Stack orchestration depends on `CodeHost.Service` rather than shelling out to a host CLI directly. +- Keep local Git behavior behind `Git` and pull/merge-request behavior behind `CodeHost`. Readiness is a provider-neutral `CodeHost` capability implemented by both GitHub (via `gh`) and GitLab (via `glab`), not host-specific stack orchestration. Concrete backends live in `services/code-host/GitHub.ts` and `services/code-host/GitLab.ts`, while their in-memory contract behavior is shared through `services/code-host/Memory.ts`; the CLI picks one backend at startup from `STACK_CODE_HOST`, `git config stack.codeHost`, or an unambiguous `origin` host. Stack orchestration depends on `CodeHost.Service` rather than shelling out to a host CLI directly. - Check the local Effect source tree when available before changing Effect APIs or versions. - Prefer `effect/Path`, `effect/FileSystem`, and `effect/unstable/process` instead of Node/Bun built-ins in app code. - Keep logic literal and debuggable over clever abstractions. diff --git a/README.md b/README.md index bc49b8c..cf8d190 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,19 @@ repos that squash-merge and delete branches. the agent do normal code work with plain `git`, then use `stack` for stack inspection, repair, merge, and undo workflows. +## Important: Git Worktrees Share Stack State + +Linked worktrees use the same common Git directory, so they also share +`.git/stack/state.json`, `.git/stack/undo.json`, and repo-local `stack.*` +configuration. A visible stack is not automatically owned by the current +worktree. + +Treat each connected stack as owned by one worktree. Pass a branch from that +owned stack to mutating commands, do not operate on another worktree's stack +without explicit coordination, and do not run stack mutations concurrently +across worktrees. If the current worktree does not have a stack, create an +independent one instead of attaching it to an unrelated visible stack. + ## Install ```bash @@ -43,24 +56,24 @@ glab auth login # GitLab 4. Preview the stack: ```bash -stack sync +stack sync ``` 5. Apply the safe maintenance workflow: ```bash -stack sync --apply +stack sync --apply ``` 6. Merge from the root when ready: ```bash -stack merge -stack merge --apply +stack merge +stack merge --apply ``` -Use `stack merge --auto` when the code host should wait for merge requirements, -then repair descendants automatically after the root lands. +Use `stack merge --auto` when the code host should wait for +merge requirements, then repair descendants automatically after the root lands. ## What It Does @@ -70,6 +83,7 @@ then repair descendants automatically after the root lands. - Records stack intent in `.git/stack/state.json`. - Repairs descendants after parent branches move or land. - Retargets PRs/MRs when needed. +- Reconciles PR/MR readiness according to the effective policy. - Refreshes stack blocks in descriptions. - Saves `.git/stack/undo.json` before mutations. @@ -99,6 +113,54 @@ git config stack.codeHost github # or: gitlab Use `STACK_CODE_HOST=github|gitlab` for a one-off override. +## Change Readiness + +Readiness policy is provider-neutral and works for GitHub PRs and GitLab MRs. +Configure a repository default with: + +```bash +git config stack.readinessMode root-ready +``` + +The supported modes are: + +| Mode | Behavior | +| ------------ | ------------------------------------------------------------------------- | +| `unmanaged` | Preserve existing readiness; use the code host's default for new changes. | +| `all-ready` | Make every change in the selected stack ready. | +| `root-ready` | Make each trunk-targeting root ready and every descendant draft. | + +`unmanaged` is the default, preserving the behavior of earlier releases. A +command-line override takes precedence over Git config for that invocation; it +does not rewrite the configured default: + +```bash +stack sync --readiness-mode root-ready +stack sync --apply --readiness-mode root-ready +stack merge --readiness-mode root-ready +``` + +An invalid configured value is a startup error even when a flag is present. +Fix it with `git config stack.readinessMode ` or unset it before retrying. + +Use `sync` as the explicit conversion and reconciliation path for an existing +stack: preview first, then apply. Readiness changes can start checks or reviews, +so `merge` does not make a draft current root ready immediately before trying to +merge it. Promote and reconcile first with `stack sync --apply`, wait for any +required checks, then run `stack merge`. After a root lands and descendants are +repaired, `merge` applies the effective mode to the remaining stack. + +```bash +gh pr checks --watch # GitHub +glab ci status --branch --wait # GitLab +``` + +Applied readiness changes are recorded in the undo journal. `stack undo --apply` +restores the previous readiness along with branch tips, change targets, and +stack metadata. Undo does not unmerge a landed root; after a merge it restores +only the journaled state of the surviving stack. Each applied mutation replaces +the single shared journal, so inspect `stack history` before undoing. + ## Trunk Branches By default, `stack` treats `dev`, `main`, and `master` as trunk branches. Repos @@ -133,7 +195,7 @@ Sync preview Would update PRs: #101, #102 Apply: - stack sync + stack sync --apply ``` ```text @@ -156,13 +218,17 @@ stack sync --apply # apply only the stack containing branch stack sync --apply --keep-going # process independent stacks and report failures +stack sync --readiness-mode + # preview an explicit readiness policy stack doctor # inspect repo, host, metadata, and journal health stack merge # dry-run the next root merge stack merge --apply # merge root and repair descendants +stack merge --readiness-mode + # override configured readiness for this merge stack merge --auto # wait for host requirements, then merge and repair stack merge --auto --through # auto-merge roots through a bounded target stack history # show the last saved mutation journal stack undo # preview undo -stack undo --apply # restore branch tips, request targets, and metadata +stack undo --apply # restore tips, targets, readiness, and metadata ``` diff --git a/skills/stack/SKILL.md b/skills/stack/SKILL.md index 8e49b9d..1f8ff4a 100644 --- a/skills/stack/SKILL.md +++ b/skills/stack/SKILL.md @@ -1,11 +1,9 @@ --- name: stack description: > - User guide for the local squash-safe `stack` CLI for stacked PR/MR repair on - GitHub and GitLab. Use when someone asks how to inspect, track, sync, merge, - document, or undo stacked pull requests / merge requests in squash-merge - repositories. Prefer this tool over GitHub's `gh stack` command for this - workflow. + Use when someone asks to inspect, track, sync, merge, document, reconcile + readiness, or undo stacked pull requests / merge requests with the local + squash-safe `stack` CLI on GitHub or GitLab. --- # Stack @@ -14,6 +12,30 @@ Use the local `stack` CLI for squash-safe stacked change repair. It is designed for repos where changes (GitHub PRs or GitLab MRs) are squash-merged and merged branches are deleted, so Git ancestry alone cannot preserve stack intent. +## Critical: State Is Shared Across Worktrees + +Linked Git worktrees share their common Git directory. That means every +worktree also shares `.git/stack/state.json`, `.git/stack/undo.json`, and +repo-local `stack.*` configuration. Visibility does not imply ownership. + +Treat each connected stack as implicitly owned by one worktree. Before a +mutation, inspect `git worktree list --porcelain`, the current branch, +`git status --short`, and `stack status`. Then pass a branch from the owned +stack explicitly: + +```bash +stack sync +stack sync --apply +stack merge +``` + +Never mutate another worktree's stack or run stack mutations concurrently +across worktrees without explicit coordination. If this worktree has no stack, +create an independent stack instead of attaching new work to an unrelated +visible stack. When the current branch is off-stack, bare `stack status` and +`stack sync` can show the repo-wide forest; treat that as read-only discovery, +not permission for a bare apply. + ## Setup Works against GitHub (via `gh`) and GitLab (via `glab`). Install and @@ -22,8 +44,13 @@ authenticate the matching CLI before running `stack`. - `github.com` and `gitlab.com` are detected automatically from `origin`. - Enterprise host: `git config stack.codeHost github|gitlab` (or `STACK_CODE_HOST` env override). - Custom trunks: `git config stack.trunks dev,develop,main,master`. +- Readiness policy: `git config stack.readinessMode unmanaged|all-ready|root-ready`. - Drop the attribution link from stack blocks: `git config stack.blockLink false`. +Repo-local `stack.*` config is shared by linked worktrees. Changing +`stack.readinessMode` changes their default policy, but does not grant ownership +of another worktree's stack. + Keep ordinary editing and commits on plain `git`. Use `stack` only for stack intent, inspection, sync, merge, and undo. @@ -41,19 +68,69 @@ parent, merge-base anchor, change number). Mutating workflows write `.git/stack/undo.json` so `stack undo --apply` can restore the previous state. Do not edit these files by hand — run `stack sync` to preview, `stack sync --apply` to fix. +## Readiness Modes + +Readiness is provider-neutral across GitHub PRs and GitLab MRs. The effective +mode comes from `--readiness-mode`, then `git config stack.readinessMode`, then +the default `unmanaged` mode. The flag is a one-command override and does not +rewrite Git config. + +Configuration is validated at startup. An invalid configured value is an error +even when a command flag is present; fix or unset it before continuing. + +| Mode | Behavior | +| ------------ | ---------------------------------------------------------------------- | +| `unmanaged` | Preserve existing readiness and use the host default for new changes. | +| `all-ready` | Make every change in the selected stack ready. | +| `root-ready` | Make each configured-trunk-targeting root ready and descendants draft. | + +Use `sync` to convert or reconcile an existing stack. Preview the exact scope +and readiness changes, then apply them: + +```bash +stack sync --readiness-mode root-ready +stack sync --apply --readiness-mode root-ready +``` + +Readiness changes can trigger checks and reviews. `merge` does not promote a +draft current root immediately before attempting to merge it. Run the scoped +`stack sync --apply` first, wait for required checks, and then merge. After a +root lands and descendant repair finishes, `merge` applies the effective mode +to the remaining stack. + +```bash +gh pr checks --watch # GitHub +glab ci status --branch --wait # GitLab +``` + ## Happy Path -Create PRs with the right target branches so the stack is self-describing: +Create changes with the right target branches so the stack is self-describing. +For GitHub: ```bash gh pr create --base dev --head stack-a gh pr create --base stack-a --head stack-b -stack sync # preview inferred links and repairs -stack sync --apply # record links, repair, retarget, refresh stack blocks ``` -That's the common loop. `stack sync` previews; `stack sync --apply` does the -work. Repeat after any parent branch changes or a squash merge lands. +For GitLab: + +```bash +glab mr create --target-branch dev --source-branch stack-a +glab mr create --target-branch stack-a --source-branch stack-b +``` + +Then preview and apply the owned stack only: + +```bash +stack sync + # preview inferred links, repairs, and readiness +stack sync --apply + # record links, repair, retarget, reconcile readiness +``` + +That's the common loop. Scoped `stack sync` previews; `stack sync --apply` does +the work. Repeat after any parent branch changes or a squash merge lands. ## Commands @@ -65,9 +142,14 @@ work. Repeat after any parent branch changes or a squash merge lands. - `stack track --onto ` — manually record stack intent only when target branches don't already encode it. - `stack sync [branch]` — preview inferred links and repairs (non-mutating). - Scopes to the current stack if no branch is given. + With no branch, it scopes to the current stack only when the current branch is + stack-relevant; from an off-stack branch, it previews the repo-wide forest. - `stack sync --apply [branch]` — infer links, remove stale links, repair - descendants, retarget changes, refresh stack blocks, show a tree summary. + descendants, retarget changes, reconcile readiness, refresh stack blocks, + show a tree summary. +- `stack sync [--readiness-mode unmanaged|all-ready|root-ready]` — override the + configured readiness policy for the preview or apply. This is the explicit + conversion path for existing changes. - `stack sync --apply --keep-going` — process independent stacks separately, report successes and failures, exit nonzero if any failed. - `stack merge [branch]` — dry-run root merge plus descendant repair. Infers @@ -78,9 +160,19 @@ work. Repeat after any parent branch changes or a squash merge lands. then repair descendants. - `stack merge --auto --through ` — repeat auto-merge one root at a time until the target lands. +- `stack merge [--readiness-mode unmanaged|all-ready|root-ready]` — override the + configured readiness policy applied to the remaining stack after repair. It + does not make a draft current root mergeable; reconcile that root with scoped + `sync --apply` first. - `stack history` — show the most recent applied repair journal. - `stack undo` — dry-run restore of the last applied mutation. -- `stack undo --apply` — restore branch tips, change targets, and stack metadata. +- `stack undo --apply` — restore branch tips, change targets, readiness, and + stack metadata. It does not unmerge a landed root; after merge, it restores + only the journaled surviving-stack state. + +Each applied mutation replaces the single shared undo journal. Run +`stack history` and confirm that the latest journal belongs to the owned stack +before previewing or applying undo. ## Stack Blocks @@ -103,6 +195,12 @@ GitHub uses `#123`; GitLab uses `!123 - Title`. ## Safety Rules +- Stack metadata, the undo journal, and repo-local `stack.*` config are shared + by linked worktrees. Operate only on the current worktree's owned stack. +- Pass an owned branch to mutating `sync` and `merge` commands. Do not turn a + repo-wide off-stack preview into a bare apply. +- Do not run stack mutations concurrently across worktrees. +- Require a clean current worktree before applying a mutation. - Bare `stack sync` never mutates branches, changes, or stack metadata. - `stack merge` is dry-run by default. - Mutating commands need `--apply` (except `merge --auto`, which waits for the diff --git a/src/cli.ts b/src/cli.ts index c723120..3ea35dd 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,14 @@ import skillContent from "../skills/stack/SKILL.md" with { type: "text" }; import { BranchError, DirtyWorktreeError, ExecError, MergeBaseError } from "./domain/model.ts"; import { renderStatus } from "./format.ts"; import * as Proc from "./platform/proc.ts"; -import { parseBlockLinkConfig, parseTrunksConfig, StackConfig, trunks } from "./services/Config.ts"; +import { + parseBlockLinkConfig, + parseReadinessMode, + parseTrunksConfig, + readinessModes, + StackConfig, + trunks, +} from "./services/Config.ts"; import { CodeHost } from "./services/CodeHost.ts"; import { CodeHostGitHub } from "./services/code-host/GitHub.ts"; import { CodeHostGitLab } from "./services/code-host/GitLab.ts"; @@ -54,6 +61,13 @@ const continueOnFailure = Flag.boolean("continue-on-failure").pipe( ), ); +const readinessMode = Flag.choice("readiness-mode", readinessModes).pipe( + Flag.withDescription( + "Readiness policy for this command: unmanaged preserves existing states, all-ready makes every change ready, and root-ready makes trunk-targeting roots ready and descendants draft.", + ), + Flag.optional, +); + const statusCommand = Command.make( "status", {}, @@ -119,14 +133,17 @@ const syncCommand = Command.make( branch: Argument.string("branch").pipe(Argument.optional), apply, continueOnFailure, + readinessMode, }, - Effect.fn(function* ({ branch, apply, continueOnFailure }) { + Effect.fn(function* ({ branch, apply, continueOnFailure, readinessMode }) { const stack = yield* Stack; const branchValue = Option.getOrUndefined(branch); + const readinessModeValue = Option.getOrUndefined(readinessMode); const items = yield* stack.sync({ apply, continueOnFailure, ...(branchValue === undefined ? {} : { branch: branchValue }), + ...(readinessModeValue === undefined ? {} : { readinessMode: readinessModeValue }), }); yield* Console.log(items.join("\n")); }), @@ -151,6 +168,10 @@ const syncCommand = Command.make( command: "stack sync --apply --continue-on-failure", description: "Sync independent stacks and summarize any failures at the end", }, + { + command: "stack sync --apply --readiness-mode root-ready", + description: "Make roots ready and descendants draft while syncing the stack", + }, ]), ); @@ -176,15 +197,18 @@ const mergeCommand = Command.make( auto, admin, through, + readinessMode, }, - Effect.fn(function* ({ branch, apply, auto, admin, through }) { + Effect.fn(function* ({ branch, apply, auto, admin, through, readinessMode }) { const stack = yield* Stack; const throughValue = Option.getOrUndefined(through); + const readinessModeValue = Option.getOrUndefined(readinessMode); const items = yield* stack.land(Option.getOrUndefined(branch), { apply, auto, admin, ...(throughValue === undefined ? {} : { through: throughValue }), + ...(readinessModeValue === undefined ? {} : { readinessMode: readinessModeValue }), }); yield* Console.log(items.join("\n")); }), @@ -217,6 +241,10 @@ const mergeCommand = Command.make( command: "stack merge effectify-watcher --apply --admin", description: "Force-merge the root GitHub PR with admin privileges, then repair descendants", }, + { + command: "stack merge effectify-watcher --apply --readiness-mode root-ready", + description: "Merge a ready root, then restore the root-ready policy after descendant repair", + }, ]), ); @@ -324,6 +352,20 @@ const live = (() => { [0, 1], ); const blockLink = parseBlockLinkConfig(blockLinkOut); + const readinessModeOut = yield* proc.exec( + root, + "git", + ["config", "--get", "stack.readinessMode"], + [0, 1], + ); + const readinessMode = parseReadinessMode(readinessModeOut); + if (readinessMode === null) { + return yield* Effect.fail( + new Error( + `invalid stack.readinessMode '${readinessModeOut.trim()}'; expected ${readinessModes.join(", ")}`, + ), + ); + } return StackConfig.layer({ root, @@ -331,6 +373,7 @@ const live = (() => { journal: path.join(git, "stack", "undo.json"), trunks: configuredTrunks.length > 0 ? configuredTrunks : trunks, blockLink, + ...(readinessMode ? { readinessMode } : {}), }); }), ).pipe(Layer.provideMerge(proc)); diff --git a/src/domain/model.ts b/src/domain/model.ts index 0b490a9..e00e19a 100644 --- a/src/domain/model.ts +++ b/src/domain/model.ts @@ -6,6 +6,9 @@ export type Source = "explicit" | "inferred" | "root"; export const version = 1; +export const ChangeReadiness = Schema.Union([Schema.Literal("draft"), Schema.Literal("ready")]); +export type ChangeReadiness = typeof ChangeReadiness.Type; + export const BranchName = Schema.String.pipe(Schema.brand("BranchName")); export type BranchName = typeof BranchName.Type; @@ -78,6 +81,7 @@ export class UndoEntry extends Schema.Class("UndoEntry")({ pr: Schema.NullOr(PrNumber), base: Schema.NullOr(BranchName), created: Schema.NullOr(PrNumber), + readiness: Schema.optional(ChangeReadiness), pushRemotes: Schema.optional(Schema.Array(Schema.String)), }) {} @@ -381,6 +385,7 @@ export const undoEntry = (value: { pr: number | null; base: string | null; created: number | null; + readiness?: ChangeReadiness; pushRemotes?: ReadonlyArray; }) => new UndoEntry({ @@ -389,6 +394,7 @@ export const undoEntry = (value: { pr: value.pr === null ? null : prNumber(value.pr), base: value.base === null ? null : branchName(value.base), created: value.created === null ? null : prNumber(value.created), + ...(value.readiness === undefined ? {} : { readiness: value.readiness }), ...(value.pushRemotes === undefined ? {} : { pushRemotes: Array.from(value.pushRemotes) }), }); diff --git a/src/repairPlan.ts b/src/repairPlan.ts index 3c031bb..545d87b 100644 --- a/src/repairPlan.ts +++ b/src/repairPlan.ts @@ -1,3 +1,4 @@ +import type { ChangeReadiness } from "./domain/model.ts"; import type { Mode, StackResultItem } from "./stackResult.ts"; export interface RebaseBranchPlan { @@ -18,6 +19,7 @@ export interface CreatePullPlan { readonly branch: string; readonly base: string; readonly pr: number | null; + readonly readiness?: ChangeReadiness; } export const rebaseBranch = ( @@ -42,4 +44,5 @@ export const createPull = (plan: CreatePullPlan, mode: Mode): StackResultItem => branch: plan.branch, base: plan.base, pr: plan.pr, + ...(plan.readiness === undefined ? {} : { readiness: plan.readiness }), }); diff --git a/src/services/CodeHost.ts b/src/services/CodeHost.ts index d8cad62..7dff7cf 100644 --- a/src/services/CodeHost.ts +++ b/src/services/CodeHost.ts @@ -1,6 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import type { CodeHostError, PullMeta, PullRef } from "../domain/model.ts"; +import type { ChangeReadiness, CodeHostError, PullMeta, PullRef } from "../domain/model.ts"; export type Provider = "github" | "gitlab"; @@ -8,6 +8,11 @@ export interface Capabilities { readonly adminMerge: boolean; } +export interface CreateOptions { + readonly headRepository?: string | null; + readonly readiness?: ChangeReadiness; +} + export interface Interface { readonly provider: Provider; readonly capabilities: Capabilities; @@ -25,6 +30,10 @@ export interface Interface { readonly change: (number: number) => Effect.Effect; readonly edit: (pr: number, base: string) => Effect.Effect; readonly body: (pr: number, body: string) => Effect.Effect; + readonly setReadiness: ( + pr: number, + readiness: ChangeReadiness, + ) => Effect.Effect; readonly close: (pr: number) => Effect.Effect; readonly create: ( branch: string, @@ -32,7 +41,7 @@ export interface Interface { title: string, body: string, labels: ReadonlyArray, - headRepository?: string | null, + options?: CreateOptions, ) => Effect.Effect; } diff --git a/src/services/Config.ts b/src/services/Config.ts index 34ad528..be7afe3 100644 --- a/src/services/Config.ts +++ b/src/services/Config.ts @@ -8,6 +8,15 @@ export type Trunk = "dev" | "develop" | "main" | "master"; export const trunks: ReadonlyArray> = ["dev", "main", "master"]; +export const readinessModes = ["unmanaged", "all-ready", "root-ready"] as const; +export type ReadinessMode = (typeof readinessModes)[number]; + +export const parseReadinessMode = (value: string): ReadinessMode | null | undefined => { + const normalized = value.trim().toLowerCase(); + if (normalized === "") return undefined; + return readinessModes.find((mode) => mode === normalized) ?? null; +}; + export const parseBlockLinkConfig = (value: string): boolean | undefined => { const normalized = value.trim().toLowerCase(); if (normalized === "") return undefined; @@ -28,6 +37,7 @@ export interface StackConfigService { readonly journal: string; readonly trunks: ReadonlyArray; readonly blockLink: boolean; + readonly readinessMode: ReadinessMode; readonly codeHostConcurrency: number; readonly codeHostWaitIntervalMillis: number; } @@ -41,6 +51,7 @@ export class StackConfig extends Context.Service; blockLink?: boolean | undefined; + readinessMode?: ReadinessMode; codeHostConcurrency?: number; codeHostWaitIntervalMillis?: number; }) => @@ -54,6 +65,7 @@ export class StackConfig extends Context.Service branchName(name)), blockLink: opts.blockLink ?? true, + readinessMode: opts.readinessMode ?? "unmanaged", codeHostConcurrency: opts.codeHostConcurrency ?? 4, codeHostWaitIntervalMillis: opts.codeHostWaitIntervalMillis ?? 5_000, }); diff --git a/src/services/Stack.ts b/src/services/Stack.ts index e5e2dc0..0d8a7fc 100644 --- a/src/services/Stack.ts +++ b/src/services/Stack.ts @@ -10,6 +10,7 @@ import { BranchRef, branchName, branchRef, + type ChangeReadiness, DirtyWorktreeError, MergeBaseError, PullMeta, @@ -32,7 +33,7 @@ import * as RepairPlan from "../repairPlan.ts"; import * as StackGraph from "../stackGraph.ts"; import * as StackBlock from "../stackBlock.ts"; import * as StackResult from "../stackResult.ts"; -import { StackConfig } from "./Config.ts"; +import { type ReadinessMode, StackConfig } from "./Config.ts"; import { Git } from "./Git.ts"; import { CodeHost } from "./CodeHost.ts"; import * as Progress from "./Progress.ts"; @@ -49,12 +50,14 @@ export interface StackService { readonly auto?: boolean; readonly admin?: boolean; readonly through?: string; + readonly readinessMode?: ReadinessMode; }, ) => Effect.Effect, StackError>; readonly sync: (opts?: { readonly apply?: boolean; readonly branch?: string; readonly continueOnFailure?: boolean; + readonly readinessMode?: ReadinessMode; }) => Effect.Effect, StackError>; readonly doctor: () => Effect.Effect, StackError>; readonly last: () => Effect.Effect, StackError>; @@ -132,6 +135,118 @@ ${note}`; const trunk = (name: string) => cfg.trunks.some((item) => item === name); const step = (message: string) => progress.emit({ _tag: "Step", message }); const wait = (message: string) => progress.emit({ _tag: "Wait", message }); + const effectiveReadinessMode = (override?: ReadinessMode) => override ?? cfg.readinessMode; + const desiredReadiness = ( + mode: ReadinessMode, + parent: string, + ): ChangeReadiness | undefined => { + if (mode === "unmanaged") return undefined; + if (mode === "all-ready") return "ready"; + return trunk(parent) ? "ready" : "draft"; + }; + const pullReadiness = (pull: PullRef): ChangeReadiness => (pull.draft ? "draft" : "ready"); + const resolvedParent = ( + link: StackLink, + state: ReturnType, + refs: ReadonlyArray, + ) => { + const live = new Set(refs.map((ref) => String(ref.name))); + const links = new Map(state.links.map((item) => [String(item.branch), item])); + const seen = new Set(); + let parent = String(link.parent); + while (!trunk(parent) && !live.has(parent)) { + if (seen.has(parent)) return String(link.parent); + seen.add(parent); + const next = links.get(parent); + if (!next) return parent; + parent = String(next.parent); + } + return parent; + }; + const readinessPlan = ( + state: ReturnType, + refs: ReadonlyArray, + pulls: ReadonlyArray, + readinessMode: ReadinessMode, + mode: StackResult.Mode, + ) => { + const byBranch = new Map(pulls.map((pull) => [String(pull.head), pull])); + return state.links.flatMap((link) => { + const pull = byBranch.get(String(link.branch)); + const to = desiredReadiness(readinessMode, resolvedParent(link, state, refs)); + if (!pull || !to) return []; + const from = pullReadiness(pull); + if (from === to) return []; + return [ + { + _tag: "SetReadiness", + mode, + branch: String(link.branch), + pr: Number(pull.number), + from, + to, + } satisfies StackResult.StackResultItem, + ]; + }); + }; + const upsertUndoEntry = ( + entries: Array, + value: Parameters[0], + ) => { + const index = entries.findIndex((item) => item.branch === value.branch); + const current = index >= 0 ? entries[index]! : null; + const readiness = current?.readiness ?? value.readiness; + const pushRemotes = value.pushRemotes ?? current?.pushRemotes; + const next = undoEntry({ + branch: value.branch, + backup: current?.backup ?? value.backup, + pr: current?.pr ?? value.pr, + base: current?.base ?? value.base, + created: value.created ?? current?.created ?? null, + ...(readiness ? { readiness } : {}), + ...(pushRemotes ? { pushRemotes } : {}), + }); + if (index >= 0) entries[index] = next; + else entries.push(next); + return next; + }; + const applyReadiness = Effect.fn("Stack.applyReadiness")(function* ( + items: ReadonlyArray, + journal: { + readonly at: string; + readonly state: ReturnType; + readonly entries: Array; + readonly actions: Array; + }, + ) { + for (const item of items) { + upsertUndoEntry(journal.entries, { + branch: item.branch, + backup: null, + pr: item.pr, + base: null, + created: null, + readiness: item.from, + }); + journal.actions.push(item); + yield* store.writeUndo( + undoState( + journal.at, + journal.state, + journal.entries, + StackResult.renderAll(journal.actions, reference, requestLabel), + ), + ); + yield* step(StackResult.render(item, reference, requestLabel)); + yield* codeHost.setReadiness(item.pr, item.to); + } + return undoState( + journal.at, + journal.state, + journal.entries, + StackResult.renderAll(journal.actions, reference, requestLabel), + ); + }); const mergeFailure = (err: unknown) => new StackOperationError( `${err instanceof Error ? err.message : String(err)}\n\n` + @@ -196,6 +311,7 @@ ${note}`; readonly pulls: ReadonlyArray; readonly actions: ReadonlyArray; readonly mode: StackResult.Mode; + readonly applyCommand?: string; readonly failed?: { readonly branch: string; readonly parent: string }; }) => { const trunkNames = cfg.trunks.map(String); @@ -217,6 +333,10 @@ ${note}`; StackResult.StackResultItem & { readonly _tag: "CreatePull" } >(); const updatedPrs = new Set(); + const readiness = new Map< + string, + StackResult.StackResultItem & { readonly _tag: "SetReadiness" } + >(); let backups = 0; for (const action of opts.actions) { if (action._tag === "Rebase") rebased.set(action.branch, action.parent); @@ -224,6 +344,7 @@ ${note}`; if (action._tag === "CreatePull") created.set(action.branch, action); if (action._tag === "Backup") backups += 1; if (action._tag === "UpdateStackLinks") updatedPrs.add(action.pr); + if (action._tag === "SetReadiness") readiness.set(action.branch, action); } const failedBranch = opts.failed?.branch ?? null; @@ -273,6 +394,12 @@ ${note}`; ? { icon: "◌", note: `would create ${requestLabel}` } : { icon: "✓", note: `created ${requestLabel}` }; } + const readinessChange = readiness.get(branch); + if (readinessChange) { + return opts.mode === "dry-run" + ? { icon: "◌", note: `would mark ${readinessChange.to}` } + : { icon: "✓", note: `marked ${readinessChange.to}` }; + } return { icon: "●", note: "" }; }; @@ -316,10 +443,20 @@ ${note}`; .join(", ")}`, ); } + if (readiness.size > 0) { + const verb = opts.mode === "dry-run" ? "Would update readiness" : "Updated readiness"; + summary.push( + `${verb}: ${[...readiness.values()] + .sort((a, b) => a.pr - b.pr) + .map((item) => `${reference(item.pr)} ${item.to}`) + .join(", ")}`, + ); + } if (backups > 0 && opts.mode === "apply") summary.push(`Backups created: ${backups}`); if (summary.length > 0) lines.push("", ...summary); - if (opts.mode === "dry-run") lines.push("", "Apply:", " stack sync --apply"); - else if (!opts.failed && (backups > 0 || updatedPrs.size > 0)) { + if (opts.mode === "dry-run") + lines.push("", "Apply:", ` ${opts.applyCommand ?? "stack sync --apply"}`); + else if (!opts.failed && (backups > 0 || updatedPrs.size > 0 || readiness.size > 0)) { lines.push("", "Undo:", " stack undo --apply"); } return lines; @@ -378,6 +515,7 @@ ${note}`; case "Rebase": case "Push": case "CreatePull": + case "SetReadiness": return action.branch; case "RetargetPull": return null; @@ -697,9 +835,9 @@ ${note}`; readonly apply: boolean; readonly saved?: Map; readonly journalState?: ReturnType; - readonly initialEntries?: ReadonlyArray; readonly journalActions?: ReadonlyArray; readonly initialActions?: ReadonlyArray; + readonly readinessMode?: ReadinessMode; readonly replayAnchors?: ReadonlyMap; readonly writeState?: ( state: ReturnType, @@ -748,7 +886,7 @@ ${note}`; const tips = new Map(); const prior = new Map(); const moved = new Set(); - const entries: Array = Array.from(opts.initialEntries ?? []); + const entries = new Array(); const next: Array = []; let journal = apply && (initialActions.length > 0 || entries.length > 0); @@ -966,18 +1104,14 @@ ${note}`; actions.push(...RepairPlan.rebaseBranch(rebase, mode)); if (apply) { - const priorEntry = entries.find((item) => item.branch === link.branch) ?? null; - const entry = undoEntry({ + upsertUndoEntry(entries, { branch: link.branch, backup, - pr: priorEntry?.pr ?? pr?.number ?? link.pr ?? null, - base: priorEntry?.base ?? base, - created: priorEntry?.created ?? null, + pr: pr?.number ?? link.pr ?? null, + base, + created: null, pushRemotes: targetRemotes, }); - const entryIndex = entries.findIndex((item) => item.branch === link.branch); - if (entryIndex >= 0) entries[entryIndex] = entry; - else entries.push(entry); journal = true; yield* RepairExecution.applyRebaseBranch(rebase, { git, @@ -1008,18 +1142,14 @@ ${note}`; } satisfies RepairPlan.RetargetPullPlan; actions.push(RepairPlan.retargetPull(retarget, mode)); if (apply) { - if (!entries.some((item) => item.branch === link.branch)) { - entries.push( - undoEntry({ - branch: link.branch, - backup: null, - pr: now.number, - base, - created, - }), - ); - journal = true; - } + upsertUndoEntry(entries, { + branch: link.branch, + backup: null, + pr: now.number, + base, + created, + }); + journal = true; yield* RepairExecution.applyRetargetPull(retarget, { checkpoint, step, @@ -1043,21 +1173,18 @@ ${note}`; const open = prs.get(link.branch) ?? null; if (!open) { + const readiness = desiredReadiness(opts.readinessMode ?? cfg.readinessMode, parent); if (apply) { const prev = previous; const nextPr = draft(link, parent, prev); - if (!entries.some((item) => item.branch === link.branch)) { - entries.push( - undoEntry({ - branch: link.branch, - backup: null, - pr: now?.number ?? link.pr ?? null, - base, - created: null, - }), - ); - journal = true; - } + upsertUndoEntry(entries, { + branch: link.branch, + backup: null, + pr: now?.number ?? link.pr ?? null, + base, + created: null, + }); + journal = true; yield* step(`create ${requestLabel} for ${link.branch} -> ${parent}`); yield* checkpoint(); const made = yield* codeHost.create( @@ -1066,7 +1193,10 @@ ${note}`; nextPr.title, nextPr.body, nextPr.labels, - headRepository, + { + headRepository, + ...(readiness ? { readiness } : {}), + }, ); created = made.number; num = made.number; @@ -1075,29 +1205,16 @@ ${note}`; branch: String(link.branch), base: parent, pr: Number(made.number), + ...(readiness ? { readiness } : {}), } satisfies RepairPlan.CreatePullPlan; actions.push(RepairPlan.createPull(createdPull, mode)); - const i = entries.findIndex((item) => item.branch === link.branch); - if (i >= 0) { - entries[i] = undoEntry({ - branch: entries[i]!.branch, - backup: entries[i]!.backup, - pr: entries[i]!.pr, - base: entries[i]!.base, - created: made.number, - ...(entries[i]!.pushRemotes ? { pushRemotes: entries[i]!.pushRemotes } : {}), - }); - } else { - entries.push( - undoEntry({ - branch: link.branch, - backup: null, - pr: now?.number ?? link.pr ?? null, - base, - created: made.number, - }), - ); - } + upsertUndoEntry(entries, { + branch: link.branch, + backup: null, + pr: now?.number ?? link.pr ?? null, + base, + created: made.number, + }); journal = true; yield* checkpoint(); } else { @@ -1107,6 +1224,7 @@ ${note}`; branch: String(link.branch), base: parent, pr: null, + ...(readiness ? { readiness } : {}), }, mode, ), @@ -1162,6 +1280,15 @@ ${note}`; const dryRun = !apply; const requestedBranch = opts?.branch; const continueOnFailure = opts?.continueOnFailure ?? false; + const readinessMode = effectiveReadinessMode(opts?.readinessMode); + const applyCommand = [ + "stack", + "sync", + ...(requestedBranch ? [requestedBranch] : []), + "--apply", + ...(continueOnFailure ? ["--continue-on-failure"] : []), + ...(opts?.readinessMode ? ["--readiness-mode", opts.readinessMode] : []), + ].join(" "); const current = requestedBranch && dryRun ? "" : yield* git.current(); return yield* Effect.gen(function* () { if (!dryRun) yield* clean(); @@ -1234,6 +1361,7 @@ ${note}`; journalState: state, replayAnchors, initialActions: scopedInitial, + readinessMode, ...(writeState ? { writeState } : {}), preserveUndo, }); @@ -1245,7 +1373,27 @@ ${note}`; !dryRun && changedOpenPulls ? yield* codeHost.changes() : pulls, ); const notes = yield* linksFor(repair.state, !dryRun, new Set(), notesPulls); - const changed = repair.actions.length > 0 || notes.actions.length > 0; + const postPulls = dryRun + ? scopedPulls + : yield* changesForLinks(repair.state.links, yield* codeHost.changes()); + const postReadiness = readinessPlan( + repair.state, + refs, + postPulls, + readinessMode, + mode, + ); + const readinessActions = [...repair.actions, ...postReadiness]; + const finalUndo = + !dryRun && postReadiness.length > 0 + ? yield* applyReadiness(postReadiness, { + at: repair.undo?.at ?? (yield* timestamp()), + state, + entries: Array.from(repair.undo?.entries ?? []), + actions: Array.from(repair.actions), + }) + : repair.undo; + const changed = readinessActions.length > 0 || notes.actions.length > 0; const lines = !changed ? renderSyncTree({ title: "Stack is current", @@ -1253,15 +1401,17 @@ ${note}`; pulls: scopedPulls, actions: [], mode, + applyCommand, }) : renderSyncTree({ title: dryRun ? "Sync preview" : "Synced stack", state: repair.state, - pulls: scopedPulls, - actions: [...repair.actions, ...notes.actions], + pulls: postPulls, + actions: [...readinessActions, ...notes.actions], mode, + applyCommand, }); - return { lines, undo: repair.undo }; + return { lines, undo: finalUndo }; }), ); @@ -1279,13 +1429,24 @@ ${note}`; const sections = new Array(); const aggregateEntries = new Array(); const aggregateActions = new Array(); + const aggregateBranches = new Set(); + const aggregateActionLines = new Set(); let aggregateAt: string | null = null; const rememberUndo = (run: ReturnType | null) => { if (!run) return; aggregateAt ??= String(run.at); - aggregateEntries.push(...run.entries); - aggregateActions.push(...run.actions); + for (const entry of run.entries) { + const branch = String(entry.branch); + if (aggregateBranches.has(branch)) continue; + aggregateBranches.add(branch); + aggregateEntries.push(entry); + } + for (const action of run.actions) { + if (aggregateActionLines.has(action)) continue; + aggregateActionLines.add(action); + aggregateActions.push(action); + } }; for (const root of roots) { @@ -1521,7 +1682,15 @@ ${note}`; }), ); - return [current, clean, ...trunks, pulls, state, undo]; + return [ + current, + clean, + ...trunks, + `info readiness mode: ${cfg.readinessMode}`, + pulls, + state, + undo, + ]; }), ); @@ -1597,12 +1766,14 @@ ${note}`; readonly auto?: boolean; readonly admin?: boolean; readonly through?: string; + readonly readinessMode?: ReadinessMode; }, ) => Effect.gen(function* () { const apply = opts?.apply ?? false; const auto = opts?.auto ?? false; const admin = opts?.admin ?? false; + const readinessMode = effectiveReadinessMode(opts?.readinessMode); if (apply && auto) { return yield* Effect.fail( new StackOperationError("use either --apply or --auto, not both"), @@ -1647,6 +1818,13 @@ ${note}`; new StackOperationError(`no open ${requestLabel} found for ${target}`), ); } + if (readinessMode !== "unmanaged" && pr.draft) { + return yield* Effect.fail( + new StackOperationError( + `${reference(Number(pr.number))} (${target}) is draft. Reconcile readiness and wait for checks before merging:\n\n stack sync ${target} --apply --readiness-mode ${readinessMode}`, + ), + ); + } const root = link.parent; const stamp = yield* timestamp(); @@ -1759,7 +1937,14 @@ ${note}`; scopedState, refs.filter((item) => item.name !== target), repairPulls, - { apply: false }, + { apply: false, readinessMode }, + ); + const plannedReadiness = readinessPlan( + plannedRepair.state, + refs.filter((item) => item.name !== target), + repairPulls, + readinessMode, + "dry-run", ); if (active) { yield* ensureRepairableWorktrees([ @@ -1803,6 +1988,7 @@ ${note}`; saved: new Map([[target, name]]), journalState: nextState, journalActions: retargetActions, + readinessMode, writeState: writeScopedState(branches), }, ); @@ -1811,10 +1997,36 @@ ${note}`; yield* codeHost.changes(), ); const notes = yield* linksFor(repair.state, true, landed, repairedPulls); + const postPulls = yield* changesForLinks( + repair.state.links, + yield* codeHost.changes(), + ); + const postReadiness = readinessPlan( + repair.state, + nextRefs, + postPulls, + readinessMode, + "apply", + ); + if (postReadiness.length > 0) { + yield* applyReadiness(postReadiness, { + at: repair.undo?.at ?? (yield* timestamp()), + state: nextState, + entries: Array.from(repair.undo?.entries ?? []), + actions: Array.from(repair.actions), + }); + } if (current !== target) yield* git.switch(current); const tail = next ? `next root: ${next}` : "next root: none"; const view = yield* diagram(branches); - return [...actions, ...repair.lines, ...notes.lines, tail, ...view]; + return [ + ...actions, + ...repair.lines, + ...notes.lines, + ...StackResult.renderAll(postReadiness, reference, requestLabel), + tail, + ...view, + ]; }), ); @@ -1853,7 +2065,12 @@ ${note}`; } const tail = next ? `next root: ${next}` : "next root: none"; - return [...actions, ...plannedRepair.lines, tail]; + return [ + ...actions, + ...plannedRepair.lines, + ...StackResult.renderAll(plannedReadiness, reference, requestLabel), + tail, + ]; }), ); @@ -1871,7 +2088,12 @@ ${note}`; for (const target of chain) { if (items.length > 0) items.push(""); - items.push(...(yield* landOne(target, { auto: true }))); + items.push( + ...(yield* landOne(target, { + auto: true, + ...(opts.readinessMode ? { readinessMode: opts.readinessMode } : {}), + })), + ); } items.push(`merged through: ${stop}`); @@ -1896,6 +2118,20 @@ ${note}`; const restore = new Set( run.entries.flatMap((item) => (item.backup ? [String(item.branch)] : [])), ); + const restoreReadiness = Effect.fn("Stack.undo.restoreReadiness")(function* ( + readiness: ChangeReadiness, + ) { + for (const item of run.entries) { + if (item.readiness !== readiness || !item.pr || item.created) continue; + actions.push(`${mode}mark ${reference(Number(item.pr))} ${readiness}`); + if (!apply) continue; + const current = yield* codeHost.change(item.pr); + if (current.draft === (readiness === "draft")) continue; + yield* codeHost.setReadiness(item.pr, readiness); + } + }); + + yield* restoreReadiness("draft"); if (restore.has(current)) { actions.push(`${mode}switch to ${trunk}`); @@ -1931,6 +2167,8 @@ ${note}`; } } + yield* restoreReadiness("ready"); + actions.push(`${mode}restore stack metadata`); if (apply) { diff --git a/src/services/code-host/GitHub.ts b/src/services/code-host/GitHub.ts index 3209bc0..c8bcfdf 100644 --- a/src/services/code-host/GitHub.ts +++ b/src/services/code-host/GitHub.ts @@ -198,6 +198,12 @@ export const layer = Layer.effect( run(["pr", "edit", `${pr}`, "--body", body]).pipe(Effect.asVoid), ); + const setReadiness = Effect.fn("CodeHost.github.setReadiness")((pr, readiness) => + run(["pr", "ready", `${pr}`, ...(readiness === "draft" ? ["--undo"] : [])]).pipe( + Effect.asVoid, + ), + ); + const close = Effect.fn("CodeHost.github.close")((pr: number) => run(["pr", "close", `${pr}`]).pipe(Effect.asVoid), ); @@ -208,8 +214,9 @@ export const layer = Layer.effect( title: string, body: string, labels: ReadonlyArray, - headRepository?: string | null, + options?: CodeHost.CreateOptions, ) { + const headRepository = options?.headRepository; const head = headRepository ? `${headRepository.split("/")[0]}:${branch}` : branch; const created = yield* run([ "pr", @@ -223,6 +230,7 @@ export const layer = Layer.effect( "--body", body, ...labels.flatMap((label) => ["--label", label]), + ...(options?.readiness === "draft" ? ["--draft"] : []), ]); const number = Number(created.trim().match(/\/pull\/(\d+)\/?$/)?.[1]); @@ -236,7 +244,7 @@ export const layer = Layer.effect( headRepository: headRepository?.toLowerCase() ?? null, base, url: created.trim(), - draft: false, + draft: options?.readiness === "draft", }); }); @@ -249,6 +257,7 @@ export const layer = Layer.effect( change, edit, body, + setReadiness, close, create, }); diff --git a/src/services/code-host/GitLab.ts b/src/services/code-host/GitLab.ts index a32496b..b235036 100644 --- a/src/services/code-host/GitLab.ts +++ b/src/services/code-host/GitLab.ts @@ -236,6 +236,10 @@ export const layer = Layer.effect( ]).pipe(Effect.asVoid), ); + const setReadiness = Effect.fn("CodeHost.gitlab.setReadiness")((pr, readiness) => + run(["mr", "update", `${pr}`, `--${readiness}`, "--yes"]).pipe(Effect.asVoid), + ); + const close = Effect.fn("CodeHost.gitlab.close")((pr: number) => run(["mr", "close", `${pr}`]).pipe(Effect.asVoid), ); @@ -246,8 +250,9 @@ export const layer = Layer.effect( title: string, body: string, labels: ReadonlyArray, - headRepository?: string | null, + options?: CodeHost.CreateOptions, ) { + const headRepository = options?.headRepository; const created = yield* run([ "mr", "create", @@ -262,6 +267,7 @@ export const layer = Layer.effect( body, "--yes", ...labels.flatMap((label) => ["--label", label]), + ...(options?.readiness === "draft" ? ["--draft"] : []), ]); const number = Number(created.trim().match(/\/merge_requests\/(\d+)\/?$/)?.[1]); @@ -275,7 +281,7 @@ export const layer = Layer.effect( headRepository: headRepository ?? null, base, url: created.trim(), - draft: false, + draft: options?.readiness === "draft", }); }); @@ -288,6 +294,7 @@ export const layer = Layer.effect( change, edit, body, + setReadiness, close, create, }); diff --git a/src/services/code-host/Memory.ts b/src/services/code-host/Memory.ts index 2a97720..f025f22 100644 --- a/src/services/code-host/Memory.ts +++ b/src/services/code-host/Memory.ts @@ -144,17 +144,17 @@ export const layer = (opts: Options) => title: string, body: string, labels: ReadonlyArray, - headRepository?: string | null, + options?: CodeHost.CreateOptions, ) { const number = next++; const made = pullRef({ number, title, head: branch, - headRepository: headRepository ?? null, + headRepository: options?.headRepository ?? null, base, url: opts.url(number), - draft: false, + draft: options?.readiness === "draft", }); yield* record(`create ${branch} ${base}`); yield* Ref.update(pullsRef, (pulls) => [...pulls, made]); @@ -166,7 +166,7 @@ export const layer = (opts: Options) => title, body, head: branch, - headRepository: headRepository ?? null, + headRepository: options?.headRepository ?? null, base, url: made.url, draft: made.draft, @@ -177,6 +177,52 @@ export const layer = (opts: Options) => ); return made; }); + const setReadiness = Effect.fn("CodeHost.memory.setReadiness")(function* ( + pr: number, + readiness, + ) { + yield* requireOpen(pr); + const draft = readiness === "draft"; + yield* record(`${readiness} ${pr}`); + yield* Ref.update(pullsRef, (pulls) => + pulls.map((item) => + item.number === pr + ? pullRef({ + number: item.number, + title: item.title, + head: item.head, + headRepository: item.headRepository, + base: item.base, + url: item.url, + draft, + checks: item.checks, + }) + : item, + ), + ); + yield* Ref.update(metasRef, (metas) => { + const nextMetas = new Map(metas); + const current = nextMetas.get(pr); + if (current) { + nextMetas.set( + pr, + pullMeta({ + number: current.number, + title: current.title, + body: current.body, + head: current.head, + headRepository: current.headRepository, + base: current.base, + url: current.url, + draft, + state: current.state, + labels: current.labels, + }), + ); + } + return nextMetas; + }); + }); const close = Effect.fn("CodeHost.memory.close")((pr: number) => Effect.gen(function* () { yield* requireOpen(pr); @@ -213,6 +259,7 @@ export const layer = (opts: Options) => change, edit, body, + setReadiness, close, create, }); diff --git a/src/stackResult.ts b/src/stackResult.ts index 91de99f..256e44a 100644 --- a/src/stackResult.ts +++ b/src/stackResult.ts @@ -1,4 +1,4 @@ -import type { StackLink } from "./domain/model.ts"; +import type { ChangeReadiness, StackLink } from "./domain/model.ts"; export type Mode = "apply" | "dry-run"; @@ -56,6 +56,15 @@ export type StackResultItem = readonly branch: string; readonly base: string; readonly pr: number | null; + readonly readiness?: ChangeReadiness; + } + | { + readonly _tag: "SetReadiness"; + readonly mode: Mode; + readonly branch: string; + readonly pr: number; + readonly from: ChangeReadiness; + readonly to: ChangeReadiness; } | { readonly _tag: "UpdateStackLinks"; readonly mode: Mode; readonly pr: number }; @@ -98,9 +107,13 @@ export const render = ( case "RetargetPull": return `${prefix(item.mode)}retarget ${reference(item.pr)} to ${item.base}`; case "CreatePull": - return item.mode === "apply" && item.pr !== null - ? `create ${requestLabel.toLowerCase()} ${reference(item.pr)} for ${item.branch} -> ${item.base}` - : `would create ${requestLabel.toLowerCase()} for ${item.branch} -> ${item.base}`; + return `${ + item.mode === "apply" && item.pr !== null + ? `create ${requestLabel.toLowerCase()} ${reference(item.pr)}` + : `would create ${requestLabel.toLowerCase()}` + } for ${item.branch} -> ${item.base}${item.readiness ? ` as ${item.readiness}` : ""}`; + case "SetReadiness": + return `${prefix(item.mode)}mark ${reference(item.pr)} (${item.branch}) ${item.to}`; case "UpdateStackLinks": return `${item.mode === "apply" ? "update" : "would update"} ${requestLabel} body: ${reference(item.pr)} Stack block`; } diff --git a/tests/stack.test.ts b/tests/stack.test.ts index 2fc0e4f..578f49a 100644 --- a/tests/stack.test.ts +++ b/tests/stack.test.ts @@ -26,7 +26,10 @@ import * as StackBlock from "../src/stackBlock.ts"; import * as StackGraph from "../src/stackGraph.ts"; import { parseBlockLinkConfig, + parseReadinessMode, parseTrunksConfig, + type ReadinessMode, + readinessModes, StackConfig, trunks, } from "../src/services/Config.ts"; @@ -110,6 +113,7 @@ const gitAndCodeHost = (service: Partial) => change: (number) => Effect.fail(new CodeHostChangeNotFoundError(number)), edit: () => Effect.void, body: () => Effect.void, + setReadiness: () => Effect.void, close: () => Effect.void, create: (branch, base) => Effect.fail(unused("gh", ["pr", "create", branch, base])), }; @@ -131,13 +135,21 @@ const stackTestLayer = (opts: { readonly bases?: Readonly>; readonly current?: string; readonly state?: StackState; + readonly readinessMode?: ReadinessMode; readonly service?: Partial; readonly progress?: Array; }) => { const pulls = opts.pulls ?? []; + const config = opts.readinessMode + ? StackConfig.layer({ + root: "/tmp/stack", + trunks: ["dev"], + readinessMode: opts.readinessMode, + }).pipe(Layer.provide(NodeServices.layer)) + : cfg; return Stack.layer.pipe( Layer.provideMerge(opts.progress ? Progress.memory(opts.progress) : Progress.noop), - Layer.provideMerge(cfg), + Layer.provideMerge(config), Layer.provideMerge( gitAndCodeHost({ refs: () => Effect.succeed(opts.refs), @@ -253,6 +265,49 @@ const integrationGitHub = (opts: { return nextItems; }); }); + const setReadiness: CodeHost.Interface["setReadiness"] = (pr, readiness) => + Effect.gen(function* () { + const draft = readiness === "draft"; + yield* record(`${readiness} ${pr}`); + yield* Ref.update(pulls, (items) => + items.map((item) => + item.number === pr + ? pullRef({ + number: item.number, + title: item.title, + head: item.head, + headRepository: item.headRepository, + base: item.base, + url: item.url, + draft, + checks: item.checks, + }) + : item, + ), + ); + yield* Ref.update(metas, (items) => { + const nextItems = new Map(items); + const item = nextItems.get(pr); + if (item) { + nextItems.set( + pr, + pullMeta({ + number: item.number, + title: item.title, + body: item.body, + head: item.head, + headRepository: item.headRepository, + base: item.base, + url: item.url, + draft, + state: item.state, + labels: item.labels, + }), + ); + } + return nextItems; + }); + }); const create = ( branch: string, base: string, @@ -319,6 +374,7 @@ const integrationGitHub = (opts: { change: getPull, edit, body: updateBody, + setReadiness, close: (pr) => Ref.update(pulls, (items) => items.filter((item) => item.number !== pr)), create, }); @@ -836,6 +892,8 @@ const makeLand = ( codeHost: Partial = {}, includeUnrelatedRoot = false, forkStackC = false, + readinessMode: ReadinessMode = "unmanaged", + draftBranches: ReadonlySet = new Set(), ) => { const seen: Array = []; const refs = new Map([ @@ -853,21 +911,21 @@ const makeLand = ( head: "stack-a", base: "dev", url: "u4", - draft: false, + draft: draftBranches.has("stack-a"), }), pullRef({ number: 5, head: "stack-b", base: "stack-a", url: "u5", - draft: false, + draft: draftBranches.has("stack-b"), }), pullRef({ number: 3, head: "stack-c", base: forkStackC ? "stack-a" : "stack-b", url: "u3", - draft: false, + draft: draftBranches.has("stack-c"), }), ]; if (includeUnrelatedRoot) { @@ -930,7 +988,7 @@ const makeLand = ( Layer.provideMerge(progress ? Progress.memory(progress) : Progress.noop), Layer.provideMerge(NodeServices.layer), Layer.provideMerge( - StackConfig.layer({ root: "/tmp/stack", trunks: ["dev"] }).pipe( + StackConfig.layer({ root: "/tmp/stack", trunks: ["dev"], readinessMode }).pipe( Layer.provide(NodeServices.layer), ), ), @@ -953,20 +1011,22 @@ const makeLand = ( Effect.sync(() => void seen.push(`wait ${pr} ${merged ? "merged" : "open"}`)), refs: () => Effect.succeed(Array.from(refs.values())), changes: () => Effect.succeed(pulls), - change: (pr: number) => - Effect.succeed( + change: (pr: number) => { + const found = pulls.find((pull) => pull.number === pr); + return Effect.succeed( pullMeta({ number: pr, title: "fix+refactor(vcs): old title", body: "## Summary\n- old body\n\nStacked on #4.\n", - head: "stack-a", - base: "dev", - url: "u4", - draft: false, + head: found?.head ?? "stack-a", + base: found?.base ?? "dev", + url: found?.url ?? "u4", + draft: found?.draft ?? false, state: "OPEN", labels: [new PullLabel({ name: "beta" })], }), - ), + ); + }, current: () => Effect.succeed(currentBranch), switch: (branch: string) => Effect.sync(() => void seen.push(`switch ${branch}`)), head: (name: string) => @@ -1019,6 +1079,21 @@ const makeLand = ( }), body: (pr: number, body: string) => Effect.sync(() => void seen.push(`body ${pr} ${body.includes("### [Stack]")}`)), + setReadiness: (pr, readiness) => + Effect.sync(() => { + seen.push(`${readiness} ${pr}`); + pulls = pulls.map((pull) => + pull.number === pr + ? pullRef({ + number: pull.number, + head: pull.head, + base: pull.base, + url: pull.url, + draft: readiness === "draft", + }) + : pull, + ); + }), close: () => Effect.void, create: ( branch: string, @@ -1247,6 +1322,28 @@ describe("StackConfig", () => { expect(parseBlockLinkConfig(" ")).toBeUndefined(); expect(parseBlockLinkConfig("maybe")).toBeUndefined(); }); + + it("parses the complete readiness mode enum", () => { + expect(readinessModes).toEqual(["unmanaged", "all-ready", "root-ready"]); + expect(parseReadinessMode(" unmanaged ")).toBe("unmanaged"); + expect(parseReadinessMode("ALL-READY")).toBe("all-ready"); + expect(parseReadinessMode("root-ready")).toBe("root-ready"); + expect(parseReadinessMode(" ")).toBeUndefined(); + expect(parseReadinessMode("all-draft")).toBeNull(); + }); + + it.effect("defaults readiness management to unmanaged", () => + Effect.gen(function* () { + const config = yield* StackConfig; + expect(config.readinessMode).toBe("unmanaged"); + }).pipe( + Effect.provide( + StackConfig.layer({ root: "/tmp/stack", trunks: ["dev"] }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ), + ); }); describe("StackGraph", () => { @@ -1744,14 +1841,9 @@ describe("GitHub", () => { return Effect.gen(function* () { const github = yield* CodeHost.Service; - const created = yield* github.create( - "feature/x", - "main", - "restacked", - "body", - [], - "contributor/project", - ); + const created = yield* github.create("feature/x", "main", "restacked", "body", [], { + headRepository: "contributor/project", + }); expect(Number(created.number)).toBe(7); expect(String(created.url)).toBe("https://github.com/upstream/project/pull/7"); @@ -1760,6 +1852,38 @@ describe("GitHub", () => { Effect.provide(CodeHostGitHub.layer.pipe(Layer.provideMerge(cfg), Layer.provideMerge(proc))), ); }); + + it.effect("creates drafts and changes GitHub readiness through gh", () => { + const calls: Array> = []; + const proc = Layer.succeed( + Proc.Service, + Proc.Service.of({ + exec: (_cwd, tool, args) => + Effect.sync(() => { + calls.push([tool, ...args]); + return args[1] === "create" ? "https://github.com/owner/project/pull/7" : ""; + }), + }), + ); + + return Effect.gen(function* () { + const github = yield* CodeHost.Service; + const created = yield* github.create("feature/x", "main", "title", "body", [], { + readiness: "draft", + }); + yield* github.setReadiness(7, "ready"); + yield* github.setReadiness(7, "draft"); + + expect(created.draft).toBe(true); + expect(calls[0]).toContain("--draft"); + expect(calls.slice(1)).toEqual([ + ["gh", "pr", "ready", "7"], + ["gh", "pr", "ready", "7", "--undo"], + ]); + }).pipe( + Effect.provide(CodeHostGitHub.layer.pipe(Layer.provideMerge(cfg), Layer.provideMerge(proc))), + ); + }); }); describe("GitLab", () => { @@ -2138,14 +2262,9 @@ describe("GitLab", () => { return Effect.gen(function* () { const gitlab = yield* CodeHost.Service; - const created = yield* gitlab.create( - "feature/x", - "main", - "restacked", - "body", - [], - "contributor/project", - ); + const created = yield* gitlab.create("feature/x", "main", "restacked", "body", [], { + headRepository: "contributor/project", + }); expect(Number(created.number)).toBe(7); expect(String(created.url)).toBe("https://gitlab.com/upstream/project/-/merge_requests/7"); @@ -2155,6 +2274,40 @@ describe("GitLab", () => { ); }); + it.effect("creates drafts and changes GitLab readiness through glab", () => { + const calls: Array> = []; + const proc = Layer.succeed( + Proc.Service, + Proc.Service.of({ + exec: (_cwd, tool, args) => + Effect.sync(() => { + calls.push([tool, ...args]); + return args[1] === "create" + ? "https://gitlab.com/owner/project/-/merge_requests/7" + : ""; + }), + }), + ); + + return Effect.gen(function* () { + const gitlab = yield* CodeHost.Service; + const created = yield* gitlab.create("feature/x", "main", "title", "body", [], { + readiness: "draft", + }); + yield* gitlab.setReadiness(7, "ready"); + yield* gitlab.setReadiness(7, "draft"); + + expect(created.draft).toBe(true); + expect(calls[0]).toContain("--draft"); + expect(calls.slice(1)).toEqual([ + ["glab", "mr", "update", "7", "--ready", "--yes"], + ["glab", "mr", "update", "7", "--draft", "--yes"], + ]); + }).pipe( + Effect.provide(CodeHostGitLab.layer.pipe(Layer.provideMerge(cfg), Layer.provideMerge(proc))), + ); + }); + it.effect("continues waiting while a GitLab merge request is locked", () => { const calls: Array> = []; let views = 0; @@ -2354,6 +2507,235 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); + it.effect("sync reconciles configured readiness, supports overrides, and undoes changes", () => { + const readinessCalls: Array = []; + const events: Array = []; + let pulls = [ + pullRef({ number: 1, head: "root", base: "dev", url: "u1", draft: false }), + pullRef({ number: 2, head: "child", base: "root", url: "u2", draft: false }), + pullRef({ number: 3, head: "leaf", base: "child", url: "u3", draft: false }), + ]; + const setReadiness: CodeHost.Interface["setReadiness"] = (number, readiness) => + Effect.sync(() => { + readinessCalls.push(`${readiness} ${number}`); + events.push(`${readiness} ${number}`); + pulls = pulls.map((pull) => + pull.number === number + ? pullRef({ + number: pull.number, + title: pull.title, + head: pull.head, + headRepository: pull.headRepository, + base: pull.base, + url: pull.url, + draft: readiness === "draft", + checks: pull.checks, + }) + : pull, + ); + }); + const layer = stackTestLayer({ + current: "leaf", + readinessMode: "root-ready", + refs: [ref("dev", "dev"), ref("root", "root"), ref("child", "child"), ref("leaf", "leaf")], + bases: bases(["root", "dev", "dev"], ["child", "root", "root"], ["leaf", "child", "child"]), + state: stackState([ + stackLink({ branch: "root", parent: "dev", anchor: "dev", pr: 1 }), + stackLink({ branch: "child", parent: "root", anchor: "root", pr: 2 }), + stackLink({ branch: "leaf", parent: "child", anchor: "child", pr: 3 }), + ]), + service: { + changes: () => Effect.succeed(pulls), + change: (number) => Effect.succeed(metaFor(pulls.find((pull) => pull.number === number)!)), + body: (number) => Effect.sync(() => void events.push(`body ${number}`)), + setReadiness, + }, + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + const store = yield* Store; + + const preview = yield* stack.sync(); + expect(preview).toContain(" └─ ◌ child #2 would mark draft"); + expect(preview).toContain(" └─ ◌ leaf #3 would mark draft"); + expect(readinessCalls).toEqual([]); + expect(yield* stack.sync({ readinessMode: "all-ready" })).toContain( + " stack sync --apply --readiness-mode all-ready", + ); + + yield* stack.sync({ apply: true }); + expect(readinessCalls).toEqual(["draft 2", "draft 3"]); + expect(events.indexOf("draft 2")).toBeGreaterThan( + Math.max(...events.map((event, index) => (event.startsWith("body ") ? index : -1))), + ); + expect(pulls.map((pull) => pull.draft)).toEqual([false, true, true]); + expect((yield* store.readUndo())?.entries.map((entry) => entry.readiness)).toEqual([ + "ready", + "ready", + ]); + + yield* stack.undo(true); + expect(readinessCalls).toEqual(["draft 2", "draft 3", "ready 2", "ready 3"]); + expect(pulls.map((pull) => pull.draft)).toEqual([false, false, false]); + + yield* stack.sync({ apply: true }); + yield* stack.sync({ apply: true, readinessMode: "all-ready" }); + expect(readinessCalls.slice(-4)).toEqual(["draft 2", "draft 3", "ready 2", "ready 3"]); + expect(pulls.map((pull) => pull.draft)).toEqual([false, false, false]); + + const calls = readinessCalls.length; + yield* stack.sync({ apply: true, readinessMode: "all-ready" }); + expect(readinessCalls).toHaveLength(calls); + }).pipe(Effect.provide(layer)); + }); + + it.effect("unmanaged sync preserves existing readiness", () => { + const readinessCalls: Array = []; + let pulls = [ + pullRef({ number: 1, head: "root", base: "dev", url: "u1", draft: true }), + pullRef({ number: 2, head: "child", base: "root", url: "u2", draft: false }), + ]; + const layer = stackTestLayer({ + current: "child", + refs: [ref("dev", "dev"), ref("root", "root"), ref("child", "child")], + pulls, + bases: bases(["root", "dev", "dev"], ["child", "root", "root"]), + state: stackState([ + stackLink({ branch: "root", parent: "dev", anchor: "dev", pr: 1 }), + stackLink({ branch: "child", parent: "root", anchor: "root", pr: 2 }), + ]), + service: { + changes: () => Effect.succeed(pulls), + setReadiness: (number, readiness) => + Effect.sync(() => { + readinessCalls.push(`${readiness} ${number}`); + pulls = pulls.map((pull) => + pull.number === number + ? pullRef({ + number: pull.number, + head: pull.head, + base: pull.base, + url: pull.url, + draft: readiness === "draft", + }) + : pull, + ); + }), + }, + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true }); + expect(readinessCalls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("scoped readiness sync does not touch independent stacks", () => { + const readinessCalls: Array = []; + let pulls = [ + pr(1, "app-root", "dev"), + pr(2, "app-child", "app-root"), + pr(3, "other-root", "dev"), + pr(4, "other-child", "other-root"), + ]; + const layer = stackTestLayer({ + current: "dev", + readinessMode: "root-ready", + refs: [ + ref("dev", "dev"), + ref("app-root", "app-root"), + ref("app-child", "app-child"), + ref("other-root", "other-root"), + ref("other-child", "other-child"), + ], + pulls, + bases: bases( + ["app-root", "dev", "dev"], + ["app-child", "app-root", "app-root"], + ["other-root", "dev", "dev"], + ["other-child", "other-root", "other-root"], + ), + state: stackState([ + stackLink({ branch: "app-root", parent: "dev", anchor: "dev", pr: 1 }), + stackLink({ branch: "app-child", parent: "app-root", anchor: "app-root", pr: 2 }), + stackLink({ branch: "other-root", parent: "dev", anchor: "dev", pr: 3 }), + stackLink({ branch: "other-child", parent: "other-root", anchor: "other-root", pr: 4 }), + ]), + service: { + changes: () => Effect.succeed(pulls), + setReadiness: (number, readiness) => + Effect.sync(() => { + readinessCalls.push(`${readiness} ${number}`); + pulls = pulls.map((pull) => + pull.number === number + ? pullRef({ + number: pull.number, + head: pull.head, + base: pull.base, + url: pull.url, + draft: readiness === "draft", + }) + : pull, + ); + }), + }, + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ branch: "app-child", apply: true }); + expect(readinessCalls).toEqual(["draft 2"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("replacement creation uses the effective readiness mode", () => { + const creationReadiness = new Array(); + let pulls = [pr(1, "root", "dev"), pr(3, "leaf", "child")]; + const layer = stackTestLayer({ + current: "leaf", + readinessMode: "root-ready", + refs: [ref("dev", "dev"), ref("root", "root"), ref("child", "child"), ref("leaf", "leaf")], + bases: bases(["root", "dev", "dev"], ["child", "root", "root"], ["leaf", "child", "child"]), + state: stackState([ + stackLink({ branch: "root", parent: "dev", anchor: "dev", pr: 1 }), + stackLink({ branch: "child", parent: "root", anchor: "root", pr: 2 }), + stackLink({ branch: "leaf", parent: "child", anchor: "child", pr: 3 }), + ]), + service: { + changes: () => Effect.succeed(pulls), + change: (number) => { + const pull = pulls.find((item) => item.number === number); + return pull + ? Effect.succeed(metaFor(pull)) + : Effect.fail(new CodeHostChangeNotFoundError(number)); + }, + create: (branch, base, title, _body, _labels, options) => + Effect.sync(() => { + creationReadiness.push(options?.readiness); + const made = pullRef({ + number: 4, + title, + head: branch, + base, + url: "u4", + draft: options?.readiness === "draft", + }); + pulls = [...pulls, made]; + return made; + }), + }, + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true }); + expect(creationReadiness).toEqual(["draft"]); + expect(pulls.find((pull) => pull.head === "child")?.draft).toBe(true); + }).pipe(Effect.provide(layer)); + }); + it.effect("sync previews stale metadata reconciliation", () => { const layer = stackTestLayer({ current: "stack-b", @@ -2814,6 +3196,7 @@ describe("Stack", () => { expect(items).toContain("ok current branch: effectify-format"); expect(items).toContain("ok worktree clean"); expect(items).toContain("ok trunk branch: dev"); + expect(items).toContain("info readiness mode: unmanaged"); expect(items).toContain("ok open PRs visible: 5"); expect(items).toContain("ok stack metadata: 0 link(s)"); expect(items).toContain("ok undo journal: none"); @@ -3530,6 +3913,78 @@ describe("Stack", () => { }).pipe(Effect.provide(test.layer)); }); + it.effect("managed merge requires a draft root to be synced first", () => { + const calls: Array = []; + const root = pullRef({ number: 1, head: "root", base: "dev", url: "u1", draft: true }); + const layer = stackTestLayer({ + current: "root", + readinessMode: "root-ready", + refs: [ref("dev", "dev"), ref("root", "root")], + pulls: [root], + bases: bases(["root", "dev", "dev"]), + state: stackState([stackLink({ branch: "root", parent: "dev", anchor: "dev", pr: 1 })]), + service: { + merge: (number) => Effect.sync(() => void calls.push(`merge ${number}`)), + setReadiness: (number, readiness) => + Effect.sync(() => void calls.push(`${readiness} ${number}`)), + }, + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* Effect.flip(stack.land("root", { apply: true })); + expect(error.message).toContain("stack sync root --apply --readiness-mode root-ready"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("managed merge promotes the next root only after descendant repair", () => { + const test = makeLand( + [], + "stack-a", + null, + {}, + false, + false, + "root-ready", + new Set(["stack-b", "stack-c"]), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + const store = yield* Store; + const output = yield* stack.land("stack-a", { apply: true }); + + expect(output).toContain("mark #5 (stack-b) ready"); + expect(test.seen).toContain("ready 5"); + expect(test.seen).not.toContain("ready 3"); + expect(test.seen.indexOf("ready 5")).toBeGreaterThan( + Math.max(...test.seen.map((item, index) => (item.startsWith("body ") ? index : -1))), + ); + expect((yield* store.readUndo())?.entries.find((entry) => entry.pr === 5)?.readiness).toBe( + "draft", + ); + }).pipe(Effect.provide(test.layer)); + }); + + it.effect("root-ready merge reconciles and undoes readiness after repair", () => { + const test = makeLand([], "stack-a", null, {}, false, false, "root-ready"); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.land("stack-a", { apply: true }); + + const lastBody = Math.max( + ...test.seen.map((item, index) => (item.startsWith("body ") ? index : -1)), + ); + expect(test.seen).not.toContain("draft 5"); + expect(test.seen.indexOf("draft 3")).toBeGreaterThan(lastBody); + + yield* stack.undo(true); + expect(test.seen.indexOf("ready 3")).toBeGreaterThan(test.seen.indexOf("draft 3")); + }).pipe(Effect.provide(test.layer)); + }); + it.effect("land removes root-only stack metadata", () => { let changes = [pr(4, "stack-a", "dev")]; const layer = stackTestLayer({ @@ -3772,8 +4227,9 @@ describe("Stack", () => { }, push: (branch, remote = "origin") => Effect.sync(() => void seen.push(`push ${branch} ${remote}`)), - create: (branch, base, _title, _body, _labels, headRepository) => + create: (branch, base, _title, _body, _labels, options) => Effect.sync(() => { + const headRepository = options?.headRepository; seen.push(`create ${headRepository}`); const made = pullRef({ number: 5, @@ -3897,6 +4353,31 @@ describe("Stack", () => { }).pipe(Effect.provide(test.layer)); }); + it.effect("land auto propagates a readiness override through every merge", () => { + const test = makeLand( + [], + "stack-a", + null, + {}, + false, + false, + "unmanaged", + new Set(["stack-b", "stack-c"]), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.land(undefined, { + auto: true, + through: "5", + readinessMode: "root-ready", + }); + + expect(test.seen).toContain("ready 5"); + expect(test.seen).toContain("ready 3"); + }).pipe(Effect.provide(test.layer)); + }); + it.effect("land auto through stays on the selected stack when another root exists", () => { const test = makeLand([], "stack-a", null, {}, true); @@ -5066,11 +5547,19 @@ describe("CodeHost", () => { const log: Array = []; return Effect.gen(function* () { const host = yield* CodeHost.Service; - const created = yield* host.create("feature/x", "main", "title", "body", ["bug"]); + const created = yield* host.create("feature/x", "main", "title", "body", ["bug"], { + readiness: "draft", + }); expect(String(created.head)).toBe("feature/x"); expect(String(created.base)).toBe("main"); + expect(created.draft).toBe(true); expect(yield* host.changes()).toHaveLength(1); + yield* host.setReadiness(created.number, "ready"); + expect((yield* host.change(created.number)).draft).toBe(false); + yield* host.setReadiness(created.number, "draft"); + expect((yield* host.changes())[0]?.draft).toBe(true); + yield* host.edit(created.number, "dev"); yield* host.body(created.number, "updated body"); const meta = yield* host.change(created.number); @@ -5089,6 +5578,8 @@ describe("CodeHost", () => { expect(yield* host.changes()).toHaveLength(0); expect(log).toEqual([ "create feature/x main", + `ready ${created.number}`, + `draft ${created.number}`, `edit ${created.number} dev`, `body ${created.number}`, `auto ${created.number}`, From 9bd6e16b2a2ef9764b26866d1081fd21b9be8851 Mon Sep 17 00:00:00 2001 From: Gabe Lease Date: Mon, 13 Jul 2026 21:29:22 -0700 Subject: [PATCH 2/2] chore(release): mark readiness modes as minor --- .changeset/calm-stacks-ready.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calm-stacks-ready.md b/.changeset/calm-stacks-ready.md index 59f5f55..f76ecd3 100644 --- a/.changeset/calm-stacks-ready.md +++ b/.changeset/calm-stacks-ready.md @@ -1,5 +1,5 @@ --- -"@kitlangton/stack": patch +"@kitlangton/stack": minor --- Add configurable, provider-neutral readiness policies, reconciliation, and readiness-aware undo for stack sync and merge workflows.