1use 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#[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
63pub 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#[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,
99 feature,
100 name,
101 arguments,
102 state,
103 provider,
104 secret,
105 options,
106 Mode::Plan,
107 None,
108 );
109 let restored = crate::review::restore(&reviews);
110 if restored.is_empty() {
111 return result;
112 }
113 let message = format!(
114 "{}; undone: the skill changed the person's review ({}) — only `speccoder review` writes it",
115 result.message(),
116 restored.join(", ")
117 );
118 let status = result.status();
119 result.reworded(status, &message)
120}
121
122#[allow(clippy::too_many_arguments)]
125pub(crate) fn run_scoped(
126 path: &Path,
127 name: &str,
128 arguments: &str,
129 state: &Path,
130 provider: Arc<dyn ModelProvider>,
131 secret: Option<&str>,
132 options: &RunOptions,
133 rules: &'static crate::format::Rules,
134) -> CommandResult {
135 let reviews = crate::review::snapshot(path);
136 let result = run_unguarded(
137 path,
138 None,
139 name,
140 arguments,
141 state,
142 provider,
143 secret,
144 options,
145 Mode::Scoped(rules),
146 None,
147 );
148 let _ = crate::review::restore(&reviews);
149 result
150}
151
152#[derive(Clone, Copy)]
154pub(crate) enum Mode {
155 Plan,
157 Scoped(&'static crate::format::Rules),
159 Explore,
162}
163
164#[allow(clippy::too_many_arguments)]
168pub(crate) fn run_exploring(
169 path: &Path,
170 name: &str,
171 idea: &str,
172 state: &Path,
173 provider: Arc<dyn ModelProvider>,
174 secret: Option<&str>,
175 options: &RunOptions,
176) -> (CommandResult, serde_json::Value) {
177 let mut report = serde_json::Value::Null;
180 let result = run_unguarded(
181 path,
182 None,
183 name,
184 idea,
185 state,
186 provider,
187 secret,
188 options,
189 Mode::Explore,
190 Some(&mut report),
191 );
192 (result, report)
193}
194
195#[allow(clippy::too_many_arguments)]
196fn run_unguarded(
197 path: &Path,
198 feature: Option<&str>,
199 name: &str,
200 arguments: &str,
201 state: &Path,
202 provider: Arc<dyn ModelProvider>,
203 secret: Option<&str>,
204 options: &RunOptions,
205 mode: Mode,
206 report_out: Option<&mut serde_json::Value>,
207) -> CommandResult {
208 let scoped = match mode {
209 Mode::Scoped(rules) => Some(rules),
210 Mode::Plan | Mode::Explore => None,
211 };
212 let exploring = matches!(mode, Mode::Explore);
213 let refuse = |status, m: String| CommandResult::new("skill", status, &redact(&m, secret));
214 let s = match crate::setup(path, feature, state, "skill") {
215 Ok(s) => s,
216 Err(r) => return *r,
217 };
218 let root = PathBuf::from(&s.snapshot.root);
219 let planning = s.format.planning();
220 let installed = skills::discover_with(&root, planning);
221 let fallback =
223 (exploring && name == speccoder_core::explore::FALLBACK).then(|| skills::Skill {
224 name: name.to_owned(),
225 description: "explore an idea: approaches to choose from".to_owned(),
226 path: String::new(),
227 invocable: true,
228 refused: None,
229 });
230 let Some(skill) = fallback.as_ref().or_else(|| {
231 installed
232 .iter()
233 .find(|k| k.name == name || k.name == format!("{}{name}", planning.prefix))
234 }) else {
235 return refuse(
236 CommandStatus::NotFound,
237 format!("no skill `{name}` (see `speccoder spec`)"),
238 );
239 };
240 if let Some(why) = &skill.refused {
241 return refuse(
242 CommandStatus::UsageError,
243 format!("`{}` is not hosted: {why}", skill.name),
244 );
245 }
246 if !skill.invocable {
247 return refuse(
248 CommandStatus::UsageError,
249 format!("`{}` is marked user-invocable: false", skill.name),
250 );
251 }
252 let event = core_event(planning, &skill.name)
255 .filter(|_| !exploring)
256 .map(str::to_owned);
257 let mut hook_lines = Vec::new();
258 if let Some(e) = &event {
259 match run_hooks_in(
260 &format!("before_{e}"),
261 arguments,
262 path,
263 feature,
264 state,
265 &provider,
266 secret,
267 options,
268 ) {
269 Ok(lines) => hook_lines.extend(lines),
270 Err(stop) => return *stop,
271 }
272 }
273 let instruction = match if fallback.is_some() {
274 Ok(format!(
275 "{}\n\n# The idea\n\n{arguments}",
276 substrate::host::EXPLORE_FALLBACK
277 ))
278 } else if exploring {
279 skills::instruction(&root, skill, arguments)
281 .map(|i| format!("{i}\n\n# The idea\n\n{arguments}"))
282 } else {
283 skills::instruction(&root, skill, arguments)
284 } {
285 Ok(i) => i,
286 Err(e) => return refuse(CommandStatus::Error, e),
287 };
288 let config_text = verify::find_config(&root)
289 .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
290 .unwrap_or_default();
291 let budget = match substrate::run_config::parse(&config_text) {
292 Ok(c) => c,
293 Err(e) => {
294 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
295 .with_code("CONFIG-INVALID");
296 }
297 };
298
299 let tmp = s.plane_dir.join("tmp");
300 let before = match worktree::checkpoint(&root, &tmp) {
301 Ok(c) => c,
302 Err(e) => {
303 return refuse(
304 CommandStatus::GitPrecondition,
305 format!(
306 "a hosted skill needs a git repository with a commit: {}",
307 e.0
308 ),
309 );
310 }
311 };
312 let turns = Arc::new(Turns::default());
315 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
316 &s.plane_dir,
317 s.model.prices,
318 Some(Arc::new(Counted {
319 turns: Arc::clone(&turns),
320 next: options.progress.clone(),
321 }) as Arc<dyn progress::Progress>),
322 ));
323 live.event(&progress::Event::TaskStarted {
324 feature: "skill".to_owned(),
325 task: skill.name.clone(),
326 title: skill.description.clone(),
327 });
328 let rt = match crate::tokio_runtime() {
329 Ok(rt) => rt,
330 Err(e) => return refuse(CommandStatus::RunFailed, e),
331 };
332 let runner = match crate::sandbox::runner_at(&root) {
334 Ok((r, _)) => r,
335 Err(e) => return refuse(CommandStatus::ConfigError, e),
336 };
337 let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
338 let short = skill
343 .name
344 .strip_prefix(planning.prefix)
345 .unwrap_or(&skill.name);
346 let creates = planning.creates.contains(&short) && !exploring;
347 let selected = s.snapshot.feature.as_ref().map(|f| f.feature.dir.clone());
348 let env: Vec<(String, String)> = match (planning.current_env, &selected) {
349 (Some(var), Some(dir)) if !creates => {
350 vec![(var.to_owned(), canonical.join(dir).display().to_string())]
351 }
352 _ => Vec::new(),
353 };
354 let server_policy = if exploring {
355 tools::policy::Policy::new(&[], &[])
356 .rules(s.format.rules())
357 .exploring(tools::policy::Exploring {
358 paths: planning.explore_paths,
359 commands: planning.explore_commands,
360 })
361 } else {
362 tools::policy::Policy::new(&[], &[])
363 .rules(scoped.unwrap_or_else(|| s.format.rules()))
364 .authoring()
365 };
366 let server = match tools::WorkspaceTools::new(&root, server_policy) {
367 Ok(t) => Arc::new(t.with_runner(runner).with_env(env)),
368 Err(e) => return refuse(CommandStatus::Error, e.to_string()),
369 };
370 let mut input = substrate::host::input(
371 &skill.name,
372 &instruction,
373 &canonical.display().to_string(),
374 (budget.max_turns, budget.max_tool_calls),
375 options.approver.is_some(),
376 );
377 if scoped.is_some() {
378 input["settings"]["scope"] = serde_json::json!("bug");
379 }
380 if exploring {
381 input["settings"]["mode"] = serde_json::json!("explore");
382 }
383 let outcome = rt.block_on(async {
384 let plane = substrate::Plane::open_with(
385 &s.plane_dir,
386 &s.model,
387 Arc::clone(&provider),
388 Some(server),
389 substrate::Hooks {
390 progress: Some(Arc::clone(&live)),
391 format: Some(s.format),
392 ..substrate::Hooks::default()
393 },
394 )?;
395 let outcome = plane
397 .skill(&skill.name, agentplane::core::Tainted::trusted(input))
398 .await?;
399 let context = crate::approvals::PresentContext {
402 root: Some(canonical.clone()),
403 ..crate::approvals::PresentContext::default()
404 };
405 plane
406 .decide_inline(outcome, options.approver.as_deref(), &context)
407 .await
408 });
409 let outcome = outcome.map(|i| {
412 let _ = crate::actions::allow_always_inline(
413 &root,
414 &i.always.first().map_or_else(String::new, |a| {
415 a.task.split('/').next().unwrap_or_default().to_owned()
416 }),
417 &i.always,
418 state,
419 );
420 i.outcome
421 });
422 let outcome = match outcome {
423 Ok(o) => o,
424 Err(e) => return refuse(CommandStatus::RunFailed, e.0),
425 };
426 let output = outcome
427 .output
428 .as_ref()
429 .map(|o| o.peek().clone())
430 .unwrap_or(serde_json::Value::Null);
431 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
432 if let Some(out) = report_out {
433 out.clone_from(&output["report"]);
434 }
435 let status = if succeeded {
436 output["status"].as_str().unwrap_or("failed").to_owned()
437 } else {
438 "failed".to_owned()
439 };
440 let changes: Vec<(String, String)> = worktree::checkpoint(&root, &tmp)
441 .and_then(|after| worktree::diff(&root, &before, &after))
442 .unwrap_or_default()
443 .into_iter()
444 .map(|c| {
445 let kind = match c.kind {
446 worktree::ChangeKind::Added => "added",
447 worktree::ChangeKind::Modified => "modified",
448 worktree::ChangeKind::Deleted => "deleted",
449 };
450 (c.path, kind.to_owned())
451 })
452 .collect();
453 let report = SkillRunReport {
454 skill: skill.name.clone(),
455 run_id: outcome.run_id.to_string(),
456 summary: output["report"]["summary"].as_str().map(str::to_owned),
457 questions: output["report"]["questions"]
458 .as_array()
459 .map(|a| {
460 a.iter()
461 .filter_map(|q| q.as_str().map(str::to_owned))
462 .collect()
463 })
464 .unwrap_or_default(),
465 changes,
466 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
467 pending: output.get("pending").cloned(),
468 reason: output["reason"]
469 .as_str()
470 .map(str::to_owned)
471 .or_else(|| (!succeeded).then(|| format!("{:?}", outcome.status))),
472 usage: output.get("usage").cloned(),
473 status: status.clone(),
474 hooks: Vec::new(),
475 current: None,
476 };
477 let mut report = report;
480 if creates && succeeded {
481 let known: Vec<&str> = s.snapshot.features.iter().map(|f| f.dir.as_str()).collect();
482 let made = s
483 .format
484 .inspect(&root, None)
485 .map(|after| after.features)
486 .unwrap_or_default()
487 .into_iter()
488 .rfind(|f| !known.contains(&f.dir.as_str()));
489 if let Some(f) = made {
490 match s.format.set_current(&root, Some(&f.dir)) {
491 Ok(pointer) => {
492 report.current = Some(format!("{} (recorded in {pointer})", f.name));
493 }
494 Err(e) => report.current = Some(format!("{} (not recorded: {e})", f.name)),
495 }
496 }
497 } else if let Some(f) = s.snapshot.feature.as_ref() {
498 report.current = Some(format!(
499 "{} ({})",
500 f.feature.name,
501 crate::current::reason_text(s.snapshot.selection.reason, s.format)
502 ));
503 }
504 let tokens = |k: &str| {
505 report
506 .usage
507 .as_ref()
508 .and_then(|u| u[k].as_u64())
509 .unwrap_or(0)
510 };
511 let (code, message) = match status.as_str() {
512 "completed" => (
513 CommandStatus::Ok,
514 format!(
515 "{} done: {} file(s) changed in your checkout, uncommitted — review them (git diff, git status)",
516 skill.name,
517 report.changes.len()
518 ),
519 ),
520 "failed" => (
521 CommandStatus::RunFailed,
522 format!(
523 "{} failed: {}",
524 skill.name,
525 report.reason.clone().unwrap_or_default()
526 ),
527 ),
528 other => (
529 CommandStatus::NeedsHuman,
530 format!(
531 "{} stopped ({other}): {}",
532 skill.name,
533 report
535 .pending
536 .as_ref()
537 .filter(|p| p.is_object())
538 .map(|p| tools::policy::unasked(
539 p,
540 &format!(
541 "run it again with prompts (`speccoder spec {}` without `--no-prompt`) and approve it when asked",
542 skill
543 .name
544 .strip_prefix(s.format.planning().prefix)
545 .unwrap_or(&skill.name)
546 ),
547 ))
548 .or_else(|| report.reason.clone().filter(|r| !r.is_empty()))
549 .or_else(|| {
550 (!report.questions.is_empty())
551 .then(|| format!("open questions: {}", report.questions.join(" | ")))
552 })
553 .or_else(|| report
554 .pending
555 .as_ref()
556 .map(|p| p["reason"].as_str().unwrap_or_default().to_owned())
557 .filter(|r| !r.is_empty()))
558 .or_else(|| report
560 .summary
561 .clone()
562 .filter(|r| !r.trim().is_empty())
563 .map(|r| format!("the skill says: {r}")))
564 .unwrap_or_else(|| format!(
565 "the skill ended without saying why — run it again (speccoder spec {})",
566 skill
567 .name
568 .strip_prefix(s.format.planning().prefix)
569 .unwrap_or(&skill.name)
570 ))
571 ),
572 ),
573 };
574 crate::controls::record_skill_usage(
577 &s.plane_dir,
578 &skill.name,
579 [
580 tokens("input_tokens"),
581 tokens("output_tokens"),
582 tokens("cache_read_tokens"),
583 tokens("cache_write_tokens"),
584 ],
585 s.model.prices,
586 u64::from(turns.calls()),
587 );
588 live.event(&progress::Event::TaskFinished {
589 task: skill.name.clone(),
590 outcome: status,
591 message: redact(&message, secret),
592 run: None,
593 input_tokens: tokens("input_tokens"),
594 output_tokens: tokens("output_tokens"),
595 cache_read_tokens: tokens("cache_read_tokens"),
596 cache_write_tokens: tokens("cache_write_tokens"),
597 usd: None,
598 regression: None,
599 });
600 if code == CommandStatus::Ok
601 && let Some(e) = &event
602 {
603 match run_hooks_in(
604 &format!("after_{e}"),
605 arguments,
606 path,
607 feature,
608 state,
609 &provider,
610 secret,
611 options,
612 ) {
613 Ok(lines) => hook_lines.extend(lines),
614 Err(stop) => {
615 report.hooks = hook_lines;
616 return CommandResult::carried("skill", &stop)
617 .with_data(CommandData::Skill(Box::new(report)));
618 }
619 }
620 }
621 report.hooks = hook_lines;
622 CommandResult::new("skill", code, &redact(&message, secret))
623 .with_data(CommandData::Skill(Box::new(report)))
624}
625
626#[must_use]
628pub fn run(
629 path: &Path,
630 feature: Option<&str>,
631 name: &str,
632 arguments: &str,
633 options: &RunOptions,
634) -> CommandResult {
635 crate::with_driver("skill", path, None, |state, provider, key| {
636 run_in(
637 path,
638 feature,
639 name,
640 arguments,
641 state,
642 Arc::clone(provider),
643 Some(key),
644 options,
645 )
646 })
647}
648
649pub fn run_hooks(
653 event: &str,
654 arguments: &str,
655 path: &Path,
656 state: &Path,
657 provider: &Arc<dyn ModelProvider>,
658 secret: Option<&str>,
659 options: &RunOptions,
660) -> Result<Vec<String>, Box<CommandResult>> {
661 run_hooks_in(
662 event, arguments, path, None, state, provider, secret, options,
663 )
664}
665
666#[allow(clippy::too_many_arguments)]
668pub fn run_hooks_in(
669 event: &str,
670 arguments: &str,
671 path: &Path,
672 feature: Option<&str>,
673 state: &Path,
674 provider: &Arc<dyn ModelProvider>,
675 secret: Option<&str>,
676 options: &RunOptions,
677) -> Result<Vec<String>, Box<CommandResult>> {
678 let root = root_of(path)?;
679 let plan = crate::hooks::plan(&root, event)
680 .map_err(|e| Box::new(CommandResult::new("hooks", CommandStatus::ConfigError, &e)))?;
681 let mut lines: Vec<String> = Vec::new();
682 for hook in &plan.run {
683 let note = |text: String| {
684 if let Some(p) = &options.progress {
685 p.event(&progress::Event::Notice { text });
686 }
687 };
688 note(format!(
689 "{event} hook ({}): {}",
690 hook.extension, hook.command
691 ));
692 let res = run_in(
693 path,
694 feature,
695 &hook.skill(),
696 arguments,
697 state,
698 Arc::clone(provider),
699 secret,
700 options,
701 );
702 if res.status() != CommandStatus::Ok {
703 let status = if res.status() == CommandStatus::NotFound {
704 CommandStatus::ConfigError
705 } else {
706 res.status()
707 };
708 return Err(Box::new(CommandResult::new(
709 "hooks",
710 status,
711 &format!(
712 "mandatory {event} hook `{}` ({}) did not complete: {}",
713 hook.command,
714 hook.extension,
715 res.message()
716 ),
717 )));
718 }
719 lines.push(format!(
720 "ran {event} hook {} ({})",
721 hook.command, hook.extension
722 ));
723 }
724 for hook in &plan.offer {
725 let line = crate::hooks::offer_line(event, hook);
726 if let Some(p) = &options.progress {
727 p.event(&progress::Event::Notice { text: line.clone() });
728 }
729 lines.push(line);
730 }
731 for (hook, why) in &plan.skipped {
732 lines.push(format!(
733 "skipped {event} hook {} ({}): {why}",
734 hook.command, hook.extension
735 ));
736 }
737 Ok(lines)
738}
739
740fn core_event<'a>(planning: &Planning, skill: &'a str) -> Option<&'a str> {
742 let short = skill.strip_prefix(planning.prefix).unwrap_or(skill);
743 planning.core.contains(&short).then_some(short)
744}
745
746#[must_use]
750pub fn uncommitted(path: &Path, feature: Option<&str>) -> Vec<String> {
751 let format = crate::format::at(path);
752 let Some(snap) = format.open(path, feature) else {
753 return Vec::new();
754 };
755 let Some(f) = snap.feature.as_ref() else {
756 return Vec::new();
757 };
758 let (top, dir) = repo_dir(Path::new(&snap.root), &f.feature.dir);
759 crate::git::Git::at(&top)
760 .run(&[
761 "status",
762 "--porcelain",
763 "-z",
764 "--untracked-files=all",
765 "--",
766 &dir,
767 ])
768 .map(|out| {
769 String::from_utf8_lossy(&out)
770 .split('\0')
771 .filter(|e| e.len() > 3)
772 .map(|e| e[3..].to_owned())
773 .collect()
774 })
775 .unwrap_or_default()
776}
777
778#[must_use]
782pub fn commit_planning(path: &Path, feature: Option<&str>, paths: &[String]) -> CommandResult {
783 commit_planning_as(path, feature, paths, None)
784}
785
786#[must_use]
788pub fn commit_planning_as(
789 path: &Path,
790 feature: Option<&str>,
791 paths: &[String],
792 subject: Option<&str>,
793) -> CommandResult {
794 let refuse = |status, m: &str| CommandResult::new("commit", status, m);
795 let format = crate::format::at(path);
796 let Some(snap) = format.open(path, feature) else {
797 return refuse(
798 CommandStatus::NotAProject,
799 &(format.messages().not_a_project)(path),
800 );
801 };
802 let Some(f) = snap.feature.as_ref() else {
803 return refuse(
804 CommandStatus::SelectionNeeded,
805 "no current feature to commit for",
806 );
807 };
808 let (top, unit) = repo_dir(Path::new(&snap.root), &f.feature.dir);
809 let dir = format!("{unit}/");
810 if paths.is_empty() {
811 return refuse(CommandStatus::NotFound, "nothing to commit");
812 }
813 let inside = |p: &str| {
816 p.strip_prefix(&dir).is_some_and(|rest| {
817 rest.split('/')
818 .all(|c| !c.is_empty() && c != "." && c != "..")
819 })
820 };
821 if let Some(p) = paths.iter().find(|p| !inside(p)) {
822 return refuse(
823 CommandStatus::UsageError,
824 &format!(
825 "`{p}` is not in {}; only planning files are committed here",
826 f.feature.dir
827 ),
828 );
829 }
830 let git = crate::git::Git::at(&top);
831 let names: Vec<&str> = paths
832 .iter()
833 .map(|p| p.rsplit('/').next().unwrap_or(p))
834 .collect();
835 let message = subject.map_or_else(
836 || format!("spec({}): {}", f.feature.name, names.join(", ")),
837 |t| format!("spec({}): {t}", f.feature.name),
838 );
839 let mut add = vec!["add", "--"];
840 add.extend(paths.iter().map(String::as_str));
841 let mut commit = vec!["commit", "-q", "-m", &message, "--"];
842 commit.extend(paths.iter().map(String::as_str));
843 match git.run(&add).and_then(|_| git.run(&commit)) {
844 Ok(_) => {
845 let id = git
846 .line(&["rev-parse", "--short", "HEAD"])
847 .unwrap_or_default();
848 CommandResult::new(
849 "commit",
850 CommandStatus::Ok,
851 &format!("committed {} file(s) as {id} \"{message}\"", paths.len()),
852 )
853 }
854 Err(e) => refuse(
855 CommandStatus::GitPrecondition,
856 &format!("commit failed: {}", e.0),
857 ),
858 }
859}
860
861#[must_use]
865pub fn commit_exactly(root: &Path, paths: &[String], message: &str) -> CommandResult {
866 let refuse = |status, m: &str| CommandResult::new("commit", status, m);
867 if paths.is_empty() {
868 return refuse(CommandStatus::NotFound, "nothing to commit");
869 }
870 let (top, _) = repo_dir(root, "");
871 let git = crate::git::Git::at(&top);
872 let mut add = vec!["add", "--"];
873 add.extend(paths.iter().map(String::as_str));
874 let mut commit = vec!["commit", "-q", "-m", message, "--"];
875 commit.extend(paths.iter().map(String::as_str));
876 match git.run(&add).and_then(|_| git.run(&commit)) {
877 Ok(_) => {
878 let id = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
879 CommandResult::new("commit", CommandStatus::Ok, &id)
880 }
881 Err(e) => refuse(
882 CommandStatus::GitPrecondition,
883 &format!("commit failed: {}", e.0),
884 ),
885 }
886}
887
888fn repo_dir(root: &Path, unit_dir: &str) -> (std::path::PathBuf, String) {
891 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
892 let top = crate::git::Git::at(root)
893 .toplevel()
894 .ok()
895 .flatten()
896 .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
897 let prefix = canonical(root)
898 .strip_prefix(&top)
899 .map(|p| p.to_string_lossy().replace('\\', "/"))
900 .unwrap_or_default();
901 let dir = if prefix.is_empty() {
902 unit_dir.to_owned()
903 } else {
904 format!("{prefix}/{unit_dir}")
905 };
906 (top, dir)
907}
908
909#[derive(Debug, Default)]
911struct Turns(std::sync::atomic::AtomicU32);
912
913impl Turns {
914 fn calls(&self) -> u32 {
916 self.0.load(std::sync::atomic::Ordering::SeqCst) + 1
917 }
918}
919
920#[derive(Debug)]
922struct Counted {
923 turns: Arc<Turns>,
924 next: Option<Arc<dyn progress::Progress>>,
925}
926
927impl progress::Progress for Counted {
928 fn event(&self, event: &progress::Event) {
929 if let progress::Event::ToolCall { turn, .. } = event {
930 self.turns
931 .0
932 .fetch_max(*turn, std::sync::atomic::Ordering::SeqCst);
933 }
934 if let Some(n) = &self.next {
935 n.event(event);
936 }
937 }
938}