diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-20-managed-workspace-lifecycle.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-20-managed-workspace-lifecycle.md new file mode 100644 index 000000000..e4cc38505 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-20-managed-workspace-lifecycle.md @@ -0,0 +1,12 @@ +### Start and finish features on a clean workspace automatically + +Boatstack can now manage the working area for each feature so you never build on a +stale branch or leave old worktrees behind. When enabled, it cuts a fresh branch +(or worktree) from the up-to-date default branch as a feature begins, and after +the feature's pull request has merged it offers to reclaim that worktree and +branch — you reply `c` to clean up or `k` to keep. Cleanup only removes local +work that has already landed: it never touches a remote branch, never merges +anything, and never discards uncommitted or unmerged changes without an explicit +override. The behavior is configured under `workspace` in your project file +(`enabled`, `mode`, `cleanup`, `cleanup_after`) and is off for any project that +does not opt in. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go index a98b2f5c3..7c370a3b5 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go @@ -597,9 +597,75 @@ func publishPRCommand(arguments []string) int { return 0 } +func workspaceCutCommand(arguments []string) int { + flags := flag.NewFlagSet("workspace-cut", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository to cut the feature workspace in") + feature := flags.String("feature", "", "feature slug used to derive the branch name") + branch := flags.String("branch", "", "explicit branch name; overrides --feature derivation") + if err := flags.Parse(arguments); err != nil { + return 2 + } + result, err := boatstack.CutFeatureWorkspace(boatstack.WorkspaceCutOptions{Repo: *repo, Feature: *feature, Branch: *branch}) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(result) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + if result.VerificationStatus != "VERIFIED" { + return 1 + } + return 0 +} + +func workspaceCleanupCommand(arguments []string) int { + flags := flag.NewFlagSet("workspace-cleanup", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose finished workspace should be removed") + branch := flags.String("branch", "", "branch whose workspace should be cleaned up") + confirm := flags.Bool("confirm", false, "human confirmation to remove the workspace") + force := flags.Bool("force", false, "override the merge gate and discard uncommitted or unmerged work") + if err := flags.Parse(arguments); err != nil { + return 2 + } + result, err := boatstack.CleanupFeatureWorkspace(boatstack.WorkspaceCleanupOptions{Repo: *repo, Branch: *branch, Confirm: *confirm, Force: *force}) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(result) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + if result.VerificationStatus == "BLOCKED" { + return 1 + } + return 0 +} + +func workspaceStatusCommand(arguments []string) int { + flags := flag.NewFlagSet("workspace-status", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository to inspect") + branch := flags.String("branch", "", "branch whose workspace should be reported") + if err := flags.Parse(arguments); err != nil { + return 2 + } + result, err := boatstack.FeatureWorkspaceStatus(*repo, *branch) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(result) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + return 0 +} + func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -651,6 +717,12 @@ func run() int { return bootstrapSafetyHookCommand(os.Args[2:]) case "check-safety": return checkSafetyCommand(os.Args[2:]) + case "workspace-cut": + return workspaceCutCommand(os.Args[2:]) + case "workspace-cleanup": + return workspaceCleanupCommand(os.Args[2:]) + case "workspace-status": + return workspaceStatusCommand(os.Args[2:]) case "version": fmt.Printf("Boatstack %s (%s)\n", boatstack.Version, boatstack.SourceCommit) return 0 diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export.go b/labs/12-product-engineering-loop/product-engineering-loop/export.go index a37179b83..3fde1cb57 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -110,6 +110,25 @@ func ValidateConfig(config ProjectConfig) error { return fmt.Errorf("unsupported adapter: %s", adapter) } } + if err := validateWorkspaceConfig(config.Workspace); err != nil { + return err + } + return nil +} + +// validateWorkspaceConfig rejects only explicit invalid enum values. Empty +// values are legal and resolve to defaults at use, so configs written before the +// workspace block existed remain valid. +func validateWorkspaceConfig(workspace Workspace) error { + if mode := workspace.Mode; mode != "" && mode != "worktree" && mode != "branch" { + return fmt.Errorf("workspace.mode must be \"worktree\" or \"branch\"") + } + if cleanup := workspace.Cleanup; cleanup != "" && cleanup != "confirm" && cleanup != "auto" && cleanup != "off" { + return fmt.Errorf("workspace.cleanup must be \"confirm\", \"auto\", or \"off\"") + } + if after := workspace.CleanupAfter; after != "" && after != "merge" && after != "ship" { + return fmt.Errorf("workspace.cleanup_after must be \"merge\" or \"ship\"") + } return nil } @@ -235,19 +254,21 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } operations := map[string]string{ - "boatstack-next": "Run the project-local helper next-status --repo . --json. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub, or advance a gate. Translate the structured result into the canonical response contract. Show the verified feature and active slice when present. Distinguish NOT_STARTED and SOURCE_PLAN_READY, whose next operation is auto-plan, from FEATURE_COMPLETE, which responds Feature complete and requires no action. If verification_status is BLOCKED, name the ambiguity or invalid evidence and make its safe restoration the one action; never clear artifacts. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result. Otherwise make the returned next_operation the one next action.", - "boatstack-run": "First run the read-only next-status --repo . --json. If SOURCE_PLAN_READY, execute auto-plan without Git preflight and pause at its normal decision or approval boundary. If NOT_STARTED, respond Start a Boatstack feature and ask the user to save exactly one host Plan-mode file, then run /auto-plan; do not fetch or require a feature branch. If FEATURE_COMPLETE, respond Feature complete with No action required without requiring a remote or fetching. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage, up to three complete automated repair-and-gate cycles for the active slice in this invocation. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence and do not create durable autopilot state. Report the feature, active slice, stages completed during this invocation, completion or pause reason, repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", - "auto-plan": "Discover exactly one saved Plan-mode file and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions.", - "plan-gate": "Run check-plan read-only, present its fingerprint and all open decisions, and require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the resolved human, RFC3339 timestamp, and exact displayed fingerprint so it writes only approval.md. While pending, respond Ready for your approval and render the one next action as: Reply `a` to approve. After recording, respond Approved — ready to build and make entering the host execution mode and running /build the one next action. Remain in Plan mode; do not compile or request an early mode switch.", - "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the approved boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", - "repair": "First run next-status --repo . --json. Repair requires an active managed delivery and the user's exact free-form requested change. If NOT_STARTED or SOURCE_PLAN_READY, respond No active delivery to repair and make /auto-plan the one next action; do not ask for repair details. If DRAFT_PLAN or APPROVED, route to the returned plan-gate or build operation because no managed delivery exists yet. If FEATURE_COMPLETE and the user supplied an exact correction, preserve the published evidence and plan a linked Boatstack feature with parent_delivery set to the completed feature; otherwise ask for the exact correction. Stop on BLOCKED or INVALID_STATE and preserve all artifacts. For an active delivery, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts. Compare the exact request with approved intent. Classify it as implementation_repair, verification_repair, review_repair, requirement_amendment, or needs_clarification, then invoke record-change before any product edit. Same-intent repairs may proceed at the returned RESUME_STAGE; requirement amendments and ambiguous intent must stop for a concise plan amendment or one clarifying question. Never edit changes.md or managed delivery state directly. After a repair, reuse the existing /test-gate and /review-gate; do not invent repair-specific gates. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", - "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", - "review-gate": "Read delivery-status and review the active slice's actual diff against approved intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact rather than commits, PR metadata, artifacts, or test commands. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject a changed or untested diff and a missing or malformed required changelog entry. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", - "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance; add UI evidence, security/privacy, migration, or operations sections only when the diff makes them relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready and render the one next action as: Reply `o` to open PR. If update, render: Reply `u` to update PR. If manual, preserve the preview and give one manual publication action. Continue accepting the full replies open PR and update PR for compatibility without advertising them. Only after the matching state-scoped shortcut or compatible full reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization. If publish-pr returns UPDATE_AVAILABLE, keep Review the PR as the only next action and append a collapsed update notice saying no files changed and /boatstack-update may be run from the clean default branch after this feature PR merges. Do not check for releases before successful publication.", - "boatstack-update": "Prepare a visible Boatstack infrastructure update; never mix it into product work or merge it. First run the current helper doctor and force check-update. If current, respond Boatstack is current with No action required. Before mutation fetch the default ref, then require the current clean default branch whose HEAD equals origin/; otherwise respond Update postponed and make finishing the current feature, switching to the clean default branch, and rerunning /boatstack-update the one action. Ensure no update PR or branch already exists, create chore/update-boatstack-v, then run the installer fetched from that exact release tag with BOATSTACK_MODE=update, BOATSTACK_VERSION=, BOATSTACK_REPO=, and BOATSTACK_YES=1. Use install.sh on macOS/Linux and install.ps1 on Windows. The verified update must preserve configuration, adapters, integrations, and user-owned host settings, run doctor, and touch only Boatstack infrastructure. Show the version transition, release notes and link, integration state, exact diff, changed paths, checksums, rollout, and rollback. Respond Boatstack update ready and render the one next action as: Reply `o` to open update PR. Continue accepting the full reply open update PR for compatibility without advertising it. Only the matching state-scoped shortcut or compatible full reply authorizes staging the installer-reported paths, committing chore: update Boatstack to , normal push, and opening a reviewer-ready update PR. If GitHub auth is unavailable, preserve the branch and give one manual publication action. After publication respond Update PR opened with the link and make Review the PR the one next action. On one collision or health failure, respond Update needs attention and make addressing that named problem the one next action. Never merge automatically.", - "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.", - "ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require the state-scoped reply o to open or u to update the PR before publication, recheck the preview against current evidence, and never merge or deploy. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready before confirmation or PR opened after publication.", - "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate. Respond Improvement proposed and make reviewing or authorizing the experiment the one next action.", + "boatstack-next": "Run the project-local helper next-status --repo . --json. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub, or advance a gate. Translate the structured result into the canonical response contract. Show the verified feature and active slice when present. Distinguish NOT_STARTED and SOURCE_PLAN_READY, whose next operation is auto-plan, from FEATURE_COMPLETE, which responds Feature complete and requires no action. If verification_status is BLOCKED, name the ambiguity or invalid evidence and make its safe restoration the one action; never clear artifacts. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result. Otherwise make the returned next_operation the one next action.", + "boatstack-run": "First run the read-only next-status --repo . --json. If SOURCE_PLAN_READY, execute auto-plan without Git preflight and pause at its normal decision or approval boundary. If NOT_STARTED, respond Start a Boatstack feature and ask the user to save exactly one host Plan-mode file, then run /auto-plan; do not fetch or require a feature branch. If FEATURE_COMPLETE, respond Feature complete with No action required without requiring a remote or fetching. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage, up to three complete automated repair-and-gate cycles for the active slice in this invocation. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence and do not create durable autopilot state. Report the feature, active slice, stages completed during this invocation, completion or pause reason, repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", + "auto-plan": "Discover exactly one saved Plan-mode file and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions.", + "plan-gate": "Run check-plan read-only, present its fingerprint and all open decisions, and require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the resolved human, RFC3339 timestamp, and exact displayed fingerprint so it writes only approval.md. While pending, respond Ready for your approval and render the one next action as: Reply `a` to approve. After recording, respond Approved — ready to build and make entering the host execution mode and running /build the one next action. Remain in Plan mode; do not compile or request an early mode switch.", + "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the approved boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", + "repair": "First run next-status --repo . --json. Repair requires an active managed delivery and the user's exact free-form requested change. If NOT_STARTED or SOURCE_PLAN_READY, respond No active delivery to repair and make /auto-plan the one next action; do not ask for repair details. If DRAFT_PLAN or APPROVED, route to the returned plan-gate or build operation because no managed delivery exists yet. If FEATURE_COMPLETE and the user supplied an exact correction, preserve the published evidence and plan a linked Boatstack feature with parent_delivery set to the completed feature; otherwise ask for the exact correction. Stop on BLOCKED or INVALID_STATE and preserve all artifacts. For an active delivery, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts. Compare the exact request with approved intent. Classify it as implementation_repair, verification_repair, review_repair, requirement_amendment, or needs_clarification, then invoke record-change before any product edit. Same-intent repairs may proceed at the returned RESUME_STAGE; requirement amendments and ambiguous intent must stop for a concise plan amendment or one clarifying question. Never edit changes.md or managed delivery state directly. After a repair, reuse the existing /test-gate and /review-gate; do not invent repair-specific gates. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", + "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", + "review-gate": "Read delivery-status and review the active slice's actual diff against approved intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact rather than commits, PR metadata, artifacts, or test commands. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject a changed or untested diff and a missing or malformed required changelog entry. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", + "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance; add UI evidence, security/privacy, migration, or operations sections only when the diff makes them relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready and render the one next action as: Reply `o` to open PR. If update, render: Reply `u` to update PR. If manual, preserve the preview and give one manual publication action. Continue accepting the full replies open PR and update PR for compatibility without advertising them. Only after the matching state-scoped shortcut or compatible full reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization. If publish-pr returns UPDATE_AVAILABLE, keep Review the PR as the only next action and append a collapsed update notice saying no files changed and /boatstack-update may be run from the clean default branch after this feature PR merges. Do not check for releases before successful publication.", + "boatstack-update": "Prepare a visible Boatstack infrastructure update; never mix it into product work or merge it. First run the current helper doctor and force check-update. If current, respond Boatstack is current with No action required. Before mutation fetch the default ref, then require the current clean default branch whose HEAD equals origin/; otherwise respond Update postponed and make finishing the current feature, switching to the clean default branch, and rerunning /boatstack-update the one action. Ensure no update PR or branch already exists, create chore/update-boatstack-v, then run the installer fetched from that exact release tag with BOATSTACK_MODE=update, BOATSTACK_VERSION=, BOATSTACK_REPO=, and BOATSTACK_YES=1. Use install.sh on macOS/Linux and install.ps1 on Windows. The verified update must preserve configuration, adapters, integrations, and user-owned host settings, run doctor, and touch only Boatstack infrastructure. Show the version transition, release notes and link, integration state, exact diff, changed paths, checksums, rollout, and rollback. Respond Boatstack update ready and render the one next action as: Reply `o` to open update PR. Continue accepting the full reply open update PR for compatibility without advertising it. Only the matching state-scoped shortcut or compatible full reply authorizes staging the installer-reported paths, committing chore: update Boatstack to , normal push, and opening a reviewer-ready update PR. If GitHub auth is unavailable, preserve the branch and give one manual publication action. After publication respond Update PR opened with the link and make Review the PR the one next action. On one collision or health failure, respond Update needs attention and make addressing that named problem the one next action. Never merge automatically.", + "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.", + "ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require the state-scoped reply o to open or u to update the PR before publication, recheck the preview against current evidence, and never merge or deploy. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready before confirmation or PR opened after publication.", + "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate. Respond Improvement proposed and make reviewing or authorizing the experiment the one next action.", + "workspace-cut": "Cut a fresh managed workspace for an approved feature before building, so work never starts on a stale branch. Surfaced by boatstack-next at the approved-to-build transition when workspace.enabled and the working tree is still on the default branch; the user does not invoke it directly. Run the project-local helper workspace-cut --repo . --feature . It fetches origin, creates a new branch from the up-to-date default branch, and in worktree mode adds a linked worktree; it never rewrites history, reuses an existing branch, or names the workspace after the base branch. Report the created branch and, in worktree mode, its path, then continue to build on the new workspace.", + "workspace-cleanup": "Reclaim a published feature's managed workspace once its work has landed. This operation is surfaced by boatstack-next after publication; the user does not invoke it directly. Run the project-local helper workspace-status --repo . --branch to report whether the pull request is merged, using the GitHub CLI with a local-ancestry fallback. When workspace.cleanup_after is merge, offer removal only once the PR is confirmed merged; if it is still open, report that and offer to keep waiting or, only on an explicit human override request, proceed. Never remove a workspace with uncommitted or unmerged work without an explicit forced override, and never delete a remote branch or merge anything; cleanup reclaims only the local worktree and branch. In confirm mode respond Workspace ready to clean up and render the one next action as: Reply `c` to clean up, or `k` to keep. Only after the exact reply c run workspace-cleanup --repo . --branch with --confirm (add --force only for an explicit override); on k respond Workspace kept with no action required. In auto mode reclaim a merged workspace without a prompt; in off mode do not offer cleanup. After removal, report whether the worktree and branch were reclaimed.", } if contains(adapters, "cursor") { @@ -285,7 +306,7 @@ description: Use when the user asks what is next in Boatstack, asks Boatstack to # Boatstack adapter - Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, and retro. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, check for an active managed delivery. If one exists and ordinary user language reports a problem or asks for a modification, automatically use repair even when the user did not name the operation. + Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, check for an active managed delivery. If one exists and ordinary user language reports a problem or asks for a modification, automatically use repair even when the user did not name the operation. Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details. Internal helper names must not appear in the primary response. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/init.go b/labs/12-product-engineering-loop/product-engineering-loop/init.go index c4bb8a3b5..e29874427 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/init.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/init.go @@ -161,8 +161,9 @@ func defaultConfig(repo, testCommand string) ProjectConfig { Name: filepath.Base(repo), DefaultBranch: branch, Context: detectContext(repo), Commands: map[string]string{"test": testCommand}, }, - Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true}, - Adapters: []string{"cursor", "claude", "codex", "github"}, + Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true}, + Workspace: Workspace{Enabled: true, Mode: "worktree", Cleanup: "confirm", CleanupAfter: "merge"}, + Adapters: []string{"cursor", "claude", "codex", "github"}, Integrations: map[string]IntegrationState{ "gstack": {Requested: false, Version: GStackRef}, "spec-kit": {Requested: false, Version: SpecKitVersion}, diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next.go b/labs/12-product-engineering-loop/product-engineering-loop/next.go index 67f3f1a5f..baff20a34 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go @@ -273,6 +273,12 @@ func ResolveNext(repoPath string) (NextStatus, error) { base.ObservedStage = "APPROVED" base.NextOperation = "build" base.Reason = "The saved feature has an approval receipt but no active delivery state." + // Cut a fresh workspace before building so work never starts on a + // stale base branch. Local-only check; the cut itself fetches origin. + if workspaceEnabled(repo) && needsFreshCut(repo, feature) { + base.NextOperation = "workspace-cut" + base.Reason = fmt.Sprintf("Feature %q is approved; cut a fresh workspace from the default branch before building.", feature) + } } else { base.ObservedStage = "DRAFT_PLAN" base.NextOperation = "plan-gate" @@ -291,10 +297,23 @@ func ResolveNext(repoPath string) (NextStatus, error) { base.NextOperation = "none" if len(completed) == 1 { base.Feature = completed[0].Feature - if len(completed[0].Slices) > 0 { - base.ActiveSlice = completed[0].Slices[len(completed[0].Slices)-1].ID + head := "" + if slices := completed[0].Slices; len(slices) > 0 { + last := slices[len(slices)-1] + base.ActiveSlice = last.ID + head = last.HeadBranch } base.Reason = fmt.Sprintf("All managed slices for feature %q are already published.", completed[0].Feature) + // When workspace management is on and the shipped feature still has a + // linked worktree locally, surface cleanup as the next step. This is a + // local-only check; merge confirmation and gating happen in the + // workspace-cleanup operation, never here. + if head != "" && workspaceEnabled(repo) { + if path := worktreePathForBranch(repo, head); path != "" { + base.NextOperation = "workspace-cleanup" + base.Reason = fmt.Sprintf("Feature %q is published; its workspace on %q can be cleaned up.", completed[0].Feature, head) + } + } } else { base.Reason = "All managed delivery states are already published." } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md index 7300040fe..8bda630f6 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md @@ -19,6 +19,7 @@ INTENT -> REVIEW_GATE -> SHIP_GATE -> PR_OPEN + -> WORKSPACE_CLEANUP (when workspace management is on and the feature's PR has merged) -> RETRO ``` @@ -101,6 +102,8 @@ Lead with a plain outcome, never a machine code such as `PASS`, `PLAN_APPROVED`, | `ship-gate` preview / published | **PR ready** -> reply `o` to open or `u` to update the previewed PR; **PR opened** -> review the PR; never imply merge authorization | | `boatstack-update` current / postponed / prepared / published / blocked | **Boatstack is current** -> no action required; **Update postponed** -> finish feature work and rerun from the clean default branch; **Boatstack update ready** -> reply `o` to open the update PR; **Update PR opened** -> review the PR; **Update needs attention** -> address the one reported collision or health failure | | `retro` | **Improvement proposed** -> review or authorize the experiment | +| `workspace-cut` (surfaced by `boatstack-next` at approved -> build) | **Fresh workspace cut** -> build on the new branch/worktree; **Workspace already fresh** -> continue to build | +| `workspace-cleanup` (surfaced by `boatstack-next` after publication) | **Workspace ready to clean up** -> reply `c` to remove the worktree and branch, or `k` to keep; **Workspace kept** -> no action required; **Workspace still open** -> the PR is not merged yet, keep waiting or override explicitly | ### Foreground run coordinator @@ -118,9 +121,13 @@ Finite input uses one global, state-scoped reply grammar: | `o` | New feature, ad-hoc, or Boatstack-update PR preview | Open the exact previewed PR | `open PR` or `open update PR` | | `u` | Existing PR preview | Update the exact previewed PR | `update PR` | | `r` | One or more finite questions with exactly one marked recommendation each | Accept every recommendation displayed in that response | Explicitly name the recommended choices | +| `c` | Published feature whose merged workspace can be reclaimed | Clean up the feature's worktree and branch | `clean up` | +| `k` | Published feature whose workspace can be reclaimed | Keep the workspace for now | `keep` | Trim surrounding whitespace and match shortcuts case-insensitively against the complete reply. Bracketed forms such as `[o]`, embedded letters, and shortcuts from another state are ordinary text. Continue accepting the full replies for compatibility, but do not advertise them in user-facing responses. +Before `c` removes a workspace, confirm the merge and safety gates in the `WORKSPACE_CLEANUP` contract. `c` never discards uncommitted or unmerged work and never deletes remote branches or merges anything; it only reclaims the local worktree and branch of an already-published feature. + Shortcuts never bypass gate prerequisites. Before `o` or `u` mutates GitHub, recheck the preview fingerprint, committed diff, evidence, authentication, and any required manual commit or push. Never interpret `r` as plan approval, PR publication, identity, secret input, permission escalation, policy bypass, destructive recovery authorization, or another exceptional safety decision. Free-text and operation-command prompts remain explicit. For each finite product question, show 2-3 mutually exclusive choices with compact inline-code keys and exactly one label suffixed `(Recommended)`. With one question, use `1a`, `1b`, and `1c`; with multiple questions, continue with `2a`, `2b`, and so on. End with one reply hint using the keys and `r`. A standalone `r` is valid only when every displayed question has exactly one recommendation; echo the question-to-answer mapping before recording each answer as `ANSWERED` with explicit human provenance. Otherwise ask again without choosing. @@ -379,6 +386,14 @@ There is no public `/pr-brief` operation. When the user asks in natural language Adaptive sections for security/privacy, migrations, UI evidence, or operations appear only when relevant. Model attribution belongs inside collapsed provenance. If GitHub CLI authentication is unavailable, keep the validated preview and provide one manual publication action instead of losing the work. +### `PLAN_APPROVED -> WORKSPACE_CUT` + +When `workspace.enabled` is set and an approved feature is still on the default branch with no branch or worktree of its own, `boatstack-next` routes to `workspace-cut` before `build`. The `workspace-cut` operation fetches `origin`, cuts a fresh branch from the up-to-date default branch, and in `worktree` mode adds a linked worktree; in `branch` mode it switches in place. It never rewrites history, reuses an existing branch, or names the workspace after the base branch. Once the feature already has its own branch or worktree, this step is skipped and the flow proceeds straight to `build`, so a workspace you cut yourself is respected. + +### `PR_OPEN -> WORKSPACE_CLEANUP` + +When `workspace.enabled` is set, `boatstack-next` surfaces `workspace-cleanup` for a published feature whose managed worktree still exists locally. The `workspace-cleanup` operation checks the pull request's merge state (GitHub CLI, falling back to local ancestry) and reports it. When `workspace.cleanup_after` is `merge`, cleanup is offered only once the PR is confirmed merged; while it is still open, the workspace is kept and the human may keep waiting or override explicitly. Cleanup never removes a workspace with uncommitted or unmerged work without an explicit forced override, and it reclaims only the local worktree and branch — it never deletes a remote branch or merges anything. In `confirm` mode the human reclaims the workspace with the exact reply `c` (or keeps it with `k`); `auto` mode reclaims a merged workspace without a prompt; `off` disables cleanup. A fresh feature workspace is likewise cut from the up-to-date default branch when a new feature begins, so work never starts on a stale branch. + ### `PR_OPEN -> RETRO` Record unexpected friction and outcomes. A retro may propose a loop move, but it may not mutate durable instructions automatically. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/runtime.go b/labs/12-product-engineering-loop/product-engineering-loop/runtime.go index 6b87a02d0..2716ae07f 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/runtime.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/runtime.go @@ -33,6 +33,7 @@ type ProjectConfig struct { SchemaVersion int `json:"schema_version"` Project Project `json:"project"` Workflow Workflow `json:"workflow"` + Workspace Workspace `json:"workspace,omitempty"` Adapters []string `json:"adapters"` Integrations map[string]IntegrationState `json:"integrations,omitempty"` } @@ -59,6 +60,18 @@ type IntegrationState struct { Detail string `json:"detail,omitempty"` } +// Workspace declares how Boatstack manages the per-feature working area: a fresh +// cut from the up-to-date default branch when a feature starts, and cleanup once +// the feature ships. The managed unit is a git worktree or an in-place branch; +// the empty zero value (Enabled=false) preserves Boatstack's prior behavior of +// never creating or removing worktrees or branches. +type Workspace struct { + Enabled bool `json:"enabled,omitempty"` // master switch; false = Boatstack touches no worktrees/branches + Mode string `json:"mode,omitempty"` // "worktree" | "branch" (default "worktree") + Cleanup string `json:"cleanup,omitempty"` // "confirm" | "auto" | "off" (default "confirm") + CleanupAfter string `json:"cleanup_after,omitempty"` // "merge" | "ship" (default "merge") +} + func ReadCanonical(path string) ([]byte, error) { return canonical.ReadFile(path) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/workspace.go b/labs/12-product-engineering-loop/product-engineering-loop/workspace.go new file mode 100644 index 000000000..e2c7d2719 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/workspace.go @@ -0,0 +1,419 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const workspaceSchemaVersion = 1 + +// workspaceGit and workspaceGh are indirected so tests can substitute +// deterministic git and GitHub CLI behavior. They default to the same helpers +// the rest of the package uses. +var ( + workspaceGit = gitCommand + workspaceGh = func(repo string, arguments ...string) (string, error) { + return commandOutput(repo, "gh", arguments...) + } +) + +// ResolvedWorkspace is the workspace policy with empty fields filled from the +// documented defaults. Enabled is never defaulted: a config without a workspace +// block, or with enabled=false, keeps Boatstack's prior hands-off behavior. +type ResolvedWorkspace struct { + Enabled bool + Mode string + Cleanup string + CleanupAfter string +} + +func resolveWorkspace(workspace Workspace) ResolvedWorkspace { + resolved := ResolvedWorkspace{ + Enabled: workspace.Enabled, + Mode: workspace.Mode, + Cleanup: workspace.Cleanup, + CleanupAfter: workspace.CleanupAfter, + } + if resolved.Mode == "" { + resolved.Mode = "worktree" + } + if resolved.Cleanup == "" { + resolved.Cleanup = "confirm" + } + if resolved.CleanupAfter == "" { + resolved.CleanupAfter = "merge" + } + return resolved +} + +// workspaceEnabled reports whether workspace management is on, swallowing config +// errors as "off" so read-only callers never fail on a malformed project file. +func workspaceEnabled(repo string) bool { + policy, err := loadWorkspacePolicy(repo) + if err != nil { + return false + } + return policy.Enabled +} + +// needsFreshCut reports whether an approved feature still has to be moved off the +// base branch onto its own fresh workspace. It is a local-only check: true only +// when the feature has no existing branch or worktree and the working tree is +// still on the default branch. +func needsFreshCut(repo, feature string) bool { + branch := branchForFeature(feature) + if branch == "" { + return false + } + if branchExists(repo, branch) || worktreePathForBranch(repo, branch) != "" { + return false + } + current, _ := workspaceGit(repo, "branch", "--show-current") + return strings.TrimSpace(current) == defaultPRBase(repo) +} + +func loadWorkspacePolicy(repo string) (ResolvedWorkspace, error) { + config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if err != nil { + return ResolvedWorkspace{}, err + } + return resolveWorkspace(config.Workspace), nil +} + +// branchForFeature derives the branch name for a feature slug when the caller +// does not supply an explicit branch. +func branchForFeature(feature string) string { + slug := previewSlug(feature) + if slug == "" { + return "" + } + return "feat/" + slug +} + +// WorkspaceCutOptions requests a fresh per-feature workspace cut from the +// up-to-date default branch. +type WorkspaceCutOptions struct { + Repo string + Feature string + Branch string +} + +// WorkspaceCut is the deterministic result of a fresh-cut request. +type WorkspaceCut struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` + Mode string `json:"mode,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + Branch string `json:"branch,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + Created bool `json:"created"` + Reason string `json:"reason"` +} + +func blockedCut(reason string) WorkspaceCut { + return WorkspaceCut{SchemaVersion: workspaceSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason} +} + +// CutFeatureWorkspace creates a fresh branch (and, in worktree mode, a linked +// worktree) rooted at the freshly-fetched default branch. It never switches an +// existing branch's history, never deletes anything, and refuses to reuse a +// branch name that already exists. +func CutFeatureWorkspace(options WorkspaceCutOptions) (WorkspaceCut, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return blockedCut(err.Error()), nil + } + if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + return blockedCut("This repository has no Boatstack project installation."), nil + } + policy, err := loadWorkspacePolicy(repo) + if err != nil { + return blockedCut("Boatstack could not read the workspace policy: " + err.Error()), nil + } + if !policy.Enabled { + return blockedCut("Workspace management is disabled (workspace.enabled=false)."), nil + } + + branch := strings.TrimSpace(options.Branch) + if branch == "" { + branch = branchForFeature(options.Feature) + } + if branch == "" { + return blockedCut("A feature slug or explicit branch is required to cut a workspace."), nil + } + + base := defaultPRBase(repo) + if branch == base { + return blockedCut(fmt.Sprintf("Refusing to cut a workspace named after the base branch %q.", base)), nil + } + + // Freshen the base from origin when a remote is available; a local-only + // repository is still cuttable from its local base. + if _, originErr := workspaceGit(repo, "remote", "get-url", "origin"); originErr == nil { + if _, fetchErr := workspaceGit(repo, "fetch", "origin"); fetchErr != nil { + return blockedCut("Boatstack could not fetch origin before cutting: " + fetchErr.Error()), nil + } + } + baseCommit, err := resolveBaseCommit(repo, base) + if err != nil { + return blockedCut(err.Error()), nil + } + + if _, existsErr := workspaceGit(repo, "rev-parse", "--verify", "refs/heads/"+branch+"^{commit}"); existsErr == nil { + return blockedCut(fmt.Sprintf("Branch %q already exists; choose a new feature or clean up the old workspace first.", branch)), nil + } + + result := WorkspaceCut{ + SchemaVersion: workspaceSchemaVersion, + VerificationStatus: "VERIFIED", + Mode: policy.Mode, + BaseBranch: base, + Branch: branch, + Created: true, + } + + switch policy.Mode { + case "branch": + if _, err := workspaceGit(repo, "switch", "-c", branch, baseCommit); err != nil { + return blockedCut("Boatstack could not create the branch: " + err.Error()), nil + } + result.Reason = fmt.Sprintf("Cut fresh branch %q from %s.", branch, base) + default: // "worktree" + path := filepath.Join(repo, ".product-loop", "worktrees", previewSlug(branch)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return blockedCut("Boatstack could not prepare the worktree directory: " + err.Error()), nil + } + if _, err := workspaceGit(repo, "worktree", "add", "-b", branch, path, baseCommit); err != nil { + return blockedCut("Boatstack could not add the worktree: " + err.Error()), nil + } + result.WorktreePath = path + result.Reason = fmt.Sprintf("Cut fresh worktree for branch %q from %s at %s.", branch, base, path) + } + return result, nil +} + +// workspaceMergeStatus reports whether the branch's work has landed. It prefers +// the GitHub CLI's authoritative PR state and falls back to local ancestry when +// gh is unavailable, always reporting which source answered. +func workspaceMergeStatus(repo, branch, base string) (bool, string) { + if out, err := workspaceGh(repo, "pr", "view", branch, "--json", "state", "-q", ".state"); err == nil { + return strings.EqualFold(strings.TrimSpace(out), "MERGED"), "gh" + } + for _, target := range []string{"refs/remotes/origin/" + base, "refs/heads/" + base, base} { + if _, err := workspaceGit(repo, "merge-base", "--is-ancestor", "refs/heads/"+branch, target); err == nil { + return true, "git" + } + } + return false, "git" +} + +// worktreePathForBranch returns the linked worktree path checked out on branch, +// or "" when the branch is not checked out in a separate worktree. +func worktreePathForBranch(repo, branch string) string { + out, err := workspaceGit(repo, "worktree", "list", "--porcelain") + if err != nil { + return "" + } + current := "" + for _, line := range strings.Split(out, "\n") { + switch { + case strings.HasPrefix(line, "worktree "): + current = strings.TrimSpace(strings.TrimPrefix(line, "worktree ")) + case strings.HasPrefix(line, "branch "): + if strings.TrimSpace(strings.TrimPrefix(line, "branch ")) == "refs/heads/"+branch { + return current + } + } + } + return "" +} + +func branchExists(repo, branch string) bool { + _, err := workspaceGit(repo, "rev-parse", "--verify", "refs/heads/"+branch+"^{commit}") + return err == nil +} + +// WorkspaceCleanupOptions requests removal of a finished per-feature workspace. +type WorkspaceCleanupOptions struct { + Repo string + Branch string + Confirm bool // the human supplied the cleanup confirmation + Force bool // override the merge gate and discard uncommitted/unmerged work +} + +// WorkspaceCleanup is the deterministic result of a cleanup request. +type WorkspaceCleanup struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED | NEEDS_CONFIRMATION + Branch string `json:"branch,omitempty"` + Mode string `json:"mode,omitempty"` + Merged bool `json:"merged"` + MergeSource string `json:"merge_source,omitempty"` + WorktreeRemoved bool `json:"worktree_removed"` + BranchDeleted bool `json:"branch_deleted"` + Reason string `json:"reason"` +} + +func blockedCleanup(branch, reason string) WorkspaceCleanup { + return WorkspaceCleanup{SchemaVersion: workspaceSchemaVersion, VerificationStatus: "BLOCKED", Branch: branch, Reason: reason} +} + +// CleanupFeatureWorkspace removes a finished workspace only when it is safe: the +// PR must be merged (unless cleanup_after is "ship" or Force overrides), there +// must be no uncommitted or unmerged work (unless Force), and confirm-mode must +// receive the human confirmation before anything is deleted. +func CleanupFeatureWorkspace(options WorkspaceCleanupOptions) (WorkspaceCleanup, error) { + branch := strings.TrimSpace(options.Branch) + repo, err := ResolveRepository(options.Repo) + if err != nil { + return blockedCleanup(branch, err.Error()), nil + } + if branch == "" { + return blockedCleanup(branch, "A branch is required to clean up a workspace."), nil + } + if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + return blockedCleanup(branch, "This repository has no Boatstack project installation."), nil + } + policy, err := loadWorkspacePolicy(repo) + if err != nil { + return blockedCleanup(branch, "Boatstack could not read the workspace policy: "+err.Error()), nil + } + if policy.Cleanup == "off" && !options.Force { + return blockedCleanup(branch, "Workspace cleanup is disabled (workspace.cleanup=off)."), nil + } + + worktreePath := worktreePathForBranch(repo, branch) + if !branchExists(repo, branch) && worktreePath == "" { + return WorkspaceCleanup{ + SchemaVersion: workspaceSchemaVersion, VerificationStatus: "VERIFIED", Branch: branch, + Mode: policy.Mode, Reason: fmt.Sprintf("No workspace for branch %q; nothing to clean up.", branch), + }, nil + } + + base := defaultPRBase(repo) + if branch == base { + return blockedCleanup(branch, fmt.Sprintf("Refusing to clean up the base branch %q.", base)), nil + } + if current, _ := workspaceGit(repo, "branch", "--show-current"); strings.TrimSpace(current) == branch && worktreePath == "" { + return blockedCleanup(branch, fmt.Sprintf("Branch %q is the current branch; switch away before cleaning it up.", branch)), nil + } + + merged, source := workspaceMergeStatus(repo, branch, base) + result := WorkspaceCleanup{ + SchemaVersion: workspaceSchemaVersion, Branch: branch, Mode: policy.Mode, + Merged: merged, MergeSource: source, + } + + if policy.CleanupAfter == "merge" && !merged && !options.Force { + result.VerificationStatus = "BLOCKED" + result.Reason = fmt.Sprintf("PR for %q is not merged yet; keeping the workspace. Re-run with force to clean up early.", branch) + return result, nil + } + + // Refuse to discard work the user has not landed unless explicitly forced. + if !options.Force { + if worktreePath != "" { + if dirty, _ := workspaceGit(worktreePath, "status", "--porcelain"); strings.TrimSpace(dirty) != "" { + result.VerificationStatus = "BLOCKED" + result.Reason = fmt.Sprintf("Workspace %q has uncommitted changes; commit or discard them, or force cleanup.", branch) + return result, nil + } + } + if !merged { + for _, target := range []string{"refs/remotes/origin/" + base, "refs/heads/" + base, base} { + if _, err := workspaceGit(repo, "merge-base", "--is-ancestor", "refs/heads/"+branch, target); err == nil { + merged = true + break + } + } + if !merged && policy.CleanupAfter != "ship" { + result.VerificationStatus = "BLOCKED" + result.Reason = fmt.Sprintf("Branch %q has commits not merged into %s; force cleanup to discard them.", branch, base) + return result, nil + } + } + } + result.Merged = merged + + if policy.Cleanup == "confirm" && !options.Confirm && !options.Force { + result.VerificationStatus = "NEEDS_CONFIRMATION" + result.Reason = fmt.Sprintf("Ready to remove the workspace for %q. Confirm cleanup to proceed.", branch) + return result, nil + } + + if worktreePath != "" { + removeArgs := []string{"worktree", "remove", worktreePath} + if options.Force { + removeArgs = append(removeArgs, "--force") + } + if _, err := workspaceGit(repo, removeArgs...); err != nil { + return blockedCleanup(branch, "Boatstack could not remove the worktree: "+err.Error()), nil + } + result.WorktreeRemoved = true + } + if branchExists(repo, branch) { + // Once the merge/safety gates above have cleared, force-delete so a + // squash- or rebase-merged PR (whose local ref is not a local ancestor + // of the base) is still removable. + deleteFlag := "-d" + if options.Force || result.Merged { + deleteFlag = "-D" + } + if _, err := workspaceGit(repo, "branch", deleteFlag, branch); err != nil { + return blockedCleanup(branch, "Boatstack could not delete the branch: "+err.Error()), nil + } + result.BranchDeleted = true + } + result.VerificationStatus = "VERIFIED" + result.Reason = fmt.Sprintf("Cleaned up the workspace for %q.", branch) + return result, nil +} + +// WorkspaceStatus reports whether a branch's workspace still exists and whether +// it is safe to clean up, so the flow can surface cleanup without forcing it. +type WorkspaceStatus struct { + SchemaVersion int `json:"schema_version"` + Branch string `json:"branch,omitempty"` + Exists bool `json:"exists"` + WorktreePath string `json:"worktree_path,omitempty"` + Merged bool `json:"merged"` + MergeSource string `json:"merge_source,omitempty"` + CleanupDue bool `json:"cleanup_due"` + Reason string `json:"reason"` +} + +// FeatureWorkspaceStatus inspects one branch's workspace. It is read-only. +func FeatureWorkspaceStatus(repoPath, branch string) (WorkspaceStatus, error) { + branch = strings.TrimSpace(branch) + repo, err := ResolveRepository(repoPath) + if err != nil { + return WorkspaceStatus{}, err + } + if branch == "" { + return WorkspaceStatus{}, fmt.Errorf("workspace status requires a branch") + } + status := WorkspaceStatus{SchemaVersion: workspaceSchemaVersion, Branch: branch} + worktreePath := worktreePathForBranch(repo, branch) + status.WorktreePath = worktreePath + status.Exists = worktreePath != "" || branchExists(repo, branch) + if !status.Exists { + status.Reason = fmt.Sprintf("No workspace exists for branch %q.", branch) + return status, nil + } + base := defaultPRBase(repo) + status.Merged, status.MergeSource = workspaceMergeStatus(repo, branch, base) + policy, policyErr := loadWorkspacePolicy(repo) + requireMerged := true + if policyErr == nil { + requireMerged = policy.CleanupAfter != "ship" + } + status.CleanupDue = status.Merged || !requireMerged + if status.CleanupDue { + status.Reason = fmt.Sprintf("Workspace for %q is ready to clean up.", branch) + } else { + status.Reason = fmt.Sprintf("Workspace for %q is still open (PR not merged).", branch) + } + return status, nil +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/workspace_test.go b/labs/12-product-engineering-loop/product-engineering-loop/workspace_test.go new file mode 100644 index 000000000..cbddd5502 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/workspace_test.go @@ -0,0 +1,476 @@ +package boatstack + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func workspaceGitDo(t *testing.T, dir string, arguments ...string) { + t.Helper() + out, err := exec.Command("git", append([]string{"-C", dir}, arguments...)...).CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v: %s", strings.Join(arguments, " "), err, out) + } +} + +// workspaceRepo builds a real git repository with one commit on main and a +// Boatstack project.json carrying the given workspace policy. +func workspaceRepo(t *testing.T, ws Workspace) string { + t.Helper() + repo := t.TempDir() + workspaceGitDo(t, repo, "init", "-b", "main") + workspaceGitDo(t, repo, "config", "user.name", "Boatstack Test") + workspaceGitDo(t, repo, "config", "user.email", "boatstack@example.test") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("test\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, repo, "add", "README.md") + workspaceGitDo(t, repo, "commit", "-m", "initial") + config := ProjectConfig{ + SchemaVersion: 1, + Project: Project{Name: "test", DefaultBranch: "main", Commands: map[string]string{"test": "go test ./..."}}, + Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true}, + Workspace: ws, + Adapters: []string{"cursor"}, + } + raw, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, ".product-loop"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + return repo +} + +func withWorkspaceGh(t *testing.T, fn func(string, ...string) (string, error)) { + t.Helper() + old := workspaceGh + workspaceGh = fn + t.Cleanup(func() { workspaceGh = old }) +} + +func ghState(state string) func(string, ...string) (string, error) { + return func(string, ...string) (string, error) { return state, nil } +} + +func ghUnavailable() func(string, ...string) (string, error) { + return func(string, ...string) (string, error) { return "", fmt.Errorf("gh: not found") } +} + +func defaultWorkspace() Workspace { + return Workspace{Enabled: true, Mode: "worktree", Cleanup: "confirm", CleanupAfter: "merge"} +} + +func TestResolveWorkspaceAppliesDefaults(t *testing.T) { + got := resolveWorkspace(Workspace{Enabled: true}) + if got.Mode != "worktree" || got.Cleanup != "confirm" || got.CleanupAfter != "merge" { + t.Fatalf("unexpected resolved defaults: %+v", got) + } + if resolveWorkspace(Workspace{}).Enabled { + t.Fatal("empty workspace must resolve to disabled") + } + explicit := resolveWorkspace(Workspace{Enabled: true, Mode: "branch", Cleanup: "auto", CleanupAfter: "ship"}) + if explicit.Mode != "branch" || explicit.Cleanup != "auto" || explicit.CleanupAfter != "ship" { + t.Fatalf("explicit values overwritten: %+v", explicit) + } +} + +func TestValidateWorkspaceConfig(t *testing.T) { + valid := []Workspace{ + {}, + {Enabled: true}, + {Mode: "worktree", Cleanup: "confirm", CleanupAfter: "merge"}, + {Mode: "branch", Cleanup: "off", CleanupAfter: "ship"}, + {Cleanup: "auto"}, + } + for _, ws := range valid { + if err := validateWorkspaceConfig(ws); err != nil { + t.Fatalf("expected %+v valid: %v", ws, err) + } + } + invalid := []Workspace{ + {Mode: "detached"}, + {Cleanup: "prompt"}, + {CleanupAfter: "review"}, + } + for _, ws := range invalid { + if err := validateWorkspaceConfig(ws); err == nil { + t.Fatalf("expected %+v invalid", ws) + } + } +} + +func TestCutFeatureWorkspaceWorktreeMode(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "add-widget"}) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "VERIFIED" || !result.Created || result.Branch != "feat/add-widget" || result.Mode != "worktree" { + t.Fatalf("unexpected cut: %+v", result) + } + if result.WorktreePath == "" { + t.Fatal("worktree mode must report a worktree path") + } + if _, err := os.Stat(result.WorktreePath); err != nil { + t.Fatalf("worktree directory missing: %v", err) + } + if !branchExists(repo, "feat/add-widget") { + t.Fatal("branch was not created") + } +} + +func TestCutFeatureWorkspaceBranchMode(t *testing.T) { + ws := defaultWorkspace() + ws.Mode = "branch" + repo := workspaceRepo(t, ws) + result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Branch: "feat/inline"}) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "VERIFIED" || result.Mode != "branch" || result.WorktreePath != "" { + t.Fatalf("unexpected branch-mode cut: %+v", result) + } + current, _ := gitCommand(repo, "branch", "--show-current") + if strings.TrimSpace(current) != "feat/inline" { + t.Fatalf("branch mode did not switch to feature branch, on %q", current) + } +} + +func TestCutFeatureWorkspaceRefusesExistingBranch(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + workspaceGitDo(t, repo, "branch", "feat/dupe") + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "dupe"}) + if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "already exists") { + t.Fatalf("expected existing-branch block: %+v", result) + } +} + +func TestCutFeatureWorkspaceRefusesBaseBranch(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Branch: "main"}) + if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "base branch") { + t.Fatalf("expected base-branch block: %+v", result) + } +} + +func TestCutFeatureWorkspaceDisabled(t *testing.T) { + repo := workspaceRepo(t, Workspace{Enabled: false}) + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "x"}) + if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "disabled") { + t.Fatalf("expected disabled block: %+v", result) + } +} + +func TestWorkspaceMergeStatusPrefersGh(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + withWorkspaceGh(t, ghState("MERGED")) + if merged, source := workspaceMergeStatus(repo, "feat/x", "main"); !merged || source != "gh" { + t.Fatalf("gh MERGED not honored: merged=%v source=%s", merged, source) + } + withWorkspaceGh(t, ghState("OPEN")) + if merged, source := workspaceMergeStatus(repo, "feat/x", "main"); merged || source != "gh" { + t.Fatalf("gh OPEN not honored: merged=%v source=%s", merged, source) + } +} + +func TestWorkspaceMergeStatusFallsBackToGit(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + withWorkspaceGh(t, ghUnavailable()) + // Merged: branch is an ancestor of main. + workspaceGitDo(t, repo, "branch", "feat/landed") + if merged, source := workspaceMergeStatus(repo, "feat/landed", "main"); !merged || source != "git" { + t.Fatalf("git ancestry merged not detected: merged=%v source=%s", merged, source) + } + // Not merged: branch has a commit main does not contain. + workspaceGitDo(t, repo, "switch", "-c", "feat/ahead") + if err := os.WriteFile(filepath.Join(repo, "ahead.txt"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, repo, "add", "ahead.txt") + workspaceGitDo(t, repo, "commit", "-m", "ahead") + workspaceGitDo(t, repo, "switch", "main") + if merged, _ := workspaceMergeStatus(repo, "feat/ahead", "main"); merged { + t.Fatal("branch with unmerged commit reported as merged") + } +} + +func TestCleanupBlocksWhenNotMerged(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "open-feature"}); err != nil { + t.Fatal(err) + } + withWorkspaceGh(t, ghState("OPEN")) + result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/open-feature", Confirm: true}) + if result.VerificationStatus != "BLOCKED" || result.Merged || !strings.Contains(result.Reason, "not merged") { + t.Fatalf("expected not-merged block: %+v", result) + } + if !branchExists(repo, "feat/open-feature") { + t.Fatal("blocked cleanup must not delete the branch") + } +} + +func TestCleanupNeedsConfirmation(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "ready"}); err != nil { + t.Fatal(err) + } + withWorkspaceGh(t, ghState("MERGED")) + result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/ready", Confirm: false}) + if result.VerificationStatus != "NEEDS_CONFIRMATION" { + t.Fatalf("expected confirmation gate: %+v", result) + } + if !branchExists(repo, "feat/ready") { + t.Fatal("confirmation gate must not delete anything") + } +} + +func TestCleanupRemovesMergedWorktree(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "done"}) + if err != nil { + t.Fatal(err) + } + // A committed change in the worktree keeps it clean but non-empty. + if err := os.WriteFile(filepath.Join(cut.WorktreePath, "done.txt"), []byte("done\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, cut.WorktreePath, "add", "done.txt") + workspaceGitDo(t, cut.WorktreePath, "commit", "-m", "done") + withWorkspaceGh(t, ghState("MERGED")) + result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/done", Confirm: true}) + if result.VerificationStatus != "VERIFIED" || !result.WorktreeRemoved || !result.BranchDeleted { + t.Fatalf("expected full cleanup: %+v", result) + } + if _, err := os.Stat(cut.WorktreePath); !os.IsNotExist(err) { + t.Fatal("worktree directory was not removed") + } + if branchExists(repo, "feat/done") { + t.Fatal("branch was not deleted") + } +} + +func TestCleanupDirtyWorktreeBlocked(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "dirty"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cut.WorktreePath, "wip.txt"), []byte("wip\n"), 0o644); err != nil { + t.Fatal(err) + } + withWorkspaceGh(t, ghState("MERGED")) + result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/dirty", Confirm: true}) + if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "uncommitted") { + t.Fatalf("expected dirty block: %+v", result) + } + if _, err := os.Stat(cut.WorktreePath); err != nil { + t.Fatal("blocked cleanup must not remove a dirty worktree") + } +} + +func TestCleanupForceDiscardsDirtyUnmerged(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "abandon"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cut.WorktreePath, "wip.txt"), []byte("wip\n"), 0o644); err != nil { + t.Fatal(err) + } + withWorkspaceGh(t, ghState("OPEN")) + result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/abandon", Force: true}) + if result.VerificationStatus != "VERIFIED" || !result.WorktreeRemoved || !result.BranchDeleted { + t.Fatalf("force cleanup should discard everything: %+v", result) + } +} + +func TestCleanupDisabled(t *testing.T) { + ws := defaultWorkspace() + ws.Cleanup = "off" + repo := workspaceRepo(t, ws) + if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "keep"}); err != nil { + t.Fatal(err) + } + result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/keep", Confirm: true}) + if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "disabled") { + t.Fatalf("expected cleanup-off block: %+v", result) + } +} + +func TestCleanupNothingToClean(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/ghost", Confirm: true}) + if result.VerificationStatus != "VERIFIED" || !strings.Contains(result.Reason, "nothing to clean") { + t.Fatalf("expected idempotent no-op: %+v", result) + } +} + +func TestCleanupAutoModeSkipsConfirmation(t *testing.T) { + ws := defaultWorkspace() + ws.Cleanup = "auto" + repo := workspaceRepo(t, ws) + if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "auto-clean"}); err != nil { + t.Fatal(err) + } + withWorkspaceGh(t, ghState("MERGED")) + result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/auto-clean", Confirm: false}) + if result.VerificationStatus != "VERIFIED" || !result.BranchDeleted { + t.Fatalf("auto cleanup should not require confirmation: %+v", result) + } +} + +func writeApprovedFeature(t *testing.T, repo, feature string) { + t.Helper() + dir := filepath.Join(repo, ".product-loop", "features", feature) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "plan.md"), []byte("# Plan\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "approval.md"), []byte("# Approval\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestResolveNextRoutesToWorkspaceCutWhenApprovedOnBase(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + writeApprovedFeature(t, repo, "newthing") + status, err := ResolveNext(repo) + if err != nil { + t.Fatal(err) + } + if status.ObservedStage != "APPROVED" || status.NextOperation != "workspace-cut" { + t.Fatalf("expected workspace-cut routing: %+v", status) + } +} + +func TestResolveNextApprovedBuildsWhenWorkspaceExists(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + writeApprovedFeature(t, repo, "cutdone") + if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "cutdone"}); err != nil { + t.Fatal(err) + } + status, err := ResolveNext(repo) + if err != nil { + t.Fatal(err) + } + if status.NextOperation != "build" { + t.Fatalf("expected build once workspace exists: %+v", status) + } +} + +func TestResolveNextApprovedBuildsWhenWorkspaceDisabled(t *testing.T) { + repo := workspaceRepo(t, Workspace{Enabled: false}) + writeApprovedFeature(t, repo, "plainfeat") + status, err := ResolveNext(repo) + if err != nil { + t.Fatal(err) + } + if status.NextOperation != "build" { + t.Fatalf("disabled workspace must go straight to build: %+v", status) + } +} + +func writeCompletedDelivery(t *testing.T, repo, feature, headBranch string) { + t.Helper() + dir := filepath.Join(repo, ".product-loop", "features", feature) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(dir, "plan.lock.json") + if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil { + t.Fatal(err) + } + hash, err := SHA256File(lockPath) + if err != nil { + t.Fatal(err) + } + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: hash, + ActiveIndex: 1, + Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "PUBLISHED", HeadBranch: headBranch}}, + }); err != nil { + t.Fatal(err) + } +} + +func TestResolveNextRoutesToWorkspaceCleanupAfterPublication(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "shipped"}); err != nil { + t.Fatal(err) + } + writeCompletedDelivery(t, repo, "shipped", "feat/shipped") + status, err := ResolveNext(repo) + if err != nil { + t.Fatal(err) + } + if status.ObservedStage != "FEATURE_COMPLETE" || status.NextOperation != "workspace-cleanup" { + t.Fatalf("expected cleanup routing: %+v", status) + } +} + +func TestResolveNextFeatureCompleteStaysNoneWithoutWorktree(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + writeCompletedDelivery(t, repo, "shipped", "feat/no-worktree") + status, err := ResolveNext(repo) + if err != nil { + t.Fatal(err) + } + if status.ObservedStage != "FEATURE_COMPLETE" || status.NextOperation != "none" { + t.Fatalf("expected none without a live worktree: %+v", status) + } +} + +func TestResolveNextFeatureCompleteStaysNoneWhenWorkspaceDisabled(t *testing.T) { + repo := workspaceRepo(t, Workspace{Enabled: false}) + // A worktree exists on disk, but management is off, so cleanup is not surfaced. + workspaceGitDo(t, repo, "worktree", "add", "-b", "feat/manual", filepath.Join(repo, "wt-manual")) + writeCompletedDelivery(t, repo, "shipped", "feat/manual") + status, err := ResolveNext(repo) + if err != nil { + t.Fatal(err) + } + if status.NextOperation != "none" { + t.Fatalf("disabled workspace must not route to cleanup: %+v", status) + } +} + +func TestFeatureWorkspaceStatus(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "reportable"}); err != nil { + t.Fatal(err) + } + withWorkspaceGh(t, ghState("OPEN")) + open, err := FeatureWorkspaceStatus(repo, "feat/reportable") + if err != nil { + t.Fatal(err) + } + if !open.Exists || open.Merged || open.CleanupDue { + t.Fatalf("open workspace status wrong: %+v", open) + } + withWorkspaceGh(t, ghState("MERGED")) + merged, err := FeatureWorkspaceStatus(repo, "feat/reportable") + if err != nil { + t.Fatal(err) + } + if !merged.Exists || !merged.Merged || !merged.CleanupDue { + t.Fatalf("merged workspace status wrong: %+v", merged) + } + missing, err := FeatureWorkspaceStatus(repo, "feat/never") + if err != nil { + t.Fatal(err) + } + if missing.Exists { + t.Fatalf("missing workspace reported as existing: %+v", missing) + } +}