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..d6621bf 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,73 @@ 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, + originRemoteName: args.originRemoteName, + upstreamRemoteName: args.upstreamRemoteName, + upstreamRemoteUrl: env('UPSTREAM_REMOTE_URL'), + }); + 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..83380d2 --- /dev/null +++ b/src/workspace-land.ts @@ -0,0 +1,490 @@ +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); + + 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; + } + + git(['update-ref', `refs/heads/${targetLane}`, candidateLaneSha, localExpected], cwd); + + 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/tests/workspace.test.ts b/tests/workspace.test.ts new file mode 100644 index 0000000..6f89b6f --- /dev/null +++ b/tests/workspace.test.ts @@ -0,0 +1,292 @@ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { expect, test } from 'vitest'; +import { createWorkspace } from '../src/workspace-create.js'; +import { landWorkspace, WorkspaceLandError } from '../src/workspace-land.js'; +import { removeWorkspace } from '../src/workspace-remove.js'; +import { inspectWorkspaceStatus } from '../src/workspace-status.js'; +import { readWorkspaceState, workspaceStatePath } from '../src/workspace-state.js'; + +type FixtureKind = 'basic' | 'conflict' | 'overlap'; + +type Fixture = { + tempRoot: string; + repository: string; + upstreamBare: string; + forkBare: string; + workspace: string; + laneRefs: string[]; +}; + +function runGit(args: string[], cwd: string) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error([result.stderr.trim(), result.stdout.trim()].filter(Boolean).join('\n')); + } + return result.stdout.trim(); +} + +function gitStatus(args: string[], cwd: string) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +function configureUser(repository: string) { + runGit(['config', 'user.name', 'Patchlane Test'], repository); + runGit(['config', 'user.email', 'patchlane@example.test'], repository); +} + +function commitFile(repository: string, relativePath: string, contents: string, message: string) { + const file = path.join(repository, relativePath); + writeFileSync(file, contents); + runGit(['add', relativePath], repository); + runGit(['commit', '-m', message], repository); +} + +function writeConfig(repository: string, laneRefs: string[]) { + writeFileSync( + path.join(repository, '.patchlane.yml'), + [ + 'version: 1', + 'upstream: example/upstream', + 'source: branch:main', + 'baseBranch: main', + 'syncBranch: sync/integration', + 'patchRefs:', + ...laneRefs.map((ref) => ` - ${ref}`), + 'allowedWorkflows: []', + '', + ].join('\n'), + ); +} + +function createFixture(kind: FixtureKind): Fixture { + const tempRoot = mkdtempSync(path.join(tmpdir(), 'patchlane-workspace-')); + const upstreamBare = path.join(tempRoot, 'upstream.git'); + const upstreamWork = path.join(tempRoot, 'upstream-work'); + const forkBare = path.join(tempRoot, 'fork.git'); + const repository = path.join(tempRoot, 'repository'); + const workspace = path.join(tempRoot, 'workspace'); + + runGit(['init', '--bare', '--initial-branch=main', upstreamBare], tempRoot); + runGit(['clone', upstreamBare, upstreamWork], tempRoot); + configureUser(upstreamWork); + if (kind === 'basic') commitFile(upstreamWork, 'README.md', 'upstream\n', 'Initial upstream commit'); + else commitFile(upstreamWork, 'shared.txt', 'header\nbase\nend\n', 'Initial upstream commit'); + const workflowDirectory = path.join(upstreamWork, '.github', 'workflows'); + mkdirSync(workflowDirectory, { recursive: true }); + writeFileSync(path.join(workflowDirectory, 'promote-tested-sync.yml'), 'name: Promote\n'); + writeFileSync(path.join(workflowDirectory, 'sync-upstream.yml'), 'name: Sync\n'); + runGit(['add', '.github/workflows'], upstreamWork); + runGit(['commit', '-m', 'Add workflow fixtures'], upstreamWork); + runGit(['push', 'origin', 'main'], upstreamWork); + + runGit(['init', '--bare', '--initial-branch=main', forkBare], tempRoot); + runGit(['clone', upstreamBare, repository], tempRoot); + configureUser(repository); + runGit(['remote', 'rename', 'origin', 'upstream'], repository); + runGit(['remote', 'add', 'origin', forkBare], repository); + runGit(['push', 'origin', 'main'], repository); + + let laneRefs: string[]; + if (kind === 'basic') { + laneRefs = ['patch/product']; + runGit(['switch', '-c', 'patch/product', 'upstream/main'], repository); + commitFile(repository, 'PRODUCT.md', 'product patch\n', 'Add product patch'); + runGit(['push', 'origin', 'patch/product'], repository); + } else if (kind === 'overlap') { + laneRefs = ['patch/first', 'patch/later']; + runGit(['switch', '-c', 'patch/first', 'upstream/main'], repository); + commitFile(repository, 'shared.txt', 'first\nbase\nend\n', 'Add first lane change'); + runGit(['push', 'origin', 'patch/first'], repository); + runGit(['switch', '-c', 'patch/later', 'patch/first'], repository); + commitFile(repository, 'shared.txt', 'first\nlater\nend\n', 'Add later lane change'); + runGit(['push', 'origin', 'patch/later'], repository); + } else { + laneRefs = ['patch/first', 'patch/conflict']; + runGit(['switch', '-c', 'patch/first', 'upstream/main'], repository); + commitFile(repository, 'shared.txt', 'header\nfirst\nend\n', 'Add first lane change'); + runGit(['push', 'origin', 'patch/first'], repository); + runGit(['switch', '-c', 'patch/conflict', 'upstream/main'], repository); + commitFile(repository, 'shared.txt', 'header\nconflict\nend\n', 'Add conflicting lane change'); + runGit(['push', 'origin', 'patch/conflict'], repository); + } + + runGit(['switch', 'main'], repository); + writeConfig(repository, laneRefs); + runGit(['add', '.patchlane.yml'], repository); + runGit(['commit', '-m', 'Configure Patchlane'], repository); + runGit(['push', 'origin', 'main'], repository); + + return { tempRoot, repository, upstreamBare, forkBare, workspace, laneRefs }; +} + +function cleanup(fixture: Fixture) { + rmSync(fixture.tempRoot, { force: true, recursive: true }); +} + +function commitWorkspaceChange(fixture: Fixture, contents: string, message: string) { + writeFileSync(path.join(fixture.workspace, fixture.laneRefs.length === 1 ? 'PRODUCT.md' : 'shared.txt'), contents); + runGit(['add', '.'], fixture.workspace); + runGit(['commit', '-m', message], fixture.workspace); +} + +test('creates, round-trips, lands, persists state, and removes a workspace', () => { + const fixture = createFixture('basic'); + try { + const created = createWorkspace({ + cwd: fixture.repository, + lane: 'patch/product', + path: fixture.workspace, + name: 'product', + upstreamRemoteUrl: fixture.upstreamBare, + }); + expect(created.state).toMatchObject({ + id: 'product', + branch: 'patchlane/work/product', + targetLane: 'patch/product', + laneOrder: ['patch/product'], + landedLaneSha: null, + }); + expect(existsSync(workspaceStatePath(fixture.repository, 'product'))).toBe(true); + expect(readWorkspaceState(fixture.repository, 'product')).toMatchObject(created.state); + + expect(inspectWorkspaceStatus({ cwd: fixture.workspace }).landingStatus).toBe('nothing_to_land'); + commitWorkspaceChange(fixture, 'product patch\nworkspace change\n', 'Change product patch'); + expect(inspectWorkspaceStatus({ cwd: fixture.workspace })).toMatchObject({ + commitsToLand: 1, + workingTree: 'clean', + landingStatus: 'ready', + }); + + const originalLaneSha = runGit(['rev-parse', 'refs/heads/patch/product'], fixture.repository); + const dryRun = landWorkspace({ cwd: fixture.workspace, dryRun: true, upstreamRemoteUrl: fixture.upstreamBare }); + expect(dryRun).toMatchObject({ + status: 'dry_run', + workspaceTree: dryRun.compositionTree, + pushed: false, + }); + expect(runGit(['rev-parse', 'refs/heads/patch/product'], fixture.repository)).toBe(originalLaneSha); + expect(readWorkspaceState(fixture.repository, 'product').landedLaneSha).toBeNull(); + + const landed = landWorkspace({ cwd: fixture.workspace, upstreamRemoteUrl: fixture.upstreamBare }); + expect(landed).toMatchObject({ status: 'landed', targetLane: 'patch/product', pushed: false }); + expect(runGit(['rev-parse', 'refs/heads/patch/product'], fixture.repository)).toBe(landed.candidateLaneSha); + expect(readWorkspaceState(fixture.repository, 'product')).toMatchObject({ + landedLaneSha: landed.candidateLaneSha, + landedWorkspaceHead: landed.workspaceHead, + landedLane: 'patch/product', + pushed: false, + }); + + const removed = removeWorkspace({ cwd: fixture.workspace }); + expect(removed).toMatchObject({ id: 'product', path: created.state.path, branch: 'patchlane/work/product' }); + expect(existsSync(fixture.workspace)).toBe(false); + expect(existsSync(workspaceStatePath(fixture.repository, 'product'))).toBe(false); + expect( + gitStatus(['show-ref', '--verify', '--quiet', 'refs/heads/patchlane/work/product'], fixture.repository), + ).not.toBe(0); + } finally { + cleanup(fixture); + } +}); + +test('rejects a workspace change that cannot be projected without moving lane refs', () => { + const fixture = createFixture('overlap'); + try { + createWorkspace({ + cwd: fixture.repository, + lane: 'patch/first', + path: fixture.workspace, + name: 'overlap', + upstreamRemoteUrl: fixture.upstreamBare, + }); + commitWorkspaceChange(fixture, 'first\nworkspace\nend\n', 'Change the later lane output'); + const originalLaneSha = runGit(['rev-parse', 'refs/heads/patch/first'], fixture.repository); + + let caught: unknown; + try { + landWorkspace({ cwd: fixture.workspace, upstreamRemoteUrl: fixture.upstreamBare }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WorkspaceLandError); + expect(caught).toMatchObject({ code: 'workspace_conflict' }); + expect(String(caught)).toContain('could not be projected'); + expect(runGit(['rev-parse', 'refs/heads/patch/first'], fixture.repository)).toBe(originalLaneSha); + expect(readWorkspaceState(fixture.repository, 'overlap').landedLaneSha).toBeNull(); + expect(runGit(['for-each-ref', '--format=%(refname)', 'refs/patchlane/land/overlap'], fixture.repository)).toBe( + '', + ); + + removeWorkspace({ cwd: fixture.workspace, force: true }); + } finally { + cleanup(fixture); + } +}); + +test('does not move a local lane when a pushed landing is rejected', () => { + const fixture = createFixture('basic'); + try { + createWorkspace({ + cwd: fixture.repository, + lane: 'patch/product', + path: fixture.workspace, + name: 'push-failure', + upstreamRemoteUrl: fixture.upstreamBare, + }); + commitWorkspaceChange(fixture, 'product patch\nrejected push\n', 'Prepare rejected landing'); + const originalLaneSha = runGit(['rev-parse', 'refs/heads/patch/product'], fixture.repository); + const receiveHook = path.join(fixture.forkBare, 'hooks', 'pre-receive'); + writeFileSync(receiveHook, '#!/bin/sh\nexit 1\n'); + chmodSync(receiveHook, 0o755); + + let caught: unknown; + try { + landWorkspace({ + cwd: fixture.workspace, + push: true, + upstreamRemoteUrl: fixture.upstreamBare, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WorkspaceLandError); + expect(caught).toMatchObject({ code: 'push_failed' }); + expect(runGit(['rev-parse', 'refs/heads/patch/product'], fixture.repository)).toBe(originalLaneSha); + expect(readWorkspaceState(fixture.repository, 'push-failure').landedLaneSha).toBeNull(); + expect( + runGit(['for-each-ref', '--format=%(refname)', 'refs/patchlane/land/push-failure'], fixture.repository), + ).toBe(''); + + removeWorkspace({ cwd: fixture.workspace, force: true }); + } finally { + cleanup(fixture); + } +}); + +test('cleans up a worktree when composition fails during creation', () => { + const fixture = createFixture('conflict'); + try { + expect(() => + createWorkspace({ + cwd: fixture.repository, + lane: 'patch/first', + path: fixture.workspace, + name: 'conflict', + upstreamRemoteUrl: fixture.upstreamBare, + }), + ).toThrow(/Failed to replay commit|conflict/i); + expect(existsSync(fixture.workspace)).toBe(false); + expect( + gitStatus(['show-ref', '--verify', '--quiet', 'refs/heads/patchlane/work/conflict'], fixture.repository), + ).not.toBe(0); + expect(existsSync(workspaceStatePath(fixture.repository, 'conflict'))).toBe(false); + } finally { + cleanup(fixture); + } +});