Skip to main content

grex_core/
sync.rs

1//! Sync orchestrator — M3 Stage B slice 6.
2//!
3//! Glues the building blocks shipped in slices 1–5b into a single runnable
4//! pipeline:
5//!
6//! 1. Walk a pack tree via [`Walker`] + [`FsPackLoader`] + a `GitBackend`.
7//! 2. Run plan-phase validators (manifest-level + graph-level).
8//! 3. Execute every action via a pluggable [`ActionExecutor`]
9//!    ([`PlanExecutor`] for dry-run, [`FsExecutor`] for wet-run).
10//! 4. Record each step as an [`Event::Sync`] entry in the pack-root's
11//!    `.grex/events.jsonl` event log.
12//!
13//! # Traversal order
14//!
15//! Nodes are executed in **depth-first post-order**: children fully install
16//! before their parent. Rationale: parent packs commonly `require:` artifacts
17//! created by children (e.g. a parent symlink whose `src` lives inside a
18//! child). Running the root last matches the overlay-style dotfile-install
19//! intent authors expect, and it matches how `walker.walk` is structured
20//! (children are hydrated before the recursion returns).
21//!
22//! # Decoupling
23//!
24//! The CLI crate drives this module through a thin `run()` entry point;
25//! [`SyncOptions`] is `#[non_exhaustive]` so new knobs (parallelism, filter
26//! expressions, ref overrides) can land in later milestones without breaking
27//! CLI callers. Errors aggregate into [`SyncError`] with a small, stable
28//! variant set.
29
30use std::borrow::Cow;
31use std::fs;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34
35use chrono::{DateTime, Utc};
36use globset::{Glob, GlobSet, GlobSetBuilder};
37use thiserror::Error;
38use tokio_util::sync::CancellationToken;
39
40use crate::execute::{
41    ActionExecutor, ExecCtx, ExecError, ExecResult, ExecStep, FsExecutor, MetaVisitedSet,
42    PlanExecutor, Platform, StepKind,
43};
44use crate::fs::{ManifestLock, ScopedLock};
45use crate::git::GixBackend;
46use crate::lockfile::{
47    compute_actions_hash, read_lockfile, write_lockfile, LockEntry, LockfileError,
48};
49use crate::manifest::{append_event, read_all, Event, ACTION_ERROR_SUMMARY_MAX, SCHEMA_VERSION};
50use crate::pack::{Action, PackValidationError};
51use crate::plugin::{PackTypeRegistry, Registry};
52use crate::scheduler::Scheduler;
53use crate::tree::{FsPackLoader, PackGraph, PackNode, TreeError, Walker};
54use crate::vars::VarEnv;
55
56/// Inputs to [`run`].
57///
58/// Fields are public-writable so call sites can construct with struct
59/// literals and `..SyncOptions::default()`. Marked `#[non_exhaustive]`
60/// so future knobs (parallelism, filter expressions, additional ref
61/// strategies) can land without breaking library consumers who
62/// constructed with explicit-literal syntax. Forces callers to use
63/// struct-update syntax (`..Default::default()`).
64#[non_exhaustive]
65#[derive(Debug, Clone)]
66pub struct SyncOptions {
67    /// When `true`, use [`PlanExecutor`] (no filesystem mutations).
68    pub dry_run: bool,
69    /// When `false`, skip plan-phase validators (manifest + graph). Debug
70    /// escape hatch; production callers should leave this `true`.
71    pub validate: bool,
72    /// Override workspace directory. `None` → the parent pack root itself
73    /// (children resolve as flat siblings of the parent pack root).
74    pub workspace: Option<PathBuf>,
75    /// Global ref override (`grex sync --ref <sha|branch|tag>`). When
76    /// `Some`, every child pack clone/checkout uses this ref instead of
77    /// the declared `child.ref`. Empty strings are rejected at the CLI
78    /// layer.
79    pub ref_override: Option<String>,
80    /// Pack-path filter patterns (`grex sync --only <glob>`). Raw glob
81    /// strings — compiled internally via an in-crate `globset` helper so the
82    /// `globset` crate version does not leak into the public API.
83    /// `None` / empty means every pack runs (M3 semantics). Matching is
84    /// against the pack's **workspace-relative** path normalized to
85    /// forward-slash form.
86    pub only_patterns: Option<Vec<String>>,
87    /// Bypass the lockfile hash-match skip (`grex sync --force`). When
88    /// `true`, every pack re-executes even if its `actions_hash` is
89    /// unchanged from the prior lockfile.
90    pub force: bool,
91    /// Max parallel pack ops for this sync run (feat-m6-1).
92    ///
93    /// * `None` → callers default to `num_cpus::get()` at CLI layer.
94    ///   Library callers who construct `SyncOptions` directly and leave
95    ///   this `None` get `num_cpus::get()` semantics too — the sync
96    ///   driver resolves the default in one place so the scheduler slot
97    ///   on every `ExecCtx` is always populated.
98    /// * `Some(0)` → unbounded (`Semaphore::MAX_PERMITS`).
99    /// * `Some(1)` → serial fast-path.
100    /// * `Some(n >= 2)` → bounded parallel.
101    pub parallel: Option<usize>,
102    /// v1.2.0 Stage 1.l prep — when `true`, walker Phase 2 may drop
103    /// dirty trees during prune. Still refuses ignored content unless
104    /// [`SyncOptions::force_prune_with_ignored`] is also `true`.
105    /// Default `false` preserves v1.1.1 behavior (refuse all dirty
106    /// drops).
107    pub force_prune: bool,
108    /// v1.2.0 Stage 1.l prep — when `true` (implies
109    /// [`SyncOptions::force_prune`]), walker Phase 2 also drops
110    /// ignored content. Hard override — the strongest level. Default
111    /// `false` preserves v1.1.1 behavior.
112    pub force_prune_with_ignored: bool,
113    /// v1.2.0 Stage 1.h opt-in — when `true`, the walker rewrites a
114    /// legacy v1.1.1 lockfile in place to the v1.2.0 shape. When
115    /// `false` (default), the walker errors on the legacy shape so
116    /// migration is always an explicit caller decision.
117    pub migrate_lockfile: bool,
118    /// v1.2.0 Stage 1.j prep — when `true` (default), the walker
119    /// descends into nested meta-children. `doctor --shallow` flips
120    /// this to `false` so only the immediate workspace is inspected.
121    pub recurse: bool,
122    /// v1.2.0 Stage 1.j prep — pairs with
123    /// [`SyncOptions::recurse`] for `--shallow=N`. `None` (default)
124    /// is unbounded recursion when `recurse` is `true`. `Some(n)`
125    /// caps depth at `n` levels of nesting.
126    pub max_depth: Option<usize>,
127}
128
129impl Default for SyncOptions {
130    fn default() -> Self {
131        Self {
132            dry_run: false,
133            validate: true,
134            workspace: None,
135            ref_override: None,
136            only_patterns: None,
137            force: false,
138            parallel: None,
139            // v1.2.0 Stage 1.m additions — defaults preserve v1.1.1
140            // behavior. Each field is a dormant placeholder until
141            // its corresponding walker stage wires it.
142            force_prune: false,
143            force_prune_with_ignored: false,
144            migrate_lockfile: false,
145            recurse: true,
146            max_depth: None,
147        }
148    }
149}
150
151/// Compile raw `--only` pattern strings into a [`globset::GlobSet`].
152/// Empty / absent input yields `Ok(None)` so M3's zero-config path
153/// (every pack runs) stays the default.
154fn compile_only_globset(patterns: Option<&Vec<String>>) -> Result<Option<GlobSet>, SyncError> {
155    let Some(pats) = patterns else { return Ok(None) };
156    if pats.is_empty() {
157        return Ok(None);
158    }
159    let mut builder = GlobSetBuilder::new();
160    for p in pats {
161        let glob = Glob::new(p)
162            .map_err(|source| SyncError::InvalidOnlyGlob { pattern: p.clone(), source })?;
163        builder.add(glob);
164    }
165    let set = builder
166        .build()
167        .map_err(|source| SyncError::InvalidOnlyGlob { pattern: pats.join(","), source })?;
168    Ok(Some(set))
169}
170
171impl SyncOptions {
172    /// Default options: wet-run, validators enabled, default workspace path.
173    #[must_use]
174    pub fn new() -> Self {
175        Self::default()
176    }
177
178    /// Set `dry_run`.
179    #[must_use]
180    pub fn with_dry_run(mut self, dry_run: bool) -> Self {
181        self.dry_run = dry_run;
182        self
183    }
184
185    /// Set `validate`.
186    #[must_use]
187    pub fn with_validate(mut self, validate: bool) -> Self {
188        self.validate = validate;
189        self
190    }
191
192    /// Set `workspace` override.
193    #[must_use]
194    pub fn with_workspace(mut self, workspace: Option<PathBuf>) -> Self {
195        self.workspace = workspace;
196        self
197    }
198
199    /// Set `ref_override` (`--ref`).
200    #[must_use]
201    pub fn with_ref_override(mut self, ref_override: Option<String>) -> Self {
202        self.ref_override = ref_override;
203        self
204    }
205
206    /// Set `only_patterns` (`--only`). Empty vector or `None` disables
207    /// the filter.
208    #[must_use]
209    pub fn with_only_patterns(mut self, patterns: Option<Vec<String>>) -> Self {
210        self.only_patterns = patterns;
211        self
212    }
213
214    /// Set `force` (`--force`).
215    #[must_use]
216    pub fn with_force(mut self, force: bool) -> Self {
217        self.force = force;
218        self
219    }
220
221    /// Set `parallel` (`--parallel`). See [`SyncOptions::parallel`] for
222    /// the `None` / `Some(0)` / `Some(1)` / `Some(n)` semantics.
223    #[must_use]
224    pub fn with_parallel(mut self, parallel: Option<usize>) -> Self {
225        self.parallel = parallel;
226        self
227    }
228
229    /// Set `force_prune` (`--force-prune`). See
230    /// [`SyncOptions::force_prune`] for the override matrix.
231    #[must_use]
232    pub fn with_force_prune(mut self, force_prune: bool) -> Self {
233        self.force_prune = force_prune;
234        self
235    }
236
237    /// Set `force_prune_with_ignored` (`--force-prune-with-ignored`).
238    /// See [`SyncOptions::force_prune_with_ignored`] for the override
239    /// matrix.
240    #[must_use]
241    pub fn with_force_prune_with_ignored(mut self, force_prune_with_ignored: bool) -> Self {
242        self.force_prune_with_ignored = force_prune_with_ignored;
243        self
244    }
245}
246
247/// One executed (or planned) action step in a sync run.
248///
249/// Marked `#[non_exhaustive]` so new observability fields (timestamps,
250/// plugin provenance) can land without breaking library consumers who
251/// destructure the struct.
252#[non_exhaustive]
253#[derive(Debug, Clone)]
254pub struct SyncStep {
255    /// Name of the pack that owned the action.
256    pub pack: String,
257    /// 0-based index into the pack's top-level `actions` vector.
258    pub action_idx: usize,
259    /// The [`ExecStep`] record emitted by the executor.
260    pub exec_step: ExecStep,
261}
262
263/// Outcome of a [`run`] invocation.
264///
265/// On fail-fast termination, `halted` carries the error that stopped the
266/// sync; every completed step up to that point is still in `steps` so
267/// callers can render a partial transcript.
268///
269/// Marked `#[non_exhaustive]` so new report-level fields (run id, metrics)
270/// can land without breaking library consumers who destructure the struct.
271#[non_exhaustive]
272#[derive(Debug)]
273pub struct SyncReport {
274    /// Fully-walked pack graph (present even on halted runs).
275    pub graph: PackGraph,
276    /// Steps produced by the executor, in execution order.
277    pub steps: Vec<SyncStep>,
278    /// `Some(e)` if execution stopped before all actions ran.
279    pub halted: Option<SyncError>,
280    /// Non-fatal manifest-append warnings (one per failed event append).
281    /// Kept as a separate field because spec marks event-log write failures
282    /// as non-aborting.
283    pub event_log_warnings: Vec<String>,
284    /// `Some(r)` when the pre-run teardown scan found orphaned backup
285    /// files or dangling [`Event::ActionStarted`] records from a prior
286    /// crashed run. Informational only — the report is still returned and
287    /// the sync proceeds. CLI renderers should surface a warning so the
288    /// operator can decide whether to run a future `grex doctor` verb.
289    pub pre_run_recovery: Option<RecoveryReport>,
290    /// One entry per child whose legacy `.grex/workspace/<name>/` layout
291    /// was relocated (or considered for relocation) on this sync. Empty
292    /// when no legacy directory was found — the common case for any
293    /// workspace built fresh on v1.1.0+. CLI renderers should surface
294    /// the list so operators see what changed.
295    pub workspace_migrations: Vec<WorkspaceMigration>,
296}
297
298/// One legacy-layout migration attempt. `outcome` distinguishes the
299/// move-succeeded case from the don't-clobber-user-data case so CLI
300/// renderers can present different advice to the operator.
301#[non_exhaustive]
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct WorkspaceMigration {
304    /// Source path under the legacy `.grex/workspace/<name>/` location,
305    /// rendered relative to the pack root for log readability.
306    pub from: PathBuf,
307    /// Destination flat-sibling path `<pack_root>/<name>/`, relative to
308    /// the pack root.
309    pub to: PathBuf,
310    /// What happened.
311    pub outcome: MigrationOutcome,
312}
313
314/// Outcome of one legacy-layout migration attempt.
315#[non_exhaustive]
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum MigrationOutcome {
318    /// Legacy directory was renamed onto the flat-sibling slot.
319    Migrated,
320    /// Both legacy and flat-sibling slots existed. Skipped — the user
321    /// must inspect and reconcile manually so we never silently delete
322    /// either.
323    SkippedBothExist,
324    /// Flat-sibling slot already had a non-grex file or directory in
325    /// the way. Skipped — refusing to clobber user data even when the
326    /// legacy slot is plainly the source of truth.
327    SkippedDestOccupied,
328    /// `fs::rename` failed (e.g. cross-volume, ACL denied). The legacy
329    /// directory is still in place; surfaced so the operator can move
330    /// it manually.
331    Failed { error: String },
332}
333
334/// Rich context attached to a [`SyncError::Halted`] variant.
335///
336/// Packages the pack + action position together with the underlying
337/// executor error and an optional human-readable recovery hint. Marked
338/// `#[non_exhaustive]` so future fields (step transcript, timestamp) can
339/// land without breaking `match` arms or struct destructures.
340#[non_exhaustive]
341#[derive(Debug)]
342pub struct HaltedContext {
343    /// Name of the pack that owned the halted action.
344    pub pack: String,
345    /// 0-based index into the pack's top-level `actions` vector.
346    pub action_idx: usize,
347    /// Short action kind tag (e.g. `"symlink"`, `"exec"`).
348    pub action_name: String,
349    /// Underlying executor error.
350    pub error: ExecError,
351    /// Optional next-step suggestion for the operator. `None` when no
352    /// generic hint applies — the executor error's own `Display` already
353    /// tells the story.
354    pub recovery_hint: Option<String>,
355}
356
357/// Error taxonomy surfaced by [`run`].
358#[non_exhaustive]
359#[derive(Debug, Error)]
360pub enum SyncError {
361    /// The pack-tree walker failed (loader error, git error, cycle, …).
362    #[error("tree walk failed: {0}")]
363    Tree(#[from] TreeError),
364    /// One or more plan-phase validators flagged the graph.
365    #[error("validation failed: {errors:?}")]
366    Validation {
367        /// Aggregated errors from manifest-level + graph-level validators.
368        errors: Vec<PackValidationError>,
369    },
370    /// An action executor returned an error.
371    ///
372    /// Retained for backward compatibility; new call sites should prefer
373    /// [`SyncError::Halted`] which carries full pack + action context.
374    /// Kept non-deprecated because [`From<ExecError>`] still materialises
375    /// the variant for non-sync-loop callers (e.g. ad-hoc helpers).
376    #[error("action execution failed: {0}")]
377    Exec(#[from] ExecError),
378    /// Action execution halted; full context (pack, action index, error,
379    /// optional recovery hint) lives in [`HaltedContext`]. This is the
380    /// variant the sync driver emits — [`SyncError::Exec`] is only
381    /// surfaced by ancillary code paths.
382    #[error(
383        "sync halted at pack `{}` action #{} ({}): {}",
384        .0.pack, .0.action_idx, .0.action_name, .0.error
385    )]
386    Halted(Box<HaltedContext>),
387    /// Another `grex` process (or thread) already holds the workspace-level
388    /// lock. The running sync refused to start to avoid racing two concurrent
389    /// walkers into the same workspace. If the lock file at `lock_path` is
390    /// stale (no other grex is actually running), remove it by hand.
391    #[error(
392        "workspace `{workspace}` is locked by another grex process (remove {lock_path:?} if stale)"
393    )]
394    WorkspaceBusy {
395        /// Resolved workspace directory that the current run tried to lock.
396        workspace: PathBuf,
397        /// Sidecar lock file that is currently held.
398        lock_path: PathBuf,
399    },
400    /// Reading or parsing the resolved-state lockfile failed. Surfaced as
401    /// its own variant (rather than folded into `Validation`) because a
402    /// corrupt / unreadable lockfile is an I/O or schema fault, not a
403    /// dependency-satisfaction fault. Resolution is operator-level
404    /// (restore a backup, delete the file, re-sync), not author-level.
405    #[error("lockfile `{path}` failed to load: {source}")]
406    Lockfile {
407        /// Lockfile path that failed to load.
408        path: PathBuf,
409        /// Underlying lockfile error.
410        #[source]
411        source: LockfileError,
412    },
413    /// One of the `--only <GLOB>` patterns failed to compile. Surfaced
414    /// as its own variant so the CLI can map it to a dedicated usage
415    /// error exit code instead of the generic sync-failure bucket.
416    #[error("invalid --only glob `{pattern}`: {source}")]
417    InvalidOnlyGlob {
418        /// The raw pattern string that failed to compile.
419        pattern: String,
420        /// Underlying globset error.
421        #[source]
422        source: globset::Error,
423    },
424    /// Migrating the v1.x event log (`grex.jsonl`) to the v2 canonical
425    /// path (`.grex/events.jsonl`) failed. Operator-level resolution
426    /// (check filesystem permissions, free disk space, then retry).
427    #[error("event-log migration failed: {0}")]
428    EventLogMigration(#[source] crate::manifest::ManifestError),
429    /// Cooperative cancellation fired (Ctrl-C / SIGTERM) during a
430    /// parallel sync. v1.2.0 Stage 1.g wires the rayon walker to surface
431    /// this distinct-from-failure variant so the CLI can exit with a
432    /// dedicated cancellation code instead of a generic sync error.
433    /// Dormant until Stage 1.g — the existing CLI does not yet emit it.
434    #[error("sync cancelled by user")]
435    SchedulerCancelled,
436}
437
438impl Clone for SyncError {
439    fn clone(&self) -> Self {
440        // `TreeError` / `ExecError` do not implement `Clone` (they wrap
441        // `std::io::Error`-adjacent values). Halts carry only a display
442        // rendering in the report; we re-materialise via a synthetic
443        // `Validation` variant so `SyncReport` can be `Clone`-safe for
444        // observability tooling without widening the taxonomy.
445        match self {
446            Self::Tree(e) => Self::Validation {
447                errors: vec![PackValidationError::DependsOnUnsatisfied {
448                    pack: "<tree>".into(),
449                    required: e.to_string(),
450                }],
451            },
452            Self::Validation { errors } => Self::Validation { errors: errors.clone() },
453            Self::Exec(e) => Self::Validation {
454                errors: vec![PackValidationError::DependsOnUnsatisfied {
455                    pack: "<exec>".into(),
456                    required: e.to_string(),
457                }],
458            },
459            Self::Halted(ctx) => Self::Validation {
460                errors: vec![PackValidationError::DependsOnUnsatisfied {
461                    pack: ctx.pack.clone(),
462                    required: format!(
463                        "action #{} ({}): {}",
464                        ctx.action_idx, ctx.action_name, ctx.error
465                    ),
466                }],
467            },
468            Self::WorkspaceBusy { workspace, lock_path } => {
469                Self::WorkspaceBusy { workspace: workspace.clone(), lock_path: lock_path.clone() }
470            }
471            Self::Lockfile { path, source } => Self::Validation {
472                errors: vec![PackValidationError::DependsOnUnsatisfied {
473                    pack: "<lockfile>".into(),
474                    required: format!("{}: {source}", path.display()),
475                }],
476            },
477            Self::InvalidOnlyGlob { pattern, source } => Self::Validation {
478                errors: vec![PackValidationError::DependsOnUnsatisfied {
479                    pack: "<only-glob>".into(),
480                    required: format!("{pattern}: {source}"),
481                }],
482            },
483            Self::EventLogMigration(source) => Self::Validation {
484                errors: vec![PackValidationError::DependsOnUnsatisfied {
485                    pack: "<event-log-migration>".into(),
486                    required: source.to_string(),
487                }],
488            },
489            Self::SchedulerCancelled => Self::SchedulerCancelled,
490        }
491    }
492}
493
494/// Run a full sync over the pack tree rooted at `pack_root`.
495///
496/// Resolution rules:
497/// * If `pack_root` is a directory the walker looks for
498///   `<pack_root>/.grex/pack.yaml`.
499/// * If `pack_root` ends in `.yaml` / `.yml` it is loaded verbatim.
500/// * Workspace defaults to the pack root directory itself when
501///   `opts.workspace` is `None`. Children resolve as flat siblings of the
502///   parent pack root (since v1.1.0).
503///
504/// # Errors
505///
506/// Returns the first error that halts the pipeline — see [`SyncError`] for
507/// the taxonomy.
508///
509/// `cancel` is the cooperative cancellation handle threaded through the
510/// pipeline by feat-m7-1 stage 2. Stage 2 only wires the parameter; the
511/// `is_cancelled()` polls land in stages 3-4 (scheduler + pack-lock
512/// acquire). CLI callers pass a never-cancelled sentinel
513/// (`CancellationToken::new()`); the MCP server passes a token tied to
514/// the request lifetime.
515pub fn run(
516    pack_root: &Path,
517    opts: &SyncOptions,
518    cancel: &CancellationToken,
519) -> Result<SyncReport, SyncError> {
520    // Stage 2 is signature-only — silence "unused parameter" without
521    // hiding it behind `_` (downstream stages will read it).
522    let _ = cancel;
523    let workspace = prepare_workspace(pack_root, opts)?;
524    let (mut ws_lock, ws_lock_path) = open_workspace_lock(&workspace)?;
525    let _ws_guard = match ws_lock.try_acquire() {
526        Ok(Some(g)) => g,
527        Ok(None) => {
528            return Err(SyncError::WorkspaceBusy {
529                workspace: workspace.clone(),
530                lock_path: ws_lock_path,
531            });
532        }
533        Err(e) => return Err(workspace_lock_err(&ws_lock_path, &e.to_string())),
534    };
535
536    // Compile `--only` patterns into a GlobSet here so the
537    // `globset` crate version does not leak into `SyncOptions`.
538    let only_set = compile_only_globset(opts.only_patterns.as_ref())?;
539
540    // Auto-migrate legacy `.grex/workspace/<name>/` layout BEFORE the
541    // walker resolves children. Idempotent: a fresh v1.1.0+ workspace
542    // sees no legacy directory and the function no-ops.
543    let workspace_migrations = migrate_legacy_workspace(pack_root);
544
545    let graph =
546        walk_and_validate(pack_root, &workspace, opts.validate, opts.ref_override.as_deref())?;
547    let prep = prepare_run_context(pack_root, &graph, &workspace)?;
548    log_force_flag(opts.force);
549
550    let mut report = SyncReport {
551        graph,
552        steps: Vec::new(),
553        halted: None,
554        event_log_warnings: Vec::new(),
555        pre_run_recovery: prep.pre_run_recovery,
556        workspace_migrations,
557    };
558
559    let mut next_lock = prep.prior_lock.clone();
560    // feat-m6 B1: resolve `--parallel` once and build the scheduler
561    // shared across every `ExecCtx` in this run. Library callers who
562    // leave `opts.parallel == None` default to `num_cpus::get()` here
563    // (clamped `>= 1`) so the scheduler slot is always populated —
564    // `ctx.scheduler` being `None` would strand acquire-sites into
565    // unbounded concurrency. See `.omne/cfg/concurrency.md` §Scheduler.
566    let resolved_parallel: usize = opts.parallel.unwrap_or_else(|| num_cpus::get().max(1));
567    let scheduler = Arc::new(Scheduler::new(resolved_parallel));
568    run_actions(
569        &mut report,
570        &prep.order,
571        &prep.vars,
572        &workspace,
573        &prep.event_log,
574        &prep.lock_path,
575        opts.dry_run,
576        &prep.prior_lock,
577        &mut next_lock,
578        &prep.registry,
579        &prep.pack_type_registry,
580        only_set.as_ref(),
581        opts.force,
582        resolved_parallel,
583        &scheduler,
584    );
585
586    persist_lockfile_if_clean(&mut report, &prep.lockfile_path, &next_lock, opts.dry_run);
587    Ok(report)
588}
589
590/// Bag of context pieces assembled once at the top of [`run`]. Grouping
591/// them keeps [`run`] under the workspace's 50-LOC function lint without
592/// smearing the read of sequential setup across helpers. Fields are
593/// consumed piecemeal by the actions loop; no getters needed.
594struct RunContext {
595    order: Vec<usize>,
596    vars: VarEnv,
597    event_log: PathBuf,
598    lock_path: PathBuf,
599    lockfile_path: PathBuf,
600    prior_lock: std::collections::HashMap<String, LockEntry>,
601    registry: Arc<Registry>,
602    pack_type_registry: Arc<PackTypeRegistry>,
603    pre_run_recovery: Option<RecoveryReport>,
604}
605
606/// Build the per-run context: traversal order, vars env, event/lockfile
607/// paths, prior lockfile state, bootstrap registry, and (optionally) a
608/// pre-run recovery scan. Kept narrow so [`run`] stays small.
609///
610/// `workspace` is the resolved workspace directory (post `--workspace`
611/// override) so the recovery scan looks for `.grex.bak` artefacts under
612/// the actual on-disk location children were materialised at — not
613/// under the pack root, which differs from the workspace whenever the
614/// CLI's `--workspace` flag is used. Pre-fix this anchoring drift
615/// caused recovery scans to miss every backup left under an override
616/// workspace.
617fn prepare_run_context(
618    pack_root: &Path,
619    graph: &PackGraph,
620    workspace: &Path,
621) -> Result<RunContext, SyncError> {
622    let event_log = event_log_path(pack_root);
623    let lock_path = event_lock_path(&event_log);
624    let vars = VarEnv::from_os();
625    let order = post_order(graph);
626    let pre_run_recovery = scan_recovery(workspace, &event_log).ok().filter(|r| !r.is_empty());
627    let lockfile_path = lockfile_path(pack_root);
628    let prior_lock = load_prior_lock(&lockfile_path)?;
629    let registry = Arc::new(Registry::bootstrap());
630    let pack_type_registry = Arc::new(bootstrap_pack_type_registry());
631    Ok(RunContext {
632        order,
633        vars,
634        event_log,
635        lock_path,
636        lockfile_path,
637        prior_lock,
638        registry,
639        pack_type_registry,
640        pre_run_recovery,
641    })
642}
643
644/// Build the [`PackTypeRegistry`] the sync driver threads into every
645/// [`ExecCtx`] it constructs.
646///
647/// Default path (no `plugin-inventory` feature) hard-codes the three
648/// built-ins via [`PackTypeRegistry::bootstrap`]. With the feature on,
649/// [`PackTypeRegistry::bootstrap_from_inventory`] is preferred so any
650/// externally-submitted plugin types (mirroring the M4-E pattern for
651/// action plugins) shadow the built-ins last-writer-wins. Kept as a free
652/// helper so the `#[cfg]` split lives in one place instead of being
653/// smeared across every sync call-site.
654fn bootstrap_pack_type_registry() -> PackTypeRegistry {
655    #[cfg(feature = "plugin-inventory")]
656    {
657        let mut reg = PackTypeRegistry::bootstrap();
658        reg.register_from_inventory();
659        reg
660    }
661    #[cfg(not(feature = "plugin-inventory"))]
662    {
663        PackTypeRegistry::bootstrap()
664    }
665}
666
667/// Emit a single `tracing::info!` line when `--force` is active so
668/// operators can confirm from logs that the skip short-circuit was
669/// bypassed. Extracted so [`run`] stays small.
670fn log_force_flag(force: bool) {
671    if force {
672        tracing::info!(
673            target: "grex::sync",
674            "--force active: bypassing lockfile skip-on-hash short-circuit"
675        );
676    }
677}
678
679/// Walk the pack tree rooted at `pack_root`, optionally running the
680/// plan-phase validators. Extracted so [`run`] stays under the
681/// workspace's 50-LOC per-function lint threshold.
682fn walk_and_validate(
683    pack_root: &Path,
684    workspace: &Path,
685    validate: bool,
686    ref_override: Option<&str>,
687) -> Result<PackGraph, SyncError> {
688    let loader = FsPackLoader::new();
689    let backend = GixBackend::new();
690    let walker = Walker::new(&loader, &backend, workspace.to_path_buf())
691        .with_ref_override(ref_override.map(str::to_string));
692    let graph = walker.walk(pack_root)?;
693    if validate {
694        validate_graph(&graph)?;
695    }
696    Ok(graph)
697}
698
699/// Load the prior lockfile (`grex.lock.jsonl`). Missing file yields an
700/// empty map; parse errors are fatal since writes are atomic and a torn
701/// lockfile therefore indicates real corruption that must be resolved
702/// before a fresh sync is safe. Parse/IO failures surface as
703/// [`SyncError::Lockfile`] — this is an I/O / schema fault, not a
704/// dependency-satisfaction fault, so it gets its own taxonomy slot.
705fn load_prior_lock(
706    lockfile_path: &Path,
707) -> Result<std::collections::HashMap<String, LockEntry>, SyncError> {
708    read_lockfile(lockfile_path)
709        .map_err(|source| SyncError::Lockfile { path: lockfile_path.to_path_buf(), source })
710}
711
712/// Persist `next_lock` atomically to `lockfile_path` whenever this was
713/// not a dry-run. On a halt the map has already had the halted pack's
714/// entry removed (see `run_actions`), so persisting now preserves every
715/// *successful* pack's fresh entry while guaranteeing absence of an
716/// entry for the halted pack — next sync sees no prior hash there and
717/// re-executes from scratch (route (b) halt-state gating). Write errors
718/// surface as non-fatal warnings on the report.
719fn persist_lockfile_if_clean(
720    report: &mut SyncReport,
721    lockfile_path: &Path,
722    next_lock: &std::collections::HashMap<String, LockEntry>,
723    dry_run: bool,
724) {
725    if dry_run {
726        return;
727    }
728    if let Err(e) = write_lockfile(lockfile_path, next_lock) {
729        tracing::warn!(target: "grex::sync", "lockfile write failed: {e}");
730        report.event_log_warnings.push(format!("{}: {e}", lockfile_path.display()));
731    }
732}
733
734/// Canonical location of the resolved-state lockfile
735/// (`<pack_root>/.grex/grex.lock.jsonl`). Colocated with the event log
736/// so both audit artifacts live under a single `.grex/` sidecar.
737fn lockfile_path(pack_root: &Path) -> PathBuf {
738    pack_root_dir(pack_root).join(".grex").join("grex.lock.jsonl")
739}
740
741/// Create the workspace directory if it does not yet exist.
742fn ensure_workspace_dir(workspace: &Path) -> Result<(), SyncError> {
743    if !workspace.exists() {
744        std::fs::create_dir_all(workspace).map_err(|e| SyncError::Validation {
745            errors: vec![PackValidationError::DependsOnUnsatisfied {
746                pack: "<workspace>".into(),
747                required: format!("{}: {e}", workspace.display()),
748            }],
749        })?;
750    }
751    Ok(())
752}
753
754/// Open (but do not acquire) the workspace-level lock file.
755fn open_workspace_lock(workspace: &Path) -> Result<(ScopedLock, PathBuf), SyncError> {
756    let ws_lock_path = workspace_lock_path(workspace);
757    let ws_lock = ScopedLock::open(&ws_lock_path)
758        .map_err(|e| workspace_lock_err(&ws_lock_path, &e.to_string()))?;
759    Ok((ws_lock, ws_lock_path))
760}
761
762/// Build a `Validation` error describing a workspace-lock failure.
763fn workspace_lock_err(ws_lock_path: &Path, reason: &str) -> SyncError {
764    SyncError::Validation {
765        errors: vec![PackValidationError::DependsOnUnsatisfied {
766            pack: "<workspace-lock>".into(),
767            required: format!("{}: {reason}", ws_lock_path.display()),
768        }],
769    }
770}
771
772/// Single source of truth for the legacy workspace directory name.
773/// Pre-`v1.1.0` `resolve_workspace` joined `.grex/workspace/` onto the
774/// pack root by default; the auto-migration in
775/// [`migrate_legacy_workspace`] is the only place that legacy literal
776/// is allowed to appear in `crates/grex-core/src/`. The grep gate in
777/// the v1.1.0 release checklist allows this one constant.
778const LEGACY_WORKSPACE_DIR: &str = ".grex/workspace";
779
780/// Auto-migrate any legacy `.grex/workspace/<name>/` child layout left
781/// over from v1.0.x to the v1.1.0 flat-sibling layout. Idempotent: a
782/// fresh workspace built on v1.1.0+ sees no `.grex/workspace/`
783/// directory and the function no-ops.
784///
785/// Per-child outcomes:
786///
787/// * **Both legacy + flat-sibling exist** → `SkippedBothExist`. The
788///   user needs to inspect (perhaps the legacy is stale, perhaps it is
789///   the source of truth); we never silently delete either.
790/// * **Flat-sibling slot occupied by a non-grex file or non-empty dir**
791///   → `SkippedDestOccupied`. Refuse to clobber user data.
792/// * **Legacy exists, flat-sibling absent** → `Migrated` via atomic
793///   `fs::rename`. Same-volume move is the common case (the migration
794///   stays inside `pack_root`); cross-volume failures surface as
795///   `Failed { error }` with the OS message so the operator can move
796///   manually.
797/// * **Legacy absent** → silent no-op (not recorded in the report).
798///
799/// After all per-child decisions: orphan `.grex.sync.lock` under the
800/// legacy workspace is removed (best-effort) and the empty
801/// `.grex/workspace/` directory is rmdir'd (best-effort). Both are
802/// soft-failures: leaving them on disk is harmless, surfacing the
803/// errors as a sync abort would be over-strict.
804///
805/// Discovery is by directory listing, not by parent-manifest parse —
806/// migration must work even when the parent manifest itself was
807/// rewritten between versions. A child counts as "legacy" iff
808/// `<pack_root>/<LEGACY_WORKSPACE_DIR>/<name>/.git` exists (i.e. it is
809/// an actual git working tree, not stray metadata).
810fn migrate_legacy_workspace(pack_root: &Path) -> Vec<WorkspaceMigration> {
811    let root = pack_root_dir(pack_root);
812    let legacy_root = root.join(LEGACY_WORKSPACE_DIR);
813    if !legacy_root.is_dir() {
814        return Vec::new();
815    }
816    let entries = match fs::read_dir(&legacy_root) {
817        Ok(e) => e,
818        Err(e) => {
819            tracing::warn!(
820                target: "grex::sync::migrate",
821                "legacy workspace `{}` unreadable: {e}",
822                legacy_root.display(),
823            );
824            return Vec::new();
825        }
826    };
827    let mut migrations = Vec::new();
828    for entry_result in entries {
829        let entry = match entry_result {
830            Ok(e) => e,
831            Err(e) => {
832                tracing::warn!(
833                    target: "grex::sync::migrate",
834                    "skipping unreadable entry under `{}`: {e}",
835                    legacy_root.display(),
836                );
837                continue;
838            }
839        };
840        let Ok(ft) = entry.file_type() else { continue };
841        // file_type avoids symlink-following; legitimate v1.0.x children
842        // were always real directories, so anything else is skipped.
843        if ft.is_symlink() || !ft.is_dir() {
844            continue;
845        }
846        let name_os = entry.file_name();
847        let Some(name) = name_os.to_str() else { continue };
848        // Only act on entries that look like real cloned children (have
849        // a `.git`). The legacy workspace lock file (`.grex.sync.lock`)
850        // is not a directory and is filtered out by the dir check above;
851        // we clean it up explicitly after the migration loop completes.
852        let from_abs = entry.path();
853        if !from_abs.join(".git").exists() {
854            continue;
855        }
856        let to_abs = root.join(name);
857        let from_rel = PathBuf::from(LEGACY_WORKSPACE_DIR).join(name);
858        let to_rel = PathBuf::from(name);
859        let outcome = decide_and_migrate(&from_abs, &to_abs);
860        log_migration(&from_rel, &to_rel, &outcome);
861        migrations.push(WorkspaceMigration { from: from_rel, to: to_rel, outcome });
862    }
863    cleanup_legacy_workspace_root(&legacy_root);
864    migrations
865}
866
867/// Decide what to do with one legacy child + perform the move when
868/// safe. Returns the outcome to record on the [`WorkspaceMigration`].
869fn decide_and_migrate(from: &Path, to: &Path) -> MigrationOutcome {
870    let dest_exists = to.exists();
871    let dest_is_grex_repo = dest_exists && to.join(".git").exists();
872    if dest_is_grex_repo {
873        // Both legacy and flat-sibling are git repos. Refuse to choose
874        // between them; let the user resolve.
875        return MigrationOutcome::SkippedBothExist;
876    }
877    if dest_exists {
878        // Some other entry occupies the flat-sibling slot — a stray
879        // file, an empty dir, an unrelated dir. Treat as user data and
880        // leave both in place.
881        return MigrationOutcome::SkippedDestOccupied;
882    }
883    match fs::rename(from, to) {
884        Ok(()) => MigrationOutcome::Migrated,
885        Err(e) => MigrationOutcome::Failed { error: e.to_string() },
886    }
887}
888
889/// Emit one structured log line per migration so users see exactly what
890/// happened during the upgrade. Severity matches outcome: success is
891/// `info`, skips and failures are `warn` so they surface in the default
892/// log level without forcing operators to crank verbosity.
893fn log_migration(from: &Path, to: &Path, outcome: &MigrationOutcome) {
894    let from_disp = from.display();
895    let to_disp = to.display();
896    match outcome {
897        MigrationOutcome::Migrated => {
898            tracing::info!(
899                target: "grex::sync::migrate",
900                "migrated: legacy={from_disp} -> new={to_disp}",
901            );
902        }
903        MigrationOutcome::SkippedBothExist => {
904            tracing::warn!(
905                target: "grex::sync::migrate",
906                "skipped: both legacy={from_disp} and new={to_disp} exist; resolve manually",
907            );
908        }
909        MigrationOutcome::SkippedDestOccupied => {
910            tracing::warn!(
911                target: "grex::sync::migrate",
912                "skipped: destination={to_disp} occupied; leaving legacy={from_disp} in place",
913            );
914        }
915        MigrationOutcome::Failed { error } => {
916            tracing::warn!(
917                target: "grex::sync::migrate",
918                "failed: legacy={from_disp} -> new={to_disp}: {error}",
919            );
920        }
921    }
922}
923
924/// Best-effort cleanup of the legacy workspace root after migration:
925/// remove the orphan `.grex.sync.lock` (always safe — the v1.1.0
926/// workspace lock lives at `<pack_root>/.grex.sync.lock`) and try to
927/// rmdir the now-empty `.grex/workspace/` directory. Errors are logged
928/// at trace level only — both leftovers are harmless.
929fn cleanup_legacy_workspace_root(legacy_root: &Path) {
930    let orphan_lock = legacy_root.join(".grex.sync.lock");
931    if orphan_lock.exists() {
932        if let Err(e) = fs::remove_file(&orphan_lock) {
933            tracing::warn!(
934                target: "grex::sync::migrate",
935                "could not remove orphan lock `{}`: {e}",
936                orphan_lock.display(),
937            );
938        } else {
939            tracing::info!(
940                target: "grex::sync::migrate",
941                "removed orphan lock `{}`",
942                orphan_lock.display(),
943            );
944        }
945    }
946    // `remove_dir` only succeeds when the directory is empty — exactly
947    // what we want; if any unmigrated child remains, the legacy root
948    // stays put for the operator to inspect.
949    let _ = fs::remove_dir(legacy_root);
950}
951
952/// Compute the default workspace path when `override_` is absent.
953///
954/// The default is the pack root directory itself, so child packs
955/// resolve as flat siblings of the parent pack root. The rationale —
956/// alignment with the long-standing pack-spec rule that
957/// `children[].path` is a bare name — lives in the pack-spec
958/// "Validation rules" section (`man/concepts/pack-spec.md` /
959/// `grex-doc/src/concepts/pack-spec.md`).
960fn resolve_workspace(pack_root: &Path, override_: Option<&Path>) -> PathBuf {
961    if let Some(p) = override_ {
962        return p.to_path_buf();
963    }
964    pack_root_dir(pack_root)
965}
966
967/// Resolve the workspace, ensure the directory exists, and run the v1→v2
968/// event-log migration. Extracted so [`run`] and [`teardown`] stay under
969/// the workspace's 50-LOC per-function lint threshold.
970fn prepare_workspace(pack_root: &Path, opts: &SyncOptions) -> Result<PathBuf, SyncError> {
971    let workspace = resolve_workspace(pack_root, opts.workspace.as_deref());
972    ensure_workspace_dir(&workspace)?;
973    crate::manifest::ensure_event_log_migrated(&workspace).map_err(SyncError::EventLogMigration)?;
974    Ok(workspace)
975}
976
977/// If `pack_root` points at a yaml file, use its parent; otherwise use it.
978fn pack_root_dir(pack_root: &Path) -> PathBuf {
979    let is_yaml = matches!(pack_root.extension().and_then(|e| e.to_str()), Some("yaml" | "yml"));
980    if is_yaml {
981        pack_root
982            .parent()
983            .and_then(Path::parent)
984            .map_or_else(|| PathBuf::from("."), Path::to_path_buf)
985    } else {
986        pack_root.to_path_buf()
987    }
988}
989
990/// Compute the `.grex/events.jsonl` path next to the pack root.
991///
992/// Delegates to [`crate::manifest::event_log_path`] (single source of
993/// truth for the canonical event-log location).
994fn event_log_path(pack_root: &Path) -> PathBuf {
995    crate::manifest::event_log_path(&pack_root_dir(pack_root))
996}
997
998/// Compute the sidecar lock path next to the event log. One canonical slot
999/// per pack root — cooperating grex procs serialize through this file.
1000fn event_lock_path(event_log: &Path) -> PathBuf {
1001    event_log.parent().map_or_else(|| PathBuf::from(".grex.lock"), |p| p.join(".grex.lock"))
1002}
1003
1004/// Compute the sidecar lock path for the workspace itself. Lives at
1005/// `<workspace>/.grex.sync.lock` — the workspace dir is already created by
1006/// the `run()` prologue, so the lock sidecar lands beside the child clones.
1007fn workspace_lock_path(workspace: &Path) -> PathBuf {
1008    workspace.join(".grex.sync.lock")
1009}
1010
1011/// Aggregate manifest-level + graph-level validators and return their output.
1012fn validate_graph(graph: &PackGraph) -> Result<(), SyncError> {
1013    let mut errors: Vec<PackValidationError> = Vec::new();
1014    for node in graph.nodes() {
1015        if let Err(mut e) = node.manifest.validate_plan() {
1016            errors.append(&mut e);
1017        }
1018    }
1019    if let Err(mut e) = graph.validate() {
1020        errors.append(&mut e);
1021    }
1022    if errors.is_empty() {
1023        Ok(())
1024    } else {
1025        Err(SyncError::Validation { errors })
1026    }
1027}
1028
1029/// Depth-first post-order traversal of the graph starting from root.
1030///
1031/// Children fully precede their parent in the returned vector so downstream
1032/// executors install leaves first and the root last.
1033fn post_order(graph: &PackGraph) -> Vec<usize> {
1034    let mut out = Vec::with_capacity(graph.nodes().len());
1035    visit_post(graph, 0, &mut out);
1036    out
1037}
1038
1039fn visit_post(graph: &PackGraph, id: usize, out: &mut Vec<usize>) {
1040    // Collect child ids first to avoid borrow conflicts with graph iteration.
1041    let kids: Vec<usize> = graph.children_of(id).map(|n| n.id).collect();
1042    for k in kids {
1043        visit_post(graph, k, out);
1044    }
1045    out.push(id);
1046}
1047
1048/// Drive every action for every node; abort on the first [`ExecError`].
1049///
1050/// Each action is bracketed by three manifest events:
1051/// 1. [`Event::ActionStarted`] — appended **before** `execute` returns.
1052/// 2. [`Event::ActionCompleted`] — appended on `Ok(step)`.
1053/// 3. [`Event::ActionHalted`] — appended on `Err(e)` before returning.
1054///
1055/// All three writes go through the same [`ManifestLock`]-wrapped path
1056/// ([`append_manifest_event`]) and failures are recorded as non-fatal
1057/// warnings so the executor's outcome always dominates. The third append
1058/// (`ActionHalted`) lets a future `grex doctor` correlate crash recovery
1059/// with the exact action that halted.
1060// feat-m6 B1 wiring added `parallel` + `scheduler` args; the signature
1061// now pushes past the 50-LOC per-function lint by one line. Silence
1062// that one — the body itself is unchanged in scope.
1063#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1064fn run_actions(
1065    report: &mut SyncReport,
1066    order: &[usize],
1067    vars: &VarEnv,
1068    workspace: &Path,
1069    event_log: &Path,
1070    lock_path: &Path,
1071    dry_run: bool,
1072    prior_lock: &std::collections::HashMap<String, LockEntry>,
1073    next_lock: &mut std::collections::HashMap<String, LockEntry>,
1074    registry: &Arc<Registry>,
1075    pack_type_registry: &Arc<PackTypeRegistry>,
1076    only: Option<&GlobSet>,
1077    force: bool,
1078    parallel: usize,
1079    scheduler: &Arc<Scheduler>,
1080) {
1081    let plan = PlanExecutor::with_registry(registry.clone());
1082    let fs = FsExecutor::with_registry(registry.clone());
1083    let rt = build_pack_type_runtime(parallel);
1084    let visited_meta = new_visited_meta();
1085    for &id in order {
1086        let Some(node) = report.graph.node(id) else { continue };
1087        let pack_name = node.name.clone();
1088        let pack_path = node.path.clone();
1089        let actions = node.manifest.actions.clone();
1090        let manifest = node.manifest.clone();
1091        let commit_sha = node.commit_sha.clone().unwrap_or_default();
1092        let synthetic = node.synthetic;
1093        // `--only` filter + skip-on-hash short-circuits colocated in
1094        // `try_skip_or_filter` so this outer loop stays within the
1095        // 50-LOC per-function budget.
1096        if try_skip_or_filter(
1097            report,
1098            only,
1099            &pack_name,
1100            &pack_path,
1101            &actions,
1102            &commit_sha,
1103            synthetic,
1104            workspace,
1105            prior_lock,
1106            next_lock,
1107            dry_run,
1108            force,
1109        ) {
1110            continue;
1111        }
1112        let pack_halted = run_pack_lifecycle(
1113            report,
1114            vars,
1115            workspace,
1116            event_log,
1117            lock_path,
1118            dry_run,
1119            &plan,
1120            &fs,
1121            registry,
1122            pack_type_registry,
1123            &rt,
1124            &pack_name,
1125            &pack_path,
1126            &manifest,
1127            &visited_meta,
1128            scheduler,
1129        );
1130        if pack_halted {
1131            // Route (b) halt-state gating: drop any prior entry for the
1132            // halted pack so the next sync sees no prior hash and
1133            // re-executes from scratch. Successful packs in this same
1134            // run keep their freshly-upserted entries, and packs we did
1135            // not reach keep their prior entries untouched.
1136            next_lock.remove(&pack_name);
1137            return;
1138        }
1139        // Successful pack — record a fresh lockfile entry so the next
1140        // run's skip-on-hash test can succeed. Commit SHA is now plumbed
1141        // from the walker (M4-D): `PackNode::commit_sha` carries the
1142        // resolved HEAD SHA when the pack's working tree is a git
1143        // repository, otherwise an empty string keeps the hash stable.
1144        let actions_hash = compute_actions_hash(&actions, &commit_sha);
1145        upsert_lock_entry(prior_lock, next_lock, &pack_name, &commit_sha, &actions_hash, synthetic);
1146    }
1147}
1148
1149/// Build the multi-thread tokio runtime used to drive async pack-type
1150/// plugin dispatch. Pack-type plugins expose `async fn` methods via
1151/// `async_trait`, but the sync driver is synchronous end-to-end — we
1152/// block on each plugin future inside the outer action loop. Extracted
1153/// into a standalone helper so the runtime construction does not
1154/// inflate `run_actions` beyond the 50-LOC per-function budget.
1155///
1156/// # Multi-thread rationale (M5-2c)
1157///
1158/// M5-2c enabled real [`crate::plugin::pack_type::MetaPlugin`] recursion
1159/// through [`crate::execute::ExecCtx::pack_type_registry`]. The recursion
1160/// itself is purely `async` / `.await` (no nested `block_on`), but future
1161/// plugin authors may reasonably compose `block_on` calls inside
1162/// lifecycle hooks — and external callers that drive `MetaPlugin` via
1163/// `rt.block_on(...)` within their own runtime would deadlock on a
1164/// current-thread runtime the moment a hook re-enters. A multi-thread
1165/// runtime with a small worker pool lets those re-entries resolve on a
1166/// sibling worker instead of blocking the dispatcher thread.
1167///
1168/// # Worker-thread sizing (feat-m6 H6)
1169///
1170/// The worker pool is sized from the resolved `--parallel` knob so the
1171/// runtime always has enough workers to service every in-flight pack op
1172/// plus at least one sibling for nested `block_on`. Clamped to
1173/// `[2, num_cpus::get()]`: `2` preserves the pre-M6 floor (one driver +
1174/// one sibling so re-entrant hooks never deadlock), and the upper bound
1175/// caps the pool at the host's CPU count so `--parallel 0`
1176/// (unbounded-semantics) does not explode the worker count.
1177fn build_pack_type_runtime(parallel: usize) -> tokio::runtime::Runtime {
1178    let workers = parallel.clamp(2, num_cpus::get().max(2));
1179    tokio::runtime::Builder::new_multi_thread()
1180        .worker_threads(workers)
1181        .enable_all()
1182        .build()
1183        .expect("tokio runtime for pack-type dispatch")
1184}
1185
1186/// Construct a fresh [`MetaVisitedSet`] for one sync run. Walker-driven
1187/// dispatch does not attach it (see `dispatch_pack_type_plugin`), but
1188/// the argument is threaded through so future explicit-install /
1189/// teardown verbs can share the same set shape.
1190fn new_visited_meta() -> MetaVisitedSet {
1191    std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()))
1192}
1193
1194/// Combined short-circuit helper: `--only` filter + skip-on-hash. Returns
1195/// `true` when the outer loop should `continue` for this pack.
1196///
1197/// Extracted from `run_actions` so that function stays under the
1198/// workspace's 50-LOC per-function lint. Semantics are unchanged; this
1199/// is a pure structural refactor.
1200#[allow(clippy::too_many_arguments)]
1201fn try_skip_or_filter(
1202    report: &mut SyncReport,
1203    only: Option<&GlobSet>,
1204    pack_name: &str,
1205    pack_path: &Path,
1206    actions: &[Action],
1207    commit_sha: &str,
1208    current_synthetic: bool,
1209    workspace: &Path,
1210    prior_lock: &std::collections::HashMap<String, LockEntry>,
1211    next_lock: &mut std::collections::HashMap<String, LockEntry>,
1212    dry_run: bool,
1213    force: bool,
1214) -> bool {
1215    if skip_for_only_filter(only, pack_name, pack_path, workspace) {
1216        if let Some(prev) = prior_lock.get(pack_name) {
1217            next_lock.insert(pack_name.to_string(), prev.clone());
1218        }
1219        return true;
1220    }
1221    try_skip_pack(
1222        report,
1223        pack_name,
1224        pack_path,
1225        actions,
1226        commit_sha,
1227        current_synthetic,
1228        prior_lock,
1229        next_lock,
1230        dry_run,
1231        force,
1232    )
1233}
1234
1235/// Return `true` when `--only` is active and the pack's
1236/// **workspace-relative path** (normalized to forward-slash form) does
1237/// not match any of the registered globs. Name-fallback matching was
1238/// dropped in the M4-D post-review fix bundle: spec §M4 req 6 says
1239/// "pack paths" and cross-platform consistency requires a single
1240/// normalized representation rather than `display()`-formatted strings
1241/// (which use `\\` on Windows and `/` on POSIX — globset treats `\\`
1242/// as a glob-escape, not a path separator). For the root pack whose
1243/// `pack_path` is not under `workspace`, the fallback is to match
1244/// against the absolute path's forward-slash form.
1245fn skip_for_only_filter(
1246    only: Option<&GlobSet>,
1247    pack_name: &str,
1248    pack_path: &Path,
1249    workspace: &Path,
1250) -> bool {
1251    let Some(set) = only else { return false };
1252    let rel = pack_path.strip_prefix(workspace).unwrap_or(pack_path);
1253    let rel_str = rel.to_string_lossy().replace('\\', "/");
1254    let matches = set.is_match(&rel_str);
1255    if !matches {
1256        tracing::info!(
1257            target: "grex::sync",
1258            "skipping pack `{pack_name}` (rel path `{rel_str}`): does not match --only filter"
1259        );
1260    }
1261    !matches
1262}
1263
1264/// Per-pack lifecycle dispatch. Returns `true` when the sync must halt.
1265///
1266/// M5-1 Stage C replaces the blind `for action in manifest.actions` loop
1267/// with a pack-type-aware dispatch:
1268///
1269/// * [`PackType::Declarative`] retains the per-action execution shape that
1270///   M4 shipped — each action lands its own `ActionStarted` /
1271///   `ActionCompleted` / `ActionHalted` event bracket. The registry is
1272///   still consulted via [`PackTypeRegistry::get`] as a name-oracle so
1273///   mistyped packs fail closed.
1274/// * [`PackType::Meta`] / [`PackType::Scripted`] dispatch once through the
1275///   pack-type plugin's `sync` method (the sync CLI verb is the only
1276///   caller in M5-1; `install` / `update` / `teardown` verbs wire in
1277///   M5-2), returning a single aggregate [`ExecStep`]. A single event
1278///   bracket frames the async call.
1279///
1280/// Declarative is kept on the legacy per-action path because its event log
1281/// semantics (one event per action, per-step rollback context) are exactly
1282/// what plugin authors expect to observe. Unifying declarative under the
1283/// plugin dispatch is M5-2 scope — it requires reshaping the trait surface
1284/// to emit a step stream rather than a single aggregate.
1285#[allow(clippy::too_many_arguments)]
1286fn run_pack_lifecycle(
1287    report: &mut SyncReport,
1288    vars: &VarEnv,
1289    workspace: &Path,
1290    event_log: &Path,
1291    lock_path: &Path,
1292    dry_run: bool,
1293    plan: &PlanExecutor,
1294    fs: &FsExecutor,
1295    registry: &Arc<Registry>,
1296    pack_type_registry: &Arc<PackTypeRegistry>,
1297    rt: &tokio::runtime::Runtime,
1298    pack_name: &str,
1299    pack_path: &Path,
1300    manifest: &crate::pack::PackManifest,
1301    visited_meta: &MetaVisitedSet,
1302    scheduler: &Arc<Scheduler>,
1303) -> bool {
1304    let type_tag = manifest.r#type.as_str();
1305    // Name-oracle check: every pack type must be registered. Unknown
1306    // pack types halt the pack the same way M4 halted unknown actions.
1307    if pack_type_registry.get(type_tag).is_none() {
1308        let err = ExecError::UnknownAction(format!("pack type `{type_tag}`"));
1309        record_action_err(report, event_log, lock_path, pack_name, 0, "pack-type", err);
1310        return true;
1311    }
1312    match manifest.r#type {
1313        crate::pack::PackType::Declarative => run_declarative_actions(
1314            report,
1315            vars,
1316            workspace,
1317            event_log,
1318            lock_path,
1319            dry_run,
1320            plan,
1321            fs,
1322            pack_name,
1323            pack_path,
1324            manifest,
1325            &manifest.actions,
1326            scheduler,
1327        ),
1328        crate::pack::PackType::Meta | crate::pack::PackType::Scripted => dispatch_pack_type_plugin(
1329            report,
1330            vars,
1331            workspace,
1332            event_log,
1333            lock_path,
1334            registry,
1335            pack_type_registry,
1336            rt,
1337            pack_name,
1338            pack_path,
1339            manifest,
1340            type_tag,
1341            visited_meta,
1342            scheduler,
1343        ),
1344    }
1345}
1346
1347/// Run a declarative pack's actions sequentially. Preserves the M4
1348/// per-action event-log bracket (`ActionStarted` → `ActionCompleted` |
1349/// `ActionHalted`). Returns `true` when the sync must halt.
1350#[allow(clippy::too_many_arguments)]
1351fn run_declarative_actions(
1352    report: &mut SyncReport,
1353    vars: &VarEnv,
1354    workspace: &Path,
1355    event_log: &Path,
1356    lock_path: &Path,
1357    dry_run: bool,
1358    plan: &PlanExecutor,
1359    fs: &FsExecutor,
1360    pack_name: &str,
1361    pack_path: &Path,
1362    manifest: &crate::pack::PackManifest,
1363    actions: &[Action],
1364    scheduler: &Arc<Scheduler>,
1365) -> bool {
1366    // `apply_gitignore` is called per-lifecycle by each PackTypePlugin
1367    // for meta/scripted, and here for declarative (which bypasses the
1368    // plugin in `sync::run`'s per-action driver). Keeping plugins as
1369    // the single apply site everywhere else means the declarative
1370    // per-action path is the only code outside the PackTypePlugin
1371    // surface that needs a direct apply call.
1372    if !dry_run {
1373        let ctx = ExecCtx::new(vars, pack_path, workspace)
1374            .with_platform(Platform::current())
1375            .with_scheduler(scheduler);
1376        if let Err(e) = crate::plugin::pack_type::apply_gitignore(&ctx, manifest) {
1377            record_action_err(report, event_log, lock_path, pack_name, 0, "gitignore", e);
1378            return true;
1379        }
1380    }
1381    for (idx, action) in actions.iter().enumerate() {
1382        let ctx = ExecCtx::new(vars, pack_path, workspace)
1383            .with_platform(Platform::current())
1384            .with_scheduler(scheduler);
1385        let action_tag = action_kind_tag(action);
1386        append_manifest_event(
1387            event_log,
1388            lock_path,
1389            &Event::ActionStarted {
1390                ts: Utc::now(),
1391                pack: pack_name.to_string(),
1392                action_idx: idx,
1393                action_name: action_tag.to_string(),
1394            },
1395            &mut report.event_log_warnings,
1396        );
1397        let step_result =
1398            if dry_run { plan.execute(action, &ctx) } else { fs.execute(action, &ctx) };
1399        if !record_action_outcome(
1400            report,
1401            event_log,
1402            lock_path,
1403            pack_name,
1404            idx,
1405            action_tag,
1406            step_result,
1407        ) {
1408            return true;
1409        }
1410    }
1411    false
1412}
1413
1414/// Dispatch a pack-type plugin (meta / scripted) through the async
1415/// registry. Brackets the call with a single `ActionStarted` /
1416/// `ActionCompleted` / `ActionHalted` trio at index 0. Returns `true`
1417/// when the sync must halt.
1418#[allow(clippy::too_many_arguments)]
1419fn dispatch_pack_type_plugin(
1420    report: &mut SyncReport,
1421    vars: &VarEnv,
1422    workspace: &Path,
1423    event_log: &Path,
1424    lock_path: &Path,
1425    registry: &Arc<Registry>,
1426    pack_type_registry: &Arc<PackTypeRegistry>,
1427    rt: &tokio::runtime::Runtime,
1428    pack_name: &str,
1429    pack_path: &Path,
1430    manifest: &crate::pack::PackManifest,
1431    type_tag: &'static str,
1432    visited_meta: &MetaVisitedSet,
1433    scheduler: &Arc<Scheduler>,
1434) -> bool {
1435    // NB: `visited_meta` is intentionally NOT attached to the ctx here.
1436    // The sync driver already walks children in post-order via the tree
1437    // walker; attaching the visited set would trigger MetaPlugin's
1438    // real-recursion branch and cause double dispatch (walker runs child
1439    // packs as their own graph nodes, then MetaPlugin would recurse into
1440    // them again). The `visited_meta` parameter is kept on the argument
1441    // list so future explicit-install / teardown verbs that invoke
1442    // MetaPlugin directly can share the same set shape.
1443    let _ = visited_meta;
1444    let ctx = ExecCtx::new(vars, pack_path, workspace)
1445        .with_platform(Platform::current())
1446        .with_registry(registry)
1447        .with_pack_type_registry(pack_type_registry)
1448        .with_scheduler(scheduler);
1449    append_manifest_event(
1450        event_log,
1451        lock_path,
1452        &Event::ActionStarted {
1453            ts: Utc::now(),
1454            pack: pack_name.to_string(),
1455            action_idx: 0,
1456            action_name: type_tag.to_string(),
1457        },
1458        &mut report.event_log_warnings,
1459    );
1460    // SAFETY: `get` just confirmed the plugin is registered for
1461    // `type_tag`, so this unwrap cannot panic under the matched arm.
1462    let plugin = pack_type_registry
1463        .get(type_tag)
1464        .expect("pack-type plugin must be registered (guarded above)");
1465    // feat-m6 CI fix — establish a task-local tier stack frame for every
1466    // async dispatch. Without this, `TierGuard::push` (which runs inside
1467    // the plugin lifecycle and may span `.await` / thread hops under the
1468    // multi-thread runtime) has no enforcement frame to push into.
1469    let step_result = rt.block_on(crate::pack_lock::with_tier_scope(plugin.sync(&ctx, manifest)));
1470    !record_action_outcome(report, event_log, lock_path, pack_name, 0, type_tag, step_result)
1471}
1472
1473/// Pure skip-eligibility decision. Returns `Some(hash)` when the pack
1474/// is eligible for the hash-skip short-circuit, `None` otherwise.
1475///
1476/// Splitting the decision out of [`try_skip_pack`] keeps the
1477/// side-effecting transcript bookkeeping testable in isolation: the
1478/// v1.1.1 synthetic-flag-flip regression exercises this helper without
1479/// having to stand up a `SyncReport` / `PackGraph`.
1480fn skip_eligibility(
1481    actions: &[Action],
1482    commit_sha: &str,
1483    current_synthetic: bool,
1484    prior: &LockEntry,
1485    dry_run: bool,
1486    force: bool,
1487) -> Option<String> {
1488    if dry_run || force {
1489        // Dry runs must always produce the planned-step transcript so
1490        // authors can see what `sync` *would* do. `--force` is the
1491        // operator's explicit opt-out from the hash short-circuit.
1492        return None;
1493    }
1494    let hash = compute_actions_hash(actions, commit_sha);
1495    if prior.actions_hash != hash {
1496        return None;
1497    }
1498    if prior.synthetic != current_synthetic {
1499        // Pack-shape flipped between runs (real ↔ synthetic). Even
1500        // when the actions hash matches by coincidence (e.g. a
1501        // declarative pack with empty `actions[]` whose pack.yaml was
1502        // deleted, falling through to a synthetic leaf with the same
1503        // empty actions list and stable commit SHA), we must NOT
1504        // carry the stale `synthetic` flag forward. Forcing the
1505        // upsert path re-emits the entry with the current flag.
1506        return None;
1507    }
1508    Some(hash)
1509}
1510
1511/// Decide whether `pack_name` can be short-circuited via a lockfile
1512/// hash match. When the prior hash matches the freshly-computed hash,
1513/// emit a single [`ExecResult::Skipped`] step and carry the prior
1514/// lockfile entry forward unchanged. Returns `true` when the pack was
1515/// skipped.
1516///
1517/// `current_synthetic` is the walker-derived synthetic flag for this
1518/// pack on the current run. The skip eligibility check requires it to
1519/// match `prior.synthetic` so a pack-shape transition (e.g. user
1520/// deletes `pack.yaml` so a previously-real pack now walks as
1521/// synthetic) invalidates the skip and forces the lockfile entry to
1522/// be re-emitted with the fresh `synthetic` value.
1523#[allow(clippy::too_many_arguments)]
1524fn try_skip_pack(
1525    report: &mut SyncReport,
1526    pack_name: &str,
1527    pack_path: &Path,
1528    actions: &[Action],
1529    commit_sha: &str,
1530    current_synthetic: bool,
1531    prior_lock: &std::collections::HashMap<String, LockEntry>,
1532    next_lock: &mut std::collections::HashMap<String, LockEntry>,
1533    dry_run: bool,
1534    force: bool,
1535) -> bool {
1536    let Some(prior) = prior_lock.get(pack_name) else {
1537        return false;
1538    };
1539    let Some(hash) =
1540        skip_eligibility(actions, commit_sha, current_synthetic, prior, dry_run, force)
1541    else {
1542        return false;
1543    };
1544    let skipped_step = ExecStep {
1545        action_name: Cow::Borrowed("pack"),
1546        result: ExecResult::Skipped {
1547            pack_path: pack_path.to_path_buf(),
1548            actions_hash: hash.clone(),
1549        },
1550        // W4 landed `StepKind::PackSkipped` as the dedicated pack-level
1551        // short-circuit detail; we use it here instead of the prior
1552        // `Require { Satisfied, Skip }` proxy so renderers and consumers
1553        // can match on a single, purpose-built variant.
1554        details: StepKind::PackSkipped { actions_hash: hash },
1555    };
1556    report.steps.push(SyncStep {
1557        pack: pack_name.to_string(),
1558        action_idx: 0,
1559        exec_step: skipped_step,
1560    });
1561    // Carry the prior entry forward so the next-lock snapshot stays
1562    // consistent with what's on disk.
1563    next_lock.insert(pack_name.to_string(), prior.clone());
1564    true
1565}
1566
1567/// Insert or update a lockfile entry for `pack_name` with `actions_hash`.
1568///
1569/// Stores `commit_sha` verbatim — including the empty string when the
1570/// pack is not a git working tree or the HEAD probe failed.
1571/// `actions_hash` is computed over the same `commit_sha`, so the two
1572/// fields stay internally consistent: if probing starts returning a
1573/// non-empty SHA on the next run, the hash differs and the skip is
1574/// correctly invalidated. The prior-preserve carve-out that was
1575/// introduced in M4-D was unsound (hash-vs-sha drift) and is removed
1576/// by the M4-D post-review fix bundle; see spec §M4 req 4a.
1577///
1578/// `prior_lock` is consulted purely for observability: when a
1579/// previously-real pack flips to synthetic between runs (user deleted
1580/// the pack's `pack.yaml` so the walker fell back to v1.1.1
1581/// plain-git-child synthesis), a `tracing::warn!` records the
1582/// downgrade so the operator notices their declarative actions have
1583/// stopped running.
1584fn upsert_lock_entry(
1585    prior_lock: &std::collections::HashMap<String, LockEntry>,
1586    next_lock: &mut std::collections::HashMap<String, LockEntry>,
1587    pack_name: &str,
1588    commit_sha: &str,
1589    actions_hash: &str,
1590    synthetic: bool,
1591) {
1592    if synthetic {
1593        if let Some(prior) = prior_lock.get(pack_name) {
1594            if !prior.synthetic {
1595                tracing::warn!(
1596                    target: "grex::sync",
1597                    pack = pack_name,
1598                    "pack `{pack_name}` downgraded from real to synthetic — \
1599                     pack.yaml missing on disk; only `git pull` will run going forward",
1600                );
1601            }
1602        }
1603    }
1604    let installed_at = Utc::now();
1605    let entry = next_lock.get(pack_name).map_or_else(
1606        || LockEntry {
1607            id: pack_name.to_string(),
1608            // v1.1.1 convention: path == id (1:1 id↔folder). Stage 1.e
1609            // (walker rewrite) will replace this with the parent-relative
1610            // manifest path captured during the walk.
1611            path: pack_name.to_string(),
1612            sha: commit_sha.to_string(),
1613            branch: String::new(),
1614            installed_at,
1615            actions_hash: actions_hash.to_string(),
1616            schema_version: "1".to_string(),
1617            synthetic,
1618        },
1619        |prev| LockEntry {
1620            installed_at,
1621            actions_hash: actions_hash.to_string(),
1622            sha: commit_sha.to_string(),
1623            synthetic,
1624            ..prev.clone()
1625        },
1626    );
1627    next_lock.insert(pack_name.to_string(), entry);
1628}
1629
1630/// Record one action outcome into `report` + event log. Returns `false`
1631/// when the run must halt (on error); `true` otherwise.
1632fn record_action_outcome(
1633    report: &mut SyncReport,
1634    event_log: &Path,
1635    lock_path: &Path,
1636    pack_name: &str,
1637    idx: usize,
1638    action_tag: &'static str,
1639    step_result: Result<ExecStep, ExecError>,
1640) -> bool {
1641    match step_result {
1642        Ok(step) => {
1643            record_action_ok(report, event_log, lock_path, pack_name, idx, step);
1644            true
1645        }
1646        Err(e) => {
1647            record_action_err(report, event_log, lock_path, pack_name, idx, action_tag, e);
1648            false
1649        }
1650    }
1651}
1652
1653/// Success-path bookkeeping: emit legacy `Sync` summary + `ActionCompleted`
1654/// audit event, then push the step onto the report.
1655fn record_action_ok(
1656    report: &mut SyncReport,
1657    event_log: &Path,
1658    lock_path: &Path,
1659    pack_name: &str,
1660    idx: usize,
1661    step: ExecStep,
1662) {
1663    append_step_event(event_log, lock_path, pack_name, &step, &mut report.event_log_warnings);
1664    append_manifest_event(
1665        event_log,
1666        lock_path,
1667        &Event::ActionCompleted {
1668            ts: Utc::now(),
1669            pack: pack_name.to_string(),
1670            action_idx: idx,
1671            result_summary: format!("{:?}", step.result),
1672        },
1673        &mut report.event_log_warnings,
1674    );
1675    report.steps.push(SyncStep { pack: pack_name.to_string(), action_idx: idx, exec_step: step });
1676}
1677
1678/// Halt-path bookkeeping: emit `ActionHalted` audit event, then stash the
1679/// rich `HaltedContext` into `report.halted`.
1680fn record_action_err(
1681    report: &mut SyncReport,
1682    event_log: &Path,
1683    lock_path: &Path,
1684    pack_name: &str,
1685    idx: usize,
1686    action_tag: &'static str,
1687    e: ExecError,
1688) {
1689    let error_summary = truncate_error_summary(&e);
1690    append_manifest_event(
1691        event_log,
1692        lock_path,
1693        &Event::ActionHalted {
1694            ts: Utc::now(),
1695            pack: pack_name.to_string(),
1696            action_idx: idx,
1697            action_name: action_tag.to_string(),
1698            error_summary,
1699        },
1700        &mut report.event_log_warnings,
1701    );
1702    let recovery_hint = recovery_hint_for(&e);
1703    report.halted = Some(SyncError::Halted(Box::new(HaltedContext {
1704        pack: pack_name.to_string(),
1705        action_idx: idx,
1706        action_name: action_tag.to_string(),
1707        error: e,
1708        recovery_hint,
1709    })));
1710}
1711
1712/// Short stable kind-tag for an [`crate::pack::Action`]. Mirrors the
1713/// `ACTION_*` constants used by [`crate::execute::step`] so the audit log
1714/// stays uniform.
1715fn action_kind_tag(action: &crate::pack::Action) -> &'static str {
1716    use crate::pack::Action;
1717    match action {
1718        Action::Symlink(_) => "symlink",
1719        Action::Unlink(_) => "unlink",
1720        Action::Env(_) => "env",
1721        Action::Mkdir(_) => "mkdir",
1722        Action::Rmdir(_) => "rmdir",
1723        Action::Require(_) => "require",
1724        Action::When(_) => "when",
1725        Action::Exec(_) => "exec",
1726    }
1727}
1728
1729/// Produce a bounded human summary of an [`ExecError`] for
1730/// [`Event::ActionHalted::error_summary`]. Keeps the written JSONL line
1731/// from pathological blowup when captured stderr is large.
1732fn truncate_error_summary(err: &ExecError) -> String {
1733    let mut s = err.to_string();
1734    if s.len() > ACTION_ERROR_SUMMARY_MAX {
1735        s.truncate(ACTION_ERROR_SUMMARY_MAX);
1736        s.push_str("…[truncated]");
1737    }
1738    s
1739}
1740
1741/// Best-effort recovery hint for common [`ExecError`] shapes. Returns
1742/// `None` when no generic advice applies; the error's own `Display`
1743/// output is already shown by the `Halted` variant's format string.
1744fn recovery_hint_for(err: &ExecError) -> Option<String> {
1745    match err {
1746        ExecError::SymlinkDestOccupied { .. } => Some(
1747            "set `backup: true` on the symlink action, or remove the conflicting entry by hand"
1748                .into(),
1749        ),
1750        ExecError::SymlinkPrivilegeDenied { .. } => {
1751            Some("enable Windows Developer Mode or re-run grex as administrator".into())
1752        }
1753        ExecError::SymlinkCreateAfterBackupFailed { backup, .. } => {
1754            Some(format!("backup left at `{}`; restore manually then re-run", backup.display()))
1755        }
1756        ExecError::RmdirNotEmpty { .. } => {
1757            Some("set `force: true` on the rmdir action to recurse".into())
1758        }
1759        ExecError::EnvPersistenceDenied { .. } => {
1760            Some("re-run elevated (Machine scope needs admin)".into())
1761        }
1762        _ => None,
1763    }
1764}
1765
1766/// Append one [`Event::Sync`] record summarising an [`ExecStep`].
1767///
1768/// Failures log a warning and are recorded in the report's
1769/// `event_log_warnings`; they do not abort the sync (spec: event-log write
1770/// failures are non-fatal).
1771///
1772/// # Concurrency
1773///
1774/// The append is serialized through a [`ManifestLock`] held across the
1775/// write. The lock is acquired **per action** (not once across the full
1776/// traversal) so cooperating grex processes can observe mid-progress log
1777/// state between actions; fd-lock acquisition is cheap on modern kernels
1778/// and sync runs are dominated by executor side effects, not lock waits.
1779/// This closes the bypass gap surfaced by the M3 concurrency review where
1780/// `append_event` was called without any cross-process serialisation.
1781fn append_step_event(
1782    log: &Path,
1783    lock_path: &Path,
1784    pack: &str,
1785    step: &ExecStep,
1786    warnings: &mut Vec<String>,
1787) {
1788    let summary = format!("{}:{:?}", step.action_name, step.result);
1789    let event = Event::Sync { ts: Utc::now(), id: pack.to_string(), sha: summary };
1790    if let Err(e) = append_event_locked(log, lock_path, &event) {
1791        tracing::warn!(target: "grex::sync", "manifest append failed: {e}");
1792        warnings.push(format!("{}: {e}", log.display()));
1793    }
1794    // Schema version is recorded once at the manifest level by existing
1795    // manifest code; this stub uses the constant to keep a single source of
1796    // truth for forward-compat.
1797    let _ = SCHEMA_VERSION;
1798}
1799
1800/// Append a single [`Event`] under the shared [`ManifestLock`] path.
1801/// Failures are logged and recorded as non-fatal warnings — the spec
1802/// marks event-log write failures as non-aborting so a transient disk
1803/// error must not kill a sync mid-stream.
1804fn append_manifest_event(log: &Path, lock_path: &Path, event: &Event, warnings: &mut Vec<String>) {
1805    if let Err(e) = append_event_locked(log, lock_path, event) {
1806        tracing::warn!(target: "grex::sync", "manifest append failed: {e}");
1807        warnings.push(format!("{}: {e}", log.display()));
1808    }
1809}
1810
1811/// Acquire [`ManifestLock`] and append one event. Parent dir of the log is
1812/// created lazily on first write.
1813fn append_event_locked(log: &Path, lock_path: &Path, event: &Event) -> Result<(), String> {
1814    if let Some(parent) = log.parent() {
1815        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
1816    }
1817    if let Some(parent) = lock_path.parent() {
1818        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
1819    }
1820    let mut lock = ManifestLock::open(log, lock_path).map_err(|e| e.to_string())?;
1821    lock.write(|| append_event(log, event)).map_err(|e| e.to_string())?.map_err(|e| e.to_string())
1822}
1823
1824/// Re-export a cheap helper so CLI renderers can label halted steps by node
1825/// name without reaching into the graph twice.
1826#[must_use]
1827pub fn pack_display_name(node: &PackNode) -> &str {
1828    &node.name
1829}
1830
1831/// Run a full teardown over the pack tree rooted at `pack_root`.
1832///
1833/// Mirrors [`run`] but invokes
1834/// [`crate::plugin::PackTypePlugin::teardown`] on every pack in
1835/// **reverse** post-order so a parent tears down before its children
1836/// (the inverse of install). Children composed later by an author
1837/// consequently teardown earlier, matching the declarative
1838/// auto-reverse contract (R-M5-11).
1839///
1840/// All other concerns are identical to [`run`]: workspace lock, plan-
1841/// phase validators, lockfile update skipped (teardown does not
1842/// write a `actions_hash` forward), and event-log bracketing.
1843/// Teardown does NOT consult the lockfile skip-on-hash shortcut — a
1844/// user explicitly asked to remove the pack, so we always dispatch.
1845///
1846/// # Errors
1847///
1848/// Returns the first error that halts the pipeline — see [`SyncError`].
1849///
1850/// See [`run`] for the `cancel` contract — feat-m7-1 stage 2 threads
1851/// the parameter through teardown for parity; stages 3-4 add the polls.
1852pub fn teardown(
1853    pack_root: &Path,
1854    opts: &SyncOptions,
1855    cancel: &CancellationToken,
1856) -> Result<SyncReport, SyncError> {
1857    let _ = cancel;
1858    let workspace = prepare_workspace(pack_root, opts)?;
1859    let (mut ws_lock, ws_lock_path) = open_workspace_lock(&workspace)?;
1860    let _ws_guard = match ws_lock.try_acquire() {
1861        Ok(Some(g)) => g,
1862        Ok(None) => {
1863            return Err(SyncError::WorkspaceBusy {
1864                workspace: workspace.clone(),
1865                lock_path: ws_lock_path,
1866            });
1867        }
1868        Err(e) => return Err(workspace_lock_err(&ws_lock_path, &e.to_string())),
1869    };
1870
1871    let graph =
1872        walk_and_validate(pack_root, &workspace, opts.validate, opts.ref_override.as_deref())?;
1873    let prep = prepare_run_context(pack_root, &graph, &workspace)?;
1874
1875    let mut report = SyncReport {
1876        graph,
1877        steps: Vec::new(),
1878        halted: None,
1879        event_log_warnings: Vec::new(),
1880        pre_run_recovery: prep.pre_run_recovery,
1881        // teardown does not run the legacy-layout migration — by the time
1882        // a user is tearing down, the layout has already been migrated
1883        // (or was never legacy in the first place). Surfacing an empty
1884        // list keeps the report shape symmetric with `run()`.
1885        workspace_migrations: Vec::new(),
1886    };
1887
1888    // feat-m6 B1: mirror `run()` — resolve `--parallel`, build a
1889    // Scheduler, thread it through every `ExecCtx` the teardown path
1890    // constructs. Teardown is the other user-facing verb that owns a
1891    // runtime, so it gets the same wiring.
1892    let resolved_parallel: usize = opts.parallel.unwrap_or_else(|| num_cpus::get().max(1));
1893    let scheduler = Arc::new(Scheduler::new(resolved_parallel));
1894    run_teardown(
1895        &mut report,
1896        &prep.order,
1897        &prep.vars,
1898        &workspace,
1899        &prep.event_log,
1900        &prep.lock_path,
1901        &prep.registry,
1902        &prep.pack_type_registry,
1903        resolved_parallel,
1904        &scheduler,
1905    );
1906    Ok(report)
1907}
1908
1909/// Dispatch `teardown` for every pack in **reverse** post-order.
1910/// Declarative packs go through [`crate::plugin::PackTypePlugin`]
1911/// rather than the per-action M4 path because the trait's
1912/// auto-reverse / explicit-block logic must compose with the
1913/// registry; going through the per-action path would mean
1914/// re-implementing inverse synthesis in the sync loop.
1915#[allow(clippy::too_many_arguments)]
1916fn run_teardown(
1917    report: &mut SyncReport,
1918    order: &[usize],
1919    vars: &VarEnv,
1920    workspace: &Path,
1921    event_log: &Path,
1922    lock_path: &Path,
1923    registry: &Arc<Registry>,
1924    pack_type_registry: &Arc<PackTypeRegistry>,
1925    parallel: usize,
1926    scheduler: &Arc<Scheduler>,
1927) {
1928    let rt = build_pack_type_runtime(parallel);
1929    // Reverse post-order: root first, then children. Pack-type plugin
1930    // teardown methods reverse their own children/actions, so the
1931    // outer loop only flips the inter-pack order.
1932    for &id in order.iter().rev() {
1933        let Some(node) = report.graph.node(id) else { continue };
1934        let pack_name = node.name.clone();
1935        let pack_path = node.path.clone();
1936        let manifest = node.manifest.clone();
1937        let type_tag = manifest.r#type.as_str();
1938        if pack_type_registry.get(type_tag).is_none() {
1939            let err = ExecError::UnknownAction(format!("pack type `{type_tag}`"));
1940            record_action_err(report, event_log, lock_path, &pack_name, 0, "pack-type", err);
1941            return;
1942        }
1943        let ctx = ExecCtx::new(vars, &pack_path, workspace)
1944            .with_platform(Platform::current())
1945            .with_registry(registry)
1946            .with_pack_type_registry(pack_type_registry)
1947            .with_scheduler(scheduler);
1948        append_manifest_event(
1949            event_log,
1950            lock_path,
1951            &Event::ActionStarted {
1952                ts: Utc::now(),
1953                pack: pack_name.clone(),
1954                action_idx: 0,
1955                action_name: type_tag.to_string(),
1956            },
1957            &mut report.event_log_warnings,
1958        );
1959        let plugin = pack_type_registry
1960            .get(type_tag)
1961            .expect("pack-type plugin must be registered (guarded above)");
1962        // feat-m6 CI fix — see dispatch_pack_type note.
1963        let step_result =
1964            rt.block_on(crate::pack_lock::with_tier_scope(plugin.teardown(&ctx, &manifest)));
1965        if !record_action_outcome(
1966            report,
1967            event_log,
1968            lock_path,
1969            &pack_name,
1970            0,
1971            type_tag,
1972            step_result,
1973        ) {
1974            return;
1975        }
1976    }
1977}
1978
1979/// Test-only hook: append one [`Event::Sync`] through the same
1980/// [`ManifestLock`]-serialised path the sync driver uses.
1981///
1982/// Exposed so integration tests under `tests/` can exercise the locked
1983/// append helper without spinning up a full pack tree. Not intended for
1984/// downstream consumers — the signature may change without notice.
1985#[doc(hidden)]
1986pub fn __test_append_sync_event(
1987    log: &Path,
1988    lock_path: &Path,
1989    pack: &str,
1990    action_name: &str,
1991) -> Result<(), String> {
1992    let event = Event::Sync { ts: Utc::now(), id: pack.to_string(), sha: action_name.to_string() };
1993    append_event_locked(log, lock_path, &event)
1994}
1995
1996// ----------------------------------------------------------------------
1997// PR E — pre-run teardown scan
1998// ----------------------------------------------------------------------
1999
2000/// One `ActionStarted` event in the manifest log that has no matching
2001/// `ActionCompleted` or `ActionHalted` peer.
2002///
2003/// Dangling starts are the primary crash signal: the process wrote the
2004/// pre-action event, then died before the executor returned. Callers
2005/// should surface these to the operator (diagnostics only this PR; a
2006/// future `grex doctor` verb will act on them).
2007#[non_exhaustive]
2008#[derive(Debug, Clone, PartialEq, Eq)]
2009pub struct DanglingStart {
2010    /// Pack that owned the halted action.
2011    pub pack: String,
2012    /// 0-based action index within the pack.
2013    pub action_idx: usize,
2014    /// Short action kind tag.
2015    pub action_name: String,
2016    /// Timestamp the `ActionStarted` event was written.
2017    pub started_at: DateTime<Utc>,
2018}
2019
2020/// Summary of teardown artifacts found under a pack root before a sync
2021/// begins.
2022///
2023/// Built by [`scan_recovery`]. All fields are diagnostic; the sync
2024/// proceeds regardless of what the scan finds.
2025#[non_exhaustive]
2026#[derive(Debug, Clone, Default, PartialEq, Eq)]
2027pub struct RecoveryReport {
2028    /// `<dst>.grex.bak` files sitting next to a non-symlink or missing
2029    /// original (symlink-action rollback orphan).
2030    pub orphan_backups: Vec<PathBuf>,
2031    /// `<path>.grex.bak.<timestamp>` tombstones left by `rmdir` with
2032    /// `backup: true`.
2033    pub orphan_tombstones: Vec<PathBuf>,
2034    /// `ActionStarted` events in the log with no matching
2035    /// `ActionCompleted`/`ActionHalted`.
2036    pub dangling_starts: Vec<DanglingStart>,
2037}
2038
2039impl RecoveryReport {
2040    /// `true` when the scan found nothing worth reporting.
2041    #[must_use]
2042    pub fn is_empty(&self) -> bool {
2043        self.orphan_backups.is_empty()
2044            && self.orphan_tombstones.is_empty()
2045            && self.dangling_starts.is_empty()
2046    }
2047}
2048
2049/// Walk `workspace` and the manifest log to find crash-recovery artifacts.
2050///
2051/// Inspects:
2052///
2053/// * `workspace` for `.grex.bak` orphans and timestamped `.grex.bak.<ts>`
2054///   tombstones. The workspace IS where children materialise (whether
2055///   the default flat-sibling layout under the pack root, or an
2056///   explicit `--workspace` override directory) so this single bounded
2057///   walk covers every backup site.
2058/// * `event_log` (the manifest JSONL) for `ActionStarted` entries that
2059///   have no matching `ActionCompleted` / `ActionHalted` successor.
2060///
2061/// Non-blocking: scan errors are swallowed to an empty report so a
2062/// half-readable directory cannot kill a sync that would otherwise
2063/// succeed. Call sites that want to surface scan failures should read
2064/// the manifest directly.
2065///
2066/// Pre-`v1.1.0` post-review fix this anchored at `pack_root_dir(pack_root)`,
2067/// which missed every backup under a `--workspace` override.
2068///
2069/// # Errors
2070///
2071/// Returns [`SyncError::Validation`] only when the manifest read itself
2072/// reports corruption. Filesystem traversal errors are swallowed.
2073pub fn scan_recovery(workspace: &Path, event_log: &Path) -> Result<RecoveryReport, SyncError> {
2074    let mut report = RecoveryReport::default();
2075    walk_for_backups(workspace, &mut report);
2076    if event_log.exists() {
2077        match read_all(event_log) {
2078            Ok(events) => {
2079                report.dangling_starts = collect_dangling_starts(&events);
2080            }
2081            Err(e) => {
2082                return Err(SyncError::Validation {
2083                    errors: vec![PackValidationError::DependsOnUnsatisfied {
2084                        pack: "<event-log>".into(),
2085                        required: e.to_string(),
2086                    }],
2087                });
2088            }
2089        }
2090    }
2091    Ok(report)
2092}
2093
2094/// Shallow directory walker (bounded depth = 6) that categorizes
2095/// `.grex.bak` and `.grex.bak.<ts>` filenames into the appropriate
2096/// report slot. Depth-limited so a pathological workspace with a deep
2097/// tree cannot stall the scan; realistic layouts are well under six
2098/// levels.
2099fn walk_for_backups(root: &Path, report: &mut RecoveryReport) {
2100    walk_for_backups_inner(root, report, 0);
2101}
2102
2103fn walk_for_backups_inner(dir: &Path, report: &mut RecoveryReport, depth: u32) {
2104    const MAX_DEPTH: u32 = 6;
2105    if depth > MAX_DEPTH {
2106        return;
2107    }
2108    let Ok(entries) = std::fs::read_dir(dir) else { return };
2109    for entry_result in entries {
2110        let entry = match entry_result {
2111            Ok(e) => e,
2112            Err(e) => {
2113                tracing::warn!(
2114                    target: "grex::sync::recover",
2115                    "skipping unreadable entry under `{}`: {e}",
2116                    dir.display(),
2117                );
2118                continue;
2119            }
2120        };
2121        let path = entry.path();
2122        let name = entry.file_name();
2123        let Some(name_str) = name.to_str() else { continue };
2124        if name_str.ends_with(".grex.bak") {
2125            report.orphan_backups.push(path.clone());
2126            continue;
2127        }
2128        if let Some(rest) = name_str.rsplit_once(".grex.bak.") {
2129            // `rsplit_once` returns `(prefix, suffix)`; suffix is the
2130            // timestamp chunk. Accept any non-empty suffix — the exact
2131            // timestamp shape is `fs_executor` internal.
2132            if !rest.1.is_empty() {
2133                report.orphan_tombstones.push(path.clone());
2134                continue;
2135            }
2136        }
2137        // Recurse only into real directories (not symlinks, to avoid
2138        // traversing into the workspace's cloned repos via aliased
2139        // paths). `entry.file_type()` does NOT follow symlinks (unlike
2140        // `entry.metadata()` which would dereference and report the
2141        // target's type — defeating the very check this guards). The
2142        // symlink-skip is also explicit so the intent is recoverable
2143        // from the source: backup-recovery never crosses a symlink.
2144        let Ok(ft) = entry.file_type() else { continue };
2145        if ft.is_symlink() {
2146            continue;
2147        }
2148        if ft.is_dir() {
2149            walk_for_backups_inner(&path, report, depth + 1);
2150        }
2151    }
2152}
2153
2154/// Reduce an event stream to a list of `ActionStarted` records with no
2155/// matching terminator.
2156///
2157/// Matching is positional per `(pack, action_idx)`: a later
2158/// `ActionCompleted` or `ActionHalted` with the same key clears the
2159/// entry. Whatever remains in the map after the pass is dangling.
2160fn collect_dangling_starts(events: &[Event]) -> Vec<DanglingStart> {
2161    use std::collections::HashMap;
2162    let mut open: HashMap<(String, usize), DanglingStart> = HashMap::new();
2163    for ev in events {
2164        match ev {
2165            Event::ActionStarted { ts, pack, action_idx, action_name } => {
2166                open.insert(
2167                    (pack.clone(), *action_idx),
2168                    DanglingStart {
2169                        pack: pack.clone(),
2170                        action_idx: *action_idx,
2171                        action_name: action_name.clone(),
2172                        started_at: *ts,
2173                    },
2174                );
2175            }
2176            Event::ActionCompleted { pack, action_idx, .. }
2177            | Event::ActionHalted { pack, action_idx, .. } => {
2178                open.remove(&(pack.clone(), *action_idx));
2179            }
2180            _ => {}
2181        }
2182    }
2183    let mut out: Vec<DanglingStart> = open.into_values().collect();
2184    out.sort_by_key(|a| a.started_at);
2185    out
2186}
2187
2188#[cfg(test)]
2189mod synthetic_transition_tests {
2190    //! v1.1.1 — regression cover for the pack-shape transition fixes.
2191    //!
2192    //! These tests exercise [`skip_eligibility`] / [`upsert_lock_entry`]
2193    //! directly (no walker, no fs) so the assertion is on the plumbing
2194    //! itself: skip eligibility must require synthetic-flag agreement
2195    //! even when the actions hash matches by coincidence, and the
2196    //! upsert path must record the real-to-synthetic downgrade in the
2197    //! lockfile so the operator's lockfile reflects what just happened.
2198    use super::{skip_eligibility, upsert_lock_entry, LockEntry};
2199    use crate::lockfile::compute_actions_hash;
2200    use chrono::{TimeZone, Utc};
2201    use std::collections::HashMap;
2202
2203    fn ts() -> chrono::DateTime<Utc> {
2204        Utc.with_ymd_and_hms(2026, 4, 27, 10, 0, 0).unwrap()
2205    }
2206
2207    /// Stable empty-actions hash with a fixed commit SHA. The same
2208    /// inputs feed both the prior (real) and the new (synthetic)
2209    /// configuration in the regression below, which is exactly the
2210    /// coincidental-hash-match scenario FIX 3 must catch.
2211    fn stable_hash() -> String {
2212        compute_actions_hash(&[], "deadbeef")
2213    }
2214
2215    fn prior_entry(synthetic: bool) -> LockEntry {
2216        LockEntry {
2217            id: "alpha".into(),
2218            path: "alpha".into(),
2219            sha: "deadbeef".into(),
2220            branch: "main".into(),
2221            installed_at: ts(),
2222            actions_hash: stable_hash(),
2223            schema_version: "1".into(),
2224            synthetic,
2225        }
2226    }
2227
2228    /// FIX 3 — pack flips from real → synthetic but `actions_hash` and
2229    /// `commit_sha` happen to match. The skip MUST be invalidated so
2230    /// the upsert path re-emits the lockfile entry with `synthetic =
2231    /// true`.
2232    #[test]
2233    fn skip_eligibility_invalidates_when_synthetic_flag_flips() {
2234        let prior = prior_entry(false);
2235        let decision = skip_eligibility(&[], "deadbeef", true, &prior, false, false);
2236        assert!(decision.is_none(), "skip must be invalidated when synthetic flag flips");
2237    }
2238
2239    /// Same hash, same synthetic flag → skip is allowed (baseline).
2240    #[test]
2241    fn skip_eligibility_allows_skip_when_synthetic_matches() {
2242        let prior = prior_entry(true);
2243        let decision = skip_eligibility(&[], "deadbeef", true, &prior, false, false);
2244        assert_eq!(
2245            decision.as_deref(),
2246            Some(stable_hash().as_str()),
2247            "skip must be honoured when synthetic flag matches",
2248        );
2249    }
2250
2251    /// `dry_run` and `force` always disable the skip regardless of
2252    /// flag agreement.
2253    #[test]
2254    fn skip_eligibility_respects_dry_run_and_force() {
2255        let prior = prior_entry(true);
2256        assert!(skip_eligibility(&[], "deadbeef", true, &prior, true, false).is_none());
2257        assert!(skip_eligibility(&[], "deadbeef", true, &prior, false, true).is_none());
2258    }
2259
2260    /// FIX 4 — `upsert_lock_entry` records the downgrade in the
2261    /// lockfile (entry flips to `synthetic = true`) when the prior
2262    /// entry was real. The `tracing::warn!` is fire-and-forget, but
2263    /// the lockfile transition itself is observable and must be
2264    /// correct.
2265    #[test]
2266    fn upsert_lock_entry_records_real_to_synthetic_downgrade() {
2267        let mut prior: HashMap<String, LockEntry> = HashMap::new();
2268        prior.insert(
2269            "beta".into(),
2270            LockEntry {
2271                id: "beta".into(),
2272                path: "beta".into(),
2273                sha: "deadbeef".into(),
2274                branch: "main".into(),
2275                installed_at: ts(),
2276                actions_hash: stable_hash(),
2277                schema_version: "1".into(),
2278                synthetic: false,
2279            },
2280        );
2281        let mut next: HashMap<String, LockEntry> = HashMap::new();
2282
2283        upsert_lock_entry(&prior, &mut next, "beta", "deadbeef", &stable_hash(), true);
2284
2285        let entry = next.get("beta").expect("entry must be upserted");
2286        assert!(entry.synthetic, "downgraded entry must carry synthetic = true");
2287        assert_eq!(entry.actions_hash, stable_hash(), "actions_hash must reflect current run");
2288    }
2289
2290    /// Upsert path is a no-op for the steady-state case (synthetic →
2291    /// synthetic): the entry is replaced with the current run's
2292    /// timestamp/hash but the synthetic flag is preserved. This
2293    /// guards against an over-eager warning fire.
2294    #[test]
2295    fn upsert_lock_entry_no_op_for_steady_state_synthetic() {
2296        let mut prior: HashMap<String, LockEntry> = HashMap::new();
2297        prior.insert(
2298            "gamma".into(),
2299            LockEntry {
2300                id: "gamma".into(),
2301                path: "gamma".into(),
2302                sha: "deadbeef".into(),
2303                branch: "main".into(),
2304                installed_at: ts(),
2305                actions_hash: stable_hash(),
2306                schema_version: "1".into(),
2307                synthetic: true,
2308            },
2309        );
2310        let mut next: HashMap<String, LockEntry> = HashMap::new();
2311
2312        upsert_lock_entry(&prior, &mut next, "gamma", "deadbeef", &stable_hash(), true);
2313
2314        let entry = next.get("gamma").expect("entry must be upserted");
2315        assert!(entry.synthetic, "synthetic must remain true on no-op refresh");
2316    }
2317}
2318
2319#[cfg(test)]
2320mod error_display_tests {
2321    //! v1.2.0 Stage 1.k — `SyncError` Display assertions.
2322    //!
2323    //! Pure construction + `to_string()` checks. Variants land dormant —
2324    //! Stage 1.g (rayon scheduler) wires `SchedulerCancelled` once
2325    //! cooperative cancel polls reach the parallel walker.
2326    use super::SyncError;
2327
2328    #[test]
2329    fn test_sync_error_scheduler_cancelled_display() {
2330        let err = SyncError::SchedulerCancelled;
2331        assert_eq!(err.to_string(), "sync cancelled by user");
2332    }
2333}
2334
2335#[cfg(test)]
2336mod sync_options_v1_2_0_tests {
2337    //! v1.2.0 Stage 1.m — leaf cover for new [`SyncOptions`] fields.
2338    //!
2339    //! These tests are mechanical default-value assertions plus simple
2340    //! builder/clone round-trips. They exist to lock down that:
2341    //!
2342    //! 1. Adding the new fields preserves v1.1.1 behavior (defaults
2343    //!    leave existing call sites observably unchanged).
2344    //! 2. The shape is what later walker stages (1.h / 1.j / 1.l) will
2345    //!    consume — if any of these fields are renamed or change type,
2346    //!    those stages must update in lock-step.
2347    //!
2348    //! The fields themselves are *dormant placeholders* at 1.m scope —
2349    //! no behavior wiring lives in this stage.
2350    use super::SyncOptions;
2351
2352    /// `force_prune` defaults to `false` so existing call sites refuse
2353    /// to drop dirty trees (v1.1.1 behavior).
2354    #[test]
2355    fn test_sync_options_default_force_prune_false() {
2356        let opts = SyncOptions::default();
2357        assert!(!opts.force_prune, "force_prune must default to false");
2358    }
2359
2360    /// `force_prune_with_ignored` defaults to `false` so existing call
2361    /// sites refuse to drop ignored content (v1.1.1 behavior).
2362    #[test]
2363    fn test_sync_options_default_force_prune_with_ignored_false() {
2364        let opts = SyncOptions::default();
2365        assert!(!opts.force_prune_with_ignored, "force_prune_with_ignored must default to false");
2366    }
2367
2368    /// `migrate_lockfile` defaults to `false` so the walker errors on
2369    /// legacy v1.1.1 lockfile shapes unless the caller opts in.
2370    #[test]
2371    fn test_sync_options_default_migrate_lockfile_false() {
2372        let opts = SyncOptions::default();
2373        assert!(!opts.migrate_lockfile, "migrate_lockfile must default to false");
2374    }
2375
2376    /// `recurse` defaults to `true` — the walker descends into nested
2377    /// meta-children unless `--shallow` is requested.
2378    #[test]
2379    fn test_sync_options_default_recurse_true() {
2380        let opts = SyncOptions::default();
2381        assert!(opts.recurse, "recurse must default to true");
2382    }
2383
2384    /// `max_depth` defaults to `None` — unbounded recursion when
2385    /// `recurse` is `true`.
2386    #[test]
2387    fn test_sync_options_default_max_depth_none() {
2388        let opts = SyncOptions::default();
2389        assert!(opts.max_depth.is_none(), "max_depth must default to None");
2390    }
2391
2392    /// Setting `force_prune_with_ignored = true` alongside
2393    /// `force_prune = true` is the documented "stronger" combination.
2394    /// No contradiction: `with_ignored` is the harder override and
2395    /// implies the base `force_prune` semantics. This test guards the
2396    /// invariant that both flags coexist as plain `bool` (not enum)
2397    /// so callers can set them independently without runtime panic.
2398    #[test]
2399    fn test_sync_options_force_prune_with_ignored_implies_force_prune() {
2400        let opts = SyncOptions {
2401            force_prune: true,
2402            force_prune_with_ignored: true,
2403            ..SyncOptions::default()
2404        };
2405        assert!(opts.force_prune);
2406        assert!(opts.force_prune_with_ignored);
2407    }
2408
2409    /// `max_depth = Some(n)` paired with `recurse = true` is the
2410    /// documented `--shallow=N` shape. The fields are independent
2411    /// `bool` / `Option<usize>` so callers may set `max_depth` while
2412    /// `recurse` is left at its default (`true`). Stage 1.j will
2413    /// later define the precise interaction; this test only locks
2414    /// the two fields' types and defaults.
2415    #[test]
2416    fn test_sync_options_max_depth_pairs_with_recurse() {
2417        let opts = SyncOptions { max_depth: Some(2), ..SyncOptions::default() };
2418        assert_eq!(opts.max_depth, Some(2));
2419        assert!(opts.recurse, "recurse stays at its default (true) when only max_depth is set");
2420    }
2421
2422    /// Round-trip via `Clone` — guards that all new fields participate
2423    /// in the existing `Clone` derive (no `#[clone(skip)]` slipped in).
2424    #[test]
2425    fn test_sync_options_clone_preserves_new_fields() {
2426        let opts = SyncOptions {
2427            force_prune: true,
2428            force_prune_with_ignored: true,
2429            migrate_lockfile: true,
2430            recurse: false,
2431            max_depth: Some(7),
2432            ..SyncOptions::default()
2433        };
2434        let cloned = opts.clone();
2435        assert_eq!(cloned.force_prune, opts.force_prune);
2436        assert_eq!(cloned.force_prune_with_ignored, opts.force_prune_with_ignored);
2437        assert_eq!(cloned.migrate_lockfile, opts.migrate_lockfile);
2438        assert_eq!(cloned.recurse, opts.recurse);
2439        assert_eq!(cloned.max_depth, opts.max_depth);
2440    }
2441}