grex_cli/cli/args.rs
1use clap::{Args, Parser, Subcommand};
2
3#[derive(Parser, Debug)]
4#[command(
5 name = "grex",
6 version,
7 about = "grex — nested meta-repo manager. Pack-based, agent-native, Rust-fast.",
8 long_about = "grex manages trees of git repositories as a single addressable graph. \
9 Each node is a \"pack\" — a plain git repo plus a `.grex/` contract — and every \
10 pack is a meta-pack by construction (zero children = leaf, N children = orchestrator \
11 of N more packs, recursively). One uniform command surface (`sync`, `add`, `rm`, \
12 `update`, `status`, `import`, `doctor`, `teardown`, `exec`, `run`, `serve`) operates \
13 over the whole graph regardless of depth."
14)]
15pub struct Cli {
16 #[command(flatten)]
17 pub global: GlobalFlags,
18
19 #[command(subcommand)]
20 pub verb: Verb,
21}
22
23#[derive(Args, Debug)]
24pub struct GlobalFlags {
25 /// Emit output as JSON.
26 #[arg(long, global = true, conflicts_with = "plain")]
27 pub json: bool,
28
29 /// Emit plain (non-color, non-table) output.
30 #[arg(long, global = true)]
31 pub plain: bool,
32
33 /// Show planned actions without executing them.
34 #[arg(long, global = true)]
35 pub dry_run: bool,
36
37 /// Filter packs by expression.
38 #[arg(long, global = true)]
39 pub filter: Option<String>,
40}
41
42#[derive(Subcommand, Debug)]
43pub enum Verb {
44 /// Initialize a grex workspace.
45 Init(InitArgs),
46 /// Register and clone a pack.
47 Add(AddArgs),
48 /// Teardown and remove a pack.
49 Rm(RmArgs),
50 /// List registered packs.
51 Ls(LsArgs),
52 /// Report drift vs lockfile.
53 Status(StatusArgs),
54 /// Git fetch and pull (recurse by default).
55 Sync(SyncArgs),
56 /// Sync plus re-run install on lock change.
57 Update(UpdateArgs),
58 /// Run integrity checks.
59 Doctor(DoctorArgs),
60 /// Start MCP stdio server.
61 Serve(ServeArgs),
62 /// Import legacy REPOS.json.
63 Import(ImportArgs),
64 /// Run a named action across packs.
65 Run(RunArgs),
66 /// Execute a shell command in pack context.
67 Exec(ExecArgs),
68 /// Tear down a pack tree (reverse of `sync`/`install`).
69 Teardown(TeardownArgs),
70 /// Migrate a v1.1.x lockfile in place to the v1.2.0 schema (opt-in,
71 /// idempotent). Thin shim over the v1.2.0 Stage 1.h library
72 /// migrator (`grex_core::lockfile::migrate_v1_1_1`).
73 #[command(name = "migrate-lockfile")]
74 MigrateLockfile(MigrateLockfileArgs),
75}
76
77#[derive(Args, Debug)]
78pub struct InitArgs {}
79
80#[derive(Args, Debug)]
81pub struct AddArgs {
82 /// Git URL of the pack repo.
83 pub url: String,
84 /// Optional local path (defaults to repo name).
85 pub path: Option<String>,
86}
87
88#[derive(Args, Debug)]
89pub struct RmArgs {
90 /// Local path of the pack to remove.
91 pub path: String,
92}
93
94#[derive(Args, Debug)]
95pub struct LsArgs {
96 /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
97 /// itself. Defaults to the current working directory.
98 pub pack_root: Option<std::path::PathBuf>,
99}
100
101#[derive(Args, Debug)]
102pub struct StatusArgs {}
103
104#[derive(Args, Debug)]
105pub struct SyncArgs {
106 /// Recurse into child packs.
107 #[arg(long, default_value_t = true)]
108 pub recursive: bool,
109
110 /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
111 /// itself. When omitted, `sync` prints the legacy M1 stub and exits 0.
112 pub pack_root: Option<std::path::PathBuf>,
113
114 /// Path to the pack root (formerly `--workspace`). Defaults to the
115 /// pack root directory (where `.grex/pack.yaml` lives). When set,
116 /// this path becomes the canonical meta directory: children resolve
117 /// parent-relatively as `<pack>/<child.path>`. The path MUST exist;
118 /// symlinks are resolved to their canonical inode (logged as
119 /// `pack: <input> → <canonical>` when it differs). The legacy
120 /// `--workspace` spelling is preserved as a deprecated alias and
121 /// emits a one-time warning per process; removal scheduled for
122 /// v2.0.0.
123 #[arg(long = "pack", alias = "workspace")]
124 pub pack: Option<std::path::PathBuf>,
125
126 /// Plan actions without touching the filesystem.
127 #[arg(long, short = 'n')]
128 pub dry_run: bool,
129
130 /// Suppress per-action log lines.
131 #[arg(long, short = 'q')]
132 pub quiet: bool,
133
134 /// Skip plan-phase validators. Debug-only escape hatch.
135 #[arg(long)]
136 pub no_validate: bool,
137
138 /// Override the default ref for every pack in this sync invocation.
139 /// Accepts a branch, tag, or commit SHA. Empty strings are rejected.
140 #[arg(long = "ref", value_name = "REF", value_parser = non_empty_string)]
141 pub ref_override: Option<String>,
142
143 /// Restrict sync to packs whose workspace-relative path (or name)
144 /// matches the glob. Repeat the flag to OR-combine multiple patterns
145 /// (standard `*`/`**`/`?` semantics). Non-matching packs are skipped
146 /// entirely — no action execution, no lockfile write.
147 #[arg(long = "only", value_name = "GLOB", value_parser = non_empty_string)]
148 pub only: Vec<String>,
149
150 /// Re-execute every pack even when its `actions_hash` is unchanged
151 /// from the prior lockfile. Overrides the M4-B skip-on-hash
152 /// short-circuit; dry-run semantics are unchanged.
153 #[arg(long)]
154 pub force: bool,
155
156 /// v1.2.0 Stage 1.l — Override Phase 2 prune-safety refusal for
157 /// dirty (tracked or untracked-non-ignored) working trees. Still
158 /// refuses ignored content unless `--force-prune-with-ignored` is
159 /// also set. Never overrides `GitInProgress` (mid-rebase / merge /
160 /// cherry-pick / revert / bisect).
161 #[arg(long = "force-prune")]
162 pub force_prune: bool,
163
164 /// v1.2.0 Stage 1.l — Strongest prune override. Implies
165 /// `--force-prune` and additionally drops trees whose only dirt is
166 /// in `--ignored` paths (build artefacts, `target/`, `node_modules/`).
167 /// Never overrides `GitInProgress`.
168 #[arg(long = "force-prune-with-ignored")]
169 pub force_prune_with_ignored: bool,
170
171 /// v1.2.1 Item 5b — Recursively snapshot Phase 2 prune targets to
172 /// `<meta>/.grex/trash/<ISO8601>/<basename>/` BEFORE deletion.
173 /// Audit log entry (`QuarantineStart`) is appended + fsync'd
174 /// before any byte is copied; on snapshot failure the prune
175 /// aborts and the original dest is left intact for forensics.
176 /// Requires `--force-prune` or `--force-prune-with-ignored` —
177 /// quarantine only applies to overridden prunes; clean-consent
178 /// prunes still go through the direct-unlink fast path. The
179 /// "requires one of" check is enforced in the verb handler
180 /// (see `crates/grex/src/cli/verbs/sync.rs`) since clap's
181 /// `requires`/`required_unless_present_any` semantics don't
182 /// model "X requires (A or B)" cleanly without an `ArgGroup`.
183 /// Matches Lean theorem `quarantine_snapshot_precedes_delete`.
184 #[arg(long = "quarantine")]
185 pub quarantine: bool,
186
187 /// Max parallel pack ops during this sync run (feat-m6-1).
188 ///
189 /// Semantics:
190 /// * Absent → default `num_cpus::get()` resolved in `verbs::sync`.
191 /// * `0` → unbounded (`Semaphore::MAX_PERMITS`).
192 /// * `1` → serial fast-path (preserves pre-M6 wall-order).
193 /// * `2..=1024` → bounded parallel.
194 ///
195 /// Env fallback: `GREX_PARALLEL` is honoured only when the flag is
196 /// absent. Clap reads the env var automatically via `env`.
197 ///
198 /// Distinct from the global `--parallel` on [`GlobalFlags`]; that
199 /// knob is documented as the harness-level worker cap and rejects
200 /// `0`. Sync parallelism uses `0` as the "unbounded" sentinel per
201 /// `.omne/cfg/concurrency.md`.
202 #[arg(
203 long = "parallel",
204 env = "GREX_PARALLEL",
205 value_parser = clap::value_parser!(u32).range(0..=1024),
206 )]
207 pub parallel: Option<u32>,
208
209 /// v1.2.5 — sweep `<meta>/.grex/trash/` of entries older than
210 /// `N` days at the start of every meta sync (best-effort). When
211 /// omitted, no GC fires (v1.2.1 indefinite-retention behavior is
212 /// preserved). When set, sweep failures log via tracing and do
213 /// NOT halt the sync.
214 #[arg(long = "retain-days", value_name = "N")]
215 pub retain_days: Option<u32>,
216}
217
218/// Clap `value_parser` that rejects empty or whitespace-only strings.
219/// Keeps `--ref ""`, `--ref " "`, `--only ""`, `--only "\t"` off the
220/// fast path. Whitespace-only values are rejected because they
221/// degrade silently inside the walker / globset layers rather than
222/// producing a useful error.
223fn non_empty_string(s: &str) -> Result<String, String> {
224 if s.trim().is_empty() {
225 Err("value must not be empty or whitespace-only".to_string())
226 } else {
227 Ok(s.to_string())
228 }
229}
230
231#[derive(Args, Debug)]
232pub struct UpdateArgs {
233 /// Optional pack path; if omitted, update all.
234 pub pack: Option<String>,
235}
236
237#[derive(Args, Debug)]
238pub struct DoctorArgs {
239 /// Heal gitignore drift by re-emitting the managed block. Safety:
240 /// NEVER touches the manifest or the filesystem on other checks.
241 #[arg(long)]
242 pub fix: bool,
243
244 /// Run the opt-in config-lint check (`openspec/config.yaml` +
245 /// `.omne/cfg/*.md`). Skipped by default.
246 #[arg(long = "lint-config")]
247 pub lint_config: bool,
248
249 /// v1.2.0 Stage 1.j — bound the recursive ManifestTree walk.
250 /// Omitted: walk every nested meta exhaustively (default).
251 /// `--shallow 0`: root meta only.
252 /// `--shallow N`: recurse up to `N` levels of nesting (root is
253 /// depth 0; depth-`N` metas are visited but their children are
254 /// not). The walk is read-only at every frame.
255 #[arg(long = "shallow", value_name = "N")]
256 pub shallow: Option<usize>,
257
258 /// v1.2.1 item 4 — opt-in full-filesystem scan for `.git/`
259 /// directories that are not registered in the manifest tree.
260 /// Read-only audit; complements the manifest-driven default walk.
261 /// Composes with `--shallow` (which bounds the manifest walk).
262 /// Use `--depth N` to bound the filesystem scan independently.
263 #[arg(long = "scan-undeclared")]
264 pub scan_undeclared: bool,
265
266 /// v1.2.1 item 4 — bound the `--scan-undeclared` filesystem walk.
267 /// Omitted: scan every level under the workspace (default).
268 /// `--depth 0`: workspace root only.
269 /// `--depth N`: descend up to `N` directory levels below the
270 /// workspace root. Has no effect unless `--scan-undeclared` is
271 /// also set.
272 #[arg(long = "depth", value_name = "N", requires = "scan_undeclared")]
273 pub depth: Option<usize>,
274
275 /// v1.2.5 — sweep `<workspace>/.grex/trash/` of entries older than
276 /// the supplied retention window (in days). Pairs with the
277 /// canonical retention default surfaced by
278 /// [`grex_core::tree::DEFAULT_RETAIN_DAYS`] when the operator
279 /// omits a value. Best-effort: per-entry failures log via
280 /// `tracing::warn!` and do not halt the doctor run.
281 #[arg(long = "prune-quarantine")]
282 pub prune_quarantine: bool,
283
284 /// v1.2.5 — explicit retention window for `--prune-quarantine`
285 /// (and `grex sync`'s GC sweep). Defaults to
286 /// [`grex_core::tree::DEFAULT_RETAIN_DAYS`] when `--prune-quarantine`
287 /// is set without an explicit value. Has no effect unless
288 /// `--prune-quarantine` is also passed (or threaded into
289 /// `grex sync` via the matching flag there).
290 #[arg(long = "retain-days", value_name = "N")]
291 pub retain_days: Option<u32>,
292
293 /// v1.2.5 — restore the snapshot at
294 /// `<workspace>/.grex/trash/<TS>/<BASENAME>/` back into the
295 /// workspace. When BASENAME is omitted the `<TS>/` slot must hold
296 /// exactly one child entry (otherwise restore is refused as
297 /// ambiguous). Refuses to clobber an existing dest unless
298 /// `--force` is also passed.
299 #[arg(long = "restore-quarantine", value_name = "TS[:BASENAME]", num_args = 1)]
300 pub restore_quarantine: Option<String>,
301
302 /// v1.2.5 — paired with `--restore-quarantine`: when set, remove
303 /// the existing dest before the rename. Without this flag,
304 /// restore refuses to clobber an existing dest.
305 #[arg(long = "force", requires = "restore_quarantine")]
306 pub force: bool,
307}
308
309#[derive(Args, Debug)]
310pub struct ServeArgs {
311 /// Path to the `.grex/events.jsonl` event log. Captured at server
312 /// launch and immutable for the session (per spec §"Manifest binding").
313 /// Defaults to `<workspace>/.grex/events.jsonl` when omitted, where
314 /// `<workspace>` is resolved by walking up from cwd to the nearest
315 /// `.grex/` marker. v1.x `<workspace>/grex.jsonl` event logs are
316 /// auto-migrated to the canonical location on first access.
317 #[arg(long, value_name = "PATH")]
318 pub manifest: Option<std::path::PathBuf>,
319
320 /// Path to the pack root (formerly `--workspace`) the MCP server
321 /// resolves relative paths against. Defaults to the current working
322 /// directory when omitted. The legacy `--workspace` spelling is
323 /// preserved as a deprecated alias and emits a one-time warning per
324 /// process; removal scheduled for v2.0.0.
325 #[arg(long = "pack", alias = "workspace", value_name = "PATH")]
326 pub pack: Option<std::path::PathBuf>,
327
328 /// Harness-level worker cap inherited by the MCP server's
329 /// `Scheduler` (feat-m7-1 stage 8.3). `1` = serial; range `1..=1024`.
330 /// Defaults to `std::thread::available_parallelism()` when omitted.
331 /// Distinct from `sync --parallel` which uses `0` = unbounded.
332 #[arg(
333 long = "parallel",
334 value_parser = clap::value_parser!(u32).range(1..=1024),
335 )]
336 pub parallel: Option<u32>,
337}
338
339#[derive(Args, Debug)]
340pub struct ImportArgs {
341 /// Path to a legacy REPOS.json file.
342 #[arg(long)]
343 pub from_repos_json: Option<std::path::PathBuf>,
344
345 /// Target event log (`.grex/events.jsonl`). Defaults to
346 /// `<workspace>/.grex/events.jsonl` where `<workspace>` is resolved
347 /// by walking up from cwd to the nearest `.grex/` marker.
348 #[arg(long, value_name = "PATH")]
349 pub manifest: Option<std::path::PathBuf>,
350
351 /// Verb-scoped dry-run. Alias for the global `--dry-run`; either
352 /// flag short-circuits before any manifest write.
353 #[arg(long = "dry-run", short = 'n')]
354 pub dry_run: bool,
355}
356
357#[derive(Args, Debug)]
358pub struct RunArgs {
359 /// Action name to run.
360 pub action: String,
361}
362
363#[derive(Args, Debug)]
364pub struct ExecArgs {
365 /// Shell command and args to execute.
366 #[arg(trailing_var_arg = true)]
367 pub cmd: Vec<String>,
368}
369
370#[derive(Args, Debug)]
371pub struct MigrateLockfileArgs {
372 /// Path to the pack root (formerly `--workspace`) — the meta whose
373 /// `.grex/grex.lock.jsonl` is migrated. Defaults to the current
374 /// working directory. The legacy `--workspace` spelling is preserved
375 /// as a deprecated alias and emits a one-time warning per process;
376 /// removal scheduled for v2.0.0.
377 #[arg(long = "pack", alias = "workspace", value_name = "PATH")]
378 pub pack: Option<std::path::PathBuf>,
379
380 /// Inspect-only: detect schema version and report what would happen
381 /// without writing. Lockfile bytes are unchanged.
382 #[arg(long = "dry-run", short = 'n')]
383 pub dry_run: bool,
384}
385
386#[derive(Args, Debug)]
387pub struct TeardownArgs {
388 /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
389 /// itself. When omitted, `teardown` prints a usage stub and exits 0.
390 pub pack_root: Option<std::path::PathBuf>,
391
392 /// Path to the pack root (formerly `--workspace`). Defaults to the
393 /// pack root directory (where `.grex/pack.yaml` lives). When set,
394 /// this path becomes the canonical meta directory: children resolve
395 /// parent-relatively as `<pack>/<child.path>`. The path MUST exist;
396 /// symlinks are resolved to their canonical inode (logged as
397 /// `pack: <input> → <canonical>` when it differs). The legacy
398 /// `--workspace` spelling is preserved as a deprecated alias and
399 /// emits a one-time warning per process; removal scheduled for
400 /// v2.0.0.
401 #[arg(long = "pack", alias = "workspace")]
402 pub pack: Option<std::path::PathBuf>,
403
404 /// Suppress per-action log lines.
405 #[arg(long, short = 'q')]
406 pub quiet: bool,
407
408 /// Skip plan-phase validators. Debug-only escape hatch.
409 #[arg(long)]
410 pub no_validate: bool,
411}
412
413#[cfg(test)]
414mod tests {
415 //! Direct-parse unit tests. These bypass the spawned binary and hit
416 //! `Cli::try_parse_from` in-process — much faster than `assert_cmd`.
417 use super::*;
418 use clap::Parser;
419
420 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
421 // clap's `try_parse_from` expects argv[0] to be the binary name.
422 let mut full = vec!["grex"];
423 full.extend_from_slice(args);
424 Cli::try_parse_from(full)
425 }
426
427 #[test]
428 fn init_parses_to_init_variant() {
429 let cli = parse(&["init"]).expect("init parses");
430 assert!(matches!(cli.verb, Verb::Init(_)));
431 }
432
433 #[test]
434 fn add_parses_url_and_optional_path() {
435 let cli = parse(&["add", "https://example.com/repo.git"]).expect("add url parses");
436 match cli.verb {
437 Verb::Add(a) => {
438 assert_eq!(a.url, "https://example.com/repo.git");
439 assert!(a.path.is_none());
440 }
441 _ => panic!("expected Add variant"),
442 }
443
444 let cli = parse(&["add", "https://example.com/repo.git", "local"])
445 .expect("add url + path parses");
446 match cli.verb {
447 Verb::Add(a) => {
448 assert_eq!(a.url, "https://example.com/repo.git");
449 assert_eq!(a.path.as_deref(), Some("local"));
450 }
451 _ => panic!("expected Add variant"),
452 }
453 }
454
455 #[test]
456 fn rm_parses_path() {
457 let cli = parse(&["rm", "pack-a"]).expect("rm parses");
458 match cli.verb {
459 Verb::Rm(a) => assert_eq!(a.path, "pack-a"),
460 _ => panic!("expected Rm variant"),
461 }
462 }
463
464 #[test]
465 fn sync_recursive_defaults_to_true() {
466 let cli = parse(&["sync"]).expect("sync parses");
467 match cli.verb {
468 Verb::Sync(a) => assert!(a.recursive, "sync should default to recursive=true"),
469 _ => panic!("expected Sync variant"),
470 }
471 }
472
473 #[test]
474 fn update_pack_is_optional() {
475 let cli = parse(&["update"]).expect("update parses bare");
476 match cli.verb {
477 Verb::Update(a) => assert!(a.pack.is_none()),
478 _ => panic!("expected Update variant"),
479 }
480
481 let cli = parse(&["update", "mypack"]).expect("update parses w/ pack");
482 match cli.verb {
483 Verb::Update(a) => assert_eq!(a.pack.as_deref(), Some("mypack")),
484 _ => panic!("expected Update variant"),
485 }
486 }
487
488 #[test]
489 fn exec_collects_trailing_args() {
490 let cli = parse(&["exec", "echo", "hi", "there"]).expect("exec parses");
491 match cli.verb {
492 Verb::Exec(a) => assert_eq!(a.cmd, vec!["echo", "hi", "there"]),
493 _ => panic!("expected Exec variant"),
494 }
495 }
496
497 #[test]
498 fn universal_flags_populate_on_any_verb() {
499 // `--json` and `--plain` are mutually exclusive, so split into two
500 // parses to exercise the remaining flags on both modes.
501 let cli = parse(&["ls", "--json", "--dry-run", "--filter", "kind=git"])
502 .expect("ls w/ json+dry-run+filter parses");
503 assert!(cli.global.json);
504 assert!(!cli.global.plain);
505 assert!(cli.global.dry_run);
506 assert_eq!(cli.global.filter.as_deref(), Some("kind=git"));
507
508 let cli = parse(&["ls", "--plain", "--dry-run"]).expect("ls w/ plain+dry-run parses");
509 assert!(!cli.global.json);
510 assert!(cli.global.plain);
511 }
512
513 #[test]
514 fn json_and_plain_conflict() {
515 let err =
516 parse(&["init", "--json", "--plain"]).expect_err("--json and --plain must conflict");
517 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
518 }
519
520 #[test]
521 fn parallel_not_global_rejected_on_non_sync_verb() {
522 // feat-m6 B2 — `--parallel` is sync-scoped only; it must NOT
523 // be accepted as a global flag on verbs like `init`/`ls`.
524 let err =
525 parse(&["init", "--parallel", "1"]).expect_err("--parallel on non-sync verb must fail");
526 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
527 }
528
529 #[test]
530 fn sync_parallel_one_accepted() {
531 let cli = parse(&["sync", "--parallel", "1"]).expect("sync --parallel 1 parses");
532 match cli.verb {
533 Verb::Sync(a) => assert_eq!(a.parallel, Some(1)),
534 _ => panic!("expected Sync variant"),
535 }
536 }
537
538 #[test]
539 fn sync_parallel_max_accepted() {
540 let cli = parse(&["sync", "--parallel", "1024"]).expect("sync --parallel 1024 parses");
541 match cli.verb {
542 Verb::Sync(a) => assert_eq!(a.parallel, Some(1024)),
543 _ => panic!("expected Sync variant"),
544 }
545 }
546
547 #[test]
548 fn sync_parallel_over_max_rejected() {
549 let err =
550 parse(&["sync", "--parallel", "1025"]).expect_err("sync --parallel 1025 must fail");
551 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
552 }
553
554 #[test]
555 fn import_from_repos_json_parses_as_pathbuf() {
556 let cli =
557 parse(&["import", "--from-repos-json", "./REPOS.json"]).expect("import parses path");
558 match cli.verb {
559 Verb::Import(a) => {
560 assert_eq!(
561 a.from_repos_json.as_deref(),
562 Some(std::path::Path::new("./REPOS.json"))
563 );
564 }
565 _ => panic!("expected Import variant"),
566 }
567 }
568
569 #[test]
570 fn run_requires_action() {
571 let err = parse(&["run"]).expect_err("run w/o action must fail");
572 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
573 }
574
575 #[test]
576 fn unknown_verb_fails() {
577 let err = parse(&["nope"]).expect_err("unknown verb must fail");
578 assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
579 }
580
581 #[test]
582 fn unknown_flag_fails() {
583 let err = parse(&["init", "--not-a-flag"]).expect_err("unknown flag must fail");
584 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
585 }
586
587 #[test]
588 fn test_cli_force_prune_flag_parsed() {
589 // v1.2.0 Stage 1.l — `--force-prune` toggles the SyncArgs
590 // bool. Default is `false`.
591 let cli = parse(&["sync", "."]).expect("sync . parses");
592 match cli.verb {
593 Verb::Sync(ref a) => {
594 assert!(!a.force_prune, "default --force-prune must be false");
595 assert!(
596 !a.force_prune_with_ignored,
597 "default --force-prune-with-ignored must be false"
598 );
599 }
600 _ => panic!("expected Sync variant"),
601 }
602 let cli = parse(&["sync", ".", "--force-prune"]).expect("sync --force-prune parses");
603 match cli.verb {
604 Verb::Sync(a) => {
605 assert!(a.force_prune, "--force-prune must set true");
606 assert!(
607 !a.force_prune_with_ignored,
608 "--force-prune-with-ignored stays default false"
609 );
610 }
611 _ => panic!("expected Sync variant"),
612 }
613 }
614
615 #[test]
616 fn test_cli_force_prune_with_ignored_flag_parsed() {
617 // v1.2.0 Stage 1.l — `--force-prune-with-ignored` toggles
618 // independently of `--force-prune`. Walker layer interprets the
619 // matrix; CLI just parses the bools.
620 let cli = parse(&["sync", ".", "--force-prune-with-ignored"])
621 .expect("sync --force-prune-with-ignored parses");
622 match cli.verb {
623 Verb::Sync(a) => {
624 assert!(
625 !a.force_prune,
626 "--force-prune is independent of --force-prune-with-ignored at parse layer"
627 );
628 assert!(a.force_prune_with_ignored, "--force-prune-with-ignored must set true");
629 }
630 _ => panic!("expected Sync variant"),
631 }
632 // Both flags together: caller's documented "stronger" combo.
633 let cli = parse(&["sync", ".", "--force-prune", "--force-prune-with-ignored"])
634 .expect("sync --force-prune --force-prune-with-ignored parses");
635 match cli.verb {
636 Verb::Sync(a) => {
637 assert!(a.force_prune);
638 assert!(a.force_prune_with_ignored);
639 }
640 _ => panic!("expected Sync variant"),
641 }
642 }
643
644 #[test]
645 fn cli_non_empty_string_rejects_whitespace() {
646 // F8: `--ref " "` / `--only "\t"` must be rejected by the value
647 // parser, not silently threaded into the walker / globset layer
648 // where they degrade into useless errors.
649 for bad in ["", " ", "\t", " ", "\n"] {
650 let err =
651 parse(&["sync", ".", "--ref", bad]).expect_err("whitespace --ref must be rejected");
652 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
653
654 let err = parse(&["sync", ".", "--only", bad])
655 .expect_err("whitespace --only must be rejected");
656 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
657 }
658 }
659}