diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e4be7..6761930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ this package. ## [Unreleased] +### Added + +- Inter-process mutation lock (`.fleet/lock`): concurrent `fleet` commands + from multiple processes no longer race on `state.json` (lost-update bug). + `fleet doctor` reports the lock; `--fix` removes dead ones. +- Merge-conflict prediction: on git >= 2.38, `fleet check` and the + `fleet merge` gate simulate each agent pair's merge with `git merge-tree`. + Shared files that provably merge cleanly are informational instead of + blocking. `--files-only` restores file-level behavior. +- `fleet undo`: one-command rollback of the last `fleet merge` — resets the + target branch and restores the agent's branch, worktree, and state entry. + `fleet doctor` reports when an undo is available. + +### Changed + +- **`fleet check` semantics on git >= 2.38:** overlapping files whose + committed changes auto-merge no longer fail the check (exit 0) or block + `fleet merge`. On older git, v0.1 file-level behavior is unchanged. Use + `--files-only` to force the old semantics anywhere. + ## [0.1.0] - 2026-07-17 ### Added diff --git a/README.md b/README.md index b84e0c1..a6e789d 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,12 @@ $ fleet list $ fleet check 1 collision risk detected: -┌───────────────────┬───────────────┐ -│ FILE │ AGENTS │ -├───────────────────┼───────────────┤ -│ src/api/routes.ts │ claude, codex │ -└───────────────────┴───────────────┘ -These files are touched by more than one agent (committed or uncommitted). Coordinate before merging. +┌───────────────────┬───────────────┬───────────────┐ +│ FILE │ AGENTS │ VERDICT │ +├───────────────────┼───────────────┼───────────────┤ +│ src/api/routes.ts │ claude, codex │ will conflict │ +└───────────────────┴───────────────┴───────────────┘ +Verdicts from git merge-tree simulation of each agent pair's committed work; uncommitted edits can't be simulated and stay blocking. ``` ## Installation @@ -88,6 +88,7 @@ fleet sync claude # base moved on? catch the branch up fleet exec claude -- npm test # run commands in the worktree without cd'ing fleet diff claude # review the branch before merging fleet merge claude # merge into your current branch + clean up the agent +fleet undo # …and roll that merge back if it was a mistake fleet pr claude # …or push it and open a PR via gh instead ``` @@ -98,11 +99,12 @@ fleet pr claude # …or push it and open a PR via gh instead | `fleet spawn ` | Create a worktree in `.fleet/worktrees//` on a new branch `fleet/`, then provision it (`copyOnSpawn` / `postSpawn` below) | `--from ` base branch (default: current branch) | | `fleet list` | All active agents: branch, base, ahead/behind, uncommitted count, last activity | `--json` machine-readable output | | `fleet status ` | One agent in detail: uncommitted files, diff stat vs base, ahead/behind | `--json` machine-readable output | -| `fleet check` | Table of files touched by more than one agent — collision risks before merging. Exits 1 if any are found (CI-friendly) | `--lines` only count overlapping line ranges, `--json` machine-readable output | +| `fleet check` | Table of files touched by more than one agent. On git ≥ 2.38 each shared file gets a merge-simulation verdict — files whose committed changes merge cleanly are reported but don't block or fail the check. Exits 1 on real collision risks (CI-friendly) | `--lines` only count overlapping line ranges, `--files-only` skip simulation; flag any shared file, `--json` machine-readable output | | `fleet diff ` | Full diff of the agent's branch against its base | `--base ` diff against a different branch | | `fleet sync ` | Merge the agent's base branch into its branch, catching it up. A conflicting merge is aborted — never left half-done | — | | `fleet exec -- ` | Run a shell command inside the agent's worktree (e.g. `fleet exec claude -- npm test`) | `--all` run in every worktree sequentially; exits 1 if any run fails | -| `fleet merge ` | Check for collisions, run the `preMerge` hook, merge the agent's branch into the current branch, then remove the worktree and branch. A conflicting merge is aborted — never left half-done | `--no-clean` keep the worktree and branch, `--delete-branch` explicit form of the default cleanup | +| `fleet merge ` | Check for collisions, run the `preMerge` hook, merge the agent's branch into the current branch, then remove the worktree and branch. A conflicting merge is aborted — never left half-done. Overlaps that provably merge cleanly no longer block; predicted conflicts and uncommitted overlaps still do | `--no-clean` keep the worktree and branch, `--delete-branch` explicit form of the default cleanup | +| `fleet undo` | Roll back the last `fleet merge`: reset the target branch, restore the agent's branch, worktree, and state entry. Single-level; refuses if history moved on | — | | `fleet pr ` | Push the agent's branch to `origin` and open a pull request with the [GitHub CLI](https://cli.github.com) — the review-based alternative to a local merge | `--title `, `--base `, `--draft` | | `fleet remove ` | Remove the worktree; refuses if there are uncommitted changes | `--force` discard changes, `--delete-branch` also delete the branch | | `fleet clean` | Remove agents whose branches are fully merged into their base | `--dry-run` list only, `--stale ` also remove long-idle agents (clean worktrees only; their branches are kept) | @@ -123,6 +125,13 @@ fleet list --json | jq -r '.[].name' # enumerate active agents `fleet check --lines` refines collision detection from files to line ranges: two agents editing disjoint parts of one file are reported separately instead of blocking. Ranges are computed against each pair's merge base — exact when both agents share a base, a documented heuristic otherwise (see [docs/architecture.md](docs/architecture.md)). +On git ≥ 2.38, `fleet check` upgrades from "same file" to "would actually +conflict": each pair of overlapping agents is merged in memory with +`git merge-tree`, and cleanly merging overlaps are demoted to an informational +list (they no longer exit 1). `--files-only` restores plain file-level +behavior; older git falls back to it automatically. JSON output carries +`prediction: "merge-tree" | "files"` so scripts know which semantics ran. + ## Configuration An optional `.fleetrc.json` at the repo root sets per-repo defaults. Precedence everywhere: CLI flag > `.fleetrc.json` > built-in default. diff --git a/docs/architecture.md b/docs/architecture.md index cacbc5e..afaf39e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,10 +29,25 @@ graph TD `fleet exec -- ` re-joins the argv into one shell command (`src/lib/proc.ts` `shellJoin`, a pragmatic quoting heuristic for sh and cmd.exe) and runs it with the worktree as cwd; `--all` fans out sequentially so output never interleaves. `fleet pr` never bundles GitHub logic: it verifies the `gh` binary exists (before pushing anything), pushes the branch to `origin`, and shells out to `gh pr create`. -### Line-level checking (`fleet check --lines`) +### Refinements: line ranges and merge simulation File-level overlap stays the default signal, but `--lines` refines it: for each agent, one `git diff -U0 ` inside the worktree yields the edited line ranges of committed *and* uncommitted work at once, in old-side (merge-base) coordinates — the only coordinate system two agents' diffs share. Ranges are intersected per file across agents; files whose edits are disjoint are reported informationally instead of counting as collisions (and don't affect the exit code). Untracked and binary files have no line info and stay whole-file collisions. Caveat: when two agents were spawned from different bases their merge bases differ, so cross-agent line numbers are a heuristic, not a guarantee — which is why `--lines` is opt-in. +On git >= 2.38 `fleet check` adds a stronger layer by default: for every pair +of agents sharing a file it runs `git merge-tree --write-tree` — a real +in-memory three-way merge over the shared object database, touching no +worktree or index. Shared files then carry a verdict: **will conflict** +(simulation found conflict markers), **uncommitted edits** (a sharer has +uncommitted changes there, or a branch is missing — simulation can't see +those, so they fail closed), or clean (committed sides auto-merge; reported +informationally, exit 0). `fleet merge`'s gate filters `check`'s collisions, +so it inherits these semantics: provably clean overlaps stop blocking merges, +while predicted conflicts and uncommitted overlaps still refuse. A wrong +"clean" verdict is caught by the merge's own abort-on-conflict safety net — +prediction sharpens the signal; the safety guarantee never rested on it. +`--files-only` opts out; git < 2.38 falls back automatically (`fleet doctor` +reports which mode you get). + `list`, `status`, `check`, and `doctor` accept `--json` and print their result object verbatim — the same data the human output renders, for scripts, CI gates, and the agents themselves. Switchyard needs git >= 2.31 (`rev-parse --path-format=absolute`, used to resolve the main repo root from inside any worktree); `fleet doctor` verifies this. @@ -71,6 +86,34 @@ Switchyard also writes `.fleet/` into `.git/info/exclude` (not `.gitignore`) on Writes go through a write-then-rename (`state.json.tmp` → `state.json`) in `src/lib/state.ts`, so a crash mid-write can't corrupt the file. Commands tolerate drift between state and reality (a manually deleted worktree shows as `worktree missing` in `fleet list`; a manually deleted branch becomes a `fleet clean` candidate) rather than crashing — and `fleet doctor --fix` actively repairs drift: it rebuilds a corrupted `state.json` from real `git worktree list` output, adopts orphaned worktrees back into state, removes leftover non-worktree directories under `.fleet/worktrees/`, and prunes entries whose worktree is gone (branches are never deleted by doctor). Rebuilt entries carry re-derived `baseBranch`/`createdAt` values, not the originals. +## Mutation lock + +Every mutating command (`spawn`, `merge`, `remove`, `clean`, `sync`, +`doctor --fix`, `undo`) runs under `.fleet/lock` — an atomically created file +holding the holder's PID, command, and start time (`src/lib/lock.ts`). This +serializes read→modify→write cycles on `state.json` across processes, which +matters because agents themselves run `fleet` commands concurrently. Waiters +retry for up to 10 s, then fail naming the holder. A lock whose PID is dead is +taken over automatically; `fleet doctor` reports lock state and `--fix` +removes dead locks. Read-only commands and `fleet exec` never take the lock +(exec runs long agent workloads). The lock is same-machine only — consistent +with state being local by design — and reentrant within one process +(merge → autoClean → clean). In-process parallel mutation remains unsupported. + +## Undo model + +`fleet merge` records its pre-merge world before touching anything: two refs — +`refs/fleet/undo-head` (target branch HEAD) and `refs/fleet/undo-branch` +(agent branch tip) — pin the commits against GC even after the branch is +deleted, and after success `.fleet/undo.json` stores the agent record, target +branch, and post-merge HEAD. A failed or aborted merge deletes the refs and +writes no record, so `fleet undo` can never act on a failed merge. Undo +refuses unless the current branch, HEAD, and refs all match the record and +the main worktree has no tracked changes; then it hard-resets the target +branch, recreates the branch/worktree that cleanup removed, restores the +state entry, and clears the record. Single-level by design: any new merge +overwrites the slot. `fleet doctor` reports a pending record. + ## Config file An optional `.fleetrc.json` at the repo root (committed or not — the user's choice) provides per-repo defaults, read by `src/lib/config.ts`: diff --git a/src/cli.ts b/src/cli.ts index 1cb66bd..ed428cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { remove } from './commands/remove.js'; import { spawn } from './commands/spawn.js'; import { status } from './commands/status.js'; import { sync } from './commands/sync.js'; +import { undo } from './commands/undo.js'; import { watch } from './commands/watch.js'; import { FleetError } from './lib/errors.js'; @@ -82,8 +83,9 @@ program .command('check') .description('flag files touched by more than one agent (exits 1 if any are found)') .option('--lines', 'only count files whose edited line ranges actually overlap') + .option('--files-only', 'skip merge simulation; flag any shared file (v0.1 behavior)') .option('--json', 'print machine-readable JSON instead of a table') - .action((opts: { lines?: boolean; json?: boolean }) => + .action((opts: { lines?: boolean; filesOnly?: boolean; json?: boolean }) => run(async () => { const result = await check(opts); if (result.collisions.length > 0) process.exitCode = 1; @@ -148,6 +150,11 @@ program run(() => merge(name, opts)), ); +program + .command('undo') + .description('roll back the last fleet merge: branch pointer, agent branch, worktree, state') + .action(() => run(() => undo())); + program .command('doctor') .description('diagnose state/reality drift; exits 1 if problems remain unfixed') diff --git a/src/commands/check.ts b/src/commands/check.ts index ecfd4ab..2325de3 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -6,10 +6,12 @@ import { changedFilesVsBase, getMainRepoRoot, gitAt, + supportsMergeTree, uncommittedFiles, } from '../lib/git.js'; import { formatRanges, parseUnifiedDiff, rangesOverlap } from '../lib/lines.js'; import type { FileRanges } from '../lib/lines.js'; +import { predictMergeConflicts } from '../lib/mergetree.js'; import { readState, worktreeAbsPath } from '../lib/state.js'; import type { AgentRecord } from '../lib/state.js'; @@ -21,6 +23,8 @@ export interface CheckOptions { * ranges actually overlap; report disjoint same-file edits separately. */ lines?: boolean; + /** Skip merge simulation entirely; flag any shared file (v0.1 behavior). */ + filesOnly?: boolean; cwd?: string; } @@ -32,12 +36,18 @@ export interface Collision { * 'whole-file' when line info is unknowable (binary/untracked files, …). */ overlap?: string; + /** merge-tree mode only: why this shared file is still a collision. */ + verdict?: 'conflicts' | 'uncommitted'; } export interface CheckResult { collisions: Collision[]; /** --lines only: multi-agent files whose edits touch disjoint lines. */ disjoint?: Collision[]; + /** merge-tree mode only: shared files whose committed changes auto-merge. */ + cleanMerges?: Collision[]; + /** Which detection semantics ran. */ + prediction: 'merge-tree' | 'files'; agentsChecked: number; } @@ -52,9 +62,12 @@ export async function check(options: CheckOptions = {}): Promise { const state = readState(repoRoot); const agents = Object.values(state.agents).sort((a, b) => a.name.localeCompare(b.name)); const json = options.json ?? false; + const capable = await supportsMergeTree(git); + const useMergeTree = capable && !(options.filesOnly ?? false); + const prediction: 'merge-tree' | 'files' = useMergeTree ? 'merge-tree' : 'files'; if (agents.length < 2) { - const result: CheckResult = { collisions: [], agentsChecked: agents.length }; + const result: CheckResult = { collisions: [], prediction, agentsChecked: agents.length }; if (options.lines) result.disjoint = []; if (json) { console.log(JSON.stringify(result, null, 2)); @@ -71,8 +84,14 @@ export async function check(options: CheckOptions = {}): Promise { // --lines only: file -> agent -> edited ranges (merge-base coordinates). const rangesByFile = new Map>(); + // merge-tree mode: simulation can't see uncommitted work, and can't run at + // all when a branch is missing — both fail closed rather than silently clean. + const uncommittedByAgent = new Map>(); + const unsimulatable = new Set(); + for (const record of agents) { const files = new Set(); + const uncommitted = new Set(); if ( (await branchExists(git, record.branch)) && (await branchExists(git, record.baseBranch)) @@ -80,11 +99,17 @@ export async function check(options: CheckOptions = {}): Promise { for (const f of await changedFilesVsBase(git, record.baseBranch, record.branch)) { files.add(f); } + } else { + unsimulatable.add(record.name); } const abs = worktreeAbsPath(repoRoot, record); if (existsSync(abs)) { - for (const f of await uncommittedFiles(abs)) files.add(f.path); + for (const f of await uncommittedFiles(abs)) { + files.add(f.path); + uncommitted.add(f.path); + } } + uncommittedByAgent.set(record.name, uncommitted); for (const file of files) { const touchers = agentsByFile.get(file) ?? []; touchers.push(record.name); @@ -105,30 +130,82 @@ export async function check(options: CheckOptions = {}): Promise { .map(([file, names]) => ({ file, agents: [...names].sort() })) .sort((a, b) => a.file.localeCompare(b.file)); + // Merge simulation: every unordered pair of agents sharing a file gets a real + // in-memory three-way merge, and each shared file inherits the strongest + // verdict across the pairs that touch it (conflicts > uncommitted > clean). + let working: Collision[] = multiAgent; + let cleanMerges: Collision[] | undefined; + if (useMergeTree && multiAgent.length > 0) { + const byName = new Map(agents.map((a) => [a.name, a])); + const pairKeys = new Set(); + for (const { agents: names } of multiAgent) { + for (let i = 0; i < names.length; i += 1) { + for (let j = i + 1; j < names.length; j += 1) { + pairKeys.add(`${names[i]}\n${names[j]}`); + } + } + } + const conflicted = new Set(); + for (const key of pairKeys) { + const [a, b] = key.split('\n').map((n) => byName.get(n)); + if (!a || !b || unsimulatable.has(a.name) || unsimulatable.has(b.name)) continue; + const res = await predictMergeConflicts(git, a.branch, b.branch); + for (const f of res.conflictedFiles) conflicted.add(f); + } + const verdicted: Collision[] = []; + cleanMerges = []; + for (const c of multiAgent) { + if (conflicted.has(c.file)) { + verdicted.push({ ...c, verdict: 'conflicts' }); + } else if ( + c.agents.some((n) => unsimulatable.has(n) || uncommittedByAgent.get(n)?.has(c.file)) + ) { + // Simulation can't see uncommitted work or missing branches: fail closed. + verdicted.push({ ...c, verdict: 'uncommitted' }); + } else { + cleanMerges.push(c); + } + } + working = verdicted; + } else if (useMergeTree) { + cleanMerges = []; + } + + /** Overlapping edited ranges for one file, or undefined when disjoint. */ + const lineOverlap = (file: string, names: string[]): string | undefined => { + const perAgent = rangesByFile.get(file); + // An agent with no parsed ranges has no net change vs merge-base. + const entries = names.map((n) => perAgent?.get(n) ?? []); + const overlap = rangesOverlap(entries); + if (overlap === 'whole') return 'whole-file'; + return overlap.length > 0 ? formatRanges(overlap) : undefined; + }; + let collisions: Collision[]; let disjoint: Collision[] | undefined; if (options.lines) { - collisions = []; - disjoint = []; - for (const { file, agents: names } of multiAgent) { - const perAgent = rangesByFile.get(file); - // An agent with no parsed ranges has no net change vs merge-base. - const entries = names.map((n) => perAgent?.get(n) ?? []); - const overlap = rangesOverlap(entries); - if (overlap === 'whole') { - collisions.push({ file, agents: names, overlap: 'whole-file' }); - } else if (overlap.length > 0) { - collisions.push({ file, agents: names, overlap: formatRanges(overlap) }); - } else { - disjoint.push({ file, agents: names }); + if (useMergeTree) { + // Verdict decides collision-ness; lines are extra context on each row. + collisions = working.map((c) => ({ ...c, overlap: lineOverlap(c.file, c.agents) ?? '' })); + } else { + collisions = []; + disjoint = []; + for (const { file, agents: names } of working) { + const overlap = lineOverlap(file, names); + if (overlap !== undefined) { + collisions.push({ file, agents: names, overlap }); + } else { + disjoint.push({ file, agents: names }); + } } } } else { - collisions = multiAgent; + collisions = working; } - const result: CheckResult = { collisions, agentsChecked: agents.length }; + const result: CheckResult = { collisions, prediction, agentsChecked: agents.length }; if (disjoint !== undefined) result.disjoint = disjoint; + if (cleanMerges !== undefined) result.cleanMerges = cleanMerges; if (json) { console.log(JSON.stringify(result, null, 2)); @@ -139,25 +216,41 @@ export async function check(options: CheckOptions = {}): Promise { console.log(ok(`No collisions across ${agents.length} agents.`)); } else { console.log(fail(`${plural(collisions.length, 'collision risk')} detected:`)); + const headers = ['FILE', 'AGENTS']; + if (options.lines) headers.push('LINES'); + if (useMergeTree) headers.push('VERDICT'); + console.log( + table( + headers, + collisions.map((c) => { + const row = [c.file, c.agents.join(', ')]; + if (options.lines) row.push(c.overlap ?? ''); + if (useMergeTree) { + row.push(c.verdict === 'conflicts' ? 'will conflict' : 'uncommitted edits'); + } + return row; + }), + ), + ); console.log( - options.lines - ? table( - ['FILE', 'AGENTS', 'LINES'], - collisions.map((c) => [c.file, c.agents.join(', '), c.overlap ?? '']), - ) - : table( - ['FILE', 'AGENTS'], - collisions.map((c) => [c.file, c.agents.join(', ')]), - ), + dim( + useMergeTree + ? "Verdicts from git merge-tree simulation of each agent pair's committed work; uncommitted edits can't be simulated and stay blocking." + : options.lines + ? 'Line ranges are relative to the merge base — exact when the agents share a base, a heuristic otherwise.' + : 'These files are touched by more than one agent (committed or uncommitted). ' + + 'Coordinate before merging.' + + (capable ? '' : ' (file-level only: git < 2.38 lacks merge-tree)'), + ), ); + } + if (cleanMerges && cleanMerges.length > 0) { console.log( dim( - options.lines - ? 'Line ranges are relative to the merge base — exact when the agents share a base, a heuristic otherwise.' - : 'These files are touched by more than one agent (committed or uncommitted). ' + - 'Coordinate before merging.', + `${plural(cleanMerges.length, 'shared file')} whose committed changes merge cleanly (not counted):`, ), ); + for (const c of cleanMerges) console.log(dim(` ${c.file} (${c.agents.join(', ')})`)); } if (disjoint && disjoint.length > 0) { console.log( diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 497486a..47916c0 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -2,17 +2,21 @@ import { existsSync, readdirSync, rmSync } from 'node:fs'; import path from 'node:path'; import { simpleGit } from 'simple-git'; import type { SimpleGit } from 'simple-git'; -import { dim, fail, ok, warn } from '../lib/format.js'; +import { dim, fail, ok, relativeTime, warn } from '../lib/format.js'; import { + atLeast, currentBranch, defaultBaseBranch, getMainRepoRoot, gitAt, + gitVersion, + MERGE_TREE_MIN, pruneWorktrees, } from '../lib/git.js'; -import { withLock } from '../lib/lock.js'; +import { holdingLock, lockPath, lockStatus, withLock } from '../lib/lock.js'; import { readState, worktreeAbsPath, worktreesDir, writeState } from '../lib/state.js'; import type { AgentRecord, FleetState } from '../lib/state.js'; +import { readUndoRecord } from '../lib/undo.js'; /** Minimum git version Switchyard needs (`--path-format=absolute` support). */ export const MIN_GIT = { major: 2, minor: 31 }; @@ -26,7 +30,15 @@ export interface DoctorOptions { } export interface DoctorCheck { - name: 'git-version' | 'repository' | 'state-file' | 'orphaned-worktrees' | 'stale-entries'; + name: + | 'git-version' + | 'conflict-prediction' + | 'repository' + | 'lock' + | 'undo-record' + | 'state-file' + | 'orphaned-worktrees' + | 'stale-entries'; /** Whether the check was healthy before any fixing. */ ok: boolean; detail: string; @@ -63,6 +75,19 @@ async function doctorRun(options: DoctorOptions = {}): Promise { checks.push(await checkGitVersion()); + { + const v = await gitVersion(simpleGit()); + const capable = v !== null && atLeast(v, MERGE_TREE_MIN); + checks.push({ + name: 'conflict-prediction', + ok: true, // informational: old git is a capability note, not ill health + detail: capable + ? `available (git ${v.major}.${v.minor}; merge-tree needs ${MERGE_TREE_MIN.major}.${MERGE_TREE_MIN.minor}+)` + : `unavailable — file-level checks only (git ${v ? `${v.major}.${v.minor}` : 'unknown'} < ${MERGE_TREE_MIN.major}.${MERGE_TREE_MIN.minor})`, + fixed: false, + }); + } + let repoRoot: string; try { repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd()); @@ -77,6 +102,46 @@ async function doctorRun(options: DoctorOptions = {}): Promise { return finish(checks, options.json ?? false); } + // --- mutation lock --------------------------------------------------------- + const lock = lockStatus(repoRoot); + if (lock.state === 'none' || holdingLock()) { + // Our own lock (doctor --fix runs locked) is not a finding. + checks.push({ name: 'lock', ok: true, detail: 'no mutation lock held', fixed: false }); + } else if (lock.state === 'live') { + checks.push({ + name: 'lock', + ok: true, + detail: `held by live pid ${lock.info?.pid ?? '?'} (${lock.info?.command ?? 'unknown'}, ${Math.round(lock.ageMs / 1000)}s)`, + fixed: false, + }); + } else if (fix) { + rmSync(lockPath(repoRoot), { force: true }); + checks.push({ + name: 'lock', + ok: false, + detail: `stale lock removed (pid ${lock.info?.pid ?? 'unknown'} is gone)`, + fixed: true, + }); + } else { + checks.push({ + name: 'lock', + ok: false, + detail: `stale lock: pid ${lock.info?.pid ?? 'unknown'} is gone — --fix will remove it`, + fixed: false, + }); + } + + // --- pending undo ---------------------------------------------------------- + const undoRec = readUndoRecord(repoRoot); + checks.push({ + name: 'undo-record', + ok: true, // informational + detail: undoRec + ? `fleet undo available: merge of ${undoRec.agent.branch} into ${undoRec.into} (${relativeTime(undoRec.mergedAt)})` + : 'no pending undo record', + fixed: false, + }); + const git = gitAt(repoRoot); const worktrees = await listGitWorktrees(git); const fleetWorktrees = worktrees.filter( diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 93081b4..31ed352 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -5,16 +5,25 @@ import { dim, ok, plural, warn } from '../lib/format.js'; import { currentBranch, deleteBranch, + deleteRef, getMainRepoRoot, gitAt, pruneWorktrees, removeWorktree, + revParseOid, uncommittedFiles, + updateRef, verifyBranch, } from '../lib/git.js'; import { withLock } from '../lib/lock.js'; import { runShell } from '../lib/proc.js'; import { getAgent, readState, worktreeAbsPath, writeState } from '../lib/state.js'; +import { + clearUndoRecord, + UNDO_BRANCH_REF, + UNDO_HEAD_REF, + writeUndoRecord, +} from '../lib/undo.js'; import { check } from './check.js'; import { clean } from './clean.js'; @@ -118,11 +127,28 @@ async function mergeLocked( } } + // Record the pre-merge world for `fleet undo`: refs now (GC-safe even after + // the branch is deleted), the JSON record only after success — undo must + // never be able to act on a failed merge. Any merge attempt invalidates a + // previous undo record, since these refs are single-slot. + const headBefore = await revParseOid(git, 'HEAD'); + const branchTip = await revParseOid(git, record.branch); + if (!headBefore || !branchTip) { + throw new FleetError( + 'Could not resolve HEAD or the agent branch; refusing to merge without undo state.', + ); + } + clearUndoRecord(repoRoot); + await updateRef(git, UNDO_HEAD_REF, headBefore); + await updateRef(git, UNDO_BRANCH_REF, branchTip); + try { await git.merge([record.branch]); } catch (err) { const status = await git.status(); if (status.conflicted.length > 0) { + await deleteRef(git, UNDO_HEAD_REF); + await deleteRef(git, UNDO_BRANCH_REF); // Never leave the repo mid-merge: abort before reporting. await git.raw(['merge', '--abort']); throw new FleetError( @@ -131,6 +157,8 @@ async function mergeLocked( `\nThe merge was aborted — ${into} is unchanged. Resolve manually with \`git merge ${record.branch}\` when ready.`, ); } + await deleteRef(git, UNDO_HEAD_REF); + await deleteRef(git, UNDO_BRANCH_REF); const message = err instanceof Error ? err.message : String(err); throw new FleetError(`git merge failed before starting: ${message}`); } @@ -170,6 +198,19 @@ async function mergeLocked( } } + const headAfter = (await revParseOid(git, 'HEAD')) ?? headBefore; + writeUndoRecord(repoRoot, { + version: 1, + agent: record, + into, + headBefore, + branchTip, + headAfter, + cleaned, + branchDeleted, + mergedAt: new Date().toISOString(), + }); + let autoCleaned: string[] = []; if (config.autoClean && doClean) { const swept = await clean({ cwd: repoRoot }); diff --git a/src/commands/undo.ts b/src/commands/undo.ts new file mode 100644 index 0000000..37de89d --- /dev/null +++ b/src/commands/undo.ts @@ -0,0 +1,118 @@ +import { existsSync } from 'node:fs'; +import { FleetError } from '../lib/errors.js'; +import { dim, ok, plural } from '../lib/format.js'; +import { + addWorktreeForBranch, + branchExists, + createBranchAt, + currentBranch, + deleteRef, + getMainRepoRoot, + gitAt, + resetHardTo, + revParseOid, + uncommittedFiles, +} from '../lib/git.js'; +import { withLock } from '../lib/lock.js'; +import { readState, worktreeAbsPath, writeState } from '../lib/state.js'; +import { clearUndoRecord, readUndoRecord, UNDO_BRANCH_REF, UNDO_HEAD_REF } from '../lib/undo.js'; + +export interface UndoOptions { + cwd?: string; +} + +export interface UndoResult { + agent: string; + into: string; + restoredBranch: boolean; + restoredWorktree: boolean; +} + +/** + * Roll back the last `fleet merge`: reset the target branch to its pre-merge + * commit, recreate the agent's branch and worktree if the merge cleaned them + * up, and restore the state entry. Single level — a newer merge overwrites + * the record. Every precondition is checked before anything is touched. + */ +export async function undo(options: UndoOptions = {}): Promise { + const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd()); + return withLock(repoRoot, 'undo', () => undoLocked(repoRoot)); +} + +async function undoLocked(repoRoot: string): Promise { + const git = gitAt(repoRoot); + const record = readUndoRecord(repoRoot); + if (!record) { + throw new FleetError('Nothing to undo: no fleet merge has been recorded.'); + } + + const undoHead = await revParseOid(git, UNDO_HEAD_REF); + const undoBranch = await revParseOid(git, UNDO_BRANCH_REF); + if (undoHead !== record.headBefore || undoBranch !== record.branchTip) { + throw new FleetError( + 'Undo refs are missing or do not match the recorded merge — refusing to guess.\n' + + 'Delete .fleet/undo.json to discard the record.', + ); + } + + const branch = await currentBranch(git); + if (branch !== record.into) { + throw new FleetError( + `The merge went into "${record.into}" but the current branch is ${branch ?? '(detached HEAD)'}.\n` + + `Check out ${record.into} first, then re-run fleet undo.`, + ); + } + + const head = await revParseOid(git, 'HEAD'); + if (head !== record.headAfter) { + throw new FleetError( + 'History moved since the merge: HEAD is no longer the recorded post-merge commit.\n' + + 'Undo would discard newer work — refusing. Revert manually if you still need to.', + ); + } + + // Untracked files are untouched by reset --hard; only tracked changes block. + const dirty = (await uncommittedFiles(repoRoot)).filter((f) => f.status !== '??'); + if (dirty.length > 0) { + throw new FleetError( + `The main worktree has ${plural(dirty.length, 'uncommitted change')}. ` + + 'Commit or stash them first, then re-run fleet undo.', + ); + } + + await resetHardTo(git, record.headBefore); + + let restoredBranch = false; + if (record.branchDeleted && !(await branchExists(git, record.agent.branch))) { + await createBranchAt(git, record.agent.branch, record.branchTip); + restoredBranch = true; + } + + let restoredWorktree = false; + const abs = worktreeAbsPath(repoRoot, record.agent); + if (record.cleaned && !existsSync(abs)) { + await addWorktreeForBranch(git, abs, record.agent.branch); + restoredWorktree = true; + } + + const state = readState(repoRoot); + state.agents[record.agent.name] = record.agent; + writeState(repoRoot, state); + + await deleteRef(git, UNDO_HEAD_REF); + await deleteRef(git, UNDO_BRANCH_REF); + clearUndoRecord(repoRoot); + + console.log(ok(`Undid the merge of ${record.agent.branch} into ${record.into}.`)); + console.log(` ${record.into} reset to ${record.headBefore.slice(0, 12)}`); + if (restoredBranch) console.log(` branch ${record.agent.branch} restored`); + if (restoredWorktree) console.log(` worktree restored at ${abs}`); + console.log(dim(' single-level: the undo record is now cleared')); + + return { + agent: record.agent.name, + into: record.into, + restoredBranch, + restoredWorktree, + }; +} diff --git a/src/lib/git.ts b/src/lib/git.ts index 19304b6..fe99ae8 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -165,6 +165,80 @@ export async function isMergedInto(git: SimpleGit, branch: string, base: string) return Number(out.trim()) === 0; } +/** OID of `ref`'s commit, or null when it doesn't resolve. */ +export async function revParseOid(git: SimpleGit, ref: string): Promise { + try { + const out = await git.raw(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); + return out.trim() || null; + } catch { + return null; + } +} + +export async function updateRef(git: SimpleGit, ref: string, oid: string): Promise { + await git.raw(['update-ref', ref, oid]); +} + +export async function deleteRef(git: SimpleGit, ref: string): Promise { + await git.raw(['update-ref', '-d', ref]); +} + +/** Create a branch at a commit without checking it out. */ +export async function createBranchAt(git: SimpleGit, branch: string, oid: string): Promise { + await git.raw(['branch', branch, oid]); +} + +/** Attach a worktree for an existing branch (addWorktree creates a new branch). */ +export async function addWorktreeForBranch( + git: SimpleGit, + worktreePath: string, + branch: string, +): Promise { + await git.raw(['worktree', 'add', worktreePath, branch]); +} + +export async function resetHardTo(git: SimpleGit, oid: string): Promise { + await git.raw(['reset', '--hard', oid]); +} + +export interface GitVersion { + major: number; + minor: number; +} + +/** Parse `git version` output ("git version 2.45.1.windows.1" → 2.45). */ +export function parseGitVersion(raw: string): GitVersion | null { + const m = /git version (\d+)\.(\d+)/.exec(raw); + return m ? { major: Number(m[1]), minor: Number(m[2]) } : null; +} + +export function atLeast(v: GitVersion, min: GitVersion): boolean { + return v.major > min.major || (v.major === min.major && v.minor >= min.minor); +} + +/** Minimum git for `merge-tree --write-tree` (real in-memory merges). */ +export const MERGE_TREE_MIN: GitVersion = { major: 2, minor: 38 }; + +let cachedGitVersion: GitVersion | null | undefined; + +/** Installed git version, cached per process (one git binary per PATH). */ +export async function gitVersion(git: SimpleGit): Promise { + if (cachedGitVersion === undefined) { + try { + cachedGitVersion = parseGitVersion(await git.raw(['version'])); + } catch { + cachedGitVersion = null; + } + } + return cachedGitVersion; +} + +/** Whether `fleet check` can use merge simulation on this machine. */ +export async function supportsMergeTree(git: SimpleGit): Promise { + const v = await gitVersion(git); + return v !== null && atLeast(v, MERGE_TREE_MIN); +} + /** * Ensure `.fleet/` is ignored via `.git/info/exclude` so Switchyard never dirties * the repos it manages — even ones whose .gitignore doesn't mention it. diff --git a/src/lib/lock.ts b/src/lib/lock.ts index 3050eb2..b30fd8f 100644 --- a/src/lib/lock.ts +++ b/src/lib/lock.ts @@ -42,6 +42,17 @@ export function lockPath(repoRoot: string): string { return path.join(fleetDir(repoRoot), 'lock'); } +/** + * True when this process is inside a `withLock` body, i.e. the lock on disk is + * the one we took. Callers that inspect the lock (`fleet doctor`) need this to + * tell "someone else is mutating" from "I am the mutator" — a PID comparison + * can't, since a foreign lock file may carry our PID after reuse, and `doctor + * --fix` inspects the very lock it holds. + */ +export function holdingLock(): boolean { + return holdDepth > 0; +} + /** True when a process with this PID is alive on this machine. */ function pidAlive(pid: number): boolean { try { diff --git a/src/lib/mergetree.ts b/src/lib/mergetree.ts new file mode 100644 index 0000000..3b393cd --- /dev/null +++ b/src/lib/mergetree.ts @@ -0,0 +1,35 @@ +import type { SimpleGit } from 'simple-git'; + +export interface MergePrediction { + /** Paths git reports as conflicted when the two branches merge in memory. */ + conflictedFiles: string[]; +} + +/** + * Real three-way merge of two branches in memory (`git merge-tree + * --write-tree`, git >= 2.38): no worktree, index, or ref is touched. On + * conflict, git exits 1 with the conflicted paths on stdout and an empty + * stderr — which simple-git surfaces as normal output, not an error (the same + * quirk `branchExists` documents), so both outcomes flow through the parser. + * + * -z framing, as observed from git itself: + * NUL [ NUL]… NUL [ NUL]… + * The conflicted-path list runs until the empty record that separates it from + * the human-readable "Auto-merging"/"CONFLICT" messages; a clean merge emits + * the OID and nothing else. Callers must gate on `supportsMergeTree` first. + */ +export async function predictMergeConflicts( + git: SimpleGit, + branchA: string, + branchB: string, +): Promise { + const out = await git.raw(['merge-tree', '--write-tree', '--name-only', '-z', branchA, branchB]); + const records = out.split('\0'); + const conflictedFiles: string[] = []; + for (const record of records.slice(1)) { + if (record === '' || record === '\n') break; + conflictedFiles.push(record); + } + conflictedFiles.sort((a, b) => a.localeCompare(b)); + return { conflictedFiles }; +} diff --git a/src/lib/undo.ts b/src/lib/undo.ts new file mode 100644 index 0000000..901ca1b --- /dev/null +++ b/src/lib/undo.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fleetDir } from './state.js'; +import type { AgentRecord } from './state.js'; + +/** Refs pinning the pre-merge commits so they survive branch deletion and GC. */ +export const UNDO_HEAD_REF = 'refs/fleet/undo-head'; +export const UNDO_BRANCH_REF = 'refs/fleet/undo-branch'; + +/** Everything `fleet undo` needs to roll back the last `fleet merge`. */ +export interface UndoRecord { + version: 1; + agent: AgentRecord; + /** Branch the merge went into. */ + into: string; + headBefore: string; + branchTip: string; + headAfter: string; + /** Whether merge's cleanup removed the worktree (and state entry). */ + cleaned: boolean; + branchDeleted: boolean; + mergedAt: string; +} + +export function undoPath(repoRoot: string): string { + return path.join(fleetDir(repoRoot), 'undo.json'); +} + +export function readUndoRecord(repoRoot: string): UndoRecord | null { + const file = undoPath(repoRoot); + if (!existsSync(file)) return null; + try { + // Strip a UTF-8 BOM, same as readState: Windows editors add one. + const parsed = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) as UndoRecord; + if (parsed.version !== 1) return null; + if (typeof parsed.headBefore !== 'string' || typeof parsed.agent?.name !== 'string') { + return null; + } + return parsed; + } catch { + return null; + } +} + +export function writeUndoRecord(repoRoot: string, record: UndoRecord): void { + const file = undoPath(repoRoot); + const tmpFile = `${file}.tmp`; + // Write-then-rename, same as state.json: a crash mid-write can't leave a + // half-written record that `fleet undo` would then act on. + writeFileSync(tmpFile, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); + renameSync(tmpFile, file); +} + +export function clearUndoRecord(repoRoot: string): void { + rmSync(undoPath(repoRoot), { force: true }); +} diff --git a/tests/check.test.ts b/tests/check.test.ts index 9419271..84936f1 100644 --- a/tests/check.test.ts +++ b/tests/check.test.ts @@ -28,7 +28,10 @@ describe('fleet check', () => { const result = await check({ cwd: repo.root }); expect(result.agentsChecked).toBe(2); - expect(result.collisions).toEqual([{ file: 'src.txt', agents: ['alice', 'bob'] }]); + // Both agents rewrite the whole two-line fixture: a genuine predicted conflict. + expect(result.collisions).toEqual([ + { file: 'src.txt', agents: ['alice', 'bob'], verdict: 'conflicts' }, + ]); }); it('counts uncommitted edits as collision risk', async () => { @@ -39,7 +42,9 @@ describe('fleet check', () => { writeFileSync(path.join(worktreePath(repo.root, 'bob'), 'src.txt'), 'bob wip\n'); const result = await check({ cwd: repo.root }); - expect(result.collisions).toEqual([{ file: 'src.txt', agents: ['alice', 'bob'] }]); + expect(result.collisions).toEqual([ + { file: 'src.txt', agents: ['alice', 'bob'], verdict: 'uncommitted' }, + ]); }); it('reports no collisions when agents touch disjoint files', async () => { @@ -55,7 +60,7 @@ describe('fleet check', () => { it('skips the check when fewer than two agents exist', async () => { await spawn('alice', { cwd: repo.root }); const result = await check({ cwd: repo.root }); - expect(result).toEqual({ collisions: [], agentsChecked: 1 }); + expect(result).toEqual({ collisions: [], prediction: 'merge-tree', agentsChecked: 1 }); }); it('--json prints the result as parseable JSON', async () => { @@ -70,11 +75,112 @@ describe('fleet check', () => { vi.mocked(console.log).mock.calls.at(-1)?.[0] as string, ) as typeof result; expect(printed).toEqual(result); - expect(printed.collisions).toEqual([{ file: 'src.txt', agents: ['alice', 'bob'] }]); + expect(printed.collisions).toEqual([ + { file: 'src.txt', agents: ['alice', 'bob'], verdict: 'conflicts' }, + ]); }); }); -describe('fleet check --lines', () => { +describe('merge-tree verdicts', () => { + const EIGHT_LINES = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n'; + + it('classifies a same-line overlap as a conflicts collision', async () => { + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile(worktreePath(repo.root, 'alice'), 'src.txt', 'alice\n', 'feat: a'); + await commitFile(worktreePath(repo.root, 'bob'), 'src.txt', 'bob\n', 'feat: b'); + + const result = await check({ cwd: repo.root }); + + expect(result.prediction).toBe('merge-tree'); + expect(result.collisions).toEqual([ + { file: 'src.txt', agents: ['alice', 'bob'], verdict: 'conflicts' }, + ]); + expect(result.cleanMerges).toEqual([]); + }); + + it('demotes a cleanly merging overlap to cleanMerges (no collision)', async () => { + await commitFile(repo.root, 'many.txt', EIGHT_LINES, 'chore: seed'); + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile( + worktreePath(repo.root, 'alice'), + 'many.txt', + EIGHT_LINES.replace('l1\n', 'l1 alice\n'), + 'feat: top', + ); + await commitFile( + worktreePath(repo.root, 'bob'), + 'many.txt', + EIGHT_LINES.replace('l8\n', 'l8 bob\n'), + 'feat: bottom', + ); + + const result = await check({ cwd: repo.root }); + + expect(result.collisions).toEqual([]); + expect(result.cleanMerges).toEqual([{ file: 'many.txt', agents: ['alice', 'bob'] }]); + }); + + it('keeps overlaps with uncommitted edits blocking (fail closed)', async () => { + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile(worktreePath(repo.root, 'alice'), 'src.txt', 'alice\n', 'feat: a'); + writeFileSync(path.join(worktreePath(repo.root, 'bob'), 'src.txt'), 'bob uncommitted\n'); + + const result = await check({ cwd: repo.root }); + + expect(result.collisions).toEqual([ + { file: 'src.txt', agents: ['alice', 'bob'], verdict: 'uncommitted' }, + ]); + }); + + it('--files-only restores v0.1 semantics', async () => { + await commitFile(repo.root, 'many.txt', EIGHT_LINES, 'chore: seed'); + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile( + worktreePath(repo.root, 'alice'), + 'many.txt', + EIGHT_LINES.replace('l1\n', 'l1 alice\n'), + 'feat: top', + ); + await commitFile( + worktreePath(repo.root, 'bob'), + 'many.txt', + EIGHT_LINES.replace('l8\n', 'l8 bob\n'), + 'feat: bottom', + ); + + const result = await check({ filesOnly: true, cwd: repo.root }); + + expect(result.prediction).toBe('files'); + expect(result.collisions).toEqual([{ file: 'many.txt', agents: ['alice', 'bob'] }]); + expect(result.cleanMerges).toBeUndefined(); + }); + + it('--lines combines: verdict plus line overlap on the same collision', async () => { + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile(worktreePath(repo.root, 'alice'), 'src.txt', 'alice\n', 'feat: a'); + await commitFile(worktreePath(repo.root, 'bob'), 'src.txt', 'bob\n', 'feat: b'); + + const result = await check({ lines: true, cwd: repo.root }); + + expect(result.prediction).toBe('merge-tree'); + expect(result.collisions).toHaveLength(1); + const [collision] = result.collisions; + expect(collision).toMatchObject({ file: 'src.txt', verdict: 'conflicts' }); + expect(collision?.overlap).toBeDefined(); + }); +}); + +// The line-refinement layer owns the `disjoint` bucket, which only exists when +// merge simulation is off — on capable git a cleanly-merging overlap is decided +// by its verdict and lands in `cleanMerges` instead. These tests therefore pin +// the files-only path explicitly; the combined path is covered by the +// '--lines combines' case in 'merge-tree verdicts' above. +describe('fleet check --lines (files-only mode)', () => { const numberedLines = (): string[] => Array.from({ length: 12 }, (_, i) => `line${i + 1}`); async function seedNumberedFile(): Promise { @@ -94,7 +200,7 @@ describe('fleet check --lines', () => { await commitFile(worktreePath(repo.root, 'alice'), 'big.txt', editLine('alice', 2), 'feat: alice edit'); await commitFile(worktreePath(repo.root, 'bob'), 'big.txt', editLine('bob', 10), 'feat: bob edit'); - const result = await check({ lines: true, cwd: repo.root }); + const result = await check({ lines: true, filesOnly: true, cwd: repo.root }); expect(result.collisions).toEqual([]); expect(result.disjoint).toEqual([{ file: 'big.txt', agents: ['alice', 'bob'] }]); @@ -108,7 +214,7 @@ describe('fleet check --lines', () => { // bob's overlapping edit is uncommitted — still measured from the merge base. writeFileSync(path.join(worktreePath(repo.root, 'bob'), 'big.txt'), editLine('bob', 5)); - const result = await check({ lines: true, cwd: repo.root }); + const result = await check({ lines: true, filesOnly: true, cwd: repo.root }); expect(result.collisions).toEqual([ { file: 'big.txt', agents: ['alice', 'bob'], overlap: '5' }, @@ -122,7 +228,7 @@ describe('fleet check --lines', () => { writeFileSync(path.join(worktreePath(repo.root, 'alice'), 'new.txt'), 'a\n'); writeFileSync(path.join(worktreePath(repo.root, 'bob'), 'new.txt'), 'b\n'); - const result = await check({ lines: true, cwd: repo.root }); + const result = await check({ lines: true, filesOnly: true, cwd: repo.root }); expect(result.collisions).toEqual([ { file: 'new.txt', agents: ['alice', 'bob'], overlap: 'whole-file' }, diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index b3a3bd5..5b7c12d 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -1,11 +1,14 @@ +import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { doctor } from '../src/commands/doctor.js'; +import { merge } from '../src/commands/merge.js'; import { spawn } from '../src/commands/spawn.js'; import { branchExists, gitAt } from '../src/lib/git.js'; +import { lockPath } from '../src/lib/lock.js'; import { readState, statePath, writeState } from '../src/lib/state.js'; -import { makeTempRepo, worktreePath } from './helpers.js'; +import { commitFile, makeTempRepo, worktreePath } from './helpers.js'; import type { TempRepo } from './helpers.js'; let repo: TempRepo; @@ -117,3 +120,76 @@ describe('fleet doctor', () => { expect(await branchExists(gitAt(repo.root), 'fleet/alice')).toBe(true); }); }); + +describe('mutation lock check', () => { + function writeLock(pid: number): void { + mkdirSync(path.join(repo.root, '.fleet'), { recursive: true }); + writeFileSync( + lockPath(repo.root), + JSON.stringify({ pid, command: 'spawn', startedAt: new Date().toISOString() }), + 'utf8', + ); + } + + /** PID that existed and is now certainly dead: a `node -e ""` that already exited. */ + function deadPid(): number { + const child = spawnSync(process.execPath, ['-e', '']); + if (child.pid === undefined) throw new Error('could not spawn a probe process'); + return child.pid; + } + + it('reports a live lock as healthy information', async () => { + writeLock(process.pid); + const result = await doctor({ cwd: repo.root }); + const check = result.checks.find((c) => c.name === 'lock'); + expect(check?.ok).toBe(true); + expect(check?.detail).toMatch(/held by live pid/); + }); + + it('flags a stale lock and --fix removes it', async () => { + writeLock(deadPid()); + const before = await doctor({ cwd: repo.root }); + expect(before.checks.find((c) => c.name === 'lock')?.ok).toBe(false); + expect(before.healthy).toBe(false); + + writeLock(deadPid()); // doctor without --fix must not have removed it + const fixed = await doctor({ fix: true, cwd: repo.root }); + const check = fixed.checks.find((c) => c.name === 'lock'); + expect(check?.ok).toBe(true); // withLock's takeover already cleaned it + expect(existsSync(lockPath(repo.root))).toBe(false); + }); + + it('reports no lock when none is held', async () => { + const result = await doctor({ cwd: repo.root }); + expect(result.checks.find((c) => c.name === 'lock')?.detail).toBe('no mutation lock held'); + }); +}); + +describe('conflict prediction capability', () => { + it('reports conflict-prediction capability as information', async () => { + const result = await doctor({ cwd: repo.root }); + const check = result.checks.find((c) => c.name === 'conflict-prediction'); + expect(check?.ok).toBe(true); + expect(check?.detail).toMatch(/available|unavailable/); + }); +}); + +describe('undo record check', () => { + it('reports no pending undo on a fresh repo', async () => { + const result = await doctor({ cwd: repo.root }); + expect(result.checks.find((c) => c.name === 'undo-record')?.detail).toBe( + 'no pending undo record', + ); + }); + + it('reports an available undo after a merge', async () => { + await spawn('alice', { cwd: repo.root }); + await commitFile(worktreePath(repo.root, 'alice'), 'feature.txt', 'f\n', 'feat: feature'); + await merge('alice', { cwd: repo.root }); + + const result = await doctor({ cwd: repo.root }); + const check = result.checks.find((c) => c.name === 'undo-record'); + expect(check?.ok).toBe(true); + expect(check?.detail).toMatch(/fleet undo available: merge of fleet\/alice into main/); + }); +}); diff --git a/tests/git-version.test.ts b/tests/git-version.test.ts new file mode 100644 index 0000000..e94e397 --- /dev/null +++ b/tests/git-version.test.ts @@ -0,0 +1,33 @@ +import { simpleGit } from 'simple-git'; +import { describe, expect, it } from 'vitest'; +import { atLeast, MERGE_TREE_MIN, parseGitVersion, supportsMergeTree } from '../src/lib/git.js'; + +describe('parseGitVersion', () => { + it('parses plain and platform-suffixed versions', () => { + expect(parseGitVersion('git version 2.38.0')).toEqual({ major: 2, minor: 38 }); + expect(parseGitVersion('git version 2.45.1.windows.1')).toEqual({ major: 2, minor: 45 }); + expect(parseGitVersion('git version 2.39.5 (Apple Git-154)')).toEqual({ major: 2, minor: 39 }); + }); + + it('returns null for unrecognizable output', () => { + expect(parseGitVersion('')).toBeNull(); + expect(parseGitVersion('not git at all')).toBeNull(); + }); +}); + +describe('atLeast', () => { + it('compares against the merge-tree floor', () => { + expect(atLeast({ major: 2, minor: 38 }, MERGE_TREE_MIN)).toBe(true); + expect(atLeast({ major: 2, minor: 37 }, MERGE_TREE_MIN)).toBe(false); + expect(atLeast({ major: 3, minor: 0 }, MERGE_TREE_MIN)).toBe(true); + expect(atLeast({ major: 2, minor: 45 }, MERGE_TREE_MIN)).toBe(true); + }); +}); + +describe('supportsMergeTree', () => { + it('answers for the locally installed git without throwing', async () => { + // CI runners and dev machines all have modern git; the assertion that + // matters everywhere is "boolean, no crash". + await expect(supportsMergeTree(simpleGit())).resolves.toBeTypeOf('boolean'); + }); +}); diff --git a/tests/merge.test.ts b/tests/merge.test.ts index 2674897..fec4c7c 100644 --- a/tests/merge.test.ts +++ b/tests/merge.test.ts @@ -3,8 +3,9 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { merge } from '../src/commands/merge.js'; import { spawn } from '../src/commands/spawn.js'; -import { branchExists, gitAt } from '../src/lib/git.js'; +import { branchExists, gitAt, revParseOid } from '../src/lib/git.js'; import { readState } from '../src/lib/state.js'; +import { readUndoRecord, UNDO_BRANCH_REF, UNDO_HEAD_REF } from '../src/lib/undo.js'; import { commitFile, makeTempRepo, worktreePath } from './helpers.js'; import type { TempRepo } from './helpers.js'; @@ -74,6 +75,39 @@ describe('fleet merge', () => { expect(readState(repo.root).agents['bob']).toBeDefined(); }); + it('merges when the shared file merges cleanly (verdict-based gate)', async () => { + const EIGHT_LINES = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n'; + await commitFile(repo.root, 'many.txt', EIGHT_LINES, 'chore: seed'); + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile( + worktreePath(repo.root, 'alice'), + 'many.txt', + EIGHT_LINES.replace('l1\n', 'l1 alice\n'), + 'feat: top', + ); + await commitFile( + worktreePath(repo.root, 'bob'), + 'many.txt', + EIGHT_LINES.replace('l8\n', 'l8 bob\n'), + 'feat: bottom', + ); + + // v0.1 refused this; with verdicts the committed sides provably auto-merge. + const result = await merge('alice', { cwd: repo.root }); + expect(result.cleaned).toBe(true); + expect(readState(repo.root).agents['bob']).toBeDefined(); + }); + + it('still refuses when the other agent has uncommitted edits to the shared file', async () => { + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile(worktreePath(repo.root, 'alice'), 'src.txt', 'alice\n', 'feat: a'); + writeFileSync(path.join(worktreePath(repo.root, 'bob'), 'src.txt'), 'bob uncommitted\n'); + + await expect(merge('alice', { cwd: repo.root })).rejects.toThrow(/Refusing to merge/); + }); + it('aborts a conflicting merge and leaves the repo unconflicted', async () => { await spawn('alice', { cwd: repo.root }); // Both sides change the same line: guaranteed conflict, no collision @@ -160,3 +194,52 @@ describe('fleet merge', () => { expect(existsSync(path.join(repo.root, 'feature.txt'))).toBe(true); }); }); + +describe('undo recording', () => { + it('a successful merge records refs and an undo record', async () => { + await spawn('alice', { cwd: repo.root }); + const headBefore = await revParseOid(gitAt(repo.root), 'HEAD'); + await commitFile(worktreePath(repo.root, 'alice'), 'feature.txt', 'f\n', 'feat: feature'); + const branchTip = await revParseOid(gitAt(repo.root), 'fleet/alice'); + + await merge('alice', { cwd: repo.root }); + + const record = readUndoRecord(repo.root); + expect(record).toMatchObject({ + version: 1, + into: 'main', + headBefore, + branchTip, + cleaned: true, + branchDeleted: true, + }); + expect(record?.agent.name).toBe('alice'); + expect(await revParseOid(gitAt(repo.root), UNDO_HEAD_REF)).toBe(headBefore); + expect(await revParseOid(gitAt(repo.root), UNDO_BRANCH_REF)).toBe(branchTip); + expect(await revParseOid(gitAt(repo.root), 'HEAD')).toBe(record?.headAfter); + }); + + it('an aborted (conflicting) merge leaves no undo state', async () => { + await spawn('alice', { cwd: repo.root }); + await commitFile(worktreePath(repo.root, 'alice'), 'src.txt', 'agent version\n', 'feat: a'); + await commitFile(repo.root, 'src.txt', 'main version\n', 'feat: main edit'); + + await expect(merge('alice', { cwd: repo.root })).rejects.toThrow(/conflicts in 1 file/); + + expect(readUndoRecord(repo.root)).toBeNull(); + expect(await revParseOid(gitAt(repo.root), UNDO_HEAD_REF)).toBeNull(); + expect(await revParseOid(gitAt(repo.root), UNDO_BRANCH_REF)).toBeNull(); + }); + + it('a gate refusal leaves no undo state', async () => { + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile(worktreePath(repo.root, 'alice'), 'src.txt', 'alice\n', 'feat: a'); + await commitFile(worktreePath(repo.root, 'bob'), 'src.txt', 'bob\n', 'feat: b'); + + await expect(merge('alice', { cwd: repo.root })).rejects.toThrow(/Refusing to merge/); + + expect(readUndoRecord(repo.root)).toBeNull(); + expect(await revParseOid(gitAt(repo.root), UNDO_HEAD_REF)).toBeNull(); + }); +}); diff --git a/tests/mergetree.test.ts b/tests/mergetree.test.ts new file mode 100644 index 0000000..03874fa --- /dev/null +++ b/tests/mergetree.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { spawn } from '../src/commands/spawn.js'; +import { gitAt, supportsMergeTree } from '../src/lib/git.js'; +import { predictMergeConflicts } from '../src/lib/mergetree.js'; +import { commitFile, makeTempRepo, worktreePath } from './helpers.js'; +import type { TempRepo } from './helpers.js'; + +let repo: TempRepo; + +beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + repo = await makeTempRepo(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + repo.cleanup(); +}); + +const EIGHT_LINES = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n'; + +describe('predictMergeConflicts', () => { + it('pins down: conflicted merge-tree exits 1 without throwing, and reports the file', async () => { + // This is the simple-git exit-code quirk test from the spec: exit 1 with + // empty stderr must come back as parseable stdout, not an exception. + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile(worktreePath(repo.root, 'alice'), 'src.txt', 'alice version\n', 'feat: a'); + await commitFile(worktreePath(repo.root, 'bob'), 'src.txt', 'bob version\n', 'feat: b'); + + const result = await predictMergeConflicts(gitAt(repo.root), 'fleet/alice', 'fleet/bob'); + expect(result.conflictedFiles).toEqual(['src.txt']); + }); + + it('reports no conflicts for disjoint committed edits to one file', async () => { + await commitFile(repo.root, 'many.txt', EIGHT_LINES, 'chore: seed'); + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile( + worktreePath(repo.root, 'alice'), + 'many.txt', + EIGHT_LINES.replace('l1\n', 'l1 alice\n'), + 'feat: top', + ); + await commitFile( + worktreePath(repo.root, 'bob'), + 'many.txt', + EIGHT_LINES.replace('l8\n', 'l8 bob\n'), + 'feat: bottom', + ); + + const result = await predictMergeConflicts(gitAt(repo.root), 'fleet/alice', 'fleet/bob'); + expect(result.conflictedFiles).toEqual([]); + }); + + it('reports no conflicts for edits to different files', async () => { + await spawn('alice', { cwd: repo.root }); + await spawn('bob', { cwd: repo.root }); + await commitFile(worktreePath(repo.root, 'alice'), 'a.txt', 'a\n', 'feat: a'); + await commitFile(worktreePath(repo.root, 'bob'), 'b.txt', 'b\n', 'feat: b'); + + const result = await predictMergeConflicts(gitAt(repo.root), 'fleet/alice', 'fleet/bob'); + expect(result.conflictedFiles).toEqual([]); + }); + + it('local git supports merge-tree (a CI canary, not a user requirement)', async () => { + // The suite's simulation tests are only meaningful on git >= 2.38. All CI + // images ship newer git; if this ever fails, upgrade git on the runner. + expect(await supportsMergeTree(gitAt(repo.root))).toBe(true); + }); +}); diff --git a/tests/undo.test.ts b/tests/undo.test.ts new file mode 100644 index 0000000..6b6838f --- /dev/null +++ b/tests/undo.test.ts @@ -0,0 +1,97 @@ +import { existsSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { merge } from '../src/commands/merge.js'; +import { spawn } from '../src/commands/spawn.js'; +import { undo } from '../src/commands/undo.js'; +import { branchExists, gitAt, revParseOid } from '../src/lib/git.js'; +import { readState } from '../src/lib/state.js'; +import { readUndoRecord } from '../src/lib/undo.js'; +import { commitFile, makeTempRepo, worktreePath } from './helpers.js'; +import type { TempRepo } from './helpers.js'; + +let repo: TempRepo; + +beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + repo = await makeTempRepo(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + repo.cleanup(); +}); + +/** spawn → commit → merge, returning the pre-merge oids for assertions. */ +async function mergedAgent(): Promise<{ headBefore: string; branchTip: string }> { + await spawn('alice', { cwd: repo.root }); + const headBefore = await revParseOid(gitAt(repo.root), 'HEAD'); + await commitFile(worktreePath(repo.root, 'alice'), 'feature.txt', 'f\n', 'feat: feature'); + const branchTip = await revParseOid(gitAt(repo.root), 'fleet/alice'); + await merge('alice', { cwd: repo.root }); + if (!headBefore || !branchTip) throw new Error('fixture oids unresolved'); + return { headBefore, branchTip }; +} + +describe('fleet undo', () => { + it('restores branch pointer, agent branch, worktree, and state', async () => { + const { headBefore, branchTip } = await mergedAgent(); + + const result = await undo({ cwd: repo.root }); + + expect(result).toEqual({ + agent: 'alice', + into: 'main', + restoredBranch: true, + restoredWorktree: true, + }); + expect(await revParseOid(gitAt(repo.root), 'HEAD')).toBe(headBefore); + expect(existsSync(path.join(repo.root, 'feature.txt'))).toBe(false); + expect(await revParseOid(gitAt(repo.root), 'fleet/alice')).toBe(branchTip); + expect(existsSync(worktreePath(repo.root, 'alice'))).toBe(true); + expect(readState(repo.root).agents['alice']).toBeDefined(); + expect(readUndoRecord(repo.root)).toBeNull(); + }); + + it('is single-level: a second undo refuses', async () => { + await mergedAgent(); + await undo({ cwd: repo.root }); + await expect(undo({ cwd: repo.root })).rejects.toThrow(/Nothing to undo/); + }); + + it('handles --no-clean merges (nothing to restore but the branch pointer)', async () => { + await spawn('alice', { cwd: repo.root }); + const headBefore = await revParseOid(gitAt(repo.root), 'HEAD'); + await commitFile(worktreePath(repo.root, 'alice'), 'feature.txt', 'f\n', 'feat: feature'); + await merge('alice', { clean: false, cwd: repo.root }); + + const result = await undo({ cwd: repo.root }); + + expect(result).toMatchObject({ restoredBranch: false, restoredWorktree: false }); + expect(await revParseOid(gitAt(repo.root), 'HEAD')).toBe(headBefore); + expect(await branchExists(gitAt(repo.root), 'fleet/alice')).toBe(true); + expect(readState(repo.root).agents['alice']).toBeDefined(); + }); + + it('refuses when history moved past the merge', async () => { + await mergedAgent(); + await commitFile(repo.root, 'after.txt', 'x\n', 'feat: newer work'); + await expect(undo({ cwd: repo.root })).rejects.toThrow(/History moved since the merge/); + }); + + it('refuses on a different branch than the merge target', async () => { + await mergedAgent(); + await gitAt(repo.root).raw(['checkout', '-b', 'other']); + await expect(undo({ cwd: repo.root })).rejects.toThrow(/current branch is other/); + }); + + it('refuses with uncommitted tracked changes in the main worktree', async () => { + await mergedAgent(); + writeFileSync(path.join(repo.root, 'README.md'), '# modified\n'); + await expect(undo({ cwd: repo.root })).rejects.toThrow(/uncommitted change/); + }); + + it('refuses cleanly when there is nothing to undo', async () => { + await expect(undo({ cwd: repo.root })).rejects.toThrow(/Nothing to undo/); + }); +});