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
1 change: 1 addition & 0 deletions .abcd/work/DECISIONS.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -334,12 +334,15 @@ jobs:
# version — with valid checksums and provenance over the wrong artifact —
# and nothing else in this workflow would go red. Same assertion as the
# recovery driver (scripts/release.sh); the runner is x64 Linux, so the
# linux-amd64 binary is directly executable.
# linux-amd64 binary is directly executable. The compare is EXACT, not a
# substring: the dev line is the next release plus -dev (v0.11.0-dev), so
# an unstamped binary's output CONTAINS the very tag being released and a
# substring match would green on exactly the failure this step guards.
run: |
set -euo pipefail
reported="$(./bin/ferry-linux-amd64 version)"
if ! printf '%s' "$reported" | grep -qF "$TAG"; then
echo "release: built binary reports '$reported', not '$TAG' — the version stamp is broken" >&2
if [ "$reported" != "ferry $TAG" ]; then
echo "release: built binary reports '$reported', not 'ferry $TAG' — the version stamp is broken" >&2
exit 1
fi

Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ go vet ./... # static checks
go test ./... # full unit + eval suite (evals skip when FERRY_BIN is unset)
go test ./internal/agents/ # a single package
go test -run TestResolve ./internal/agents/ # a single test
make preflight # the pre-push gate: build, vet, unit + race tests, consistency lint
```

The evals drive the real binary, so they need `FERRY_BIN` pointing at this
Expand Down
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,60 @@ called out in a **Breaking** section. See
cannot bring a force-overwritten live edit back. The guidance now names
`ferry capture` first, as `status` and `apply` already do, and states plainly
what `--force` discards.
- **Guided apply names the domain it asks consent for.** The interactive
walkthrough grouped risky changes by a hand-maintained two-name list, so
keybindings, emacs, and iTerm2 Dynamic Profile changes fell into the
`dotfiles` bucket: the group header and the bulk "Apply all N change(s) in
dotfiles?" prompt named a domain the user was not reviewing, and one "yes"
covered the mixed bucket. Every domain now gets its own group under its own
name, in registry order.
- **`ferry capture` reports deps drift truthfully.** The Homebrew and npm
manifest re-dumps counted every successful dump as a captured change —
`brew bundle dump --force` rewrites unconditionally — so a clean re-run
printed "wrote 1 change(s) into the repo. Review with `git status`…" over an
empty `git status`, and "nothing has drifted" was unreachable on any machine
managing either domain. Both re-dumps now compare bytes and count only real
changes; the npm list is also written crash-safely (temp + rename) instead
of truncate-in-place — normalising its mode to 0644 — and is left untouched
when it already matches.
- **A captured-to-local terminal domain reads as clean.** Capture compared the
live iTerm2/Apple Terminal export only against the shared repo plist, while
`apply`, `status`, and `diff` all resolve the per-machine local overlay
first. After routing a terminal capture to `[l]ocal`, every later capture
re-offered the identical domain as drifted (and re-accepting it as shared
would promote machine-divergent settings into the shared repo) while
`status` reported it clean. Capture now compares against the same
local-wins source the other commands use.
- **Failed dependency installs name the failure.** The brew/apt/npm install
and dump rails — including the read-only drift dump behind `ferry status` —
discarded the package manager's combined output on error, so a failed
`apply --deps` aborted with a bare `exit status 1` and no way to tell which
package or tap failed. The manager's own diagnostics now ride the error, as
the uninstall rail's errors already did.
- **Pre-receive snapshots are containment-guarded.** The work domain's
per-receive snapshot read its target paths before any guard ran, so a parent
directory symlinked out of `$HOME` let the snapshot read and persist
out-of-home content into ferry's snapshot store — content the write boundary
downstream refuses to touch. The snapshot now runs the same resolved-parent
containment guard as restore, refusing before anything is read or written —
so a refused `work receive` now aborts up front, where it previously aborted
at the write boundary after earlier items had already landed.
- **The release version-stamp gate is an exact compare.** Both the release
workflow and `scripts/release.sh` checked the built binary's reported
version with a substring match; the development line is the next release
plus `-dev` (`v0.11.0-dev` contains `v0.11.0`), so an unstamped binary
passed the gate on exactly the modal release it guards. Both now require
the output to equal `ferry <tag>` verbatim.
- **Docs corrections.** The "one privileged step" claim in the README and
tutorial now covers both halves of the apt rail — `restore --packages`
uninstalls as root exactly as `apply --deps` installs — and the apt
reference section says so; the compatibility contract states that claim
files are written by pack, receive, and take-back alike (each account only
its own), not by pack alone; the commands reference gains the missing
`agents` parent-noun row; `make preflight` appears in the build-and-check
lists; and the consistency lint's private-tier advice now says committed
history must be amended, since `git rm --cached` alone greens the gate
while the push still publishes the file.

## [0.10.0] - 2026-07-19

Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ make build # cross-compiles bin/ferry-<goos>-<arch> (there is no plain
gofmt -l . # format gate: any output names a file needing `gofmt -w`
go vet ./... # static checks
go test ./... # full unit + eval suite (evals skip when FERRY_BIN is unset)
make preflight # the pre-push gate: build, vet, unit + race tests, consistency lint
```

The eval suite drives the real binary, so it needs `FERRY_BIN` pointing at this
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ The documentation follows [Diátaxis](https://diataxis.fr/); the map is in
blank one without `--force`, so your config is never silently erased.
- **No admin assumed**: ferry installs to `~/.local/bin` and needs no `sudo` or root
for its own operation, so it works on any account, including locked-down or managed
machines. The one exception is the opt-in `apply --deps` step on apt-based Linux,
which delegates to your package manager and must itself run under `sudo`. ferry never
edits your shell on its own.
machines. The one exception is the opt-in apt rail on Debian-style Linux — `apply
--deps` (installs) and `restore --packages` (uninstalls what `--deps` recorded) —
which delegates to your package manager and must itself run under `sudo`. ferry
never edits your shell on its own.
- **Safe with secrets**: ferry never touches `~/.ssh/`. SSH keys and other secrets are
handled out-of-band and never committed. See [SSH](docs/explanation/ssh.md).
9 changes: 5 additions & 4 deletions cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ const (
// resolved, secret-rendered content so diff and apply share identical planning.
type planItem struct {
kind planKind
// fileDomain is the owning FileDomain's scope name ("dotfiles", "agents",
// "terminals") for a kindFile item. It selects the per-domain report wording
// fileDomain is the owning FileDomain's scope name (a registry name:
// "dotfiles", "agents", "terminals", "keybindings", "emacs",
// "iterm2-profiles") for a kindFile item. It selects the per-domain report wording
// (capture guidance vs repo-authoritative vs repo-source) and the
// agents-target recording that used to be keyed on the collapsed kinds. Empty
// for kindPreference.
Expand Down Expand Up @@ -221,8 +222,8 @@ func buildPlanWithEngine(ctx *cmdContext, eng *backup.Engine) (items []planItem,

// FileDomain fan-out (fn-5): plan each in-scope FileDomain through the
// converged registry, in the registry's LOAD-BEARING order (dotfiles, agents,
// terminals), so item AND warning ORDERING matches the pre-fn-5 dispatch
// byte-for-byte. Each managed domain's rich planItems — secrets rendered,
// terminals, keybindings, emacs, iterm2-profiles), so item AND warning
// ORDERING matches the pre-fn-5 dispatch byte-for-byte. Each managed domain's rich planItems — secrets rendered,
// three-way state classified, risk assessed — is produced by its per-domain
// planner via the filePlanner upcast (the frozen domains.FileItem cannot carry
// state/skip/risk). An out-of-scope domain emits its own de-scope warnings
Expand Down
34 changes: 28 additions & 6 deletions cmd/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,10 @@ func runCapture(c *cobra.Command, _ []string) error {
// ran only after a non-deps change was accepted, so a deps-only machine reported
// "nothing has drifted" and never re-dumped.) reDumpDeps reports the file it
// wrote and counts as both an offered AND a captured change so the summary below
// reflects it. A missing/out-of-scope manager is a clean skip (no offer).
// reflects it — but ONLY when the dump actually changed the manifest's bytes:
// `brew bundle dump --force` rewrites unconditionally, so counting every
// successful dump made a clean re-run claim "wrote 1 change(s)" over an empty
// `git status`. A missing/out-of-scope manager is a clean skip (no offer).
if reDumpDeps(ctx, out) {
offered++
captured++
Expand Down Expand Up @@ -1064,10 +1067,20 @@ func captureTerminalDomain(cc captureCtx, domain string) (wrote bool, offered bo
return false, false, err
}

// Only offer when the live export actually DIFFERS from the committed repo copy
// (don't offer a no-op). An absent repo copy is itself a difference (capture
// Only offer when the live export actually DIFFERS from the bytes apply would
// deploy (don't offer a no-op). Compare against the LOCAL-WINS source exactly
// as status resolves it (terminalRepoStatusSource, which closely mirrors
// apply's terminalExportBlob — apply additionally accepts an extensionless
// overlay and fails closed on a poisoned one): a domain captured to the
// [l]ocal overlay must read as clean on the next run — comparing only the
// shared copy re-offered an already-captured domain forever, while status
// reported it clean. An absent repo copy is itself a difference (capture
// would create it).
repoBytes, _ := os.ReadFile(repoDest)
compareSrc := terminalRepoStatusSource(cc.repoPath, domain, prefID)
if _, err := safeRepoPath(cc.repoPath, compareSrc); err != nil {
return false, false, err
}
repoBytes, _ := os.ReadFile(compareSrc)
if domain == "iterm2" {
// Compare LIKE-FOR-LIKE: filter the repo side to the same allowlist so a repo
// plist that happens to carry stale volatile keys never registers as drift.
Expand Down Expand Up @@ -1516,12 +1529,17 @@ func reDumpDeps(ctx *cmdContext, out io.Writer) bool {
fmt.Fprintf(out, "deps: skipped manifest re-dump (%v)\n", err)
return false
}
path, err := deps.ReDumpManifest(depsDir, deps.ExecRunner{})
path, changed, err := deps.ReDumpManifest(depsDir, deps.ExecRunner{})
if err != nil {
// No manager / unsupported dump: report briefly, never fail capture.
fmt.Fprintf(out, "deps: skipped manifest re-dump (%v)\n", err)
return false
}
if !changed {
// The dump ran but produced the bytes already committed: no drift, so
// nothing is counted and the summary can truthfully say nothing changed.
return false
}
fmt.Fprintf(out, "deps: re-dumped manifest %s\n", relTo(ctx.RepoPath, path))
return true
}
Expand Down Expand Up @@ -1550,11 +1568,15 @@ func reDumpNpmGlobals(ctx *cmdContext, out io.Writer) bool {
fmt.Fprintf(out, "npm-globals: skipped manifest re-dump (%v)\n", err)
return false
}
path, err := deps.ReDumpNpmGlobals(depsDir, deps.ExecRunner{})
path, changed, err := deps.ReDumpNpmGlobals(depsDir, deps.ExecRunner{})
if err != nil {
fmt.Fprintf(out, "npm-globals: skipped manifest re-dump (%v)\n", err)
return false
}
if !changed {
// Committed list already matches this machine: no drift, nothing counted.
return false
}
fmt.Fprintf(out, "npm-globals: re-dumped %s\n", relTo(ctx.RepoPath, path))
return true
}
43 changes: 25 additions & 18 deletions cmd/guided_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"io"
"os"
"path/filepath"
"slices"
"sort"
"strings"

Expand Down Expand Up @@ -264,29 +265,35 @@ type riskyGroup struct {
items []planItem
}

// groupRisky buckets risky items by domain (dotfiles, then agents, then
// terminals) so the walkthrough confirms a domain wholesale or drills into it.
// groupRisky buckets risky items by FileDomain, in registry order, so the
// walkthrough confirms a domain wholesale or drills into it. Every domain gets
// its own group under its own name: a hand-maintained name list here once let
// keybindings, emacs, and iterm2-profiles fall into the "dotfiles" bucket, so
// the group header and the bulk "yes" consent named a domain the user was not
// reviewing.
func groupRisky(risky []planItem) []riskyGroup {
var dot, ag, term []planItem
byDomain := make(map[string][]planItem)
var order []string
for _, fd := range buildRegistry(nil).FileDomains {
order = append(order, fd.Name())
}
for _, it := range risky {
switch it.fileDomain {
case "agents":
ag = append(ag, it)
case "terminals":
term = append(term, it)
default:
dot = append(dot, it)
name := it.fileDomain
if name == "" {
// Non-FileDomain items (terminal preference domains) carry no
// fileDomain; they keep their pre-existing dotfiles-bucket home.
name = "dotfiles"
}
if !slices.Contains(order, name) {
order = append(order, name)
}
byDomain[name] = append(byDomain[name], it)
}
var groups []riskyGroup
if len(dot) > 0 {
groups = append(groups, riskyGroup{"dotfiles", dot})
}
if len(ag) > 0 {
groups = append(groups, riskyGroup{"agents", ag})
}
if len(term) > 0 {
groups = append(groups, riskyGroup{"terminals", term})
for _, name := range order {
if items := byDomain[name]; len(items) > 0 {
groups = append(groups, riskyGroup{name, items})
}
}
return groups
}
Expand Down
55 changes: 55 additions & 0 deletions cmd/guided_apply_group_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cmd

import "testing"

// groupRisky once bucketed every FileDomain it did not name into "dotfiles",
// so the walkthrough's group header and bulk consent named a domain the user
// was not reviewing. These tests pin the fix: every domain groups under its
// own name, in registry order, and only non-FileDomain items (which carry no
// fileDomain) fall back to the dotfiles bucket.
func TestGroupRiskyGroupsEveryDomainUnderItsOwnName(t *testing.T) {
risky := []planItem{
{domain: "iterm2-profiles:p.json", fileDomain: "iterm2-profiles"},
{domain: "emacs:init.el", fileDomain: "emacs"},
{domain: "dotfiles:.zshrc", fileDomain: "dotfiles"},
{domain: "keybindings:DefaultKeyBinding.dict", fileDomain: "keybindings"},
{domain: "agents:AGENTS.md", fileDomain: "agents"},
{domain: "terminals:alacritty.toml", fileDomain: "terminals"},
}
groups := groupRisky(risky)

wantOrder := []string{"dotfiles", "agents", "terminals", "keybindings", "emacs", "iterm2-profiles"}
if len(groups) != len(wantOrder) {
t.Fatalf("got %d groups, want %d: %+v", len(groups), len(wantOrder), groups)
}
for i, want := range wantOrder {
if groups[i].name != want {
t.Errorf("group[%d] = %q, want %q (registry order)", i, groups[i].name, want)
}
if len(groups[i].items) != 1 {
t.Errorf("group %q has %d items, want 1", groups[i].name, len(groups[i].items))
}
}
}

func TestGroupRiskyNeverLumpsOtherDomainsIntoDotfiles(t *testing.T) {
risky := []planItem{
{domain: "emacs:init.el", fileDomain: "emacs"},
{domain: "keybindings:DefaultKeyBinding.dict", fileDomain: "keybindings"},
}
for _, g := range groupRisky(risky) {
if g.name == "dotfiles" {
t.Fatalf("emacs/keybindings items grouped under %q — the bulk consent would name a domain the user is not reviewing", g.name)
}
}
}

func TestGroupRiskyFileDomainlessItemsKeepDotfilesBucket(t *testing.T) {
risky := []planItem{
{domain: "iterm2 preference domain", fileDomain: ""},
}
groups := groupRisky(risky)
if len(groups) != 1 || groups[0].name != "dotfiles" {
t.Fatalf("fileDomain-less item grouped as %+v, want the pre-existing dotfiles bucket", groups)
}
}
62 changes: 62 additions & 0 deletions cmd/terminal_status_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package cmd

import (
"os"
"path/filepath"
"testing"
)

// terminalRepoStatusSource is the single local-wins resolution both status AND
// capture compare the live preference-domain export against (apply's
// terminalExportBlob mirrors it). Capture once compared only the shared copy,
// so a domain captured to the [l]ocal overlay was re-offered as drifted on
// every later run while status reported it clean — these tests pin the shared
// seam that fix relies on.
func TestTerminalRepoStatusSourcePrefersLocalOverlay(t *testing.T) {
repo := t.TempDir()
local := filepath.Join(repo, "local", "iterm2", "com.googlecode.iterm2.plist")
if err := os.MkdirAll(filepath.Dir(local), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(local, []byte("overlay"), 0o644); err != nil {
t.Fatal(err)
}
got := terminalRepoStatusSource(repo, "iterm2", "com.googlecode.iterm2")
if got != local {
t.Errorf("with a local overlay present, source = %q, want the overlay %q", got, local)
}
}

func TestTerminalRepoStatusSourceFallsBackToShared(t *testing.T) {
repo := t.TempDir()
for domain, shared := range map[string]string{
"iterm2": filepath.Join(repo, "iterm2", "com.googlecode.iterm2.plist"),
"terminal": filepath.Join(repo, "terminal", "com.apple.Terminal.plist"),
} {
id := filepath.Base(shared)
id = id[:len(id)-len(".plist")]
if got := terminalRepoStatusSource(repo, domain, id); got != shared {
t.Errorf("%s: with no local overlay, source = %q, want shared %q", domain, got, shared)
}
}
}

func TestTerminalRepoStatusSourceIgnoresSymlinkedOverlay(t *testing.T) {
repo := t.TempDir()
outside := filepath.Join(t.TempDir(), "elsewhere.plist")
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
t.Fatal(err)
}
localDir := filepath.Join(repo, "local", "iterm2")
if err := os.MkdirAll(localDir, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(localDir, "com.googlecode.iterm2.plist")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
shared := filepath.Join(repo, "iterm2", "com.googlecode.iterm2.plist")
if got := terminalRepoStatusSource(repo, "iterm2", "com.googlecode.iterm2"); got != shared {
t.Errorf("symlinked overlay must be refused, source = %q, want shared %q", got, shared)
}
}
Loading