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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ called out in a **Breaking** section. See
`ferry bundle import`.** The two verbs move under a new `bundle` parent noun.
Their flags and behaviour are unchanged — only the command path moves. There
is no alias: the old top-level spellings now error.
- **`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`.

### Fixed

Expand Down
37 changes: 8 additions & 29 deletions cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")

Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 5 additions & 5 deletions cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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).
Expand Down
1 change: 0 additions & 1 deletion docs/reference/cli/ferry_apply.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 22 additions & 17 deletions evals/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,38 +240,43 @@ 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")
if code != 0 {
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
Expand Down
6 changes: 3 additions & 3 deletions evals/terminal_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions evals/terminal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand Down
Loading