From b6ffb9c7816ad4950915d58200a7a70c35757e97 Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Tue, 4 Aug 2026 07:38:28 -0700 Subject: [PATCH] Add workspaces --- README.md | 12 +- docs/configuration.md | 16 + docs/manual-setup.md | 19 + docs/migrations.md | 17 + docs/workspaces.md | 63 + examples/promote-tested-sync.yml | 2 +- examples/sync-upstream.yml | 2 +- package-lock.json | 4 +- package.json | 2 +- skills/manifest.json | 4 + skills/patchlane-fork-setup/SKILL.md | 12 +- .../assets/promote-tested-sync.yml | 2 +- .../assets/sync-upstream.yml | 2 +- skills/patchlane-sync-patches/SKILL.md | 4 +- skills/patchlane-workspace/SKILL.md | 62 + src/cli.ts | 85 +- src/composition-errors.ts | 22 + src/composition.ts | 532 ++++++++ src/config.ts | 53 +- src/git.ts | 149 +++ src/workspace-create.ts | 186 +++ src/workspace-land.ts | 489 ++++++++ src/workspace-remove.ts | 69 ++ src/workspace-state.ts | 293 +++++ src/workspace-status.ts | 153 +++ workspaces.md | 1067 +++++++++++++++++ 26 files changed, 3303 insertions(+), 18 deletions(-) create mode 100644 docs/workspaces.md create mode 100644 skills/patchlane-workspace/SKILL.md create mode 100644 src/composition-errors.ts create mode 100644 src/composition.ts create mode 100644 src/git.ts create mode 100644 src/workspace-create.ts create mode 100644 src/workspace-land.ts create mode 100644 src/workspace-remove.ts create mode 100644 src/workspace-state.ts create mode 100644 src/workspace-status.ts create mode 100644 workspaces.md diff --git a/README.md b/README.md index 6286961..47f8c27 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,15 @@ Then ask your coding agent: The setup skill inspects the repository, asks which upstream release or branch to track, and shows its complete plan before pushing or rewriting branches. It then creates the patch stack, validates it, and guides the first tested promotion. +For agent development after setup, create a complete composed workspace instead of editing a raw lane: + +```bash +npx patchlane workspace create --lane patch/product +# work and test in the printed directory +npx patchlane workspace status --json +npx patchlane workspace land --dry-run +``` + Prefer to configure it yourself? Follow the [manual setup guide](docs/manual-setup.md). Already using an earlier Patchlane version? Use the [migration guide](docs/migrations.md). ## How It Works @@ -28,13 +37,14 @@ Prefer to configure it yourself? Follow the [manual setup guide](docs/manual-set 4. Run the fork's existing CI on the generated branch. 5. Promote only the exact SHA that passed CI. -The promoted base and sync branches are generated output. Fork-owned changes belong on `patch/*` branches. +The promoted base and sync branches are generated output. Fork-owned changes belong on `patch/*` branches. For agent development, use a composed workspace so every lane, workflow, test, and local tool is visible while commits remain assignable to one lane. ## Documentation - [Manual setup](docs/manual-setup.md) - [Migration guide](docs/migrations.md) - [Configuration and command reference](docs/configuration.md) +- [Composed workspaces](docs/workspaces.md) ## Development diff --git a/docs/configuration.md b/docs/configuration.md index b133e46..1c831a8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -150,6 +150,22 @@ npx patchlane notify --event=sync-failed --recovered The repository defaults to `GITHUB_REPOSITORY` in Actions or the GitHub `origin` remote locally. Structured context can be supplied with flags or the `PATCHLANE_STATUS`, `PATCHLANE_RUN_URL`, `UPSTREAM_SHA`, `SYNC_SHA`, `FAILED_PATCH_REF`, `FAILED_COMMIT`, `CONFLICT_PATHS`, and `APPLIED_PATCH_REFS` environment variables. +### Composed workspaces + +Use a composed workspace for agent development instead of editing a raw patch branch: + +```bash +npx patchlane workspace create --lane patch/product +cd ../project-patch-product +npx patchlane workspace status --json +npx patchlane workspace land --dry-run +npx patchlane workspace land # local lane only +npx patchlane workspace land --push # explicit remote write +npx patchlane workspace remove +``` + +`workspace create` pins the source and every configured lane in local metadata under the Git common directory. Landing requires clean, linear history, checks that all pinned lane refs are fresh, replays commits onto exactly one lane, recomposes every lane, and requires an exact tree match. Use `--config-ref ` when the current branch does not contain `.patchlane.yml`, and `workspace remove --force` only when intentionally discarding unlanded work. + ### Install agent skills ```bash diff --git a/docs/manual-setup.md b/docs/manual-setup.md index b984afc..930deb6 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -127,6 +127,25 @@ npx patchlane bootstrap --wait After bootstrap, scheduled syncs and automatic promotions are active. On the first workflow-driven sync that publishes a new integration SHA, confirm that authentication succeeds, CI runs as a `push` for that SHA, and promotion updates the base branch. +## Agent development with composed workspaces + +Once the configured composition is valid, agents should work from a complete workspace rather than a raw patch branch: + +```bash +npx patchlane workspace create --lane patch/product +cd ../REPOSITORY-patch-product +npx patchlane workspace status --json +``` + +Make and test linear commits in the generated worktree. Before landing, validate the projection and exact recomposition: + +```bash +npx patchlane workspace land --dry-run +npx patchlane workspace land +``` + +Use `--push` only with explicit approval. A stale lane, projection conflict, or round-trip mismatch leaves configured lane refs unchanged and should be resolved rather than bypassed. + ## Adding product patches Create each additional patch independently from the same selected source: diff --git a/docs/migrations.md b/docs/migrations.md index 2e94f7a..a010ba8 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -2,6 +2,23 @@ Follow the section for the version you are adopting. Migration notes are listed newest first. +## 0.5.3 + +Patchlane 0.5.3 keeps the version-1 configuration schema, ordered `patchRefs`, generated workflows, and existing sync and promotion behavior compatible. It adds composed workspaces for agent development. + +Run the new workflow from a configured branch: + +```bash +npx patchlane@0.5.3-dev.0 agents +npx patchlane@0.5.3-dev.0 doctor +npx patchlane@0.5.3-dev.0 sync --dry-run +npx patchlane@0.5.3-dev.0 workspace create --lane patch/product +``` + +Agents should edit and test in the generated worktree, keep commits linear, and run `workspace land --dry-run` before landing. Existing forks may continue editing raw patch branches. No remote lane is changed unless `workspace land --push` is explicitly used. Workspace metadata is local Git-common-directory state and is not committed. + +Preserve branch names, patch order, workflow schedules, authentication, and repository-specific workflow changes while upgrading generated workflow package references to the released Patchlane version. Use the normal tested sync and promotion flow to roll any configuration or workflow changes forward. + ## 0.5 Patchlane 0.5 requires every version-1 `.patchlane.yml` file to define `allowedWorkflows` and updates generated workflows to authenticate with a GitHub App. Patchlane implicitly includes its generated `sync-upstream.yml` and `promote-tested-sync.yml` workflows, so list only repository-specific workflows. diff --git a/docs/workspaces.md b/docs/workspaces.md new file mode 100644 index 0000000..9051792 --- /dev/null +++ b/docs/workspaces.md @@ -0,0 +1,63 @@ +# Composed workspaces + +Patchlane workspaces provide the complete composed fork while keeping commits assigned to one configured patch lane. + +## Create a workspace + +From a configured repository worktree: + +```bash +npx patchlane workspace create --lane patch/product +``` + +Patchlane resolves the configured upstream source, fetches and pins every lane, composes them in order, and creates a disposable Git worktree at the generated path. Use `--path` or `--name` to override the destination, and `--config-ref origin/main` when the current branch does not contain `.patchlane.yml`. + +The workspace records its inputs under the repository's common Git directory in `patchlane/workspaces/.json`. This metadata is local state and is never committed to a lane. + +## Work and inspect + +Change to the path printed by `workspace create`, edit normally, commit linearly, and run the repository's normal tests. Inspect the state before editing and before landing: + +```bash +npx patchlane workspace status --json +``` + +All configured lane refs must remain unchanged while the workspace is being developed. A workspace becomes stale when any lane moves; Patchlane does not automatically refresh or rebase it. + +## Validate and land + +Preview projection and exact recomposition without moving a lane: + +```bash +npx patchlane workspace land --dry-run +``` + +For a successful preview, land locally: + +```bash +npx patchlane workspace land +``` + +Use `--push` only when a remote write is explicitly approved: + +```bash +npx patchlane workspace land --push +``` + +Landing replays the workspace commits onto exactly one target lane, recomposes all lanes using the recorded SHAs, and compares the composed tree with the tested workspace tree. It updates no lane when projection conflicts or the tree comparison fails. Remote pushes use `--force-with-lease` and are never performed by default. + +A round-trip mismatch is intentionally diagnostic. Check whether the selected lane is wrong, a later lane overwrites the change, or the workspace contains work for more than one lane. Use separate workspaces for multi-lane changes. + +## Remove + +A workspace with dirty files or unlanded commits cannot be removed accidentally: + +```bash +npx patchlane workspace remove +``` + +Use `--force` only when deliberately discarding that work. Removal deletes the registered worktree, disposable workspace branch, candidate refs, and local metadata. It does not change configured lane refs. + +## Deferred capabilities + +Patchlane 0.5.3 supports one target lane and linear history. Multi-lane commit assignment, lane dependency graphs, workspace refresh, ownership policies, automatic lane creation, and synthetic octopus commits remain future work. diff --git a/examples/promote-tested-sync.yml b/examples/promote-tested-sync.yml index 8c952d1..34fbac1 100644 --- a/examples/promote-tested-sync.yml +++ b/examples/promote-tested-sync.yml @@ -33,7 +33,7 @@ jobs: node-version: '22' - name: Run patchlane promote - run: npx patchlane@0.5.2 promote + run: npx patchlane@0.5.3-dev.0 promote env: GH_TOKEN: ${{ steps.patchlane-token.outputs.token }} EXPECTED_SYNC_SHA: ${{ github.event.workflow_run.head_sha }} diff --git a/examples/sync-upstream.yml b/examples/sync-upstream.yml index 9d50a85..926aea9 100644 --- a/examples/sync-upstream.yml +++ b/examples/sync-upstream.yml @@ -46,7 +46,7 @@ jobs: node-version: '22' - name: Run patchlane sync - run: npx patchlane@0.5.2 sync + run: npx patchlane@0.5.3-dev.0 sync env: GH_TOKEN: ${{ steps.patchlane-token.outputs.token }} UPSTREAM_SOURCE: ${{ inputs.source }} diff --git a/package-lock.json b/package-lock.json index 573bd26..86f3d9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "patchlane", - "version": "0.5.2", + "version": "0.5.3-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "patchlane", - "version": "0.5.2", + "version": "0.5.3-dev.0", "license": "MIT", "dependencies": { "cac": "^7.0.0", diff --git a/package.json b/package.json index b7bd262..cd86c92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "patchlane", - "version": "0.5.2", + "version": "0.5.3-dev.0", "description": "CLI tool for maintaining forked repositories with custom patches", "type": "module", "bin": { diff --git a/skills/manifest.json b/skills/manifest.json index 12f199a..449cdbe 100644 --- a/skills/manifest.json +++ b/skills/manifest.json @@ -8,6 +8,10 @@ { "name": "patchlane-sync-patches", "files": ["SKILL.md"] + }, + { + "name": "patchlane-workspace", + "files": ["SKILL.md"] } ] } diff --git a/skills/patchlane-fork-setup/SKILL.md b/skills/patchlane-fork-setup/SKILL.md index ac25f25..fc26b4f 100644 --- a/skills/patchlane-fork-setup/SKILL.md +++ b/skills/patchlane-fork-setup/SKILL.md @@ -7,7 +7,7 @@ description: Set up or migrate a GitHub fork to use Patchlane upstream sync auto Inspect the fork before changing anything. Confirm the default branch, remotes, existing workflows, fork-only commits, and existing `patch/*` branches. -Treat the promoted base branch as generated output. Keep fork-owned product changes, Patchlane configuration, agent skills, and workflows on focused patch branches. +Treat the promoted base branch as generated output. Keep fork-owned product changes, Patchlane configuration, agent skills, and workflows on focused patch branches. When an agent needs to make a change, prefer a composed workspace over editing a raw patch branch. ## Confirm the plan @@ -89,6 +89,16 @@ The workflows do not exist on the default branch before the first promotion. Boo 4. Confirm the generated base is rooted at the selected source. 5. On the first workflow-driven sync that publishes a new integration SHA, confirm authentication succeeds, CI runs as a `push` for that exact SHA, and promotion moves the base branch to that SHA. +## Agent workspaces + +After the fork has a valid composition, agents should create complete worktrees instead of editing a raw lane directly: + +```bash +npx patchlane workspace create --lane patch/product +``` + +The workspace includes every configured lane, Patchlane skills, CI, tests, and local tooling. Make linear commits in the reported worktree, run the repository tests, then validate with `npx patchlane workspace land --dry-run`. The selected lane is the only landing destination; inspect round-trip mismatch diagnostics rather than assigning files heuristically. Use `workspace land --push` only after explicit approval. See `docs/workspaces.md` in the repository or the `patchlane-workspace` skill for the complete workflow. + ## Finish Summarize: diff --git a/skills/patchlane-fork-setup/assets/promote-tested-sync.yml b/skills/patchlane-fork-setup/assets/promote-tested-sync.yml index 8c952d1..34fbac1 100644 --- a/skills/patchlane-fork-setup/assets/promote-tested-sync.yml +++ b/skills/patchlane-fork-setup/assets/promote-tested-sync.yml @@ -33,7 +33,7 @@ jobs: node-version: '22' - name: Run patchlane promote - run: npx patchlane@0.5.2 promote + run: npx patchlane@0.5.3-dev.0 promote env: GH_TOKEN: ${{ steps.patchlane-token.outputs.token }} EXPECTED_SYNC_SHA: ${{ github.event.workflow_run.head_sha }} diff --git a/skills/patchlane-fork-setup/assets/sync-upstream.yml b/skills/patchlane-fork-setup/assets/sync-upstream.yml index 9d50a85..926aea9 100644 --- a/skills/patchlane-fork-setup/assets/sync-upstream.yml +++ b/skills/patchlane-fork-setup/assets/sync-upstream.yml @@ -46,7 +46,7 @@ jobs: node-version: '22' - name: Run patchlane sync - run: npx patchlane@0.5.2 sync + run: npx patchlane@0.5.3-dev.0 sync env: GH_TOKEN: ${{ steps.patchlane-token.outputs.token }} UPSTREAM_SOURCE: ${{ inputs.source }} diff --git a/skills/patchlane-sync-patches/SKILL.md b/skills/patchlane-sync-patches/SKILL.md index e23e162..8c8507b 100644 --- a/skills/patchlane-sync-patches/SKILL.md +++ b/skills/patchlane-sync-patches/SKILL.md @@ -5,7 +5,7 @@ description: Update patch branches in a Patchlane-managed fork so `npx patchlane # Patchlane Patch Refresh -Start by reproducing the problem instead of guessing. Read `.patchlane.yml`, resolve its explicit upstream `source` and ordered `patchRefs`, and run or review `npx patchlane sync --dry-run` so the first failing patch branch is explicit without resetting the local sync branch. +Start by reproducing the problem instead of guessing. Read `.patchlane.yml`, resolve its explicit upstream `source` and ordered `patchRefs`, and run or review `npx patchlane sync --dry-run` so the first failing patch branch is explicit without resetting the local sync branch. When repairing code or behavior, prefer `npx patchlane workspace create --lane ` so the complete composed fork remains visible; edit a raw lane only when the user explicitly requests it. Use this workflow: @@ -31,6 +31,8 @@ Watch for common failure modes: - patches created from the wrong upstream tag or branch - later patch branches silently depending on files introduced by an earlier patch +For repairs made in a composed workspace, run `npx patchlane workspace status --json` and `npx patchlane workspace land --dry-run` before projecting the commits. A round-trip mismatch or a stale lane means the workspace must be reviewed or recreated; do not bypass the check. + Finish by summarizing: - which patch branch or branches changed diff --git a/skills/patchlane-workspace/SKILL.md b/skills/patchlane-workspace/SKILL.md new file mode 100644 index 0000000..43bc26f --- /dev/null +++ b/skills/patchlane-workspace/SKILL.md @@ -0,0 +1,62 @@ +--- +name: patchlane-workspace +description: Develop Patchlane fork changes in a complete composed workspace, then project linear commits onto one selected patch lane with exact round-trip validation. +--- + +# Patchlane Composed Workspace + +When making a change to a Patchlane fork: + +1. Do not edit a raw patch branch unless the user explicitly requests it. +2. From a configured branch, run `patchlane workspace create --lane `. +3. Work only in the generated workspace. +4. Inspect existing code across all composed lanes before changing behavior. +5. Keep the workspace history linear; do not create merge commits. +6. Commit complete, reviewable changes and run the repository's normal tests. +7. Run `patchlane workspace status --json` before landing. +8. Run `patchlane workspace land --dry-run` and review any conflict or round-trip mismatch. +9. Use an existing configured lane that matches the requested change; do not invent a lane silently. +10. Obtain approval before running `patchlane workspace land --push`. + +A workspace includes the complete composed fork: upstream code, every configured patch lane, Patchlane workflows and skills, tests, CI configuration, and development tooling. The selected lane is the only lane that receives commits during landing. Patchlane replays the workspace commits onto that lane, recomposes every lane, and requires the resulting tree to match the tested workspace tree exactly. + +## Create + +From the repository worktree containing `.patchlane.yml`: + +```bash +patchlane workspace create --lane patch/product +``` + +Use `--config-ref origin/main` when the current branch does not contain `.patchlane.yml`. Use `--path` or `--name` only when a stable custom worktree location or identifier is needed. Change directory to the reported path before editing. + +## Land + +Before landing: + +```bash +patchlane workspace status --json +patchlane workspace land --dry-run +``` + +Fix dirty files, merge commits, stale lane refs, projection conflicts, and round-trip mismatches rather than bypassing validation. A mismatch commonly means the selected lane is wrong, a later lane overwrites the change, or the workspace contains changes for multiple lanes. Split those changes into separate workspaces when appropriate. + +Land locally with: + +```bash +patchlane workspace land +``` + +Remote writes are never implicit. After reviewing the dry run and receiving approval, use: + +```bash +patchlane workspace land --push +``` + +Keep the workspace until the landed lane has been reviewed or upstreamed. Remove it only after confirming there are no unlanded changes: + +```bash +patchlane workspace remove +``` + +Use `workspace remove --force` only when intentionally discarding dirty or unlanded work. diff --git a/src/cli.ts b/src/cli.ts index 080fb93..12cfb2a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import cac from 'cac'; import { installPatchlaneAgents } from './agents-install.js'; import { bootstrapPatchlane } from './bootstrap.js'; +import { CompositionError } from './composition-errors.js'; import { loadPatchlaneConfig, NOTIFICATION_EVENTS, type NotificationEvent } from './config.js'; import { runDoctor } from './doctor.js'; import { initializePatchlane } from './init.js'; @@ -10,6 +11,10 @@ import { getPackageVersion } from './package-version.js'; import { runNotification } from './notify.js'; import { runPromoteSync } from './promote-sync.js'; import { parseUpstreamSource } from './upstream-source.js'; +import { createWorkspace, formatWorkspaceCreateJson, formatWorkspaceCreateResult } from './workspace-create.js'; +import { formatWorkspaceStatus, formatWorkspaceStatusJson, inspectWorkspaceStatus } from './workspace-status.js'; +import { formatWorkspaceLand, formatWorkspaceLandJson, landWorkspace, WorkspaceLandError } from './workspace-land.js'; +import { formatWorkspaceRemove, formatWorkspaceRemoveJson, removeWorkspace } from './workspace-remove.js'; const cli = cac('patchlane'); @@ -27,12 +32,26 @@ function env(name: string, fallback?: string) { return process.env[name] || fallback; } +function workspaceError(error: unknown, json: boolean) { + const message = error instanceof Error ? error.message : String(error); + if (json) { + const structured = error instanceof WorkspaceLandError || error instanceof CompositionError ? error : undefined; + const details = structured?.details; + process.stdout.write( + `${JSON.stringify({ status: structured?.code ?? 'error', message, ...(details ? { ...details } : {}) }, null, 2)}\n`, + ); + } else { + process.stderr.write(`${message}\n`); + } + process.exitCode = 1; +} + cli.command('agents', 'Install or update Patchlane agent skills') .option('--dir ', 'Destination directory for installed skills', { default: env('PATCHLANE_AGENTS_DIR', '.agents/skills'), }) .option('--ref ', 'Patchlane git ref to pull skills from', { - default: env('PATCHLANE_SKILLS_REF', `v${getPackageVersion()}`), + default: env('PATCHLANE_SKILLS_REF', `v${getPackageVersion()}`), }) .action((args) => { void installPatchlaneAgents({ @@ -106,6 +125,70 @@ cli.command('doctor', 'Check Patchlane configuration without changing repository if (!report.ok) process.exitCode = 1; }); +cli.command('workspace ', 'Create, inspect, land, or remove a composed Patchlane workspace') + .option('--lane ', 'Configured target lane or landing override') + .option('--path ', 'Destination worktree path') + .option('--name ', 'Workspace identifier') + .option('--source ', 'Override the configured upstream source') + .option('--config-ref ', 'Read .patchlane.yml from a Git ref') + .option('--origin-remote-name ', 'Name of the origin remote', { + default: env('ORIGIN_REMOTE_NAME', 'origin'), + }) + .option('--upstream-remote-name ', 'Name of the upstream remote', { + default: env('UPSTREAM_REMOTE_NAME', 'upstream'), + }) + .option('--dry-run', 'Validate landing without updating lane refs') + .option('--push', 'Push the updated lane with force-with-lease') + .option('--force', 'Remove even when the workspace is dirty or has unlanded commits') + .option('--json', 'Emit a machine-readable result') + .action((action, args) => { + try { + if (action === 'create') { + if (!args.lane) throw new Error('Error: --lane is required.'); + const result = createWorkspace({ + lane: args.lane, + path: args.path, + name: args.name, + source: args.source, + configRef: args.configRef, + originRemoteName: args.originRemoteName, + upstreamRemoteName: args.upstreamRemoteName, + upstreamRemoteUrl: env('UPSTREAM_REMOTE_URL'), + }); + process.stdout.write( + `${args.json ? formatWorkspaceCreateJson(result) : formatWorkspaceCreateResult(result)}\n`, + ); + return; + } + if (action === 'status') { + const result = inspectWorkspaceStatus(); + process.stdout.write( + `${args.json ? formatWorkspaceStatusJson(result) : formatWorkspaceStatus(result)}\n`, + ); + return; + } + if (action === 'land') { + const result = landWorkspace({ + lane: args.lane, + dryRun: args.dryRun === true, + push: args.push === true, + }); + process.stdout.write(`${args.json ? formatWorkspaceLandJson(result) : formatWorkspaceLand(result)}\n`); + return; + } + if (action === 'remove') { + const result = removeWorkspace({ force: args.force === true }); + process.stdout.write( + `${args.json ? formatWorkspaceRemoveJson(result) : formatWorkspaceRemove(result)}\n`, + ); + return; + } + throw new Error(`Unknown workspace action '${action}'. Use create, status, land, or remove.`); + } catch (error) { + workspaceError(error, args.json === true); + } + }); + cli.command('sync', 'Rebuild integration branch from upstream and patches') .option('--upstream-owner ', 'GitHub owner/org of the upstream repository', { default: env('UPSTREAM_OWNER', config?.upstreamOwner), diff --git a/src/composition-errors.ts b/src/composition-errors.ts new file mode 100644 index 0000000..befe024 --- /dev/null +++ b/src/composition-errors.ts @@ -0,0 +1,22 @@ +export type CompositionErrorCode = + | 'missing_lane' + | 'invalid_lane' + | 'invalid_lane_base' + | 'conflict' + | 'workflow_policy'; + +export class CompositionError extends Error { + readonly name = 'CompositionError'; + + constructor( + readonly code: CompositionErrorCode, + message: string, + readonly details: Record = {}, + ) { + super(message); + } +} + +export function isCompositionError(error: unknown): error is CompositionError { + return error instanceof CompositionError; +} diff --git a/src/composition.ts b/src/composition.ts new file mode 100644 index 0000000..0f7b6ab --- /dev/null +++ b/src/composition.ts @@ -0,0 +1,532 @@ +import type { PatchlaneConfig } from './config.js'; +import { CompositionError } from './composition-errors.js'; +import { ensureGitIdentity, git, gitResult, isValidRefName, objectExists, runProcess } from './git.js'; +import { parseUpstreamSource } from './upstream-source.js'; +import { validateWorkflowPolicy, workflowFilesAtRef } from './workflow-policy.js'; + +export type ResolvedSource = { + configuredSource: string; + label: string; + sha: string; +}; + +export type LanePlan = { + ref: string; + resolvedRef: string; + tipSha: string; + mergeBaseSha: string; + diffBaseSha: string; + commits: Array<{ + sha: string; + subject: string; + }>; + changedPaths: string[]; + warnings: string[]; +}; + +export type CompositionPlan = { + source: ResolvedSource; + lanes: LanePlan[]; + baseBranch: string; + syncBranch: string; + /** The policy is kept on the plan so every consumer validates the same tree. */ + allowedWorkflows?: string[]; +}; + +export type CompositionResult = { + headSha: string; + treeSha: string; + appliedLanes: string[]; + generatedCommits: Array<{ + lane: string; + originalSha: string; + generatedSha: string; + }>; +}; + +export type ResolveCompositionOptions = { + cwd?: string; + originRemoteName?: string; + upstreamRemoteName?: string; + upstreamRemoteUrl?: string; + source?: string; + allowDependentPatches?: boolean; + /** Use exact previously recorded lane tips instead of resolving moving refs. */ + laneTips?: Record; + /** Use an already resolved source, as required by workspace landing. */ + resolvedSource?: ResolvedSource; + fetch?: boolean; +}; + +function sourceRemoteUrl(config: PatchlaneConfig) { + return `https://github.com/${config.upstreamOwner}/${config.upstreamRepo}.git`; +} + +function ensureRemote(cwd: string, name: string, url: string | undefined, fallbackUrl: string) { + const existing = gitResult(['remote', 'get-url', name], cwd); + if (existing.status === 0) { + if (url && existing.stdout.trim() !== url) git(['remote', 'set-url', name, url], cwd); + return; + } + git(['remote', 'add', name, url ?? fallbackUrl], cwd); +} + +function fetchRemoteBranches(cwd: string, remote: string) { + const fetched = gitResult(['fetch', '--prune', '--no-tags', remote, `+refs/heads/*:refs/remotes/${remote}/*`], cwd); + if (fetched.status !== 0) { + throw new Error( + `Could not fetch remote '${remote}': ${[fetched.stderr.trim(), fetched.stdout.trim()].filter(Boolean).join('\n')}`, + ); + } +} + +function fetchLane(cwd: string, remote: string, ref: string) { + if (!isValidRefName(cwd, ref)) { + throw new CompositionError('invalid_lane', `Configured lane '${ref}' is not a valid Git ref.`, { ref }); + } + const fetched = gitResult( + ['fetch', '--prune', '--no-tags', remote, `+refs/heads/${ref}:refs/remotes/${remote}/${ref}`], + cwd, + ); + return fetched.status === 0; +} + +function parseGhJson(stdout: string): T | undefined { + try { + return JSON.parse(stdout) as T; + } catch { + return undefined; + } +} + +function resolveReleaseTag(cwd: string, config: PatchlaneConfig, selector: string) { + const repo = `${config.upstreamOwner}/${config.upstreamRepo}`; + const endpoint = `repos/${repo}/releases`; + const result = + selector === 'latest' + ? runProcess('gh', ['api', `${endpoint}/latest`], cwd, { allowFailure: true }) + : runProcess('gh', ['api', '--paginate', `${endpoint}?per_page=100`], cwd, { allowFailure: true }); + + if (result.status === 0) { + if (selector === 'latest') { + const release = parseGhJson<{ tag_name?: unknown }>(result.stdout); + if (typeof release?.tag_name === 'string' && release.tag_name) return release.tag_name; + } else { + const releases = parseGhJson>( + result.stdout, + ); + if (Array.isArray(releases)) { + if (selector === 'prerelease') { + const match = releases.find( + (release) => + release.draft !== true && + release.prerelease === true && + typeof release.tag_name === 'string', + ); + if (match && typeof match.tag_name === 'string') return match.tag_name; + } else { + let expression: RegExp; + try { + expression = new RegExp(selector); + } catch { + throw new CompositionError('invalid_lane_base', `Invalid release selector '${selector}'.`, { + selector, + }); + } + const match = releases.find( + (release) => + release.draft !== true && + typeof release.tag_name === 'string' && + expression.test(release.tag_name), + ); + if (match && typeof match.tag_name === 'string') return match.tag_name; + } + } + } + } + + // A local tag fallback keeps branch-only/offline repositories useful and is also + // helpful for tests using a local upstream without a GitHub API fixture. + const tags = git(['tag', '--list'], cwd) + .split(/\r?\n/) + .filter(Boolean) + .filter((tag) => !tag.includes('-rc') && !tag.includes('-beta') && !tag.includes('-alpha')); + if (selector === 'latest') return tags.sort().at(-1); + if (selector === 'prerelease') { + return git(['tag', '--list'], cwd) + .split(/\r?\n/) + .filter((tag) => tag.includes('-rc') || tag.includes('-beta') || tag.includes('-alpha')) + .sort() + .at(-1); + } + let expression: RegExp; + try { + expression = new RegExp(selector); + } catch { + throw new CompositionError('invalid_lane_base', `Invalid release selector '${selector}'.`, { selector }); + } + return tags.find((tag) => expression.test(tag)); +} + +function resolveSource( + cwd: string, + config: PatchlaneConfig, + options: ResolveCompositionOptions, + upstreamRemoteName: string, +): ResolvedSource { + if (options.resolvedSource) { + if (!objectExists(cwd, `${options.resolvedSource.sha}^{commit}`)) { + throw new CompositionError( + 'invalid_lane_base', + `Resolved source commit '${options.resolvedSource.sha}' is not available locally.`, + { sha: options.resolvedSource.sha }, + ); + } + return options.resolvedSource; + } + + const configuredSource = options.source ?? config.source; + const source = parseUpstreamSource(configuredSource); + if (source.kind === 'branch') { + const remoteRef = `refs/remotes/${upstreamRemoteName}/${source.ref}`; + const sha = gitResult(['rev-parse', '--verify', '--quiet', `${remoteRef}^{commit}`], cwd); + if (sha.status !== 0) { + throw new CompositionError( + 'invalid_lane_base', + `Upstream branch '${source.ref}' was not fetched from ${upstreamRemoteName}.`, + { source: configuredSource, ref: source.ref }, + ); + } + return { configuredSource, label: `branch ${source.ref}`, sha: sha.stdout.trim() }; + } + + const tag = resolveReleaseTag(cwd, config, source.selector); + if (!tag) { + throw new CompositionError('invalid_lane_base', `No upstream release matched selector '${source.selector}'.`, { + source: configuredSource, + selector: source.selector, + }); + } + const tagSha = gitResult(['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{commit}`], cwd); + if (tagSha.status !== 0) { + throw new CompositionError( + 'invalid_lane_base', + `Upstream release tag '${tag}' was not fetched from ${upstreamRemoteName}.`, + { source: configuredSource, tag }, + ); + } + return { configuredSource, label: `release ${tag}`, sha: tagSha.stdout.trim() }; +} + +function hasGeneratedAncestry(cwd: string, resolved: string, diffBase: string) { + const result = gitResult(['log', '--format=%B', `${diffBase}..${resolved}`], cwd); + if (result.status !== 0) return false; + return ( + result.stdout.includes('\nPatch-Ref:') || + result.stdout.includes('\nOriginal-Commit:') || + result.stdout.includes('apply patch/') + ); +} + +function isBasedOnSyncBranch(cwd: string, resolved: string, syncRef: string) { + if (gitResult(['rev-parse', '--verify', '--quiet', `${syncRef}^{commit}`], cwd).status !== 0) return false; + return gitResult(['merge-base', '--is-ancestor', syncRef, resolved], cwd).status === 0; +} + +function resolveLanePlan( + cwd: string, + source: ResolvedSource, + ref: string, + resolvedRef: string, + upstreamRemoteName: string, + syncRef: string, + allowDependentPatches: boolean, +): LanePlan { + const tipResult = gitResult(['rev-parse', '--verify', '--quiet', `${resolvedRef}^{commit}`], cwd); + if (tipResult.status !== 0) { + throw new CompositionError('missing_lane', `Patch lane '${ref}' could not be resolved.`, { ref }); + } + const tipSha = tipResult.stdout.trim(); + const basedOnSource = gitResult(['merge-base', '--is-ancestor', source.sha, tipSha], cwd).status === 0; + let mergeBaseSha = source.sha; + let diffBaseSha = source.sha; + if (!basedOnSource) { + const mergeBase = gitResult(['merge-base', source.sha, tipSha], cwd); + if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) { + throw new CompositionError( + 'invalid_lane_base', + `Patch lane '${ref}' has no merge base with ${source.label}.`, + { ref, source: source.sha }, + ); + } + mergeBaseSha = mergeBase.stdout.trim(); + const unique = git(['rev-list', '--ancestry-path', `${mergeBaseSha}..${tipSha}`], cwd) + .split(/\r?\n/) + .filter(Boolean); + if (unique.length) { + const oldest = unique.at(-1)!; + const tags = gitResult(['tag', '--points-at', oldest], cwd).stdout.trim(); + if (tags) diffBaseSha = oldest; + else { + const parent = gitResult(['rev-parse', `${oldest}^`], cwd).stdout.trim(); + diffBaseSha = parent || mergeBaseSha; + } + } + } + + const commitShas = git(['rev-list', '--no-merges', '--reverse', `${diffBaseSha}..${tipSha}`], cwd) + .split(/\r?\n/) + .filter(Boolean); + const commits = commitShas.map((sha) => ({ + sha, + subject: git(['log', '-1', '--format=%s', sha], cwd), + })); + const changedPaths = gitResult(['diff', '--name-only', `${diffBaseSha}...${tipSha}`], cwd) + .stdout.split(/\r?\n/) + .map((file) => file.trim()) + .filter(Boolean); + const warnings: string[] = []; + if (hasGeneratedAncestry(cwd, tipSha, diffBaseSha)) + warnings.push('Contains generated patchlane commits in ancestry'); + if (isBasedOnSyncBranch(cwd, tipSha, syncRef)) warnings.push('Appears to be based on sync branch output'); + + const upstreamCommits = commitShas.filter( + (sha) => + gitResult( + ['for-each-ref', `--contains=${sha}`, '--format=%(refname)', `refs/remotes/${upstreamRemoteName}`], + cwd, + ).stdout.trim().length > 0, + ); + if (upstreamCommits.length) { + throw new CompositionError( + 'invalid_lane_base', + `Patch lane '${ref}' includes ${upstreamCommits.length} upstream commit(s) that are not part of ${source.label}.`, + { + ref, + source: source.label, + upstreamCommits, + firstUnexpectedCommit: upstreamCommits[0], + diffBaseSha, + }, + ); + } + if (!allowDependentPatches && warnings.length) { + throw new CompositionError( + 'invalid_lane', + `Patch lane '${ref}' contains generated patchlane history or is based on sync output.`, + { ref, warnings, diffBaseSha }, + ); + } + + return { + ref, + resolvedRef, + tipSha, + mergeBaseSha, + diffBaseSha, + commits, + changedPaths, + warnings, + }; +} + +export function resolveCompositionPlan( + config: PatchlaneConfig, + options: ResolveCompositionOptions = {}, +): CompositionPlan { + const cwd = options.cwd ?? process.cwd(); + if (!config.patchRefs.length || new Set(config.patchRefs).size !== config.patchRefs.length) { + throw new CompositionError('invalid_lane', 'Configured patchRefs must contain unique lane refs.', { + patchRefs: config.patchRefs, + }); + } + for (const ref of config.patchRefs) { + if (!isValidRefName(cwd, ref)) { + throw new CompositionError('invalid_lane', `Configured lane '${ref}' is not a valid Git ref.`, { ref }); + } + } + const originRemoteName = options.originRemoteName ?? 'origin'; + const upstreamRemoteName = options.upstreamRemoteName ?? 'upstream'; + const upstreamRemoteUrl = options.upstreamRemoteUrl; + ensureRemote(cwd, upstreamRemoteName, upstreamRemoteUrl, sourceRemoteUrl(config)); + if (options.fetch !== false) { + fetchRemoteBranches(cwd, upstreamRemoteName); + for (const ref of config.patchRefs) fetchLane(cwd, originRemoteName, ref); + const source = parseUpstreamSource(options.source ?? config.source); + if (source.kind === 'release') { + const fetched = gitResult( + ['fetch', '--force', '--tags', upstreamRemoteName, '+refs/tags/*:refs/tags/*'], + cwd, + ); + if (fetched.status !== 0) { + throw new Error( + `Could not fetch tags from '${upstreamRemoteName}': ${[fetched.stderr.trim(), fetched.stdout.trim()].filter(Boolean).join('\n')}`, + ); + } + } + } + + const source = resolveSource(cwd, config, options, upstreamRemoteName); + const remoteSyncRef = `refs/remotes/${originRemoteName}/${config.syncBranch}`; + const lanes = config.patchRefs.map((ref) => { + let resolvedRef: string | undefined; + if (options.laneTips?.[ref]) { + resolvedRef = options.laneTips[ref]; + if (!objectExists(cwd, `${resolvedRef}^{commit}`)) { + throw new CompositionError('missing_lane', `Recorded patch lane '${ref}' is unavailable locally.`, { + ref, + sha: resolvedRef, + }); + } + } else { + const remoteRef = `refs/remotes/${originRemoteName}/${ref}`; + const localRef = `refs/heads/${ref}`; + resolvedRef = + gitResult(['rev-parse', '--verify', '--quiet', `${remoteRef}^{commit}`], cwd).status === 0 + ? remoteRef + : gitResult(['rev-parse', '--verify', '--quiet', `${localRef}^{commit}`], cwd).status === 0 + ? localRef + : undefined; + } + if (!resolvedRef) + throw new CompositionError('missing_lane', `Patch lane '${ref}' could not be resolved.`, { ref }); + return resolveLanePlan( + cwd, + source, + ref, + resolvedRef, + upstreamRemoteName, + remoteSyncRef, + options.allowDependentPatches ?? false, + ); + }); + + return { + source, + lanes, + baseBranch: config.baseBranch, + syncBranch: config.syncBranch, + allowedWorkflows: config.allowedWorkflows, + }; +} + +function conflictPaths(cwd: string, output: string) { + const unmerged = gitResult(['diff', '--name-only', '--diff-filter=U'], cwd).stdout.split(/\r?\n/).filter(Boolean); + if (unmerged.length) return [...new Set(unmerged)]; + return [ + ...new Set( + output.split(/\r?\n/).flatMap((line) => { + const conflict = line.match(/^CONFLICT \(.+\): Merge conflict in (.+)$/); + return conflict ? [conflict[1]!] : []; + }), + ), + ]; +} + +function commitMessage(cwd: string, lane: LanePlan, originalSha: string) { + const subject = git(['log', '-1', '--format=%s', 'HEAD'], cwd); + const body = git(['log', '-1', '--format=%b', 'HEAD'], cwd).trim(); + const patchBase = lane.diffBaseSha; + const trailers = `Patch-Ref: ${lane.ref}\nPatch-Base: ${patchBase}\nOriginal-Commit: ${originalSha}`; + return body ? `${subject}\n\n${body}\n\n${trailers}` : `${subject}\n\n${trailers}`; +} + +function replayCommit( + cwd: string, + lane: LanePlan, + commitSha: string, + recordProvenanceTrailers: boolean, +): { generatedSha?: string; empty: boolean } { + const cherryPick = gitResult(['cherry-pick', commitSha], cwd); + if (cherryPick.status !== 0) { + const output = `${cherryPick.stdout}\n${cherryPick.stderr}`; + if (output.includes('previous cherry-pick is now empty') || output.includes('nothing to commit')) { + gitResult(['cherry-pick', '--skip'], cwd, { allowFailure: true }); + return { empty: true }; + } + const paths = conflictPaths(cwd, output); + gitResult(['cherry-pick', '--abort'], cwd, { allowFailure: true }); + throw new CompositionError('conflict', `Failed to replay commit ${commitSha.slice(0, 7)} from ${lane.ref}.`, { + lane: lane.ref, + commit: commitSha, + conflictedPaths: paths, + output: output.trim(), + }); + } + + if (recordProvenanceTrailers) git(['commit', '--amend', '-m', commitMessage(cwd, lane, commitSha)], cwd); + return { generatedSha: git(['rev-parse', 'HEAD^{commit}'], cwd), empty: false }; +} + +export function composeIntoWorktree( + plan: CompositionPlan, + options: { + cwd: string; + laneOverrides?: Record; + recordProvenanceTrailers?: boolean; + }, +): CompositionResult { + const cwd = options.cwd; + ensureGitIdentity(cwd); + const recordProvenanceTrailers = options.recordProvenanceTrailers ?? true; + const generatedCommits: CompositionResult['generatedCommits'] = []; + const appliedLanes: string[] = []; + + for (const lane of plan.lanes) { + const override = options.laneOverrides?.[lane.ref]; + let commitShas: string[]; + if (override) { + if (!objectExists(cwd, `${override}^{commit}`)) { + throw new CompositionError('missing_lane', `Lane override for '${lane.ref}' is unavailable.`, { + lane: lane.ref, + sha: override, + }); + } + commitShas = git(['rev-list', '--no-merges', '--reverse', `${lane.diffBaseSha}..${override}`], cwd) + .split(/\r?\n/) + .filter(Boolean); + } else { + commitShas = lane.commits.map(({ sha }) => sha); + } + + let applied = false; + for (const commitSha of commitShas) { + const replayed = replayCommit(cwd, lane, commitSha, recordProvenanceTrailers); + if (replayed.empty || !replayed.generatedSha) continue; + applied = true; + generatedCommits.push({ lane: lane.ref, originalSha: commitSha, generatedSha: replayed.generatedSha }); + } + if (applied) appliedLanes.push(lane.ref); + } + + const headSha = git(['rev-parse', 'HEAD^{commit}'], cwd); + const treeSha = git(['rev-parse', 'HEAD^{tree}'], cwd); + if (plan.allowedWorkflows !== undefined) { + let files; + try { + files = workflowFilesAtRef(cwd, headSha); + } catch (error) { + throw new CompositionError( + 'workflow_policy', + `Could not inspect workflows at composed commit ${headSha}.`, + { + cause: error instanceof Error ? error.message : String(error), + }, + ); + } + const violations = validateWorkflowPolicy(plan.allowedWorkflows, files); + if (violations.length) { + throw new CompositionError('workflow_policy', violations[0]!.message, { + violations: violations.map(({ message }) => message), + headSha, + }); + } + } + + return { headSha, treeSha, appliedLanes, generatedCommits }; +} + +export function compositionWorkflowViolations(cwd: string, plan: CompositionPlan, commit: string) { + if (plan.allowedWorkflows === undefined) return []; + return validateWorkflowPolicy(plan.allowedWorkflows, workflowFilesAtRef(cwd, commit)); +} diff --git a/src/config.ts b/src/config.ts index 4a97fc9..ef3b7dd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { parse, stringify } from 'yaml'; import { parseUpstreamSource } from './upstream-source.js'; @@ -112,8 +113,24 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig { if (typeof patchRef !== 'string' || !patchRef.trim()) { throw new Error("Patchlane config field 'patchRefs' must contain only non-empty strings."); } - return patchRef.trim(); + const ref = patchRef.trim(); + if ( + ref.startsWith('-') || + ref.startsWith('refs/') || + ref.startsWith('/') || + ref.endsWith('/') || + ref.includes('\\') || + ref.includes('..') || + ref.includes('@{') || + ref.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw new Error(`Patchlane config field 'patchRefs' contains invalid ref '${ref}'.`); + } + return ref; }); + if (new Set(patchRefs).size !== patchRefs.length) { + throw new Error("Patchlane config field 'patchRefs' must not contain duplicates."); + } const ciWorkflow = value.ciWorkflow; if (ciWorkflow !== undefined && (typeof ciWorkflow !== 'string' || !ciWorkflow.trim())) { @@ -178,17 +195,39 @@ export function serializePatchlaneConfig(config: PatchlaneConfig) { }); } +export function parsePatchlaneConfigText(contents: string, source = PATCHLANE_CONFIG_FILE): PatchlaneConfig { + let parsed: unknown; + try { + parsed = parse(contents) as unknown; + } catch (error) { + throw new Error(`Failed to parse ${source}: ${error instanceof Error ? error.message : String(error)}`); + } + try { + return parsePatchlaneConfig(parsed); + } catch (error) { + throw new Error(`Invalid ${source}: ${error instanceof Error ? error.message : String(error)}`); + } +} + export function loadPatchlaneConfig(cwd = process.cwd(), configPath?: string): PatchlaneConfig | undefined { const resolvedPath = path.resolve(cwd, configPath ?? process.env.PATCHLANE_CONFIG ?? PATCHLANE_CONFIG_FILE); if (!existsSync(resolvedPath)) return undefined; + return parsePatchlaneConfigText( + readFileSync(resolvedPath, 'utf8'), + path.relative(cwd, resolvedPath) || resolvedPath, + ); +} - let parsed: unknown; - try { - parsed = parse(readFileSync(resolvedPath, 'utf8')) as unknown; - } catch (error) { +export function loadPatchlaneConfigAtRef(cwd = process.cwd(), ref: string): PatchlaneConfig { + const result = spawnSync('git', ['show', `${ref}:${PATCHLANE_CONFIG_FILE}`], { + cwd, + encoding: 'utf8', + }); + if (result.error || result.status !== 0) { + const detail = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join('\n'); throw new Error( - `Failed to parse ${path.relative(cwd, resolvedPath) || resolvedPath}: ${error instanceof Error ? error.message : String(error)}`, + `Could not load ${PATCHLANE_CONFIG_FILE} at ref '${ref}': ${detail || result.error?.message || 'git show failed'}`, ); } - return parsePatchlaneConfig(parsed); + return parsePatchlaneConfigText(result.stdout, `${ref}:${PATCHLANE_CONFIG_FILE}`); } diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..8b93666 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,149 @@ +import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'node:child_process'; +import path from 'node:path'; + +export type ProcessResult = { + status: number; + stdout: string; + stderr: string; + error?: Error; +}; + +export type RunGitOptions = { + allowFailure?: boolean; + env?: NodeJS.ProcessEnv; + input?: string; +}; + +export class GitError extends Error { + readonly name = 'GitError'; + + constructor( + message: string, + readonly command: string[], + readonly result?: ProcessResult, + ) { + super(message); + } +} + +export function runProcess( + command: string, + args: string[], + cwd: string, + options: { allowFailure?: boolean; env?: NodeJS.ProcessEnv; input?: string } = {}, +): ProcessResult { + const spawnOptions: SpawnSyncOptionsWithStringEncoding = { + cwd, + env: options.env ?? process.env, + encoding: 'utf8', + input: options.input, + }; + const result = spawnSync(command, args, spawnOptions); + if (result.error) { + if (!options.allowFailure) throw result.error; + return { status: 1, stdout: '', stderr: result.error.message, error: result.error }; + } + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +export function gitResult(args: string[], cwd: string, options: RunGitOptions = {}) { + return runProcess('git', args, cwd, options); +} + +export function git(args: string[], cwd: string, options: RunGitOptions = {}) { + const result = gitResult(args, cwd, { ...options, allowFailure: true }); + if (!options.allowFailure && result.status !== 0) { + const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join('\n'); + throw new GitError(detail || `git ${args.join(' ')} failed`, args, result); + } + return result.stdout.trim(); +} + +export function gitOutput(args: string[], cwd: string, options: RunGitOptions = {}) { + return git(args, cwd, options); +} + +export function gitTopLevel(cwd: string) { + return path.resolve(cwd, git(['rev-parse', '--show-toplevel'], cwd)); +} + +export function gitCommonDir(cwd: string) { + const commonDir = git(['rev-parse', '--git-common-dir'], cwd); + return path.resolve(cwd, commonDir); +} + +export function currentBranch(cwd: string) { + const result = gitResult(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +export function headSha(cwd: string) { + return git(['rev-parse', 'HEAD^{commit}'], cwd); +} + +export function refSha(cwd: string, ref: string) { + const result = gitResult(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +export function objectExists(cwd: string, object: string) { + return gitResult(['cat-file', '-e', object], cwd).status === 0; +} + +export function isValidRefName(cwd: string, ref: string) { + if (!ref || ref.startsWith('-')) return false; + return gitResult(['check-ref-format', `refs/heads/${ref}`], cwd).status === 0; +} + +export type Worktree = { + path: string; + head?: string; + branch?: string; + bare?: boolean; +}; + +export function listWorktrees(cwd: string): Worktree[] { + const result = gitResult(['worktree', 'list', '--porcelain'], cwd); + if (result.status !== 0) return []; + + const worktrees: Worktree[] = []; + let current: Worktree | undefined; + for (const line of result.stdout.split(/\r?\n/)) { + if (line.startsWith('worktree ')) { + if (current) worktrees.push(current); + current = { path: path.resolve(line.slice('worktree '.length)) }; + continue; + } + if (!current) continue; + if (line.startsWith('HEAD ')) current.head = line.slice('HEAD '.length).trim(); + else if (line.startsWith('branch ')) { + const branch = line.slice('branch '.length).trim(); + current.branch = branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch; + } else if (line === 'bare') current.bare = true; + } + if (current) worktrees.push(current); + return worktrees; +} + +export function isWorktreePathRegistered(cwd: string, worktreePath: string) { + const resolved = path.resolve(worktreePath); + return listWorktrees(cwd).some((worktree) => worktree.path === resolved); +} + +export function ensureGitIdentity(cwd: string) { + const name = gitResult(['config', 'user.name'], cwd).stdout.trim(); + const email = gitResult(['config', 'user.email'], cwd).stdout.trim(); + if (!name) git(['config', 'user.name', 'patchlane'], cwd); + if (!email) git(['config', 'user.email', 'patchlane@localhost'], cwd); +} + +export function formatGitFailure(result: ProcessResult, command = 'git') { + return ( + [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join('\n') || + `${command} exited with status ${result.status}` + ); +} diff --git a/src/workspace-create.ts b/src/workspace-create.ts new file mode 100644 index 0000000..2fde40c --- /dev/null +++ b/src/workspace-create.ts @@ -0,0 +1,186 @@ +import { existsSync, mkdirSync, realpathSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { loadPatchlaneConfig, loadPatchlaneConfigAtRef, type PatchlaneConfig } from './config.js'; +import { resolveCompositionPlan, composeIntoWorktree, type CompositionPlan } from './composition.js'; +import { git, gitResult, currentBranch, gitTopLevel } from './git.js'; +import { + parseWorkspaceState, + writeWorkspaceState, + type WorkspaceState, + workspaceStatePath, +} from './workspace-state.js'; + +export type WorkspaceCreateOptions = { + cwd?: string; + lane: string; + path?: string; + name?: string; + source?: string; + configRef?: string; + originRemoteName?: string; + upstreamRemoteName?: string; + upstreamRemoteUrl?: string; +}; + +export type WorkspaceCreateResult = { + state: WorkspaceState; + plan: CompositionPlan; +}; + +function slug(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} + +function configuredRef(cwd: string) { + const branch = currentBranch(cwd); + if (branch && gitResult(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`], cwd).status === 0) { + return branch; + } + return 'HEAD'; +} + +function defaultWorkspacePath(cwd: string, lane: string) { + const root = gitTopLevel(cwd); + const repositoryName = path.basename(root); + const laneName = slug(lane.split('/').at(-1) ?? lane) || 'workspace'; + return path.resolve(path.dirname(root), `${repositoryName}-patch-${laneName}`); +} + +function generatedWorkspaceId(lane: string, sourceSha: string) { + return `${slug(lane.split('/').at(-1) ?? lane) || 'workspace'}-${sourceSha.slice(0, 6)}`; +} + +function resolveDestination(cwd: string, requestedPath: string) { + const requested = path.resolve(cwd, requestedPath); + mkdirSync(path.dirname(requested), { recursive: true }); + return path.join(realpathSync(path.dirname(requested)), path.basename(requested)); +} + +function ensureDestinationAvailable(destination: string) { + if (existsSync(destination)) throw new Error(`Workspace destination '${destination}' already exists.`); +} + +function cleanUpWorkspace(cwd: string, destination: string, branch: string, id: string) { + gitResult(['worktree', 'remove', '--force', destination], cwd, { allowFailure: true }); + rmSync(destination, { force: true, recursive: true }); + gitResult(['branch', '-D', branch], cwd, { allowFailure: true }); + gitResult(['config', '--unset', `branch.${branch}.patchlane-workspace`], cwd, { allowFailure: true }); + const statePath = workspaceStatePath(cwd, id); + rmSync(statePath, { force: true }); +} + +function loadConfig(cwd: string, configRef: string | undefined): { config: PatchlaneConfig; ref: string } { + if (configRef) return { config: loadPatchlaneConfigAtRef(cwd, configRef), ref: configRef }; + const config = loadPatchlaneConfig(cwd); + if (!config) throw new Error('Missing .patchlane.yml. Run from a configured branch or pass --config-ref.'); + return { config, ref: configuredRef(cwd) }; +} + +export function createWorkspace(options: WorkspaceCreateOptions): WorkspaceCreateResult { + const cwd = path.resolve(options.cwd ?? process.cwd()); + if (!options.lane || !options.lane.trim()) throw new Error('workspace create requires --lane .'); + const targetLane = options.lane.trim(); + const { config, ref: configRef } = loadConfig(cwd, options.configRef); + const occurrences = config.patchRefs.filter((ref) => ref === targetLane).length; + if (occurrences !== 1) { + throw new Error(`Target lane '${targetLane}' must appear exactly once in .patchlane.yml patchRefs.`); + } + + const originRemoteName = options.originRemoteName ?? process.env.ORIGIN_REMOTE_NAME ?? 'origin'; + const upstreamRemoteName = options.upstreamRemoteName ?? process.env.UPSTREAM_REMOTE_NAME ?? 'upstream'; + const plan = resolveCompositionPlan(config, { + cwd, + originRemoteName, + upstreamRemoteName, + upstreamRemoteUrl: options.upstreamRemoteUrl ?? process.env.UPSTREAM_REMOTE_URL, + source: options.source, + }); + const id = options.name?.trim() || generatedWorkspaceId(targetLane, plan.source.sha); + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { + throw new Error(`Workspace name '${id}' must contain only lowercase letters, numbers, and hyphens.`); + } + const statePath = workspaceStatePath(cwd, id); + if (existsSync(statePath)) throw new Error(`Patchlane workspace '${id}' is already registered.`); + + const destination = resolveDestination(cwd, options.path ?? defaultWorkspacePath(cwd, targetLane)); + ensureDestinationAvailable(destination); + const branch = `patchlane/work/${id}`; + if (gitResult(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], cwd).status === 0) { + throw new Error(`Workspace branch '${branch}' already exists.`); + } + + let worktreeCreated = false; + let branchCreated = false; + try { + git(['worktree', 'add', '--detach', destination, plan.source.sha], cwd); + worktreeCreated = true; + const composition = composeIntoWorktree(plan, { cwd: destination }); + git(['branch', branch, composition.headSha], cwd); + branchCreated = true; + git(['switch', branch], destination); + git(['config', '--local', `branch.${branch}.patchlane-workspace`, id], cwd); + + const state: WorkspaceState = { + version: 1, + id, + path: destination, + branch, + createdAt: new Date().toISOString(), + configRef, + originRemoteName, + upstreamRemoteName, + source: plan.source, + targetLane, + baselineCommit: composition.headSha, + baselineTree: composition.treeSha, + laneOrder: plan.lanes.map((lane) => lane.ref), + laneTips: Object.fromEntries(plan.lanes.map((lane) => [lane.ref, lane.tipSha])), + laneDiffBases: Object.fromEntries(plan.lanes.map((lane) => [lane.ref, lane.diffBaseSha])), + landedLaneSha: null, + }; + // Validate against the registered worktree before atomically publishing state. + parseWorkspaceState(state, { cwd, requireRegisteredWorktree: true }); + writeWorkspaceState(state, cwd); + return { state, plan }; + } catch (error) { + if (worktreeCreated || branchCreated) cleanUpWorkspace(cwd, destination, branch, id); + throw error; + } +} + +export const runWorkspaceCreate = createWorkspace; + +export function formatWorkspaceCreateResult(result: WorkspaceCreateResult) { + const { state, plan } = result; + return [ + 'Created Patchlane workspace.', + '', + `Path: ${state.path}`, + `Branch: ${state.branch}`, + `Target lane: ${state.targetLane}`, + `Source: ${plan.source.label} @ ${plan.source.sha.slice(0, 7)}`, + `Baseline: ${state.baselineCommit.slice(0, 7)}`, + 'Lane order:', + ...plan.lanes.map((lane, index) => ` ${index + 1}. ${lane.ref}`), + '', + 'Work in the new directory and commit normally.', + 'Run `patchlane workspace land --dry-run` before landing.', + ].join('\n'); +} + +export function formatWorkspaceCreateJson(result: WorkspaceCreateResult) { + return JSON.stringify( + { + ...result.state, + path: result.state.path, + source: result.plan.source, + lanes: result.plan.lanes, + }, + null, + 2, + ); +} diff --git a/src/workspace-land.ts b/src/workspace-land.ts new file mode 100644 index 0000000..37d3430 --- /dev/null +++ b/src/workspace-land.ts @@ -0,0 +1,489 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { loadPatchlaneConfig, loadPatchlaneConfigAtRef } from './config.js'; +import { composeIntoWorktree, resolveCompositionPlan, type CompositionPlan } from './composition.js'; +import { ensureGitIdentity, git, gitResult } from './git.js'; +import { inspectWorkspaceStatus, type WorkspaceStatus } from './workspace-status.js'; +import { findWorkspaceState, worktreeForBranch, writeWorkspaceState, type WorkspaceState } from './workspace-state.js'; + +export type WorkspaceLandOptions = { + cwd?: string; + lane?: string; + dryRun?: boolean; + push?: boolean; + originRemoteName?: string; + upstreamRemoteName?: string; + upstreamRemoteUrl?: string; +}; + +export type WorkspaceLandResult = { + status: 'dry_run' | 'landed'; + workspaceId: string; + targetLane: string; + workspaceHead: string; + candidateLaneSha: string; + candidateCompositionSha: string; + workspaceTree: string; + compositionTree: string; + pushed: boolean; +}; + +export type WorkspaceLandErrorCode = + | 'workspace_stale' + | 'workspace_invalid' + | 'workspace_conflict' + | 'round_trip_mismatch' + | 'push_failed'; + +export class WorkspaceLandError extends Error { + readonly name = 'WorkspaceLandError'; + + constructor( + readonly code: WorkspaceLandErrorCode, + message: string, + readonly details: Record = {}, + ) { + super(message); + } +} + +function candidateRef(id: string, name: 'target' | 'composition') { + return `refs/patchlane/land/${id}/${name}`; +} + +function parseConflictPaths(cwd: string, output: string) { + const unmerged = gitResult(['diff', '--name-only', '--diff-filter=U'], cwd).stdout.split(/\r?\n/).filter(Boolean); + if (unmerged.length) return [...new Set(unmerged)]; + return [ + ...new Set( + output.split(/\r?\n/).flatMap((line) => { + const match = line.match(/^CONFLICT \(.+\): Merge conflict in (.+)$/); + return match ? [match[1]!] : []; + }), + ), + ]; +} + +function replayWorkspaceCommits(cwd: string, status: WorkspaceStatus, targetLane: string, plan: CompositionPlan) { + const commits = git(['rev-list', '--reverse', `${status.baselineCommit}..${status.workspaceHead}`], cwd) + .split(/\r?\n/) + .filter(Boolean); + let applied = false; + for (const commit of commits) { + const result = gitResult(['cherry-pick', commit], cwd); + if (result.status === 0) { + applied = true; + continue; + } + const output = `${result.stdout}\n${result.stderr}`; + if (output.includes('previous cherry-pick is now empty') || output.includes('nothing to commit')) { + gitResult(['cherry-pick', '--skip'], cwd, { allowFailure: true }); + continue; + } + const conflictedPaths = parseConflictPaths(cwd, output); + gitResult(['cherry-pick', '--abort'], cwd, { allowFailure: true }); + const likelyOriginatingLanes = plan.lanes + .filter((lane) => lane.changedPaths.some((path) => conflictedPaths.includes(path))) + .map((lane) => lane.ref); + const diagnostic = [ + conflictedPaths.length ? `Conflicted paths: ${conflictedPaths.join(', ')}` : '', + likelyOriginatingLanes.length ? `Likely originating lanes: ${likelyOriginatingLanes.join(', ')}` : '', + `Reproduction: git cherry-pick ${commit}`, + ] + .filter(Boolean) + .join('. '); + throw new WorkspaceLandError( + 'workspace_conflict', + `Workspace commit ${commit.slice(0, 7)} could not be projected onto ${targetLane}.${diagnostic ? ` ${diagnostic}.` : ''}`, + { + workspaceCommit: commit, + targetLane, + conflictedPaths, + likelyOriginatingLanes, + reproduction: `git cherry-pick ${commit}`, + }, + ); + } + return { head: git(['rev-parse', 'HEAD^{commit}'], cwd), applied }; +} + +function removeCandidate(cwd: string, ref: string) { + gitResult(['update-ref', '-d', ref], cwd, { allowFailure: true }); +} + +function createCandidateWorktree(cwd: string, ref: string, sha: string) { + const parent = mkdtempSync(path.join(tmpdir(), 'patchlane-land-')); + const worktreePath = path.join(parent, 'candidate'); + git(['update-ref', ref, sha, ''], cwd); + try { + git(['worktree', 'add', '--detach', worktreePath, ref], cwd); + } catch (error) { + removeCandidate(cwd, ref); + rmSync(parent, { force: true, recursive: true }); + throw error; + } + return { parent, worktreePath }; +} + +function removeCandidateWorktree(cwd: string, parent: string, worktreePath: string, ref: string) { + gitResult(['worktree', 'remove', '--force', worktreePath], cwd, { allowFailure: true }); + rmSync(parent, { force: true, recursive: true }); + removeCandidate(cwd, ref); +} + +function configForWorkspace(cwd: string, state: WorkspaceState) { + try { + return loadPatchlaneConfigAtRef(cwd, state.configRef); + } catch (error) { + const local = loadPatchlaneConfig(cwd); + if (local) return local; + throw error; + } +} + +function laneRemoteTip(cwd: string, remote: string, lane: string) { + const fetched = gitResult( + ['fetch', '--prune', '--no-tags', remote, `+refs/heads/${lane}:refs/remotes/${remote}/${lane}`], + cwd, + { allowFailure: true }, + ); + if (fetched.status !== 0) return undefined; + const result = gitResult(['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${lane}^{commit}`], cwd); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +function validateLaneFreshness(cwd: string, state: WorkspaceState) { + const changedLanes: Array<{ ref: string; expected: string; actual?: string }> = []; + for (const ref of state.laneOrder) { + const remote = laneRemoteTip(cwd, state.originRemoteName, ref); + const localResult = gitResult(['rev-parse', '--verify', '--quiet', `refs/heads/${ref}^{commit}`], cwd); + const local = localResult.status === 0 ? localResult.stdout.trim() : undefined; + const actual = + (remote && remote !== state.laneTips[ref] ? remote : undefined) ?? + (local && local !== state.laneTips[ref] ? local : undefined) ?? + remote ?? + local; + if (actual !== state.laneTips[ref]) changedLanes.push({ ref, expected: state.laneTips[ref]!, actual }); + } + return changedLanes; +} + +function laneOverridePlan( + config: ReturnType, + state: WorkspaceState, + cwd: string, + originRemoteName: string, + upstreamRemoteName: string, + upstreamRemoteUrl: string | undefined, +) { + if ( + config.patchRefs.length !== state.laneOrder.length || + config.patchRefs.some((ref, index) => ref !== state.laneOrder[index]) + ) { + throw new WorkspaceLandError( + 'workspace_stale', + 'The Patchlane configuration no longer has the same ordered lane set as this workspace.', + { expectedLaneOrder: state.laneOrder, actualLaneOrder: config.patchRefs }, + ); + } + return resolveCompositionPlan(config, { + cwd, + originRemoteName, + upstreamRemoteName, + upstreamRemoteUrl, + resolvedSource: state.source, + laneTips: state.laneTips, + fetch: false, + }); +} + +function mismatchDetails(cwd: string, candidateComposition: string, workspaceHead: string, plan: CompositionPlan) { + const raw = gitResult( + ['diff', '--name-status', '--no-renames', '-z', candidateComposition, workspaceHead], + cwd, + ).stdout; + const entries: Array<{ status: string; path: string; owner?: string; laterLane?: string }> = []; + const parts = raw.split('\0'); + for (let index = 0; index + 1 < parts.length; index += 2) { + const status = parts[index]; + const relativePath = parts[index + 1]; + if (!status || !relativePath) continue; + const log = gitResult(['log', '--format=%B', candidateComposition, '--', relativePath], cwd).stdout; + const owner = log.match(/(?:^|\n)Patch-Ref:\s*([^\n]+)/)?.[1]?.trim(); + const ownerIndex = owner ? plan.lanes.findIndex((lane) => lane.ref === owner) : -1; + const laterLane = plan.lanes + .slice(ownerIndex + 1) + .find((lane) => lane.changedPaths.includes(relativePath))?.ref; + entries.push({ status, path: relativePath, ...(owner ? { owner } : {}), ...(laterLane ? { laterLane } : {}) }); + } + return entries; +} + +function formatMismatch( + targetLane: string, + differences: Array<{ status: string; path: string; owner?: string; laterLane?: string }>, +) { + const lines = [ + 'Round-trip validation failed.', + '', + `Workspace target: ${targetLane}`, + '', + 'The projected lane does not reproduce the tested workspace tree:', + ]; + for (const difference of differences) { + lines.push(` ${difference.status} ${difference.path}`); + if (difference.owner) lines.push(` Last composed owner: ${difference.owner}`); + if (difference.laterLane) lines.push(` Later modifying lane: ${difference.laterLane}`); + } + lines.push( + '', + 'No lane refs were changed.', + '', + 'Possible causes:', + ' - the selected target lane is incorrect;', + ' - the change depends on another lane;', + ' - a later lane overwrites part of the projected change;', + ' - the workspace contains changes belonging to multiple lanes.', + ); + return lines.join('\n'); +} + +function localLaneLease(cwd: string, lane: string, expected: string) { + const result = gitResult(['rev-parse', '--verify', '--quiet', `refs/heads/${lane}^{commit}`], cwd); + const actual = result.status === 0 ? result.stdout.trim() : undefined; + if (actual && actual !== expected) { + throw new WorkspaceLandError('workspace_stale', `Local lane '${lane}' moved since workspace creation.`, { + changedLanes: [{ ref: lane, expected, actual }], + }); + } + return actual ?? ''; +} + +export function landWorkspace(options: WorkspaceLandOptions = {}): WorkspaceLandResult { + const cwd = options.cwd ?? process.cwd(); + const state = findWorkspaceState(cwd); + const status = inspectWorkspaceStatus({ cwd, state }); + if (status.workingTree === 'dirty') { + throw new WorkspaceLandError('workspace_invalid', 'The workspace has uncommitted changes.', { + workingTree: 'dirty', + }); + } + if (!status.baselineIsAncestor) { + throw new WorkspaceLandError( + 'workspace_invalid', + 'Workspace HEAD is not descended from the recorded baseline commit.', + { + baselineCommit: state.baselineCommit, + workspaceHead: status.workspaceHead, + }, + ); + } + if (status.mergeCommits.length) { + throw new WorkspaceLandError( + 'workspace_invalid', + 'Workspace history must be linear; merge commits are not supported.', + { + mergeCommits: status.mergeCommits, + }, + ); + } + if (!status.commitsToLand) + throw new WorkspaceLandError('workspace_invalid', 'There are no workspace commits to land.'); + + const targetLane = options.lane?.trim() || state.targetLane; + if (!state.laneOrder.includes(targetLane)) { + throw new WorkspaceLandError( + 'workspace_invalid', + `Target lane '${targetLane}' is not part of this workspace.`, + { + targetLane, + laneOrder: state.laneOrder, + }, + ); + } + const config = configForWorkspace(cwd, state); + if (!config.patchRefs.includes(targetLane)) { + throw new WorkspaceLandError( + 'workspace_invalid', + `Target lane '${targetLane}' is not configured in patchRefs.`, + ); + } + const otherWorktree = worktreeForBranch(cwd, targetLane); + if (otherWorktree && otherWorktree.path !== state.path) { + throw new WorkspaceLandError( + 'workspace_invalid', + `Target lane '${targetLane}' is checked out in another worktree.`, + { + targetLane, + worktree: otherWorktree.path, + }, + ); + } + + const changedLanes = validateLaneFreshness(cwd, state); + if (changedLanes.length) { + throw new WorkspaceLandError( + 'workspace_stale', + `One or more configured lane refs moved since workspace creation: ${changedLanes.map((lane) => lane.ref).join(', ')}.`, + { changedLanes }, + ); + } + + const originRemoteName = options.originRemoteName ?? state.originRemoteName; + const upstreamRemoteName = options.upstreamRemoteName ?? state.upstreamRemoteName; + const plan = laneOverridePlan( + config, + state, + cwd, + originRemoteName, + upstreamRemoteName, + options.upstreamRemoteUrl ?? process.env.UPSTREAM_REMOTE_URL, + ); + const targetExpected = state.laneTips[targetLane]!; + const targetRef = candidateRef(state.id, 'target'); + const compositionRef = candidateRef(state.id, 'composition'); + let targetCandidate: { parent: string; worktreePath: string } | undefined; + let compositionCandidate: { parent: string; worktreePath: string } | undefined; + let candidateLaneSha = targetExpected; + let candidateCompositionSha = ''; + let workspaceTree = git(['rev-parse', `${status.workspaceHead}^{tree}`], cwd); + let compositionTree = ''; + let pushed = false; + + try { + targetCandidate = createCandidateWorktree(cwd, targetRef, targetExpected); + ensureGitIdentity(targetCandidate.worktreePath); + const projected = replayWorkspaceCommits(targetCandidate.worktreePath, status, targetLane, plan); + candidateLaneSha = projected.head; + git(['update-ref', targetRef, candidateLaneSha, targetExpected], cwd); + + compositionCandidate = createCandidateWorktree(cwd, compositionRef, state.source.sha); + const composed = composeIntoWorktree(plan, { + cwd: compositionCandidate.worktreePath, + laneOverrides: { [targetLane]: candidateLaneSha }, + }); + candidateCompositionSha = composed.headSha; + compositionTree = composed.treeSha; + git(['update-ref', compositionRef, candidateCompositionSha, state.source.sha], cwd); + + if (workspaceTree !== compositionTree) { + const differences = mismatchDetails(cwd, candidateCompositionSha, status.workspaceHead, plan); + throw new WorkspaceLandError('round_trip_mismatch', formatMismatch(targetLane, differences), { + targetLane, + differences, + workspaceTree, + compositionTree, + }); + } + + if (options.dryRun) { + return { + status: 'dry_run', + workspaceId: state.id, + targetLane, + workspaceHead: status.workspaceHead, + candidateLaneSha, + candidateCompositionSha, + workspaceTree, + compositionTree, + pushed: false, + }; + } + + const finalChangedLanes = validateLaneFreshness(cwd, state); + if (finalChangedLanes.length) { + throw new WorkspaceLandError( + 'workspace_stale', + `One or more configured lane refs moved while the landing candidate was being built: ${finalChangedLanes.map((lane) => lane.ref).join(', ')}.`, + { changedLanes: finalChangedLanes }, + ); + } + const localExpected = localLaneLease(cwd, targetLane, targetExpected); + git(['update-ref', `refs/heads/${targetLane}`, candidateLaneSha, localExpected], cwd); + + if (options.push) { + const remoteExpected = laneRemoteTip(cwd, state.originRemoteName, targetLane); + if (remoteExpected !== targetExpected) { + throw new WorkspaceLandError( + 'workspace_stale', + `Remote lane '${targetLane}' moved before push; refusing to push.`, + { + changedLanes: [{ ref: targetLane, expected: targetExpected, actual: remoteExpected }], + }, + ); + } + const push = gitResult( + [ + 'push', + `--force-with-lease=refs/heads/${targetLane}:${targetExpected}`, + state.originRemoteName, + `${candidateLaneSha}:refs/heads/${targetLane}`, + ], + cwd, + ); + if (push.status !== 0) { + throw new WorkspaceLandError( + 'push_failed', + `Failed to push lane '${targetLane}' with force-with-lease: ${push.stderr.trim() || push.stdout.trim() || 'remote rejected the update'}.`, + { + targetLane, + localLaneSha: candidateLaneSha, + expectedRemoteSha: targetExpected, + stderr: push.stderr.trim(), + }, + ); + } + pushed = true; + } + + state.landedLaneSha = candidateLaneSha; + state.landedAt = new Date().toISOString(); + state.pushed = pushed; + state.landedWorkspaceHead = status.workspaceHead; + state.landedLane = targetLane; + writeWorkspaceState(state, cwd); + return { + status: 'landed', + workspaceId: state.id, + targetLane, + workspaceHead: status.workspaceHead, + candidateLaneSha, + candidateCompositionSha, + workspaceTree, + compositionTree, + pushed, + }; + } finally { + if (compositionCandidate) + removeCandidateWorktree( + cwd, + compositionCandidate.parent, + compositionCandidate.worktreePath, + compositionRef, + ); + else removeCandidate(cwd, compositionRef); + if (targetCandidate) + removeCandidateWorktree(cwd, targetCandidate.parent, targetCandidate.worktreePath, targetRef); + else removeCandidate(cwd, targetRef); + } +} + +export const runWorkspaceLand = landWorkspace; + +export function formatWorkspaceLand(result: WorkspaceLandResult) { + return [ + result.status === 'dry_run' ? 'Workspace landing validated.' : 'Workspace landed successfully.', + '', + `Target lane: ${result.targetLane}`, + `Workspace HEAD: ${result.workspaceHead.slice(0, 7)}`, + `Projected lane: ${result.candidateLaneSha.slice(0, 7)}`, + `Composed tree: ${result.compositionTree.slice(0, 7)}`, + `Remote push: ${result.pushed ? 'yes' : 'no'}`, + ...(result.status === 'dry_run' ? ['No lane refs were changed.'] : []), + ].join('\n'); +} + +export function formatWorkspaceLandJson(result: WorkspaceLandResult) { + return `${JSON.stringify(result, null, 2)}`; +} diff --git a/src/workspace-remove.ts b/src/workspace-remove.ts new file mode 100644 index 0000000..5ffc7dd --- /dev/null +++ b/src/workspace-remove.ts @@ -0,0 +1,69 @@ +import { rmSync } from 'node:fs'; +import { findWorkspaceState, workspaceStatePath } from './workspace-state.js'; +import { gitResult, listWorktrees } from './git.js'; +import { inspectWorkspaceStatus } from './workspace-status.js'; + +export type WorkspaceRemoveOptions = { + cwd?: string; + force?: boolean; +}; + +export type WorkspaceRemoveResult = { + id: string; + path: string; + branch: string; +}; + +export function removeWorkspace(options: WorkspaceRemoveOptions = {}): WorkspaceRemoveResult { + const cwd = options.cwd ?? process.cwd(); + const state = findWorkspaceState(cwd); + const stateFile = workspaceStatePath(cwd, state.id); + const repositoryCwd = + listWorktrees(cwd).find((worktree) => worktree.path !== state.path && !worktree.bare)?.path ?? cwd; + const status = inspectWorkspaceStatus({ cwd, state }); + const unlanded = + status.commitsToLand > 0 && (!state.landedWorkspaceHead || state.landedWorkspaceHead !== status.workspaceHead); + if (!options.force && (status.workingTree === 'dirty' || unlanded)) { + const reasons = [ + status.workingTree === 'dirty' ? 'uncommitted changes' : '', + unlanded ? 'unlanded workspace commits' : '', + ].filter(Boolean); + throw new Error( + `Refusing to remove workspace '${state.id}' with ${reasons.join(' and ')}; pass --force to remove it.`, + ); + } + + const removed = gitResult( + ['worktree', 'remove', options.force ? '--force' : '', state.path].filter(Boolean), + repositoryCwd, + ); + if (removed.status !== 0) { + throw new Error( + [removed.stderr.trim(), removed.stdout.trim()].filter(Boolean).join('\n') || + `Could not remove worktree '${state.path}'.`, + ); + } + rmSync(state.path, { force: true, recursive: true }); + gitResult(['branch', '-D', state.branch], repositoryCwd, { allowFailure: true }); + gitResult(['config', '--unset', `branch.${state.branch}.patchlane-workspace`], repositoryCwd, { + allowFailure: true, + }); + const refs = gitResult(['for-each-ref', '--format=%(refname)', `refs/patchlane/land/${state.id}`], repositoryCwd, { + allowFailure: true, + }) + .stdout.split(/\r?\n/) + .filter(Boolean); + for (const ref of refs) gitResult(['update-ref', '-d', ref], repositoryCwd, { allowFailure: true }); + rmSync(stateFile, { force: true }); + return { id: state.id, path: state.path, branch: state.branch }; +} + +export const runWorkspaceRemove = removeWorkspace; + +export function formatWorkspaceRemove(result: WorkspaceRemoveResult) { + return `Removed Patchlane workspace '${result.id}' (${result.path}).`; +} + +export function formatWorkspaceRemoveJson(result: WorkspaceRemoveResult) { + return JSON.stringify(result, null, 2); +} diff --git a/src/workspace-state.ts b/src/workspace-state.ts new file mode 100644 index 0000000..f81ce9f --- /dev/null +++ b/src/workspace-state.ts @@ -0,0 +1,293 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { ResolvedSource } from './composition.js'; +import { parseUpstreamSource } from './upstream-source.js'; +import { + currentBranch, + gitCommonDir, + isValidRefName, + isWorktreePathRegistered, + listWorktrees, + gitTopLevel, + objectExists, +} from './git.js'; + +export const WORKSPACE_STATE_VERSION = 1; +export const WORKSPACE_STATE_DIRECTORY = path.join('patchlane', 'workspaces'); + +export type WorkspaceState = { + version: 1; + id: string; + path: string; + branch: string; + createdAt: string; + configRef: string; + originRemoteName: string; + upstreamRemoteName: string; + source: ResolvedSource; + targetLane: string; + baselineCommit: string; + baselineTree: string; + laneOrder: string[]; + laneTips: Record; + laneDiffBases: Record; + landedLaneSha: string | null; + landedAt?: string; + pushed?: boolean; + landedWorkspaceHead?: string; + landedLane?: string; +}; + +type StateValidationOptions = { + cwd?: string; + requireRegisteredWorktree?: boolean; +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requiredString(value: Record, field: string) { + if (typeof value[field] !== 'string' || !value[field].trim()) { + throw new Error(`Workspace state field '${field}' must be a non-empty string.`); + } + return value[field].trim(); +} + +function validSha(value: unknown, field: string) { + if (typeof value !== 'string' || !/^[0-9a-f]{7,64}$/i.test(value)) { + throw new Error(`Workspace state field '${field}' must contain a Git object ID.`); + } + return value; +} + +function validRef(value: string, field: string, cwd?: string) { + const staticallyInvalid = + !value || + value.startsWith('-') || + value.startsWith('/') || + value.endsWith('/') || + value.startsWith('.') || + value.endsWith('.') || + value.includes('..') || + value.includes('//') || + value.includes('@{') || + /[~^:?*\\[\\]\\\\\u0000-\\u001f]/.test(value); + if (staticallyInvalid || (cwd ? !isValidRefName(cwd, value) : false)) { + throw new Error(`Workspace state field '${field}' contains invalid ref '${value}'.`); + } + return value; +} + +function parseLaneMap(value: unknown, field: string, lanes: string[]) { + if (!isPlainObject(value)) throw new Error(`Workspace state field '${field}' must be an object.`); + const keys = Object.keys(value); + if (keys.length !== lanes.length || keys.some((lane) => !lanes.includes(lane))) { + throw new Error(`Workspace state field '${field}' must contain exactly one value for every lane.`); + } + return Object.fromEntries(lanes.map((lane) => [lane, validSha(value[lane], `${field}.${lane}`)])); +} + +export function parseWorkspaceState(value: unknown, options: StateValidationOptions = {}): WorkspaceState { + if (!isPlainObject(value)) throw new Error('Workspace state must be a JSON object.'); + if (value.version !== WORKSPACE_STATE_VERSION) { + throw new Error(`Unsupported Patchlane workspace state version '${String(value.version)}'.`); + } + + const cwd = options.cwd; + const id = requiredString(value, 'id'); + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw new Error(`Workspace state field 'id' is invalid: '${id}'.`); + const workspacePath = requiredString(value, 'path'); + if (!path.isAbsolute(workspacePath)) throw new Error("Workspace state field 'path' must be absolute."); + if (options.requireRegisteredWorktree && cwd && !isWorktreePathRegistered(cwd, workspacePath)) { + throw new Error(`Workspace state path '${workspacePath}' is not a registered Git worktree.`); + } + + const branch = requiredString(value, 'branch'); + if (!branch.startsWith('patchlane/work/')) { + throw new Error(`Workspace state field 'branch' must be a disposable Patchlane workspace branch.`); + } + validRef(branch, 'branch', cwd); + const createdAt = requiredString(value, 'createdAt'); + if (Number.isNaN(Date.parse(createdAt))) throw new Error("Workspace state field 'createdAt' must be an ISO date."); + const configRef = requiredString(value, 'configRef'); + const originRemoteName = requiredString(value, 'originRemoteName'); + const upstreamRemoteName = requiredString(value, 'upstreamRemoteName'); + const targetLane = requiredString(value, 'targetLane'); + const baselineCommit = validSha(value.baselineCommit, 'baselineCommit'); + const baselineTree = validSha(value.baselineTree, 'baselineTree'); + + if (!Array.isArray(value.laneOrder) || value.laneOrder.length === 0) { + throw new Error("Workspace state field 'laneOrder' must contain at least one lane."); + } + const laneOrder = value.laneOrder.map((lane, index) => { + if (typeof lane !== 'string' || !lane.trim()) { + throw new Error(`Workspace state field 'laneOrder[${index}]' must be a non-empty string.`); + } + return validRef(lane.trim(), `laneOrder[${index}]`, cwd); + }); + if (new Set(laneOrder).size !== laneOrder.length) + throw new Error('Workspace state laneOrder must not contain duplicates.'); + if (!laneOrder.includes(targetLane)) throw new Error(`Workspace target lane '${targetLane}' is not in laneOrder.`); + + const sourceValue = value.source; + if (!isPlainObject(sourceValue)) throw new Error("Workspace state field 'source' must be an object."); + const source: ResolvedSource = { + configuredSource: requiredString(sourceValue, 'configuredSource'), + label: requiredString(sourceValue, 'label'), + sha: validSha(sourceValue.sha, 'source.sha'), + }; + try { + parseUpstreamSource(source.configuredSource); + } catch (error) { + throw new Error(`Workspace state source is invalid: ${error instanceof Error ? error.message : String(error)}`); + } + + const laneTips = parseLaneMap(value.laneTips, 'laneTips', laneOrder); + const laneDiffBases = parseLaneMap(value.laneDiffBases, 'laneDiffBases', laneOrder); + if (cwd) { + const commitObjects = [ + ['baselineCommit', baselineCommit], + ['source.sha', source.sha], + ...laneOrder.flatMap((lane) => [ + [`laneTips.${lane}`, laneTips[lane]!], + [`laneDiffBases.${lane}`, laneDiffBases[lane]!], + ]), + ]; + for (const [field, sha] of commitObjects) { + if (!objectExists(cwd, `${sha}^{commit}`)) + throw new Error(`Workspace state ${field} '${sha}' is missing from the repository.`); + } + if (!objectExists(cwd, `${baselineTree}^{tree}`)) { + throw new Error(`Workspace state baselineTree '${baselineTree}' is missing from the repository.`); + } + } + if (value.landedLaneSha !== null && value.landedLaneSha !== undefined) + validSha(value.landedLaneSha, 'landedLaneSha'); + if ( + value.landedAt !== undefined && + (typeof value.landedAt !== 'string' || Number.isNaN(Date.parse(value.landedAt))) + ) { + throw new Error("Workspace state field 'landedAt' must be an ISO date when provided."); + } + if (value.pushed !== undefined && typeof value.pushed !== 'boolean') { + throw new Error("Workspace state field 'pushed' must be a boolean when provided."); + } + if ( + value.landedLaneSha !== null && + value.landedLaneSha !== undefined && + cwd && + !objectExists(cwd, `${value.landedLaneSha}^{commit}`) + ) { + throw new Error(`Workspace state landedLaneSha '${value.landedLaneSha}' is missing from the repository.`); + } + if (value.landedWorkspaceHead !== undefined) validSha(value.landedWorkspaceHead, 'landedWorkspaceHead'); + if (value.landedWorkspaceHead !== undefined && cwd && !objectExists(cwd, `${value.landedWorkspaceHead}^{commit}`)) { + throw new Error( + `Workspace state landedWorkspaceHead '${value.landedWorkspaceHead}' is missing from the repository.`, + ); + } + if (value.landedLane !== undefined) validRef(requiredString(value, 'landedLane'), 'landedLane', cwd); + + return { + version: 1, + id, + path: path.resolve(workspacePath), + branch, + createdAt, + configRef, + originRemoteName, + upstreamRemoteName, + source, + targetLane, + baselineCommit, + baselineTree, + laneOrder, + laneTips, + laneDiffBases, + landedLaneSha: value.landedLaneSha === undefined ? null : (value.landedLaneSha as string | null), + ...(value.landedAt === undefined ? {} : { landedAt: value.landedAt as string }), + ...(value.pushed === undefined ? {} : { pushed: value.pushed as boolean }), + ...(value.landedWorkspaceHead === undefined + ? {} + : { landedWorkspaceHead: value.landedWorkspaceHead as string }), + ...(value.landedLane === undefined ? {} : { landedLane: value.landedLane as string }), + }; +} + +export function workspaceRegistryDirectory(cwd = process.cwd()) { + return path.join(gitCommonDir(cwd), WORKSPACE_STATE_DIRECTORY); +} + +export function workspaceStatePath(cwd: string, id: string) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw new Error(`Invalid workspace id '${id}'.`); + return path.join(workspaceRegistryDirectory(cwd), `${id}.json`); +} + +export function writeWorkspaceState(state: WorkspaceState, cwd = process.cwd()) { + const directory = workspaceRegistryDirectory(cwd); + mkdirSync(directory, { recursive: true }); + const target = workspaceStatePath(cwd, state.id); + const parsed = parseWorkspaceState(state, { cwd, requireRegisteredWorktree: true }); + const temporary = path.join(directory, `.${state.id}.${process.pid}.${Date.now()}.tmp`); + writeFileSync(temporary, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 }); + try { + renameSync(temporary, target); + } finally { + rmSync(temporary, { force: true }); + } + return target; +} + +function readStateFile(filePath: string, cwd: string) { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; + } catch (error) { + throw new Error( + `Failed to read workspace state '${filePath}': ${error instanceof Error ? error.message : String(error)}`, + ); + } + return parseWorkspaceState(parsed, { cwd, requireRegisteredWorktree: true }); +} + +export function listWorkspaceStates(cwd = process.cwd()) { + const directory = workspaceRegistryDirectory(cwd); + if (!existsSync(directory)) return []; + return readdirSync(directory) + .filter((file) => file.endsWith('.json')) + .sort() + .map((file) => readStateFile(path.join(directory, file), cwd)); +} + +export function readWorkspaceState(cwd = process.cwd(), id: string) { + const filePath = workspaceStatePath(cwd, id); + if (!existsSync(filePath)) throw new Error(`Patchlane workspace '${id}' is not registered.`); + return readStateFile(filePath, cwd); +} + +export function findWorkspaceState(cwd = process.cwd()) { + const topLevel = gitTopLevel(cwd); + const branch = currentBranch(cwd); + const states = listWorkspaceStates(cwd); + const state = states.find((candidate) => candidate.path === topLevel || candidate.branch === branch); + if (!state) { + throw new Error('The current directory is not a registered Patchlane workspace. Run workspace create first.'); + } + if (state.path !== topLevel && state.branch !== branch) { + throw new Error('The current worktree does not match its registered Patchlane workspace.'); + } + return state; +} + +export function removeWorkspaceState(cwd: string, id: string) { + rmSync(workspaceStatePath(cwd, id), { force: true }); +} + +export function workspaceStateIsRegistered(cwd: string, state: WorkspaceState) { + return isWorktreePathRegistered(cwd, state.path); +} + +export function worktreeForBranch(cwd: string, branch: string) { + return listWorktrees(cwd).find((worktree) => worktree.branch === branch); +} diff --git a/src/workspace-status.ts b/src/workspace-status.ts new file mode 100644 index 0000000..df3ef02 --- /dev/null +++ b/src/workspace-status.ts @@ -0,0 +1,153 @@ +import { findWorkspaceState, worktreeForBranch, type WorkspaceState } from './workspace-state.js'; +import { git, gitResult, headSha } from './git.js'; + +export type WorkspaceStatus = { + id: string; + path: string; + branch: string; + targetLane: string; + baselineCommit: string; + workspaceHead: string; + commitsToLand: number; + workingTree: 'clean' | 'dirty'; + sourceSha: string; + laneRefs: 'unchanged' | 'changed' | 'unknown'; + changedLanes: Array<{ ref: string; expected: string; actual?: string }>; + mergeCommits: string[]; + baselineIsAncestor: boolean; + targetLaneCheckedOut: boolean; + landingStatus: + | 'ready' + | 'landed' + | 'dirty' + | 'workspace_stale' + | 'invalid_baseline' + | 'non_linear' + | 'nothing_to_land' + | 'target_lane_checked_out'; + state: WorkspaceState; +}; + +function laneTip(cwd: string, state: WorkspaceState, ref: string) { + const remoteRef = `refs/remotes/${state.originRemoteName}/${ref}`; + const fetched = gitResult( + ['fetch', '--prune', '--no-tags', state.originRemoteName, `+refs/heads/${ref}:${remoteRef}`], + cwd, + { + allowFailure: true, + }, + ); + const remote = + fetched.status === 0 + ? gitResult(['rev-parse', '--verify', '--quiet', `${remoteRef}^{commit}`], cwd).stdout.trim() || undefined + : undefined; + const localResult = gitResult(['rev-parse', '--verify', '--quiet', `refs/heads/${ref}^{commit}`], cwd); + const local = localResult.status === 0 ? localResult.stdout.trim() : undefined; + if (local && local !== state.laneTips[ref]) return local; + return remote ?? local; +} + +export function inspectWorkspaceStatus(options: { cwd?: string; state?: WorkspaceState } = {}): WorkspaceStatus { + const cwd = options.cwd ?? process.cwd(); + const state = options.state ?? findWorkspaceState(cwd); + const workspaceHead = headSha(cwd); + const dirty = git(['status', '--porcelain', '--untracked-files=all'], cwd).length > 0; + const baselineIsAncestor = + gitResult(['merge-base', '--is-ancestor', state.baselineCommit, workspaceHead], cwd).status === 0; + const commitRange = baselineIsAncestor + ? git(['rev-list', '--reverse', `${state.baselineCommit}..${workspaceHead}`], cwd) + .split(/\r?\n/) + .filter(Boolean) + : []; + const mergeCommits = baselineIsAncestor + ? git(['rev-list', '--merges', `${state.baselineCommit}..${workspaceHead}`], cwd) + .split(/\r?\n/) + .filter(Boolean) + : []; + + const changedLanes = state.laneOrder.flatMap((ref) => { + const actual = laneTip(cwd, state, ref); + return actual === state.laneTips[ref] ? [] : [{ ref, expected: state.laneTips[ref]!, actual }]; + }); + const laneRefs: WorkspaceStatus['laneRefs'] = changedLanes.length ? 'changed' : 'unchanged'; + const targetWorktree = worktreeForBranch(cwd, state.targetLane); + const targetLaneCheckedOut = Boolean(targetWorktree && targetWorktree.path !== state.path); + + let landingStatus: WorkspaceStatus['landingStatus']; + if (state.landedWorkspaceHead === workspaceHead && state.landedLaneSha) landingStatus = 'landed'; + else if (dirty) landingStatus = 'dirty'; + else if (!baselineIsAncestor) landingStatus = 'invalid_baseline'; + else if (mergeCommits.length) landingStatus = 'non_linear'; + else if (changedLanes.length) landingStatus = 'workspace_stale'; + else if (targetLaneCheckedOut) landingStatus = 'target_lane_checked_out'; + else if (!commitRange.length) landingStatus = 'nothing_to_land'; + else landingStatus = 'ready'; + + return { + id: state.id, + path: state.path, + branch: state.branch, + targetLane: state.targetLane, + baselineCommit: state.baselineCommit, + workspaceHead, + commitsToLand: commitRange.length, + workingTree: dirty ? 'dirty' : 'clean', + sourceSha: state.source.sha, + laneRefs, + changedLanes, + mergeCommits, + baselineIsAncestor, + targetLaneCheckedOut, + landingStatus, + state, + }; +} + +export const runWorkspaceStatus = inspectWorkspaceStatus; + +export function formatWorkspaceStatus(status: WorkspaceStatus) { + return [ + `Patchlane workspace: ${status.id}`, + '', + `Target lane: ${status.targetLane}`, + `Baseline commit: ${status.baselineCommit.slice(0, 7)}`, + `Workspace HEAD: ${status.workspaceHead.slice(0, 7)}`, + `Commits to land: ${status.commitsToLand}`, + `Working tree: ${status.workingTree}`, + `Source SHA: ${status.sourceSha.slice(0, 7)}`, + `Lane refs: ${status.laneRefs}`, + `Landing status: ${status.landingStatus}`, + ...(status.changedLanes.length + ? [ + '', + 'Changed lanes:', + ...status.changedLanes.map( + (lane) => + ` ${lane.ref}: expected ${lane.expected.slice(0, 7)}, actual ${lane.actual?.slice(0, 7) ?? 'missing'}`, + ), + ] + : []), + ].join('\n'); +} + +export function formatWorkspaceStatusJson(status: WorkspaceStatus) { + return JSON.stringify( + { + id: status.id, + path: status.path, + branch: status.branch, + targetLane: status.targetLane, + baselineCommit: status.baselineCommit, + workspaceHead: status.workspaceHead, + commitsToLand: status.commitsToLand, + workingTree: status.workingTree, + sourceSha: status.sourceSha, + laneRefs: status.laneRefs, + changedLanes: status.changedLanes, + mergeCommits: status.mergeCommits, + landingStatus: status.landingStatus, + }, + null, + 2, + ); +} diff --git a/workspaces.md b/workspaces.md new file mode 100644 index 0000000..4e8f3e8 --- /dev/null +++ b/workspaces.md @@ -0,0 +1,1067 @@ +# Patchlane 0.5.3 Technical Plan: Composed Workspaces and Lane Landing + +## 1. Release thesis + +Patchlane 0.5.3 should let an agent: + +1. Work against the **complete composed fork**, including all product changes, Patchlane skills, tests, CI configuration, and local development tooling. +2. Declare which lane is intended to receive the work. +3. Commit normally in that composed workspace. +4. Project those commits back onto the selected lane. +5. Prove that recomposing every lane produces the **exact tree the agent developed and tested**. +6. Move or push the lane only after that proof succeeds. + +The essential invariant is: + +```text +compose(updated lanes).tree == agent workspace HEAD.tree +``` + +This retains lanes as independently reviewable and upstreamable changes while eliminating the requirement that agents develop inside incomplete lane branches. + +Patchlane 0.5 already has most of the necessary forward machinery: it resolves a source, diagnoses each patch branch, replays its commits in order, appends provenance trailers, validates the resulting workflow tree, publishes `sync/integration`, and promotes only an exact tested SHA. + +--- + +## 2. Scope decisions for 0.5.3 + +These decisions keep 0.5.3 small enough to remain a credible minor release. + +| Decision | 0.5.3 behavior | +| ---------------------------- | --------------------------------------------------- | +| Existing lane representation | Retain ordered `patchRefs` | +| Configuration schema | Retain `.patchlane.yml` `version: 1` | +| Workspace visibility | Compose every configured lane | +| Landing destination | Exactly one target lane per workspace | +| Workspace history | Linear commits only | +| Multi-lane changes | Split into separate workspaces or handle manually | +| Lane dependencies | Existing behavior only; no new dependency DAG | +| Agent integration | Agent-agnostic; expose commands and install a skill | +| Remote writes | Never by default; require `workspace land --push` | +| Actual octopus commits | Do not introduce in 0.5.3 | +| Sync and promotion | Preserve existing behavior | + +The package becomes `0.5.3 but the config remains version 1 because no required configuration field changes. The current parser strictly accepts `version: 1`, so avoiding a schema bump keeps every valid 0.5 configuration valid in 0.5.3 + +Multi-lane commit assignment, explicit lane dependency graphs, and synthetic multi-parent workspace anchors should be deferred. They are plausible later extensions, but none is necessary to solve the immediate agent-context problem. + +--- + +## 3. User-facing CLI + +Add a `workspace` command group: + +```text +patchlane workspace create +patchlane workspace status +patchlane workspace land +patchlane workspace remove +``` + +### 3.1 Create a workspace + +```bash +git switch main + +npx patchlane@0.5.3 workspace create \ + --lane patch/product +``` + +Example output: + +```text +Created Patchlane workspace. + +Path: ../project-patch-product +Branch: patchlane/work/product-a73c2f +Target lane: patch/product +Source: release v1.8.0 @ 27a9f13 +Baseline: 04fc182 +Lane order: + 1. patch/sync + 2. patch/ci + 3. patch/dev + 4. patch/product + +Work in the new directory and commit normally. +Run `patchlane workspace land --dry-run` before landing. +``` + +Supported options: + +```text +--lane Required configured target lane +--path Override generated worktree path +--name Override workspace identifier +--source Override the configured source for this workspace +--config-ref Read .patchlane.yml from a Git ref +--origin-remote-name Default: origin +--upstream-remote-name Default: upstream +--json Machine-readable result +``` + +`--config-ref` addresses the case where the command is started from a raw lane branch that does not contain `.patchlane.yml`: + +```bash +npx patchlane@0.5.3 workspace create \ + --config-ref origin/main \ + --lane patch/product +``` + +This requires a new `loadPatchlaneConfigAtRef()` alongside the existing filesystem-based loader. + +### 3.2 Inspect the workspace + +Inside the generated worktree: + +```bash +npx patchlane@0.5.3 workspace status +``` + +Example: + +```text +Patchlane workspace: product-a73c2f + +Target lane: patch/product +Baseline commit: 04fc182 +Workspace HEAD: 13e90c1 +Commits to land: 3 +Working tree: clean +Source SHA: 27a9f13 +Lane refs: unchanged +Landing status: ready +``` + +Agents should generally run: + +```bash +npx patchlane workspace status --json +``` + +before editing and again before landing. + +### 3.3 Validate or land + +Preview the operation without moving any lane: + +```bash +npx patchlane@0.5.3 workspace land --dry-run +``` + +Land into the local lane branch: + +```bash +npx patchlane@0.5.3 workspace land +``` + +Land locally and push with a remote lease: + +```bash +npx patchlane@0.5.3 workspace land --push +``` + +Additional options: + +```text +--lane Explicitly override the originally selected lane +--dry-run Perform projection and recomposition without updating refs +--push Push the updated lane using force-with-lease +--json Emit structured diagnostics +``` + +Allowing `--lane` at landing time provides an escape hatch when the wrong target was chosen initially. It still supports only one destination lane per landing operation. + +### 3.4 Remove a workspace + +```bash +npx patchlane@0.5.3 workspace remove +``` + +This removes the Git worktree, temporary workspace branch, candidate refs, and registered metadata. It must refuse when there are uncommitted or unlanded commits unless `--force` is supplied. + +--- + +## 4. Architecture: extract composition from sync + +The largest internal prerequisite is separating Patchlane’s composition engine from its GitHub Actions and publishing concerns. + +`src/integration-sync.ts` currently performs all of these responsibilities in one function: + +- Git command execution; +- remote setup and fetching; +- upstream source resolution; +- patch-ref resolution; +- lane diagnostics; +- lane validation; +- commit replay; +- conflict reporting; +- GitHub output writing; +- workflow-policy enforcement; +- sync branch checkout; +- remote publication. + +It also exits the process directly from its internal `fail()` function, which prevents reuse as a library. + +### 4.1 New core modules + +Create: + +```text +src/git.ts +src/composition.ts +src/composition-errors.ts +src/workspace-state.ts +src/workspace-create.ts +src/workspace-status.ts +src/workspace-land.ts +src/workspace-remove.ts +``` + +### 4.2 Composition types + +```ts +export type ResolvedSource = { + configuredSource: string; + label: string; + sha: string; +}; + +export type LanePlan = { + ref: string; + resolvedRef: string; + tipSha: string; + mergeBaseSha: string; + diffBaseSha: string; + commits: Array<{ + sha: string; + subject: string; + }>; + changedPaths: string[]; + warnings: string[]; +}; + +export type CompositionPlan = { + source: ResolvedSource; + lanes: LanePlan[]; + baseBranch: string; + syncBranch: string; +}; + +export type CompositionResult = { + headSha: string; + treeSha: string; + appliedLanes: string[]; + generatedCommits: Array<{ + lane: string; + originalSha: string; + generatedSha: string; + }>; +}; +``` + +### 4.3 Core APIs + +```ts +export function resolveCompositionPlan(config: PatchlaneConfig, options: ResolveCompositionOptions): CompositionPlan; + +export function composeIntoWorktree( + plan: CompositionPlan, + options: { + cwd: string; + laneOverrides?: Record; + recordProvenanceTrailers?: boolean; + }, +): CompositionResult; +``` + +`laneOverrides` is important for landing. It lets Patchlane recompose using the original pinned lane SHAs except for the target lane’s temporary candidate SHA. + +### 4.4 Error model + +Replace direct `process.exit()` calls inside reusable code with typed errors: + +```ts +export class CompositionError extends Error { + constructor( + readonly code: 'missing_lane' | 'invalid_lane' | 'invalid_lane_base' | 'conflict' | 'workflow_policy', + message: string, + readonly details: Record, + ) { + super(message); + } +} +``` + +`integration-sync.ts` catches these errors and translates them into the existing: + +- `GITHUB_OUTPUT` values; +- job summaries; +- status names; +- stderr messages; +- process exit status. + +This refactor must preserve the current sync output contract. Existing automation and notification code depends on values such as `failed_bookmark`, `failed_commit`, `conflicted_paths`, `applied_refs`, and `status`. + +### 4.5 Preserve generated provenance + +The existing composition engine amends generated commits with: + +```text +Patch-Ref: patch/product +Patch-Base: +Original-Commit: +``` + +That behavior should move intact into `composition.ts`. It is already a useful provenance index and can power mismatch diagnostics without adding ownership configuration. + +--- + +## 5. Workspace state + +Workspace state must not be committed into any lane. + +Store it under the repository’s common Git directory: + +```text +$(git rev-parse --git-common-dir)/ +└── patchlane/ + └── workspaces/ + └── product-a73c2f.json +``` + +Schema: + +```json +{ + "version": 1, + "id": "product-a73c2f", + "path": "/repos/project-patch-product", + "branch": "patchlane/work/product-a73c2f", + "createdAt": "2026-08-01T22:30:00.000Z", + "configRef": "origin/main", + "originRemoteName": "origin", + "upstreamRemoteName": "upstream", + "source": { + "configuredSource": "release:latest", + "label": "release v1.8.0", + "sha": "27a9f13..." + }, + "targetLane": "patch/product", + "baselineCommit": "04fc182...", + "baselineTree": "71bca21...", + "laneOrder": ["patch/sync", "patch/ci", "patch/dev", "patch/product"], + "laneTips": { + "patch/sync": "aba1023...", + "patch/ci": "c982da1...", + "patch/dev": "6f3be12...", + "patch/product": "8e3c821..." + }, + "laneDiffBases": { + "patch/sync": "27a9f13...", + "patch/ci": "27a9f13...", + "patch/dev": "27a9f13...", + "patch/product": "27a9f13..." + }, + "landedLaneSha": null +} +``` + +Also attach the workspace ID to the temporary branch through repository-local Git configuration: + +```bash +git config branch.patchlane/work/product-a73c2f.patchlane-workspace \ + product-a73c2f +``` + +In practice, branch names containing slashes require using the properly quoted Git config key through the process API rather than shell interpolation. + +The state parser must reject: + +- unsupported state versions; +- paths outside registered worktrees; +- invalid ref names; +- missing baseline commits; +- duplicate lane refs; +- target lanes not in `laneOrder`. + +--- + +## 6. `workspace create` algorithm + +### Step 1: Locate the repository and config + +Find the Git common directory and load `.patchlane.yml`: + +1. From the current filesystem by default. +2. From `--config-ref` using: + + ```bash + git show :.patchlane.yml + ``` + +The current config loader only reads a filesystem path, so add: + +```ts +export function parsePatchlaneConfigText(contents: string): PatchlaneConfig; +export function loadPatchlaneConfigAtRef(cwd: string, ref: string): PatchlaneConfig; +``` + +### Step 2: Validate the target lane + +The selected `--lane` must appear exactly once in `config.patchRefs`. + +Do not allow an arbitrary destination branch in 0.5.3 That would undermine Patchlane’s ability to prove that the resulting configured composition matches the workspace. + +### Step 3: Resolve and pin all inputs + +Use the same source resolution and lane diagnostics as `patchlane sync`. + +Record: + +- the resolved upstream SHA; +- every lane tip SHA; +- every lane diff base; +- lane order; +- origin and upstream remote names. + +The workspace must remain pinned to these exact values. A moving selector such as `release:latest` must not be re-resolved during projection. + +### Step 4: Compose into a temporary worktree + +Create a detached temporary worktree at the resolved source SHA and call the extracted composition engine. + +This produces a linear, generated composition using the same replay order and provenance trailers as `sync`. + +No literal octopus merge is required. The generated history already preserves lane provenance, while the tree is the complete fork the agent needs. + +### Step 5: Validate the composition + +Before exposing the workspace: + +- enforce `allowedWorkflows`; +- ensure all configured lanes apply; +- calculate the baseline tree SHA; +- optionally compare against an existing published sync tree and report differences; +- reject unresolved conflicts. + +The current sync and promotion paths both enforce the workflow allowlist against the composed or promoted tree, so the workspace path should use that same validator. + +### Step 6: Create the workspace branch and worktree + +Create: + +```text +refs/heads/patchlane/work/ +``` + +at the generated composition head, then: + +```bash +git worktree add patchlane/work/ +``` + +The workspace branch is disposable generated state. It must never be added to `patchRefs`. + +### Step 7: Register state + +Write the state file atomically: + +1. Write a temporary file. +2. `fsync` or close it. +3. Rename it to the final workspace state path. + +If worktree creation succeeds but registration fails, remove the worktree and branch. + +### Step 8: Verify agent context + +The workspace should naturally contain `.agents/skills` because `patch/sync` is part of the full composition. The current installer places versioned skills under `.agents/skills`, and the skill manifest controls which skills are installed. + +A local development environment can live in any configured lane, such as `patch/dev`, and it will be present for the same reason. No special workspace overlay format is needed. + +--- + +## 7. `workspace land` algorithm + +This is the main 0.5.3 feature. + +### Step 1: Identify and validate the workspace + +Resolve the workspace ID from the current branch or worktree path. + +Reject when: + +- the current directory is not a registered Patchlane workspace; +- the state file is missing or invalid; +- the worktree contains uncommitted changes; +- `HEAD` is not descended from `baselineCommit`; +- there are no commits to land; +- the range contains merge commits; +- the target lane is checked out in another worktree. + +A clean working tree and linear commit range should be hard requirements for 0.5.3 + +### Step 2: Verify input freshness + +Fetch relevant origin refs and compare them with `laneTips` from workspace creation. + +Every configured lane must still point to the recorded SHA. This includes non-target lanes: if another lane moved, the workspace’s composed context is stale. + +Return a structured error: + +```json +{ + "status": "workspace_stale", + "changedLanes": [ + { + "ref": "patch/ci", + "expected": "c982da1", + "actual": "80ad113" + } + ] +} +``` + +Do not automatically refresh or rebase the workspace in 0.5.3 A future `workspace refresh` command can solve that explicitly. + +### Step 3: Create a candidate target lane + +Create temporary refs: + +```text +refs/patchlane/land//target +refs/patchlane/land//composition +``` + +Add a temporary worktree at the recorded target-lane SHA. + +### Step 4: Replay workspace commits onto the lane + +List commits: + +```bash +git rev-list \ + --reverse \ + --no-merges \ + .. +``` + +Cherry-pick them in order onto the temporary target-lane candidate. + +Preserve: + +- author; +- commit subject and body; +- commit ordering. + +Do not add Patchlane trailers to source lane commits. Those commits are intended to remain clean enough to review or upstream. Patchlane provenance belongs on generated composition commits, not on lane history. + +If a cherry-pick conflicts, abort the candidate and report: + +- workspace commit; +- target lane; +- conflicted paths; +- paths’ likely originating lanes; +- reproduction commands. + +No configured lane ref moves. + +### Step 5: Recompose all lanes + +Call: + +```ts +composeIntoWorktree(plan, { + laneOverrides: { + [targetLane]: candidateTargetSha, + }, +}); +``` + +All other lanes use the exact SHAs pinned when the workspace was created. + +### Step 6: Perform the round-trip comparison + +Calculate: + +```bash +git rev-parse ^{tree} +git rev-parse ^{tree} +``` + +The hashes must match exactly. + +This validates: + +- file contents; +- executable modes; +- symlinks; +- additions and deletions; +- the interactions between the target lane and every later lane. + +Commit hashes do not need to match because the generated composition intentionally rewrites commit identities and adds provenance trailers. + +### Step 7: Diagnose mismatches + +When tree hashes differ, run: + +```bash +git diff \ + --name-status \ + \ + +``` + +For each differing path, inspect the generated baseline history for the latest `Patch-Ref` trailer affecting it. + +Example: + +```text +Round-trip validation failed. + +Workspace target: patch/product + +The projected lane does not reproduce the tested workspace tree: + + M src/auth/session.ts + Last composed owner: patch/internal-auth + Later modifying lane: patch/product-ui + + D .agents/skills/patchlane-workspace/SKILL.md + Last composed owner: patch/sync + +No lane refs were changed. + +Possible causes: + - the selected target lane is incorrect; + - the change depends on another lane; + - a later lane overwrites part of the projected change; + - the workspace contains changes belonging to multiple lanes. +``` + +This is deliberately diagnostic rather than heuristic. Patchlane should not silently repartition the work. + +### Step 8: Update the lane atomically + +After a successful round trip: + +```bash +git update-ref \ + refs/heads/patch/product \ + \ + +``` + +This is the local equivalent of a lease. If the local lane moved between validation and update, the command fails without changing it. + +Other lane refs remain byte-for-byte unchanged. + +### Step 9: Optionally push + +With `--push`, fetch the remote target lane immediately before pushing and use: + +```bash +git push origin \ + --force-with-lease=refs/heads/patch/product: \ + :refs/heads/patch/product +``` + +The explicit `--push` flag is the user’s authorization for the remote write. No prompt is necessary, which keeps the command usable by agents and CI-like tooling. + +### Step 10: Record the result + +Update workspace state: + +```json +{ + "landedLaneSha": "new-sha", + "landedAt": "2026-08-01T23:15:00.000Z", + "pushed": true +} +``` + +Do not remove the workspace automatically. The user may want to inspect the result or open an upstream PR before cleanup. + +--- + +## 8. How 0.5.3 enforces lane placement + +Patchlane should not introduce path ownership rules or ask an LLM to guess a lane. + +The enforcement model is: + +1. **Explicit destination:** `workspace create --lane patch/product`. +2. **Complete context:** all lanes remain visible during development. +3. **Isolated projection:** workspace commits are replayed only onto the chosen lane. +4. **Exact recomposition:** every configured lane is composed again. +5. **Exact tree comparison:** the result must equal what the agent tested. +6. **Atomic lease update:** the lane moves only after all checks pass. + +A wrongly selected lane will generally fail in one of two ways: + +- the workspace commits cannot be applied to the isolated lane; +- they apply, but recomposition produces a different tree. + +This is substantially stronger than a prompt telling the agent where code “should” go. + +--- + +## 9. File-level implementation plan + +| File | Change | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `src/config.ts` | Extract text parsing and add config-at-ref loading; no schema change | +| `src/cli.ts` | Add `workspace create/status/land/remove`; replace fragile eager config loading with command-local loading | +| `src/integration-sync.ts` | Convert into a sync/publish adapter over `composition.ts` | +| `src/composition.ts` | New reusable source resolution, lane planning, validation, and replay engine | +| `src/composition-errors.ts` | Typed errors and structured diagnostics | +| `src/git.ts` | Shared process runner, Git helpers, ref validation, worktree helpers | +| `src/workspace-state.ts` | State schema, atomic persistence, workspace discovery | +| `src/workspace-create.ts` | Compose and create registered worktrees | +| `src/workspace-status.ts` | Dirty-state, freshness, and commit-range inspection | +| `src/workspace-land.ts` | Candidate projection, recomposition, tree comparison, ref update, optional push | +| `src/workspace-remove.ts` | Safe worktree and state cleanup | +| `src/doctor.ts` | Reuse composition planning rather than maintaining separate partial composition logic | +| `skills/manifest.json` | Add `patchlane-workspace` | +| `skills/patchlane-workspace/SKILL.md` | Agent instructions for creating, editing, validating, and landing workspaces | +| `skills/patchlane-fork-setup/SKILL.md` | Teach setup to describe workspace-based development | +| `skills/patchlane-sync-patches/SKILL.md` | Prefer composed workspaces when repairing a lane | +| `docs/workspaces.md` | Full operating model and troubleshooting | +| `docs/configuration.md` | New commands and environment/CLI overrides | +| `docs/manual-setup.md` | Add the normal development flow | +| `docs/migrations.md` | Add a 0.5.3 section | +| `README.md` | Replace raw patch-branch editing as the recommended agent workflow | +| `package.json` | Set version `0.5.3 | + +The current `init` command generates `.patchlane.yml` plus the sync and promotion workflows, while the CLI defaults to `patch/sync,patch/ci`. Those defaults do not need to change. + +--- + +## 10. CLI refactor detail + +The current CLI eagerly loads configuration only for `sync`, `promote`, and `notify` by inspecting `process.argv[2]`. Nested commands would make that pattern increasingly fragile. + +Replace it with: + +```ts +function loadRequiredConfig(options?: { cwd?: string; configRef?: string }): PatchlaneConfig { + const config = options?.configRef + ? loadPatchlaneConfigAtRef(options.cwd ?? process.cwd(), options.configRef) + : loadPatchlaneConfig(options?.cwd); + + if (!config) { + throw new Error('Missing .patchlane.yml. Run from a composed branch or pass --config-ref.'); + } + + return config; +} +``` + +Each command loads its own config in its action callback. + +Command registration: + +```ts +cli.command('workspace create', 'Create a complete composed worktree for editing one lane'); + +cli.command('workspace status', 'Inspect the current Patchlane workspace'); + +cli.command('workspace land', 'Project workspace commits into a lane and verify recomposition'); + +cli.command('workspace remove', 'Remove a registered Patchlane workspace'); +``` + +--- + +## 11. Doctor changes + +`doctor` should remain focused on repository-wide readiness rather than becoming the workspace command. + +Refactor it to consume `resolveCompositionPlan()` for: + +- lane existence; +- source resolution; +- direct-source ancestry; +- lane commit counts; +- lane warnings; +- composed workflow inspection. + +Today `doctor` separately checks whether every remote patch branch exists and whether its source is directly ancestral. + +After the refactor, add informational output such as: + +```text +✓ Lane 'patch/sync' resolves to aba1023 with 3 fork-owned commits. +✓ Lane 'patch/ci' resolves to c982da1 with 1 fork-owned commit. +✓ Lane 'patch/product' resolves to 8e3c821 with 14 fork-owned commits. +``` + +Do not have `doctor` fail merely because abandoned workspace metadata exists. `workspace list` or `workspace status` can report that separately. + +--- + +## 12. Workflow impact + +The generated GitHub workflows require only version-pin updates. + +The sync workflow continues to run: + +```bash +npx patchlane@0.5.3 sync +``` + +The promotion workflow continues to promote only the successful `workflow_run.head_sha`. Existing App-token wiring, notification handling, workflow policy, and exact-SHA promotion remain intact. + +No workspace commands should run in scheduled automation in 0.5.3 Workspaces are an authoring construct; `sync/integration` remains the release candidate generated directly from lane refs. + +--- + +## 13. Agent skill + +Add `skills/patchlane-workspace/SKILL.md`. + +Core instructions: + +```text +When making a change to a Patchlane fork: + +1. Do not edit a raw patch branch unless the user explicitly requests it. +2. From a composed branch, run `patchlane workspace create --lane `. +3. Work only in the generated workspace. +4. Inspect existing code across all lanes before changing behavior. +5. Keep the workspace history linear. +6. Commit complete, reviewable changes. +7. Run the repository's normal tests. +8. Run `patchlane workspace land --dry-run`. +9. Review any cross-lane or round-trip mismatch. +10. Obtain approval before `workspace land --push`. +``` + +The skill should teach the agent to choose an existing lane based on the requested feature, not invent a new lane silently. Creating a new lane remains a separate explicit workflow. + +Because skills are installed by package version from the manifest, adding the skill to `manifest.json` makes `npx patchlane@0.5.3 agents` install it with the existing two skills. + +--- + +## 14. Test plan + +### 14.1 Refactor the test fixtures + +The current integration suite builds local bare upstream and fork repositories, creates patch branches, invokes the compiled CLI, and examines remote trees and GitHub outputs. Reuse that model for workspace tests. + +Extract shared helpers into: + +```text +tests/support/git.ts +tests/support/repository-fixture.ts +tests/support/patchlane-fixture.ts +``` + +### 14.2 Composition regression tests + +Existing sync tests must continue passing without semantic changes. + +Add tests proving: + +1. Extracted composition produces the same generated tree as the 0.5 implementation. +2. Existing `Patch-Ref`, `Patch-Base`, and `Original-Commit` trailers remain. +3. Existing conflict status and outputs remain unchanged. +4. Workflow-policy violations remain blocking. +5. `--allow-dependent-patches` retains its current behavior. +6. Release and branch sources both work. + +### 14.3 Workspace creation tests + +1. Creates a worktree containing all configured lanes. +2. Includes `.agents/skills` from `patch/sync`. +3. Includes development tooling from `patch/dev`. +4. Does not modify any lane ref. +5. Records exact source and lane SHAs. +6. Reads config through `--config-ref`. +7. Rejects a target lane absent from `patchRefs`. +8. Cleans up the worktree when state registration fails. +9. Rejects a destination path already in use. +10. Emits stable JSON output. + +### 14.4 Successful landing tests + +1. Agent commits one change to an upstream-owned file. +2. The commit lands on the selected lane. +3. Other lane refs remain unchanged. +4. Commit message and author are preserved. +5. Recomposed tree equals the workspace tree. +6. `--dry-run` performs all validation without moving the lane. +7. Multiple linear workspace commits are preserved in order. +8. Empty projected commits are handled without corrupting the result. +9. `--push` updates the remote with force-with-lease. +10. A failed remote lease leaves the remote and local state clearly reported. + +### 14.5 Wrong-lane and cross-lane tests + +1. Workspace targets `patch/product` but edits a file introduced only by `patch/dev`. +2. Projection conflicts and no lane moves. +3. Projection applies but a later lane overwrites the result. +4. Round-trip tree comparison fails and no lane moves. +5. Diagnostics identify the paths and likely originating lanes. +6. A workspace containing unrelated changes from two lanes is rejected. +7. `--lane` override can successfully retry with the correct destination. + +### 14.6 Staleness tests + +1. Target lane moves after workspace creation. +2. Non-target lane moves after workspace creation. +3. Local lane differs from expected remote lane. +4. Workspace baseline commit is rewritten. +5. Workspace contains a merge commit. +6. Workspace has uncommitted changes. +7. Workspace branch is checked out from an unregistered path. +8. Target lane is checked out in another worktree. + +Every failure must prove that configured lane refs remain unchanged. + +### 14.7 State and cleanup tests + +1. State round-trips through JSON. +2. Unsupported state versions are rejected. +3. Corrupt state does not cause arbitrary path deletion. +4. `workspace remove` rejects unlanded commits. +5. `workspace remove --force` removes only the registered worktree and temporary refs. +6. Candidate refs are removed after both successful and failed land attempts. + +--- + +## 15. Migration plan + +Add a `0.5.3` section at the top of `docs/migrations.md`. + +The migration should explicitly state: + +- `.patchlane.yml` remains version 1. +- `patchRefs` and their order do not change. +- Existing workflows remain compatible. +- Existing forks can continue editing raw patch branches. +- Composed workspaces are the new recommended agent workflow. +- Run `npx patchlane@0.5.3 agents` on `patch/sync` to install the workspace skill. +- Update generated workflow package references to `patchlane@0.6.0`. +- Roll those changes forward through the existing tested sync and promotion flow. + +Suggested migration commands: + +```bash +git switch patch/sync + +npx patchlane@0.5.3 agents + +# Adapt the generated workflow version pins without overwriting +# repository-specific authentication or schedules. + +npx patchlane@0.5.3 doctor +npx patchlane@0.5.3 sync --dry-run +npx patchlane@0.5.3 bootstrap --wait +``` + +The existing migration philosophy is incremental: preserve branch names, patch order, CI workflow names, schedules, and repository-specific workflow changes. The 0.5.3 guide should continue that approach. + +--- + +## 16. Implementation sequence + +### PR 1: Extract the composition engine + +Deliverables: + +- `git.ts`; +- `composition.ts`; +- typed composition errors; +- `integration-sync.ts` converted to an adapter; +- `doctor.ts` consuming shared planning where practical; +- no user-visible behavior change; +- all existing tests passing. + +This should be merged independently. It is the riskiest refactor and should not be mixed with the new feature. + +### PR 2: Workspace creation and status + +Deliverables: + +- workspace state registry; +- config-at-ref loading; +- `workspace create`; +- `workspace status`; +- worktree lifecycle safety; +- JSON output; +- creation and staleness tests. + +At this stage, agents can work in the correct complete context, but landing remains manual. + +### PR 3: Single-lane landing + +Deliverables: + +- workspace commit validation; +- temporary candidate lane; +- commit replay; +- recomposition with lane override; +- exact tree comparison; +- mismatch diagnostics; +- atomic local ref update; +- optional force-with-lease push; +- integration tests for success, wrong-lane failure, and staleness. + +This PR completes the 0.5.3 product behavior. + +### PR 4: Agent skill, documentation, and release + +Deliverables: + +- `patchlane-workspace` skill; +- updates to setup and repair skills; +- README workspace quick start; +- `docs/workspaces.md`; +- migration guide; +- package version `0.5.3` +- generated workflow assets pinned to 0.5.3 +- release notes. + +The repository’s current release process derives the version from `package.json`, generates notes, and creates a draft GitHub release, so the final release PR should include the package bump and all generated skill assets before dispatching that workflow. + +--- + +## 17. Release acceptance criteria + +Patchlane 0.5.3 is complete when all of the following are true: + +1. Every valid 0.5 `.patchlane.yml` remains valid. +2. Existing `sync`, `doctor`, `bootstrap`, `notify`, and `promote` behavior remains compatible. +3. A workspace can be created from every valid configured composition. +4. The workspace contains code from every lane, including agent skills and development tooling. +5. The workspace names exactly one target lane. +6. Linear workspace commits can be projected onto that lane. +7. No lane moves before successful recomposition. +8. The recomposed tree must exactly equal the workspace tree. +9. Wrong-lane or cross-lane changes fail with actionable diagnostics. +10. Other configured lane refs never move. +11. Remote lane refs move only with explicit `--push`. +12. Remote pushes use force-with-lease. +13. Scheduled sync still builds exclusively from configured lane refs. +14. Promotion still moves the base branch only to the exact CI-tested sync SHA. +15. The new installed skill teaches agents to use composed workspaces instead of raw lane branches. + +--- + +## 18. Explicitly deferred from 0.5.3 + +These should be documented as future possibilities rather than smuggled into the initial release: + +- assigning different workspace commits to multiple lanes; +- interactive hunk-to-lane assignment; +- lane dependency graphs; +- stacked upstream-PR generation; +- workspace refresh after lane or upstream movement; +- automatic creation of new lanes; +- automatic agent invocation; +- file or directory ownership policies; +- synthetic octopus or multi-parent workspace commits; +- automatic determination that upstream has absorbed a lane. + +The one-target-lane model is enough to establish the core architecture. Once the bidirectional round trip is proven reliable, multi-lane decomposition can be added without changing the fundamental workspace model.