Skip to main content

haz_cli/commands/
run.rs

1//! `haz run` subcommand.
2//!
3//! Wires the upstream layers ([`crate::workspace_load`],
4//! [`crate::bearing`], [`crate::target_resolve`]) to either the
5//! scheduler ([`haz_exec::run_graph::run_graph`]) or to a passive
6//! dry-run report per `EXEC-030`.
7//!
8//! Current scope:
9//!
10//! - Real execution uses [`StdProcessSpawner`], a buffered output
11//!   observer, and a never-cancelled token. Live mode and signal
12//!   handling are not yet wired here.
13//! - Dry-run prints the canonical-ordered selected task set to
14//!   stdout in a human-readable form. Cache-state predictions are
15//!   permitted by `EXEC-030` but not yet emitted; v1 emits the
16//!   plain "would run N tasks" report.
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::io::{self, Write};
20use std::path::Path;
21use std::sync::{Arc, Mutex};
22use std::time::{Instant, SystemTime, UNIX_EPOCH};
23
24use haz_cache::cache::Cache;
25use haz_dag::edge::Edge;
26use haz_dag::graph::TaskGraph;
27use haz_domain::env::EnvVarName;
28use haz_domain::settings::cache::HashAlgo;
29use haz_domain::settings::output::OutputMode;
30use haz_domain::task_id::TaskId;
31use haz_domain::workspace::Workspace;
32use haz_exec::exit_code::{CancellationSignal, exit_code_for};
33use haz_exec::output::{BufferedOutputObserver, LiveOutputObserver};
34use haz_exec::presenter::TaskPresenter;
35use haz_exec::run_graph::{RunGraphOutcome, run_graph};
36use haz_exec::run_task::RunContext;
37use haz_exec::std_impl::StdProcessSpawner;
38use haz_vfs::StdFilesystem;
39use miette::{IntoDiagnostic, Result, WrapErr};
40use tokio_util::sync::CancellationToken;
41
42use haz_discovery::bearing::bearing_project;
43use haz_target::{TargetSpec, resolve_targets};
44use haz_vcs::workspace_changeset;
45
46use crate::cli::RunArgs;
47use crate::output::color_choice::ColorChoice;
48use crate::output::epilogue::write_epilogue;
49use crate::output::presenter::CliPresenter;
50use crate::workspace_load::load_workspace;
51
52/// The hash algorithm haz-cli builds the cache against. The value
53/// MUST match the algorithm recorded in any cache manifest the
54/// run consults (`CACHE-016` step 3); switching it is an explicit
55/// future feature, not a v1 configuration knob.
56const HASH_ALGO: HashAlgo = HashAlgo::Blake3;
57
58/// Execute the `haz run` subcommand against `cwd`.
59///
60/// `cwd` is taken as a parameter (rather than read from
61/// [`std::env::current_dir`]) so unit tests can drive the function
62/// against a tempdir workspace without mutating per-process state.
63///
64/// Returns the process exit code that the binary should propagate.
65///
66/// # Errors
67///
68/// Returns a [`miette::Report`] on workspace-load, target-
69/// resolution, or scheduler-level failures. Task-level failures
70/// reach this function as a [`haz_exec::run_graph::RunGraphOutcome`]
71/// whose `task_errors` / `outcomes` carry the per-task detail; the
72/// returned exit code (via [`exit_code_for`]) signals the class.
73pub fn execute(args: &RunArgs, cwd: &Path) -> Result<i32> {
74    let (workspace, full_graph) = load_workspace(cwd)?;
75    let bearing = bearing_project(cwd, &workspace);
76
77    // EXEC-026: compute the git changeset before target
78    // resolution when a filter is active. The set lives as long
79    // as `resolve_targets` needs it (one borrow); declaring it
80    // here keeps the borrow lifetime obvious.
81    let changeset = match &args.git_filter {
82        Some(filter) => Some(
83            workspace_changeset(filter, workspace.root.as_path())
84                .into_diagnostic()
85                .wrap_err("git-changeset enumeration failed")?,
86        ),
87        None => None,
88    };
89    let spec = TargetSpec {
90        targets: args.targets.clone(),
91        all: args.all,
92        workspace: args.workspace,
93    };
94    let targets = resolve_targets(&spec, bearing, &workspace, &full_graph, changeset.as_ref())
95        .into_diagnostic()
96        .wrap_err("target resolution failed")?;
97
98    if args.dry_run {
99        let mut out = io::stdout().lock();
100        return print_dry_run_plan(&targets, &mut out)
101            .into_diagnostic()
102            .wrap_err("failed to write dry-run plan")
103            .map(|()| 0);
104    }
105
106    // `EXEC-016` precedence: CLI flag > workspace setting >
107    // default (`OutputMode::Live` per `CFG-030`). The workspace
108    // setting reads from `workspace.settings.output.mode`; the
109    // workspace's defaults are already populated by
110    // `WorkspaceSettings::default` when the explicit-mode
111    // `workspace.yml` omits the `output:` mapping (`CFG-030`).
112    let mode = args.output_mode.unwrap_or(workspace.settings.output.mode);
113    let exec_graph = restrict_graph_to_targets(&full_graph, &targets);
114    let color = ColorChoice::detect();
115    run_to_exit(workspace, exec_graph, mode, color)
116}
117
118/// Emit the human-readable dry-run plan per the v1 format.
119///
120/// Tasks are emitted in canonical `(ProjectName, TaskName)` order
121/// per `EXEC-003` (a [`BTreeSet`] of [`TaskId`] iterates in this
122/// order natively).
123fn print_dry_run_plan(targets: &BTreeSet<TaskId>, out: &mut impl Write) -> io::Result<()> {
124    if targets.is_empty() {
125        return writeln!(out, "would run 0 tasks.");
126    }
127    let suffix = if targets.len() == 1 { "" } else { "s" };
128    writeln!(out, "would run {} task{suffix}:", targets.len())?;
129    for id in targets {
130        writeln!(out, "  - {id}")?;
131    }
132    Ok(())
133}
134
135/// Project the full [`TaskGraph`] onto the resolved target set.
136///
137/// The scheduler runs every node in its graph. To make `--all`
138/// and explicit-positional selection actually narrow the run, the
139/// graph passed to [`run_graph`] is pruned to the targets and
140/// their cross-target edges. Per `EXEC-001` / `EXEC-002`, the
141/// target set is already closed under hard predecessors, so the
142/// pruned graph is admission-complete: every node's hard
143/// predecessors are still in the graph.
144fn restrict_graph_to_targets(graph: &TaskGraph, targets: &BTreeSet<TaskId>) -> TaskGraph {
145    let nodes = targets.clone();
146    let edges: BTreeSet<Edge> = graph
147        .edges
148        .iter()
149        .filter(|e| nodes.contains(&e.from) && nodes.contains(&e.to))
150        .cloned()
151        .collect();
152    TaskGraph { nodes, edges }
153}
154
155/// Build the scheduler context and run, returning the exit code.
156///
157/// An empty graph is treated as a successful zero-task run without
158/// spinning up the async runtime (`EXEC-021` first bullet,
159/// `EXEC-025` empty-workspace clause).
160fn run_to_exit(
161    workspace: Workspace,
162    graph: TaskGraph,
163    mode: OutputMode,
164    color: ColorChoice,
165) -> Result<i32> {
166    if graph.nodes.is_empty() {
167        return Ok(0);
168    }
169    let runtime = tokio::runtime::Builder::new_multi_thread()
170        .enable_all()
171        .build()
172        .into_diagnostic()
173        .wrap_err("failed to build the tokio runtime")?;
174    runtime.block_on(async move { dispatch_run_graph(workspace, graph, mode, color).await })
175}
176
177/// The async core of [`run_to_exit`].
178///
179/// Holds the cache, spawner, observer, and host-env snapshot
180/// stack-locally so their borrows feed a single [`RunContext`]
181/// value. Installs `SIGINT` / `SIGTERM` listeners on Unix that
182/// fire the run's [`CancellationToken`] and record which signal
183/// arrived; the recorded signal is then handed to
184/// [`exit_code_for`] for POSIX `128 + signum` exit-code mapping
185/// per `EXEC-021`.
186async fn dispatch_run_graph(
187    workspace: Workspace,
188    graph: TaskGraph,
189    mode: OutputMode,
190    color: ColorChoice,
191) -> Result<i32> {
192    let workspace_root = workspace.root.as_path().to_path_buf();
193    let cache = Cache::new(StdFilesystem::new(), &workspace_root, HASH_ALGO);
194    let spawner = StdProcessSpawner::new();
195    let host_env = host_env_snapshot();
196    let cancel = CancellationToken::new();
197    let captured_signal: Arc<Mutex<Option<CancellationSignal>>> = Arc::new(Mutex::new(None));
198    let signal_handle = spawn_signal_listener(cancel.clone(), Arc::clone(&captured_signal));
199    let created_at_unix = SystemTime::now()
200        .duration_since(UNIX_EPOCH)
201        .map_or(0, |d| d.as_secs());
202    let presenter: Arc<dyn TaskPresenter> = Arc::new(CliPresenter::new(color));
203
204    // Wall-clock pair captured around the scheduler call so the
205    // run-level epilogue can report total elapsed time, whatever
206    // path each individual task took (cache hit, fresh run, skip,
207    // cancel).
208    let started_at = Instant::now();
209
210    // `EXEC-016` observer selection. Both observers carry their
211    // own `Mutex` internally for cross-task line / block atomicity;
212    // both write directly to `io::stdout` / `io::stderr`.
213    // Branching inside `dispatch_run_graph` keeps the run-context
214    // borrow lifetimes obvious without erasing the observer type.
215    let outcome = match mode {
216        OutputMode::Live => {
217            let observer = LiveOutputObserver::with_presenter(
218                io::stdout(),
219                io::stderr(),
220                Arc::clone(&presenter),
221            );
222            let ctx = RunContext {
223                fs: cache.fs(),
224                cache: &cache,
225                spawner: &spawner,
226                observer: &observer,
227                workspace: &workspace,
228                graph: &graph,
229                host_env: &host_env,
230                algo: HASH_ALGO,
231                cancel: &cancel,
232            };
233            run_graph(&ctx, created_at_unix).await
234        }
235        OutputMode::Buffered => {
236            let observer = BufferedOutputObserver::with_presenter(
237                io::stdout(),
238                io::stderr(),
239                Arc::clone(&presenter),
240            );
241            let ctx = RunContext {
242                fs: cache.fs(),
243                cache: &cache,
244                spawner: &spawner,
245                observer: &observer,
246                workspace: &workspace,
247                graph: &graph,
248                host_env: &host_env,
249                algo: HASH_ALGO,
250                cancel: &cancel,
251            };
252            run_graph(&ctx, created_at_unix).await
253        }
254    }
255    .map_err(|err| miette::miette!("scheduler failed: {err}"))?;
256
257    let elapsed = started_at.elapsed();
258
259    // Whether or not a signal arrived, the listener task is no
260    // longer needed once the scheduler returns. Abort it so it
261    // does not outlive this function. (On Unix the inner
262    // `select!` blocks on signal streams indefinitely; we MUST
263    // abort or the runtime would not shut down cleanly.)
264    signal_handle.abort();
265
266    emit_run_epilogue(&outcome, elapsed, color);
267
268    let signal = *captured_signal
269        .lock()
270        .unwrap_or_else(std::sync::PoisonError::into_inner);
271    Ok(exit_code_for(&outcome, signal))
272}
273
274/// Write the run-level epilogue to stderr.
275///
276/// The epilogue is presentation-only and follows every task's
277/// per-task summary line; emitting it on stderr (rather than
278/// stdout) keeps the stdout stream a strict echo of the tasks'
279/// captured bytes per `EXEC-016`.
280fn emit_run_epilogue(outcome: &RunGraphOutcome, elapsed: std::time::Duration, color: ColorChoice) {
281    let mut stderr = io::stderr().lock();
282    let _ = write_epilogue(&mut stderr, outcome, elapsed, color);
283}
284
285/// Spawn the signal-listener background task.
286///
287/// On Unix the task awaits the first `SIGINT` or `SIGTERM`,
288/// records the corresponding [`CancellationSignal`] in
289/// `captured`, and fires `cancel` so the scheduler stops
290/// admitting new tasks and signals in-flight children
291/// (`EXEC-012`..`EXEC-014`). The task is aborted by the caller
292/// once the scheduler returns; it does NOT race itself to
293/// completion.
294///
295/// On non-Unix targets the task is a no-op: signal-driven
296/// cancellation is not yet implemented for those platforms, so
297/// runs there always observe a never-fired token. A future
298/// revision MAY add Windows `Ctrl+C` handling.
299fn spawn_signal_listener(
300    cancel: CancellationToken,
301    captured: Arc<Mutex<Option<CancellationSignal>>>,
302) -> tokio::task::JoinHandle<()> {
303    tokio::spawn(listen_for_signals(cancel, captured))
304}
305
306#[cfg(unix)]
307async fn listen_for_signals(
308    cancel: CancellationToken,
309    captured: Arc<Mutex<Option<CancellationSignal>>>,
310) {
311    use tokio::signal::unix::{SignalKind, signal};
312    // If signal handlers cannot be installed (an extremely narrow
313    // OS-level failure), the listener silently degrades to a
314    // no-op. The run is still cancellable via the OS default-
315    // action path (which would terminate the process); the
316    // cooperative cancellation path simply is not available.
317    let (Ok(mut sigint), Ok(mut sigterm)) = (
318        signal(SignalKind::interrupt()),
319        signal(SignalKind::terminate()),
320    ) else {
321        return;
322    };
323    tokio::select! {
324        _ = sigint.recv() => {
325            record_signal(&captured, CancellationSignal::Interrupt);
326            cancel.cancel();
327        }
328        _ = sigterm.recv() => {
329            record_signal(&captured, CancellationSignal::Terminate);
330            cancel.cancel();
331        }
332    }
333}
334
335#[cfg(not(unix))]
336async fn listen_for_signals(
337    _cancel: CancellationToken,
338    _captured: Arc<Mutex<Option<CancellationSignal>>>,
339) {
340    // Platform without `tokio::signal::unix`. A future revision
341    // MAY add `tokio::signal::ctrl_c` handling for Windows;
342    // until then, runs on non-Unix observe a never-fired token.
343}
344
345#[cfg(unix)]
346fn record_signal(captured: &Mutex<Option<CancellationSignal>>, sig: CancellationSignal) {
347    let mut slot = captured
348        .lock()
349        .unwrap_or_else(std::sync::PoisonError::into_inner);
350    *slot = Some(sig);
351}
352
353/// Snapshot the host environment into the form `haz-exec` expects.
354///
355/// Variables whose names violate `EnvVarName`'s identifier rules
356/// are silently dropped: such names cannot appear in any cache
357/// manifest or task `env` mapping anyway, so they are
358/// unreferenceable from the workspace.
359fn host_env_snapshot() -> BTreeMap<EnvVarName, String> {
360    let mut out = BTreeMap::new();
361    for (key, value) in std::env::vars() {
362        if let Ok(name) = EnvVarName::try_new(key) {
363            out.insert(name, value);
364        }
365    }
366    out
367}
368
369#[cfg(test)]
370mod tests {
371    use super::{execute, print_dry_run_plan};
372    use crate::cli::RunArgs;
373    use haz_domain::name::{ProjectName, TaskName};
374    use haz_domain::task_id::TaskId;
375    use std::collections::BTreeSet;
376    use std::fs;
377    use std::path::Path;
378    use std::str::FromStr;
379
380    fn write_file(path: &Path, contents: &str) {
381        if let Some(parent) = path.parent() {
382            fs::create_dir_all(parent).unwrap();
383        }
384        fs::write(path, contents).unwrap();
385    }
386
387    fn id(project: &str, task: &str) -> TaskId {
388        TaskId {
389            project: ProjectName::from_str(project).unwrap(),
390            task: TaskName::from_str(task).unwrap(),
391        }
392    }
393
394    fn dry_run_args() -> RunArgs {
395        RunArgs {
396            targets: Vec::new(),
397            all: false,
398            workspace: false,
399            git_filter: None,
400            dry_run: true,
401            output_mode: None,
402        }
403    }
404
405    fn run_args_with_targets(targets: Vec<String>) -> RunArgs {
406        RunArgs {
407            targets,
408            all: false,
409            workspace: false,
410            git_filter: None,
411            dry_run: false,
412            output_mode: None,
413        }
414    }
415
416    #[test]
417    fn dry_run_plan_empty_targets() {
418        let targets: BTreeSet<TaskId> = BTreeSet::new();
419        let mut out = Vec::<u8>::new();
420        print_dry_run_plan(&targets, &mut out).unwrap();
421        assert_eq!(String::from_utf8(out).unwrap(), "would run 0 tasks.\n");
422    }
423
424    #[test]
425    fn dry_run_plan_single_target_singular_count() {
426        let mut targets = BTreeSet::new();
427        targets.insert(id("lib", "build"));
428        let mut out = Vec::<u8>::new();
429        print_dry_run_plan(&targets, &mut out).unwrap();
430        assert_eq!(
431            String::from_utf8(out).unwrap(),
432            "would run 1 task:\n  - lib:build\n",
433        );
434    }
435
436    #[test]
437    fn dry_run_plan_multiple_targets_canonical_order() {
438        let mut targets = BTreeSet::new();
439        targets.insert(id("z", "build"));
440        targets.insert(id("a", "test"));
441        targets.insert(id("a", "build"));
442        let mut out = Vec::<u8>::new();
443        print_dry_run_plan(&targets, &mut out).unwrap();
444        assert_eq!(
445            String::from_utf8(out).unwrap(),
446            "would run 3 tasks:\n  - a:build\n  - a:test\n  - z:build\n",
447        );
448    }
449
450    /// Path to a `true(1)` binary the smoke test can spawn. The
451    /// host-env snapshot allow-lists by name and the spawner clears
452    /// the child's env, so a bare `"true"` would fail PATH lookup
453    /// in the child; using an absolute path sidesteps the issue.
454    /// Linux + macOS both ship `/usr/bin/true`; the smoke test is
455    /// skipped on hosts that lack it.
456    fn true_path() -> Option<&'static str> {
457        const CANDIDATES: &[&str] = &["/usr/bin/true", "/bin/true"];
458        CANDIDATES.iter().copied().find(|p| Path::new(p).exists())
459    }
460
461    #[test]
462    fn execute_dry_run_on_singleton_workspace_returns_zero() {
463        let tempdir = tempfile::TempDir::new().unwrap();
464        let root = tempdir.path();
465        fs::create_dir_all(root.join(".git")).unwrap();
466        write_file(
467            &root.join("haz.yml"),
468            "name: only_proj\ntasks:\n  default:\n    command: [\"true\"]\n",
469        );
470        let args = dry_run_args();
471        let code = execute(&args, root).unwrap();
472        assert_eq!(code, 0);
473    }
474
475    #[test]
476    fn execute_runs_trivial_task_to_success() {
477        let Some(true_bin) = true_path() else {
478            eprintln!("skipping: no `true` binary at /usr/bin/true or /bin/true");
479            return;
480        };
481        let tempdir = tempfile::TempDir::new().unwrap();
482        let root = tempdir.path();
483        fs::create_dir_all(root.join(".git")).unwrap();
484        write_file(
485            &root.join("haz.yml"),
486            &format!("name: only_proj\ntasks:\n  default:\n    command: [\"{true_bin}\"]\n"),
487        );
488        // `output_mode: None` falls back to the workspace setting,
489        // whose default per `CFG-030` is `OutputMode::Live`.
490        let args = run_args_with_targets(vec!["~:default".to_owned()]);
491        let code = execute(&args, root).unwrap();
492        assert_eq!(code, 0);
493    }
494
495    #[test]
496    fn execute_runs_trivial_task_in_buffered_mode() {
497        // Explicit `--output-mode buffered` exercises the
498        // `OutputMode::Buffered` arm of `dispatch_run_graph`'s
499        // observer selection. The wiring is the only change under
500        // test; the smoke target is the same `true`-spawning
501        // workspace.
502        let Some(true_bin) = true_path() else {
503            eprintln!("skipping: no `true` binary at /usr/bin/true or /bin/true");
504            return;
505        };
506        let tempdir = tempfile::TempDir::new().unwrap();
507        let root = tempdir.path();
508        fs::create_dir_all(root.join(".git")).unwrap();
509        write_file(
510            &root.join("haz.yml"),
511            &format!("name: only_proj\ntasks:\n  default:\n    command: [\"{true_bin}\"]\n"),
512        );
513        let mut args = run_args_with_targets(vec!["~:default".to_owned()]);
514        args.output_mode = Some(crate::cli::OutputMode::Buffered);
515        let code = execute(&args, root).unwrap();
516        assert_eq!(code, 0);
517    }
518
519    #[test]
520    fn exec_023_execute_default_target_without_bearing_errors() {
521        // cwd at the workspace root but in explicit mode means no
522        // project encloses it; `EXEC-023`'s NoBearingForDefault
523        // arm fires.
524        let tempdir = tempfile::TempDir::new().unwrap();
525        let root = tempdir.path();
526        write_file(&root.join(".haz/workspace.yml"), "projects:\n  - /lib\n");
527        write_file(
528            &root.join("lib/haz.yml"),
529            "name: lib\ntasks:\n  default:\n    command: [\"true\"]\n",
530        );
531        let args = RunArgs {
532            targets: Vec::new(),
533            all: false,
534            workspace: false,
535            git_filter: None,
536            dry_run: false,
537            output_mode: None,
538        };
539        let err = execute(&args, root).unwrap_err();
540        assert!(
541            format!("{err:?}").contains("no bearing project"),
542            "diagnostic should mention the no-bearing case: {err:?}",
543        );
544    }
545
546    // --- Git changeset wiring (EXEC-026..029) ---
547
548    use crate::cli::GitFilter;
549    use std::process::Command;
550
551    fn git(cwd: &Path, args: &[&str]) {
552        let output = Command::new("git")
553            .arg("-C")
554            .arg(cwd)
555            .args(args)
556            .output()
557            .expect("git binary must be available on PATH for these tests");
558        assert!(
559            output.status.success(),
560            "git {} failed:\nstdout: {}\nstderr: {}",
561            args.join(" "),
562            String::from_utf8_lossy(&output.stdout),
563            String::from_utf8_lossy(&output.stderr),
564        );
565    }
566
567    fn init_repo(root: &Path) {
568        git(root, &["init", "--quiet", "--initial-branch=main"]);
569        git(root, &["config", "user.email", "test@example.invalid"]);
570        git(root, &["config", "user.name", "Test"]);
571        git(root, &["config", "commit.gpgsign", "false"]);
572    }
573
574    fn dry_run_with_filter(filter: GitFilter) -> RunArgs {
575        RunArgs {
576            targets: Vec::new(),
577            all: false,
578            workspace: true,
579            git_filter: Some(filter),
580            dry_run: true,
581            output_mode: None,
582        }
583    }
584
585    /// Build the smoke-test workspace: two sibling projects under
586    /// a shared git repository, each declaring a single task whose
587    /// input is its own `src/main.rs`.
588    fn write_two_project_workspace(root: &Path) {
589        write_file(
590            &root.join(".haz/workspace.yml"),
591            "projects:\n  - /lib_a\n  - /lib_b\n",
592        );
593        write_file(
594            &root.join("lib_a/haz.yml"),
595            "name: lib_a\ntasks:\n  build:\n    command: [\"true\"]\n    inputs:\n      - src/main.rs\n",
596        );
597        write_file(
598            &root.join("lib_b/haz.yml"),
599            "name: lib_b\ntasks:\n  build:\n    command: [\"true\"]\n    inputs:\n      - src/main.rs\n",
600        );
601        write_file(&root.join("lib_a/src/main.rs"), "fn main() {}\n");
602        write_file(&root.join("lib_b/src/main.rs"), "fn main() {}\n");
603    }
604
605    #[test]
606    fn execute_git_changed_dry_run_succeeds_when_one_input_changes() {
607        // Two projects sharing a git repo; only `lib_a/src/main.rs`
608        // is modified after the initial commit. With `--git-changed
609        // --dry-run --workspace`, target resolution must narrow to
610        // `lib_a:build` (the closure on the singleton seed adds
611        // nothing). The smoke test asserts the wiring runs to exit
612        // 0; the per-branch matching invariants are covered by the
613        // unit tests in target_resolve and git_changeset.
614        let tempdir = tempfile::TempDir::new().unwrap();
615        let root = tempdir.path();
616        init_repo(root);
617        write_two_project_workspace(root);
618        git(root, &["add", "."]);
619        git(root, &["commit", "--quiet", "-m", "init"]);
620
621        // Modify only lib_a's input.
622        write_file(&root.join("lib_a/src/main.rs"), "fn main() { /* v2 */ }\n");
623
624        let args = dry_run_with_filter(GitFilter::Changed);
625        let code = execute(&args, root).unwrap();
626        assert_eq!(code, 0);
627    }
628
629    #[test]
630    fn execute_git_changed_dry_run_succeeds_when_no_input_changes() {
631        // No file is modified after the initial commit. The
632        // changeset is empty; the target set is empty; the dry-run
633        // plan emits "would run 0 tasks." and exits 0.
634        let tempdir = tempfile::TempDir::new().unwrap();
635        let root = tempdir.path();
636        init_repo(root);
637        write_two_project_workspace(root);
638        git(root, &["add", "."]);
639        git(root, &["commit", "--quiet", "-m", "init"]);
640
641        let args = dry_run_with_filter(GitFilter::Changed);
642        let code = execute(&args, root).unwrap();
643        assert_eq!(code, 0);
644    }
645
646    #[test]
647    fn execute_git_changed_surfaces_not_a_repo_error() {
648        // Workspace without a git repo: the filter MUST fail with
649        // a typed error before any task is selected.
650        let tempdir = tempfile::TempDir::new().unwrap();
651        let root = tempdir.path();
652        write_two_project_workspace(root);
653
654        let args = dry_run_with_filter(GitFilter::Changed);
655        let err = execute(&args, root).unwrap_err();
656        let rendered = format!("{err:?}");
657        assert!(
658            rendered.contains("not inside a git repository")
659                || rendered.contains("git-changeset enumeration"),
660            "diagnostic should name the missing-repo failure: {rendered}",
661        );
662    }
663}