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