Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 17 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand All @@ -98,11 +99,12 @@ fleet pr claude # …or push it and open a PR via gh instead
| `fleet spawn <agent>` | Create a worktree in `.fleet/worktrees/<agent>/` on a new branch `fleet/<agent>`, then provision it (`copyOnSpawn` / `postSpawn` below) | `--from <branch>` base branch (default: current branch) |
| `fleet list` | All active agents: branch, base, ahead/behind, uncommitted count, last activity | `--json` machine-readable output |
| `fleet status <agent>` | 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 <agent>` | Full diff of the agent's branch against its base | `--base <branch>` diff against a different branch |
| `fleet sync <agent>` | Merge the agent's base branch into its branch, catching it up. A conflicting merge is aborted — never left half-done | — |
| `fleet exec <agent> -- <cmd>` | 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 <agent>` | 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 <agent>` | 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 <agent>` | 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 <t>`, `--base <branch>`, `--draft` |
| `fleet remove <agent>` | 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 <days>` also remove long-idle agents (clean worktrees only; their branches are kept) |
Expand All @@ -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.
Expand Down
45 changes: 44 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,25 @@ graph TD

`fleet exec <agent> -- <cmd>` 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 <merge-base>` 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.
Expand Down Expand Up @@ -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`:
Expand Down
9 changes: 8 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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')
Expand Down
Loading
Loading