Skip to main content

speccoder_runtime/
hosting.rs

1//! `speccoder spec`: list the installed Agent Skills, or run one.
2//!
3//! A hosted planning skill works in your checkout, because what it writes —
4//! the feature's spec, plan and tasks — belongs there; the planning policy
5//! lets it write only the format's artifacts and run the format's scripts,
6//! and the changes are left uncommitted for you to review.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use agentplane::model::ModelProvider;
12use speccoder_core::{
13    CommandData, CommandResult, CommandStatus, SkillEntry, SkillRunReport, SkillsReport,
14};
15
16use crate::format::Planning;
17use crate::{RunOptions, progress, redact, skills, substrate, tools, verify, worktree};
18
19fn root_of(path: &Path) -> Result<PathBuf, Box<CommandResult>> {
20    match crate::format::open(path, None) {
21        Ok(opened) => Ok(PathBuf::from(&opened.snapshot.root)),
22        Err(r) if r.unmarked => Err(Box::new(CommandResult::new(
23            "skill",
24            CommandStatus::NotAProject,
25            crate::format::reference().messages().no_project,
26        ))),
27        Err(r) => Err(Box::new(CommandResult::new("skill", r.status, &r.message))),
28    }
29}
30
31/// `speccoder spec`: the installed skills.
32#[must_use]
33pub fn list(path: &Path) -> CommandResult {
34    let root = match root_of(path) {
35        Ok(r) => r,
36        Err(r) => return *r,
37    };
38    let format = crate::format::at(&root);
39    let found = skills::discover_with(&root, format.planning());
40    let message = if found.is_empty() {
41        format.messages().no_skills.to_owned()
42    } else {
43        format!(
44            "{} skill(s); run one with `speccoder spec <name> [arguments]`",
45            found.len()
46        )
47    };
48    CommandResult::new("skill", CommandStatus::Ok, &message).with_data(CommandData::Skills(
49        Box::new(SkillsReport {
50            skills: found
51                .into_iter()
52                .map(|s| SkillEntry {
53                    name: s.name,
54                    description: s.description,
55                    path: s.path,
56                    refused: s.refused,
57                })
58                .collect(),
59        }),
60    ))
61}
62
63/// `speccoder spec <name> [arguments]` against `state` with `provider`.
64pub fn run_with(
65    path: &Path,
66    name: &str,
67    arguments: &str,
68    state: &Path,
69    provider: Arc<dyn ModelProvider>,
70    secret: Option<&str>,
71    options: &RunOptions,
72) -> CommandResult {
73    run_in(
74        path, None, name, arguments, state, provider, secret, options,
75    )
76}
77
78/// [`run_with`] for `feature` (`--feature`; else the format's own
79/// selection): the skill's commands are told which unit is meant, and a
80/// unit the creating skill makes becomes current.
81///
82/// The person's review records (`review.json`) are the person's: a skill
83/// that changed one — through a command it was allowed to run — has the
84/// change undone, and the result says so.
85#[allow(clippy::too_many_arguments)]
86pub fn run_in(
87    path: &Path,
88    feature: Option<&str>,
89    name: &str,
90    arguments: &str,
91    state: &Path,
92    provider: Arc<dyn ModelProvider>,
93    secret: Option<&str>,
94    options: &RunOptions,
95) -> CommandResult {
96    let reviews = crate::review::snapshot(path);
97    let result = run_unguarded(
98        path, feature, name, arguments, state, provider, secret, options, None,
99    );
100    let restored = crate::review::restore(&reviews);
101    if restored.is_empty() {
102        return result;
103    }
104    let message = format!(
105        "{}; undone: the skill changed the person's review ({}) — only `speccoder review` writes it",
106        result.message(),
107        restored.join(", ")
108    );
109    let status = result.status();
110    result.reworded(status, &message)
111}
112
113/// A format's own step (a bug's assess skill) under `rules` instead of the
114/// format's planning rules: what it may write is narrowed to its own files.
115#[allow(clippy::too_many_arguments)]
116pub(crate) fn run_scoped(
117    path: &Path,
118    name: &str,
119    arguments: &str,
120    state: &Path,
121    provider: Arc<dyn ModelProvider>,
122    secret: Option<&str>,
123    options: &RunOptions,
124    rules: &'static crate::format::Rules,
125) -> CommandResult {
126    let reviews = crate::review::snapshot(path);
127    let result = run_unguarded(
128        path,
129        None,
130        name,
131        arguments,
132        state,
133        provider,
134        secret,
135        options,
136        Some(rules),
137    );
138    let _ = crate::review::restore(&reviews);
139    result
140}
141
142#[allow(clippy::too_many_arguments)]
143fn run_unguarded(
144    path: &Path,
145    feature: Option<&str>,
146    name: &str,
147    arguments: &str,
148    state: &Path,
149    provider: Arc<dyn ModelProvider>,
150    secret: Option<&str>,
151    options: &RunOptions,
152    scoped: Option<&'static crate::format::Rules>,
153) -> CommandResult {
154    let refuse = |status, m: String| CommandResult::new("skill", status, &redact(&m, secret));
155    let s = match crate::setup(path, feature, state, "skill") {
156        Ok(s) => s,
157        Err(r) => return *r,
158    };
159    let root = PathBuf::from(&s.snapshot.root);
160    let planning = s.format.planning();
161    let installed = skills::discover_with(&root, planning);
162    let Some(skill) = installed
163        .iter()
164        .find(|k| k.name == name || k.name == format!("{}{name}", planning.prefix))
165    else {
166        return refuse(
167            CommandStatus::NotFound,
168            format!("no skill `{name}` (see `speccoder spec`)"),
169        );
170    };
171    if let Some(why) = &skill.refused {
172        return refuse(
173            CommandStatus::UsageError,
174            format!("`{}` is not hosted: {why}", skill.name),
175        );
176    }
177    if !skill.invocable {
178        return refuse(
179            CommandStatus::UsageError,
180            format!("`{}` is marked user-invocable: false", skill.name),
181        );
182    }
183    // Extension hooks around the format's core skills.
184    let event = core_event(planning, &skill.name).map(str::to_owned);
185    let mut hook_lines = Vec::new();
186    if let Some(e) = &event {
187        match run_hooks_in(
188            &format!("before_{e}"),
189            arguments,
190            path,
191            feature,
192            state,
193            &provider,
194            secret,
195            options,
196        ) {
197            Ok(lines) => hook_lines.extend(lines),
198            Err(stop) => return *stop,
199        }
200    }
201    let instruction = match skills::instruction(&root, skill, arguments) {
202        Ok(i) => i,
203        Err(e) => return refuse(CommandStatus::Error, e),
204    };
205    let config_text = verify::find_config(&root)
206        .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
207        .unwrap_or_default();
208    let budget = match substrate::run_config::parse(&config_text) {
209        Ok(c) => c,
210        Err(e) => {
211            return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
212                .with_code("CONFIG-INVALID");
213        }
214    };
215
216    let tmp = s.plane_dir.join("tmp");
217    let before = match worktree::checkpoint(&root, &tmp) {
218        Ok(c) => c,
219        Err(e) => {
220            return refuse(
221                CommandStatus::GitPrecondition,
222                format!(
223                    "a hosted skill needs a git repository with a commit: {}",
224                    e.0
225                ),
226            );
227        }
228    };
229    let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
230        &s.plane_dir,
231        s.model.prices,
232        options.progress.clone(),
233    ));
234    live.event(&progress::Event::TaskStarted {
235        feature: "skill".to_owned(),
236        task: skill.name.clone(),
237        title: skill.description.clone(),
238    });
239    let rt = match crate::tokio_runtime() {
240        Ok(rt) => rt,
241        Err(e) => return refuse(CommandStatus::RunFailed, e),
242    };
243    // The format's scripts run confined to the checkout.
244    let runner = match crate::sandbox::runner_at(&root) {
245        Ok((r, _)) => r,
246        Err(e) => return refuse(CommandStatus::ConfigError, e),
247    };
248    let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
249    // Which unit the skill works on. The format's scripts are told
250    // (Spec Kit: SPECIFY_FEATURE_DIRECTORY), so they never depend on a
251    // pointer file the model may not have written — except the skill that
252    // creates a unit, which a variable would redirect into an old one.
253    let short = skill
254        .name
255        .strip_prefix(planning.prefix)
256        .unwrap_or(&skill.name);
257    let creates = planning.creates.contains(&short);
258    let selected = s.snapshot.feature.as_ref().map(|f| f.feature.dir.clone());
259    let env: Vec<(String, String)> = match (planning.current_env, &selected) {
260        (Some(var), Some(dir)) if !creates => {
261            vec![(var.to_owned(), canonical.join(dir).display().to_string())]
262        }
263        _ => Vec::new(),
264    };
265    let server = match tools::WorkspaceTools::new(
266        &root,
267        tools::policy::Policy::new(&[], &[])
268            .rules(scoped.unwrap_or_else(|| s.format.rules()))
269            .authoring(),
270    ) {
271        Ok(t) => Arc::new(t.with_runner(runner).with_env(env)),
272        Err(e) => return refuse(CommandStatus::Error, e.to_string()),
273    };
274    let mut input = substrate::host::input(
275        &skill.name,
276        &instruction,
277        &canonical.display().to_string(),
278        (budget.max_turns, budget.max_tool_calls),
279        options.approver.is_some(),
280    );
281    if scoped.is_some() {
282        input["settings"]["scope"] = serde_json::json!("bug");
283    }
284    let outcome = rt.block_on(async {
285        let plane = substrate::Plane::open_with(
286            &s.plane_dir,
287            &s.model,
288            Arc::clone(&provider),
289            Some(server),
290            substrate::Hooks {
291                progress: Some(Arc::clone(&live)),
292                format: Some(s.format),
293                ..substrate::Hooks::default()
294            },
295        )?;
296        // The installed skill and the person's arguments: trusted.
297        let outcome = plane
298            .skill(&skill.name, agentplane::core::Tainted::trusted(input))
299            .await?;
300        // Questions and calls are decided inline, recorded with who
301        // answered; "later" ends the skill (a hosted run is not resumed).
302        let context = crate::approvals::PresentContext {
303            root: Some(canonical.clone()),
304            ..crate::approvals::PresentContext::default()
305        };
306        plane
307            .decide_inline(outcome, options.approver.as_deref(), &context)
308            .await
309    });
310    // Always Allow answered in a hosted skill: its exact argv joins the
311    // allow list, as anywhere else.
312    let outcome = outcome.map(|i| {
313        let _ = crate::actions::allow_always_inline(
314            &root,
315            &i.always.first().map_or_else(String::new, |a| {
316                a.task.split('/').next().unwrap_or_default().to_owned()
317            }),
318            &i.always,
319            state,
320        );
321        i.outcome
322    });
323    let outcome = match outcome {
324        Ok(o) => o,
325        Err(e) => return refuse(CommandStatus::RunFailed, e.0),
326    };
327    let output = outcome
328        .output
329        .as_ref()
330        .map(|o| o.peek().clone())
331        .unwrap_or(serde_json::Value::Null);
332    let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
333    let status = if succeeded {
334        output["status"].as_str().unwrap_or("failed").to_owned()
335    } else {
336        "failed".to_owned()
337    };
338    let changes: Vec<(String, String)> = worktree::checkpoint(&root, &tmp)
339        .and_then(|after| worktree::diff(&root, &before, &after))
340        .unwrap_or_default()
341        .into_iter()
342        .map(|c| {
343            let kind = match c.kind {
344                worktree::ChangeKind::Added => "added",
345                worktree::ChangeKind::Modified => "modified",
346                worktree::ChangeKind::Deleted => "deleted",
347            };
348            (c.path, kind.to_owned())
349        })
350        .collect();
351    let report = SkillRunReport {
352        skill: skill.name.clone(),
353        run_id: outcome.run_id.to_string(),
354        summary: output["report"]["summary"].as_str().map(str::to_owned),
355        questions: output["report"]["questions"]
356            .as_array()
357            .map(|a| {
358                a.iter()
359                    .filter_map(|q| q.as_str().map(str::to_owned))
360                    .collect()
361            })
362            .unwrap_or_default(),
363        changes,
364        tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
365        pending: output.get("pending").cloned(),
366        reason: output["reason"]
367            .as_str()
368            .map(str::to_owned)
369            .or_else(|| (!succeeded).then(|| format!("{:?}", outcome.status))),
370        usage: output.get("usage").cloned(),
371        status: status.clone(),
372        hooks: Vec::new(),
373        current: None,
374    };
375    // A unit the creating skill made becomes current, in the format's own
376    // pointer, whether or not the model recorded it.
377    let mut report = report;
378    if creates && succeeded {
379        let known: Vec<&str> = s.snapshot.features.iter().map(|f| f.dir.as_str()).collect();
380        let made = s
381            .format
382            .inspect(&root, None)
383            .map(|after| after.features)
384            .unwrap_or_default()
385            .into_iter()
386            .rfind(|f| !known.contains(&f.dir.as_str()));
387        if let Some(f) = made {
388            match s.format.set_current(&root, Some(&f.dir)) {
389                Ok(pointer) => {
390                    report.current = Some(format!("{} (recorded in {pointer})", f.name));
391                }
392                Err(e) => report.current = Some(format!("{} (not recorded: {e})", f.name)),
393            }
394        }
395    } else if let Some(f) = s.snapshot.feature.as_ref() {
396        report.current = Some(format!(
397            "{} ({})",
398            f.feature.name,
399            crate::current::reason_text(s.snapshot.selection.reason, s.format)
400        ));
401    }
402    let tokens = |k: &str| {
403        report
404            .usage
405            .as_ref()
406            .and_then(|u| u[k].as_u64())
407            .unwrap_or(0)
408    };
409    let (code, message) = match status.as_str() {
410        "completed" => (
411            CommandStatus::Ok,
412            format!(
413                "{} done: {} file(s) changed in your checkout, uncommitted — review them (git diff, git status)",
414                skill.name,
415                report.changes.len()
416            ),
417        ),
418        "failed" => (
419            CommandStatus::RunFailed,
420            format!(
421                "{} failed: {}",
422                skill.name,
423                report.reason.clone().unwrap_or_default()
424            ),
425        ),
426        other => (
427            CommandStatus::NeedsHuman,
428            format!(
429                "{} stopped ({other}): {}",
430                skill.name,
431                report
432                    .reason
433                    .clone()
434                    .filter(|r| !r.is_empty())
435                    .or_else(|| {
436                        (!report.questions.is_empty())
437                            .then(|| format!("open questions: {}", report.questions.join(" | ")))
438                    })
439                    .or_else(|| report
440                        .pending
441                        .as_ref()
442                        .map(|p| p["reason"].as_str().unwrap_or_default().to_owned()))
443                    .unwrap_or_default()
444            ),
445        ),
446    };
447    live.event(&progress::Event::TaskFinished {
448        task: skill.name.clone(),
449        outcome: status,
450        message: redact(&message, secret),
451        run: None,
452        input_tokens: tokens("input_tokens"),
453        output_tokens: tokens("output_tokens"),
454        cache_read_tokens: tokens("cache_read_tokens"),
455        cache_write_tokens: tokens("cache_write_tokens"),
456        usd: None,
457    });
458    if code == CommandStatus::Ok
459        && let Some(e) = &event
460    {
461        match run_hooks_in(
462            &format!("after_{e}"),
463            arguments,
464            path,
465            feature,
466            state,
467            &provider,
468            secret,
469            options,
470        ) {
471            Ok(lines) => hook_lines.extend(lines),
472            Err(stop) => {
473                report.hooks = hook_lines;
474                return CommandResult::carried("skill", &stop)
475                    .with_data(CommandData::Skill(Box::new(report)));
476            }
477        }
478    }
479    report.hooks = hook_lines;
480    CommandResult::new("skill", code, &redact(&message, secret))
481        .with_data(CommandData::Skill(Box::new(report)))
482}
483
484/// `speccoder spec <name>` with the configured model, for `feature`.
485#[must_use]
486pub fn run(
487    path: &Path,
488    feature: Option<&str>,
489    name: &str,
490    arguments: &str,
491    options: &RunOptions,
492) -> CommandResult {
493    crate::with_driver("skill", path, None, |state, provider, key| {
494        run_in(
495            path,
496            feature,
497            name,
498            arguments,
499            state,
500            Arc::clone(provider),
501            Some(key),
502            options,
503        )
504    })
505}
506
507/// Runs the mandatory hooks of `event` as hosted skills and lists the
508/// optional ones. `Ok(lines)` to show; `Err` when a mandatory hook
509/// did not complete — the caller stops with it.
510pub fn run_hooks(
511    event: &str,
512    arguments: &str,
513    path: &Path,
514    state: &Path,
515    provider: &Arc<dyn ModelProvider>,
516    secret: Option<&str>,
517    options: &RunOptions,
518) -> Result<Vec<String>, Box<CommandResult>> {
519    run_hooks_in(
520        event, arguments, path, None, state, provider, secret, options,
521    )
522}
523
524/// [`run_hooks`] for `feature`, which the hook skills work on.
525#[allow(clippy::too_many_arguments)]
526pub fn run_hooks_in(
527    event: &str,
528    arguments: &str,
529    path: &Path,
530    feature: Option<&str>,
531    state: &Path,
532    provider: &Arc<dyn ModelProvider>,
533    secret: Option<&str>,
534    options: &RunOptions,
535) -> Result<Vec<String>, Box<CommandResult>> {
536    let root = root_of(path)?;
537    let plan = crate::hooks::plan(&root, event)
538        .map_err(|e| Box::new(CommandResult::new("hooks", CommandStatus::ConfigError, &e)))?;
539    let mut lines: Vec<String> = Vec::new();
540    for hook in &plan.run {
541        let note = |text: String| {
542            if let Some(p) = &options.progress {
543                p.event(&progress::Event::Notice { text });
544            }
545        };
546        note(format!(
547            "{event} hook ({}): {}",
548            hook.extension, hook.command
549        ));
550        let res = run_in(
551            path,
552            feature,
553            &hook.skill(),
554            arguments,
555            state,
556            Arc::clone(provider),
557            secret,
558            options,
559        );
560        if res.status() != CommandStatus::Ok {
561            let status = if res.status() == CommandStatus::NotFound {
562                CommandStatus::ConfigError
563            } else {
564                res.status()
565            };
566            return Err(Box::new(CommandResult::new(
567                "hooks",
568                status,
569                &format!(
570                    "mandatory {event} hook `{}` ({}) did not complete: {}",
571                    hook.command,
572                    hook.extension,
573                    res.message()
574                ),
575            )));
576        }
577        lines.push(format!(
578            "ran {event} hook {} ({})",
579            hook.command, hook.extension
580        ));
581    }
582    for hook in &plan.offer {
583        let line = crate::hooks::offer_line(event, hook);
584        if let Some(p) = &options.progress {
585            p.event(&progress::Event::Notice { text: line.clone() });
586        }
587        lines.push(line);
588    }
589    for (hook, why) in &plan.skipped {
590        lines.push(format!(
591            "skipped {event} hook {} ({}): {why}",
592            hook.command, hook.extension
593        ));
594    }
595    Ok(lines)
596}
597
598/// The `before_` / `after_` event name for a hosted core skill.
599fn core_event<'a>(planning: &Planning, skill: &'a str) -> Option<&'a str> {
600    let short = skill.strip_prefix(planning.prefix).unwrap_or(skill);
601    planning.core.contains(&short).then_some(short)
602}
603
604/// The current unit's planning files that are not committed — new or
605/// changed files under the unit's directory. `speccoder run` reads committed
606/// files only, so these are what stands between planning and a run.
607#[must_use]
608pub fn uncommitted(path: &Path, feature: Option<&str>) -> Vec<String> {
609    let format = crate::format::at(path);
610    let Some(snap) = format.open(path, feature) else {
611        return Vec::new();
612    };
613    let Some(f) = snap.feature.as_ref() else {
614        return Vec::new();
615    };
616    let (top, dir) = repo_dir(Path::new(&snap.root), &f.feature.dir);
617    crate::git::Git::at(&top)
618        .run(&[
619            "status",
620            "--porcelain",
621            "-z",
622            "--untracked-files=all",
623            "--",
624            &dir,
625        ])
626        .map(|out| {
627            String::from_utf8_lossy(&out)
628                .split('\0')
629                .filter(|e| e.len() > 3)
630                .map(|e| e[3..].to_owned())
631                .collect()
632        })
633        .unwrap_or_default()
634}
635
636/// Commits exactly `paths` (repository-root relative, inside the current
637/// unit's directory) as the unit's planning: `spec(<unit>): <files>`.
638/// Nothing else that is staged or changed goes into the commit.
639#[must_use]
640pub fn commit_planning(path: &Path, feature: Option<&str>, paths: &[String]) -> CommandResult {
641    commit_planning_as(path, feature, paths, None)
642}
643
644/// [`commit_planning`] with the commit's subject (default: the files).
645#[must_use]
646pub fn commit_planning_as(
647    path: &Path,
648    feature: Option<&str>,
649    paths: &[String],
650    subject: Option<&str>,
651) -> CommandResult {
652    let refuse = |status, m: &str| CommandResult::new("commit", status, m);
653    let format = crate::format::at(path);
654    let Some(snap) = format.open(path, feature) else {
655        return refuse(
656            CommandStatus::NotAProject,
657            &(format.messages().not_a_project)(path),
658        );
659    };
660    let Some(f) = snap.feature.as_ref() else {
661        return refuse(
662            CommandStatus::SelectionNeeded,
663            "no current feature to commit for",
664        );
665    };
666    let (top, unit) = repo_dir(Path::new(&snap.root), &f.feature.dir);
667    let dir = format!("{unit}/");
668    if paths.is_empty() {
669        return refuse(CommandStatus::NotFound, "nothing to commit");
670    }
671    // Inside the unit by its components: `<unit>/../../src/x` starts with
672    // the unit's name and is not in it.
673    let inside = |p: &str| {
674        p.strip_prefix(&dir).is_some_and(|rest| {
675            rest.split('/')
676                .all(|c| !c.is_empty() && c != "." && c != "..")
677        })
678    };
679    if let Some(p) = paths.iter().find(|p| !inside(p)) {
680        return refuse(
681            CommandStatus::UsageError,
682            &format!(
683                "`{p}` is not in {}; only planning files are committed here",
684                f.feature.dir
685            ),
686        );
687    }
688    let git = crate::git::Git::at(&top);
689    let names: Vec<&str> = paths
690        .iter()
691        .map(|p| p.rsplit('/').next().unwrap_or(p))
692        .collect();
693    let message = subject.map_or_else(
694        || format!("spec({}): {}", f.feature.name, names.join(", ")),
695        |t| format!("spec({}): {t}", f.feature.name),
696    );
697    let mut add = vec!["add", "--"];
698    add.extend(paths.iter().map(String::as_str));
699    let mut commit = vec!["commit", "-q", "-m", &message, "--"];
700    commit.extend(paths.iter().map(String::as_str));
701    match git.run(&add).and_then(|_| git.run(&commit)) {
702        Ok(_) => {
703            let id = git
704                .line(&["rev-parse", "--short", "HEAD"])
705                .unwrap_or_default();
706            CommandResult::new(
707                "commit",
708                CommandStatus::Ok,
709                &format!("committed {} file(s) as {id} \"{message}\"", paths.len()),
710            )
711        }
712        Err(e) => refuse(
713            CommandStatus::GitPrecondition,
714            &format!("commit failed: {}", e.0),
715        ),
716    }
717}
718
719/// Commits exactly `paths` (repository-relative) in the repository of
720/// `root` with `message` as given: a person-commanded write's own commit.
721/// Returns the commit id as the message.
722#[must_use]
723pub fn commit_exactly(root: &Path, paths: &[String], message: &str) -> CommandResult {
724    let refuse = |status, m: &str| CommandResult::new("commit", status, m);
725    if paths.is_empty() {
726        return refuse(CommandStatus::NotFound, "nothing to commit");
727    }
728    let (top, _) = repo_dir(root, "");
729    let git = crate::git::Git::at(&top);
730    let mut add = vec!["add", "--"];
731    add.extend(paths.iter().map(String::as_str));
732    let mut commit = vec!["commit", "-q", "-m", message, "--"];
733    commit.extend(paths.iter().map(String::as_str));
734    match git.run(&add).and_then(|_| git.run(&commit)) {
735        Ok(_) => {
736            let id = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
737            CommandResult::new("commit", CommandStatus::Ok, &id)
738        }
739        Err(e) => refuse(
740            CommandStatus::GitPrecondition,
741            &format!("commit failed: {}", e.0),
742        ),
743    }
744}
745
746/// The repository's top level and `unit_dir` (relative to `root`) relative
747/// to it: git's porcelain paths are repository-relative.
748fn repo_dir(root: &Path, unit_dir: &str) -> (std::path::PathBuf, String) {
749    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
750    let top = crate::git::Git::at(root)
751        .toplevel()
752        .ok()
753        .flatten()
754        .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
755    let prefix = canonical(root)
756        .strip_prefix(&top)
757        .map(|p| p.to_string_lossy().replace('\\', "/"))
758        .unwrap_or_default();
759    let dir = if prefix.is_empty() {
760        unit_dir.to_owned()
761    } else {
762        format!("{prefix}/{unit_dir}")
763    };
764    (top, dir)
765}