diff --git a/CHANGELOG.md b/CHANGELOG.md index 8926a84..923a778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,22 @@ called out in a **Breaking** section. See `ferry diff`, which shows what `apply` would change without writing anything. Replace any `ferry apply --dry-run` with `ferry diff`. +### Added + +- **Explanation doc: reconciling drift and conflicts.** A new page under + `docs/explanation/` explains the three inputs ferry weighs per file (live, + repo, last-applied), the four target states and what `apply` does in each, and + where a conflict resolves in each direction — `apply` refuses and hands you the + choice (`capture` to keep local, `apply --force` to take the repo), while + `capture` opts you in per change with an automatic secret block. + +### Changed + +- **`apply` and `capture` help now states the direction.** `apply`'s help makes + explicit that it moves config repo → this machine and leaves a locally-edited + file for `capture`; `capture`'s help makes explicit that it moves this machine + → the repo, the reverse of `apply`. + ### Fixed - **Release automation self-heals a missing GitHub Release.** When a version's diff --git a/cmd/commands.go b/cmd/commands.go index e6ce6d3..c4e8f40 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -28,12 +28,15 @@ var applyCmd = &cobra.Command{ Short: "Reconcile this machine to the repo (deploy dotfiles, terminal settings)", Long: `Reconcile this machine to the repo. -apply deploys in-scope dotfiles and terminal settings, layering the per-machine -.local overlay last. It is idempotent and safe to re-run after every git pull. -A run with changes walks a guided, domain-grouped review that applies safe -changes automatically and prompts on risky ones; non-interactively, risky -changes fail closed. Dependency installs are a separate, gated step run with ---deps.`, +apply moves config in one direction — repo -> this machine — deploying the +repo's version onto the machine. It writes in-scope dotfiles and terminal +settings, layering the per-machine .local overlay last. It is idempotent and +safe to re-run after every git pull. A run with changes walks a guided, +domain-grouped review that applies safe changes automatically and prompts on +risky ones; non-interactively, risky changes fail closed. A file you have +edited locally is left untouched for "ferry capture" to bring back — apply +never overwrites uncaptured local work. Dependency installs are a separate, +gated step run with --deps.`, RunE: runApply, } @@ -42,10 +45,12 @@ var captureCmd = &cobra.Command{ Short: "Pull local changes back into the repo (interactive, selective)", Long: `Pull local changes back into the repo. -capture is interactive and selective: it shows you each change and lets you -approve it, then route it shared (synced everywhere) or local (this machine -only). Only declared targets are visible, and a secret scan blocks sensitive -values from ever reaching the repo.`, +capture moves config in the other direction to apply — this machine -> the +repo — bringing local edits back into the source of truth. It is interactive +and selective: it shows you each change and lets you approve it, then route it +shared (synced everywhere) or local (this machine only). Only declared targets +are visible, and a secret scan blocks sensitive values from ever reaching the +repo.`, RunE: runCapture, } diff --git a/docs/README.md b/docs/README.md index b15d1ff..b5dcb35 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ directory holds. Every page is exactly one Diátaxis type — tutorial | [Compatibility contract](reference/compatibility.md) | Reference | Which surfaces are stable, the pre-1.0 rule, and on-disk state versioning | | [The agents domain](explanation/agents.md) | Explanation | The repo-authoritative model: harnesses, asset mappings, apply/restore semantics | | [Why ferry stays out of `~/.ssh/`](explanation/ssh.md) | Explanation | The rationale and the untouchable-`~/.ssh` invariant | +| [Reconciling drift and conflicts](explanation/reconciling-drift-and-conflicts.md) | Explanation | The four target states, and where a conflict resolves for `apply` vs `capture` | | [Scaffold a project repo](how-to/scaffold-a-repo.md) | How-to | `ferry agents scaffold`: stamp a repo with the multi-tool pipeline layout | | [Adopt an existing agent config](how-to/adopt-agent-config.md) | How-to | `ferry agents adopt`: migrate a symlink-based instruction directory into ferry | | [Move SSH keys to a new machine](how-to/move-ssh-keys.md) | How-to | Carry your SSH setup across machines yourself, out-of-band | diff --git a/docs/explanation/reconciling-drift-and-conflicts.md b/docs/explanation/reconciling-drift-and-conflicts.md new file mode 100644 index 0000000..3881aad --- /dev/null +++ b/docs/explanation/reconciling-drift-and-conflicts.md @@ -0,0 +1,111 @@ +# Reconciling drift and conflicts + +ferry keeps a machine and a git config repo in agreement. This page explains how +it decides what to do when they disagree, and — the crux — **where a conflict is +resolved depending on which way config is moving**. It is background reading; for +the commands themselves see the [commands reference](../reference/commands.md). + +## The mental model: three inputs per file + +For every managed file, ferry looks at three things: + +- **live** — the file as it is on your machine right now. +- **repo** — the file in the git config repo, the single source of truth. +- **last-applied** — a hash of what ferry last deployed to this machine. It is + ferry's memory of the last agreed state, and it is what lets ferry tell a + repo-side change apart from a local one. + +Two commands move config between live and repo, in opposite directions: + +- [`apply`](../reference/commands.md) moves **repo → this machine**: it deploys + the repo's version onto the machine. +- [`capture`](../reference/commands.md) moves **this machine → the repo**: it + brings your local edits back into the source of truth. + +`last-applied` is the fixed point both directions measure against. If live has +moved past it, you have edited the file locally. If repo has moved past it, +someone changed the source of truth (a teammate, another machine, a `git pull`). +If *both* have moved, the two directions want the same file — that is a conflict. + +## The four states, and what `apply` does + +ferry classifies each file into one state and acts on it. `status` and `diff` +report the state without changing anything; `apply` is the command that acts. + +| State | What it means | What `apply` does | +|---|---|---| +| **Clean** | live, repo and last-applied all agree | Nothing — there is nothing to do. | +| **Repo-ahead** | repo moved past last-applied; live has no uncaptured edit (or the file is a first-touch adoption) | Deploys the repo content, **backing up the prior state first** so [`restore`](../reference/commands.md) can reverse it. | +| **Locally drifted** | live moved past last-applied but repo did *not* | Leaves your edit alone. It is a **capture candidate** — there is no repo change to deploy. | +| **Conflict** | *both* live and repo moved past last-applied | **Refuses** to overwrite. You choose the winner (see below). | + +A fresh target that does not yet exist on the machine is a fifth case, +*missing* — `apply` simply creates it, like a repo-ahead deploy with nothing to +back up. + +Two details worth calling out: + +- **First-touch adoption is not a conflict.** The first time `apply` meets an + in-scope file that ferry has never managed — one with no last-applied record — + it takes ownership rather than refusing: it backs the live file up to an + immutable baseline and then deploys the repo content. Because there is no + last-applied record, there is no uncaptured edit to protect, so this is + treated as repo-ahead, not conflict. The backup keeps it reversible. +- **A conflict is reserved for the genuine case:** ferry *has* a last-applied + record for the file, your live copy differs from it (you edited a file ferry + previously managed, without capturing), *and* the repo has also moved on. Only + then does `apply` refuse. + +## Where conflicts resolve, per direction + +This is the important part. The two directions handle disagreement differently, +and deliberately so. + +### Inbound (`apply`): refuse, never silently overwrite + +When `apply` meets a genuine conflict it stops and leaves the file byte-for-byte +unchanged. It never merges and never silently overwrites uncaptured local work. +It prints the remedy for that file's kind: + +- **Dotfiles** — the fix is to pick a winner: `ferry capture` to keep your local + edit, or `ferry apply --force` to take the repo's copy. +- **Agents and terminal-config targets are repo-authoritative** — there is no + capture pass for them. The remedy is to update the repo copy (or source) to + match, or `ferry apply --force` to take the repo's version. + +So an inbound conflict is resolved by *you* choosing a direction — keep local +(capture) or take repo (`--force`) — not by ferry guessing. + +### Outbound (`capture`): opt in per change + +`capture` never sweeps everything up. It is per-change and per-file: + +- It offers only files that have actually drifted, and shows you each change. +- You **approve each change** hunk by hunk (`y` / `n`). +- For a file you approve, you **route** it: `shared` (synced everywhere) or + `local` (this machine only) — or reject it entirely. +- A **secret scan runs before any write**, so a sensitive value is blocked from + reaching the repo; you can send it to the out-of-repo secret store instead. + +Anything you do not want upstream — a machine-specific tweak, a throwaway +experiment — you route `local` or simply skip. With no input, capture writes +nothing. It also never commits or pushes; that stays your decision. + +### Why the asymmetry is deliberate + +The two directions are intentionally not mirror images: + +- **`apply` is conservative:** it never destroys uncaptured local work. On a + conflict it refuses and hands the decision back to you. +- **`capture` is selective:** it sends nothing you did not approve, and blocks + secrets automatically. + +Neither direction auto-merges, because the only correct resolver of a genuine +conflict is a human who understands what both edits meant. ferry's job is to make +the disagreement legible and reversible, then let you decide. + +## Related documentation + +- [Commands](../reference/commands.md): every `ferry ` and what it does. +- [The agents domain](agents.md): why agent files are repo-authoritative. +- [Compatibility contract](../reference/compatibility.md): which surfaces are stable. diff --git a/docs/reference/cli/ferry_apply.md b/docs/reference/cli/ferry_apply.md index 38c8185..d8b0610 100644 --- a/docs/reference/cli/ferry_apply.md +++ b/docs/reference/cli/ferry_apply.md @@ -6,12 +6,15 @@ Reconcile this machine to the repo (deploy dotfiles, terminal settings) Reconcile this machine to the repo. -apply deploys in-scope dotfiles and terminal settings, layering the per-machine -.local overlay last. It is idempotent and safe to re-run after every git pull. -A run with changes walks a guided, domain-grouped review that applies safe -changes automatically and prompts on risky ones; non-interactively, risky -changes fail closed. Dependency installs are a separate, gated step run with ---deps. +apply moves config in one direction — repo -> this machine — deploying the +repo's version onto the machine. It writes in-scope dotfiles and terminal +settings, layering the per-machine .local overlay last. It is idempotent and +safe to re-run after every git pull. A run with changes walks a guided, +domain-grouped review that applies safe changes automatically and prompts on +risky ones; non-interactively, risky changes fail closed. A file you have +edited locally is left untouched for "ferry capture" to bring back — apply +never overwrites uncaptured local work. Dependency installs are a separate, +gated step run with --deps. ``` ferry apply [flags] diff --git a/docs/reference/cli/ferry_capture.md b/docs/reference/cli/ferry_capture.md index 6062e75..f9f64a7 100644 --- a/docs/reference/cli/ferry_capture.md +++ b/docs/reference/cli/ferry_capture.md @@ -6,10 +6,12 @@ Pull local changes back into the repo (interactive, selective) Pull local changes back into the repo. -capture is interactive and selective: it shows you each change and lets you -approve it, then route it shared (synced everywhere) or local (this machine -only). Only declared targets are visible, and a secret scan blocks sensitive -values from ever reaching the repo. +capture moves config in the other direction to apply — this machine -> the +repo — bringing local edits back into the source of truth. It is interactive +and selective: it shows you each change and lets you approve it, then route it +shared (synced everywhere) or local (this machine only). Only declared targets +are visible, and a secret scan blocks sensitive values from ever reaching the +repo. ``` ferry capture [flags]