From efc0d934d762fbddb45d10791a9208c53cb53e64 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:07:11 +0100 Subject: [PATCH] feat: drop apply --dry-run in favour of the diff verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING (pre-1.0, no alias): the `apply --dry-run` flag is removed. `ferry diff` is the read-only preview — it already shares apply's buildPlan/printPlan, so the output is identical. The field ships a preview as EITHER a diff verb OR a --dry-run flag, never both; ferry keeps the verb. Comments that advertised apply --dry-run as a preview path now point at diff; the CLI reference is regenerated. Recorded under the CHANGELOG Breaking section. --- CHANGELOG.md | 6 +++++ cmd/apply.go | 37 +++++++---------------------- cmd/commands.go | 1 - cmd/context.go | 10 ++++---- docs/reference/cli/ferry_apply.md | 1 - evals/commands_test.go | 39 +++++++++++++++++-------------- evals/terminal_config_test.go | 6 ++--- evals/terminal_test.go | 4 ++-- 8 files changed, 46 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3db34..1310ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ called out in a **Breaking** section. See ## [Unreleased] +### Breaking + +- **`apply --dry-run` is removed.** The read-only preview is now solely + `ferry diff`, which shows what `apply` would change without writing anything. + Replace any `ferry apply --dry-run` with `ferry diff`. + ## [0.7.3] - 2026-07-08 ### Fixed diff --git a/cmd/apply.go b/cmd/apply.go index a890959..2635854 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -29,7 +29,7 @@ import ( func init() { // --force is an apply-only override (overwrite uncaptured local edits on a // conflict). Registered here so commands.go stays owned by the skeleton wave; - // --deps and --dry-run are already declared there. + // --deps is already declared there. applyCmd.Flags().Bool("force", false, "overwrite uncaptured local edits on conflict") // --skip-wizard is the expert opt-out from the guided walkthrough: safe changes // still auto-apply, but risky changes are NOT prompted — they FAIL CLOSED @@ -115,7 +115,7 @@ type planItem struct { // item: true when an immutable baseline exists for this domain's resource path // (HasBaseline(ResourcePath(prefID))), i.e. ferry applied it on this machine // before. It is the write-free, read-only proxy for "the terminal domain is - // already managed", so a clean machine's diff/dry-run shows "managed (re-apply + // already managed", so a clean machine's diff shows "managed (re-apply // on demand)" instead of an unconditional pending "would apply". Computed from // the live engine on the mutating path, and from a NON-MUTATING baseline stat // (baselineHasBeenApplied) on the read-only preview path — never by constructing @@ -127,7 +127,6 @@ type planItem struct { // runApply is the idempotent, transactional, non-interactive reconcile. func runApply(c *cobra.Command, _ []string) error { depsFlag, _ := c.Flags().GetBool("deps") - dryRun, _ := c.Flags().GetBool("dry-run") force, _ := c.Flags().GetBool("force") skipWizard, _ := c.Flags().GetBool("skip-wizard") @@ -139,26 +138,6 @@ func runApply(c *cobra.Command, _ []string) error { out := c.OutOrStdout() in := bufio.NewReader(c.InOrStdin()) - // --dry-run is a pure preview: take NO lock, write NOTHING. The plan is - // read-only here, so it is safe to compute it without the lock — it never - // drives a mutation on this path. - if dryRun { - // Read-only preview: buildPlan creates no ferry state (no engine, no - // state-dir mkdir). Terminal items probe the already-applied baseline via a - // non-mutating stat, so a managed domain shows "managed (re-apply on demand)" - // without ever writing. - plan, warnings, err := buildPlan(ctx) - if err != nil { - return err - } - for _, w := range warnings { - fmt.Fprintln(out, w) - } - printPlan(out, plan) - fmt.Fprintln(out, "dry-run: no changes written") - return nil - } - // Mutating apply: acquire the lock and roll back any incomplete prior run // FIRST, then compute the plan UNDER the lock so it cannot be stale, then // mutate + commit + persist last-applied. applyPlan owns that whole ordered @@ -182,11 +161,11 @@ func runApply(c *cobra.Command, _ []string) error { // buildPlan computes, without writing anything, what apply would do for every // in-scope domain. It also returns de-scope warnings for the DOTFILE domain // (dotfiles previously applied but now out of scope). It is read-only — it opens -// the last-applied store read-only so a pure preview (diff / apply --dry-run) +// the last-applied store read-only so a pure preview (diff / status) // never creates ferry state. Terminal de-scope warnings are computed separately // on the mutating path (they need the backup engine's baseline). // -// This is the READ-ONLY entry point (diff / status / apply --dry-run / init +// This is the READ-ONLY entry point (diff / status / init // preview). It stays write-free: it NEVER creates ferry's state dir OR any of its // subdirs. The terminal already-applied check is a NON-MUTATING stat of the // immutable baseline metadata (baselineHasBeenApplied → backup.HasBaselineReadOnly) @@ -316,7 +295,7 @@ func buildPlanWithEngine(ctx *cmdContext, eng *backup.Engine) (items []planItem, // the live engine; the read-only preview path (eng == nil) reads it via a // NON-MUTATING baseline stat (baselineHasBeenApplied) that never builds an // engine and so never creates the state dir/subdirs. Both observe the same - // immutable baseline, so diff/dry-run and apply agree. Only meaningful on + // immutable baseline, so diff and apply agree. Only meaningful on // darwin — a non-darwin host has no terminal baseline to record. applied := false if isDarwin() { @@ -541,7 +520,7 @@ func planDotfiles(ctx *cmdContext, home string, secretStore *secret.Store, lastA // its EFFECTIVE (composed + secret-rendered) content — the exact bytes apply would // deploy — PLUS the guided-apply risk verdict. It hashes the effective content IN // MEMORY via dotfile.ClassifyContent: NO temp file is staged and NO secret-rendered -// byte is ever written to disk, so the diff/status/dry-run preview path is fully +// byte is ever written to disk, so the diff/status preview path is fully // write-free while still observing the identical state apply's deploy path sees // (zsh source-last, whole-file local-wins, rendered secrets all agree). It then // runs the risk gate (assessRisk) over the resulting Status so planning computes @@ -630,7 +609,7 @@ func refusalWarning(name string, err error) string { // change apply unattended, and the bytes written are never stale. func applyPlan(ctx *cmdContext, force bool, gopts guidedOpts, in *bufio.Reader, out io.Writer) (retErr error) { // Obtain the transactional engine (built lazily; this is the first mutating - // use, so it creates ferry's state dir). Read-only diff/dry-run never reach here. + // use, so it creates ferry's state dir). Read-only diff/status never reach here. eng, err := ctx.Engine() if err != nil { return err @@ -1048,7 +1027,7 @@ func applyNpmGlobals(ctx *cmdContext, out io.Writer) error { return nil } -// printPlan renders the planned actions (dry-run / diff). For dotfile/overlay +// printPlan renders the planned actions (diff / status). For dotfile/overlay // targets it prints the REAL three-way classification computed during planning // (it.state) — the same resolution apply acts on — rather than a blanket "would // deploy": a clean target is shown clean, a conflict as conflict, a missing diff --git a/cmd/commands.go b/cmd/commands.go index 9302b04..e6ce6d3 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -110,7 +110,6 @@ target. --packages additionally uninstalls only the packages ferry installed.`, func init() { // Documented apply flags (lowercase kebab-case). Behaviour lands later. applyCmd.Flags().Bool("deps", false, "install declared dependencies (separate, explicit step)") - applyCmd.Flags().Bool("dry-run", false, "preview changes without writing (see also: ferry diff)") // sync flags: an optional commit message for the captured changes, and the // route-1 override (a route-2 managed repo needs neither). diff --git a/cmd/context.go b/cmd/context.go index 85c1e85..5d9ca9e 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -340,8 +340,8 @@ func safeRepoPath(repoRoot, candidate string) (string, error) { // re-wiring config + scope + engine each. // // The backup Engine is NOT constructed by loadContext: building it mkdirs+chmods -// ~/.local/state/ferry, which read-only commands (diff, status, apply --dry-run) -// must not do — they need to be write-free. Mutating callers obtain the engine +// ~/.local/state/ferry, which read-only commands (diff, status) must not do — +// they need to be write-free. Mutating callers obtain the engine // via the Engine() accessor, which builds it on first use. Read-only callers // simply never call Engine(), so loading a context creates no ferry state. type cmdContext struct { @@ -353,9 +353,9 @@ type cmdContext struct { } // Engine returns the transactional backup Engine, constructing it (and thus -// creating ~/.local/state/ferry) on first call. Only mutating commands (apply -// without --dry-run, capture, restore, init --apply) call this; read-only -// commands (diff, status, apply --dry-run) never do, so they stay write-free. +// creating ~/.local/state/ferry) on first call. Only mutating commands (apply, +// capture, restore, init --apply) call this; read-only commands (diff, status) +// never do, so they stay write-free. // // The engine is cached on the context, so repeated calls within one command // reuse the same engine (and the state dir is created at most once). diff --git a/docs/reference/cli/ferry_apply.md b/docs/reference/cli/ferry_apply.md index 26511f5..38c8185 100644 --- a/docs/reference/cli/ferry_apply.md +++ b/docs/reference/cli/ferry_apply.md @@ -21,7 +21,6 @@ ferry apply [flags] ``` --deps install declared dependencies (separate, explicit step) - --dry-run preview changes without writing (see also: ferry diff) --force overwrite uncaptured local edits on conflict -h, --help help for apply --skip-wizard skip the guided walkthrough (safe changes auto-apply; risky changes are refused, not prompted) diff --git a/evals/commands_test.go b/evals/commands_test.go index bb506aa..cebefd2 100644 --- a/evals/commands_test.go +++ b/evals/commands_test.go @@ -240,16 +240,12 @@ func TestApplyDepsFlagParses_AC_cmd_apply_deps_flag(t *testing.T) { } } -// TestApplyDryRunFlagDocumented covers AC-cmd-apply-dryrun-flag — NON-GATING / -// OPTIONAL. The mandatory preview path is `ferry diff` (AC-cmd-diff / -// AC-diff-preview-only); `apply --dry-run` is not doc-named. -// -// NOTE (doc ambiguity, ACCEPTANCE.md §ambiguities): the docs give a dedicated -// `ferry diff` command and never spell out `apply --dry-run` in prose. If -// --dry-run is NOT exposed, we SKIP (gate falls back to AC-cmd-diff). If it IS -// exposed, we additionally do a non-gating no-write check here (the behavioural -// no-write half is not in apply_test.go — it lives inline below when the flag exists). -func TestApplyDryRunFlagDocumented_AC_cmd_apply_dryrun_flag(t *testing.T) { +// TestApplyDryRunFlagRemoved regression-tests the v0.8.0 breaking removal of +// `apply --dry-run`. The read-only preview is now ONLY `ferry diff` (AC-cmd-diff), +// which shares buildPlan/printPlan with apply. The removed flag must be gone: it is +// not listed in `apply --help`, and invoking it fails with an unknown-flag parse +// error (never silently accepted). `ferry diff` still previews without writing. +func TestApplyDryRunFlagRemoved_AC_cmd_apply_dryrun_flag(t *testing.T) { t.Parallel() s := NewSandbox(t) out, errOut, code := s.Ferry("apply", "--help") @@ -257,21 +253,30 @@ func TestApplyDryRunFlagDocumented_AC_cmd_apply_dryrun_flag(t *testing.T) { t.Fatalf("`ferry apply --help` exited %d; stderr:\n%s", code, errOut) } help := out + errOut - if _, ok := containsAllFold(help, "--dry-run"); !ok { - t.Skipf("AC-cmd-apply-dryrun-flag: `apply --dry-run` not exposed; only `ferry diff` is doc-mandatory (see ACCEPTANCE.md ambiguities). Covered by AC-cmd-diff.") + if _, ok := containsAllFold(help, "--dry-run"); ok { + t.Fatalf("`apply --dry-run` is removed in v0.8.0 but still appears in help:\n%s", help) } - // Flag IS exposed: non-gating no-write check — `apply --dry-run` must report a - // would-be change without writing the managed target. + // Invoking the removed flag must fail with an unknown-flag parse error. s2 := NewSandbox(t) s2.SeedSharedManifest(t, baseManifest) s2.WriteRepoFile(t, ".zshrc", "# would be deployed\n") s2.WriteRepoFile(t, "dotfiles/.zshrc", "# would be deployed\n") tw := s2.SnapshotFile(t, s2.HomePath(".zshrc")) // absent - if _, _, code := s2.Ferry("apply", "--dry-run"); code != 0 { - t.Logf("AC-cmd-apply-dryrun-flag (non-gating): `apply --dry-run` exited %d", code) + dOut, dErr, dCode := s2.Ferry("apply", "--dry-run") + if dCode == 0 { + t.Fatalf("`apply --dry-run` should fail (flag removed) but exited 0; stdout:\n%s", dOut) + } + if !looksLikeUnknownFlag(dOut + dErr) { + t.Errorf("`apply --dry-run` failed but not with an unknown-flag error; output:\n%s", dOut+dErr) + } + tw.AssertUnchanged(t) // a rejected flag must not write the managed target + + // `ferry diff` is the surviving read-only preview: it must run and write nothing. + if _, dfErr, dfCode := s2.Ferry("diff"); dfCode != 0 { + t.Fatalf("`ferry diff` exited %d; stderr:\n%s", dfCode, dfErr) } - tw.AssertUnchanged(t) // dry-run must not write the managed target + tw.AssertUnchanged(t) // diff must not write the managed target } // looksLikeUnknownFlag reports whether output contains a typical "flag not diff --git a/evals/terminal_config_test.go b/evals/terminal_config_test.go index 6fc84ed..00faeac 100644 --- a/evals/terminal_config_test.go +++ b/evals/terminal_config_test.go @@ -179,9 +179,9 @@ func TestTerminalConfigDriftGuidanceNoCapture(t *testing.T) { t.Fatalf("local edit: %v", err) } - stdout, errOut, code := s.Ferry("apply", "--dry-run") + stdout, errOut, code := s.Ferry("diff") if code != 0 { - t.Fatalf("dry-run exited %d; stderr:\n%s", code, errOut) + t.Fatalf("diff exited %d; stderr:\n%s", code, errOut) } // The wezterm line must surface the drift but never send the user to capture. line := "" @@ -192,7 +192,7 @@ func TestTerminalConfigDriftGuidanceNoCapture(t *testing.T) { } } if line == "" { - t.Fatalf("no wezterm line in dry-run output:\n%s", stdout) + t.Fatalf("no wezterm line in diff output:\n%s", stdout) } if strings.Contains(line, "ferry capture") { t.Errorf("drifted terminal target points at `ferry capture` (no config-file terminal capture pass exists); line:\n%s", line) diff --git a/evals/terminal_test.go b/evals/terminal_test.go index 6bae805..1a2a045 100644 --- a/evals/terminal_test.go +++ b/evals/terminal_test.go @@ -73,7 +73,7 @@ func diffPlansTerminalChange(diffLower string, app terminalApp) bool { } // nativePrefMarkers are words that, when associated with a terminal domain in -// diff/dry-run output, signal NATIVE-PREFERENCE handling distinct from a generic +// diff output, signal NATIVE-PREFERENCE handling distinct from a generic // file/dotfile copy. The bare domain NAME is intentionally excluded — a plain // dotfile-copy impl could also print the name; only a preference/native qualifier // (or the `defaults` recorder firing) proves native handling. @@ -92,7 +92,7 @@ var fileCopyMarkers = []string{ // TestTerminalConfig covers AC-terminal-config (GATING on macOS): for BOTH iTerm2 // AND Apple Terminal, an in-scope terminal domain is handled as a macOS-native -// preference domain (named as such in diff/dry-run output, OR a stubbed `defaults` +// preference domain (named as such in diff output, OR a stubbed `defaults` // recorder fires), NOT as a plain dotfile copy. An impl that handles no terminal // domain — or only one of the two — must not fully pass. Out-of-scope => no // terminal change (tripwire).