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 instruction = match crate::layout::stack_text(&root).filter(|_| {
291 let short = skill
292 .name
293 .strip_prefix(planning.prefix)
294 .unwrap_or(&skill.name);
295 !exploring && matches!(short, "plan" | "tasks" | "propose")
296 }) {
297 Some(stack) => format!("{instruction}\n\n{stack}"),
298 None => instruction,
299 };
300 let config_text = verify::find_config(&root)
301 .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
302 .unwrap_or_default();
303 let budget = match substrate::run_config::parse(&config_text) {
304 Ok(c) => c,
305 Err(e) => {
306 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
307 .with_code("CONFIG-INVALID");
308 }
309 };
310
311 let tmp = s.plane_dir.join("tmp");
312 let before = match worktree::checkpoint(&root, &tmp) {
313 Ok(c) => c,
314 Err(e) => {
315 return refuse(
316 CommandStatus::GitPrecondition,
317 format!(
318 "a hosted skill needs a git repository with a commit: {}",
319 e.0
320 ),
321 );
322 }
323 };
324 let turns = Arc::new(Turns::default());
327 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
328 &s.plane_dir,
329 s.model.prices,
330 Some(Arc::new(Counted {
331 turns: Arc::clone(&turns),
332 next: options.progress.clone(),
333 }) as Arc<dyn progress::Progress>),
334 ));
335 live.event(&progress::Event::TaskStarted {
336 feature: "skill".to_owned(),
337 task: skill.name.clone(),
338 title: skill.description.clone(),
339 });
340 let rt = match crate::tokio_runtime() {
341 Ok(rt) => rt,
342 Err(e) => return refuse(CommandStatus::RunFailed, e),
343 };
344 let runner = match crate::sandbox::runner_at(&root) {
346 Ok((r, _)) => r,
347 Err(e) => return refuse(CommandStatus::ConfigError, e),
348 };
349 let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
350 let short = skill
355 .name
356 .strip_prefix(planning.prefix)
357 .unwrap_or(&skill.name);
358 let creates = planning.creates.contains(&short) && !exploring;
359 let selected = s.snapshot.feature.as_ref().map(|f| f.feature.dir.clone());
360 let env: Vec<(String, String)> = match (planning.current_env, &selected) {
361 (Some(var), Some(dir)) if !creates => {
362 vec![(var.to_owned(), canonical.join(dir).display().to_string())]
363 }
364 _ => Vec::new(),
365 };
366 let server_policy = if exploring {
367 tools::policy::Policy::new(&[], &[])
368 .rules(s.format.rules())
369 .exploring(tools::policy::Exploring {
370 paths: planning.explore_paths,
371 commands: planning.explore_commands,
372 })
373 } else {
374 tools::policy::Policy::new(&[], &[])
375 .rules(scoped.unwrap_or_else(|| s.format.rules()))
376 .authoring()
377 };
378 let server = match tools::WorkspaceTools::new(&root, server_policy) {
379 Ok(t) => Arc::new(t.with_runner(runner).with_env(env)),
380 Err(e) => return refuse(CommandStatus::Error, e.to_string()),
381 };
382 let mut input = substrate::host::input(
383 &skill.name,
384 &instruction,
385 &canonical.display().to_string(),
386 (budget.max_turns, budget.max_tool_calls),
387 options.approver.is_some(),
388 );
389 if scoped.is_some() {
390 input["settings"]["scope"] = serde_json::json!("bug");
391 }
392 if exploring {
393 input["settings"]["mode"] = serde_json::json!("explore");
394 }
395 let outcome = rt.block_on(async {
396 let plane = substrate::Plane::open_with(
397 &s.plane_dir,
398 &s.model,
399 Arc::clone(&provider),
400 Some(server),
401 substrate::Hooks {
402 progress: Some(Arc::clone(&live)),
403 format: Some(s.format),
404 ..substrate::Hooks::default()
405 },
406 )?;
407 let outcome = plane
409 .skill(&skill.name, agentplane::core::Tainted::trusted(input))
410 .await?;
411 let context = crate::approvals::PresentContext {
414 root: Some(canonical.clone()),
415 ..crate::approvals::PresentContext::default()
416 };
417 plane
418 .decide_inline(outcome, options.approver.as_deref(), &context)
419 .await
420 });
421 let outcome = outcome.map(|i| {
424 let _ = crate::actions::allow_always_inline(
425 &root,
426 &i.always.first().map_or_else(String::new, |a| {
427 a.task.split('/').next().unwrap_or_default().to_owned()
428 }),
429 &i.always,
430 state,
431 );
432 i.outcome
433 });
434 let outcome = match outcome {
435 Ok(o) => o,
436 Err(e) => return refuse(CommandStatus::RunFailed, e.0),
437 };
438 let output = outcome
439 .output
440 .as_ref()
441 .map(|o| o.peek().clone())
442 .unwrap_or(serde_json::Value::Null);
443 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
444 if let Some(out) = report_out {
445 out.clone_from(&output["report"]);
446 }
447 let status = if succeeded {
448 output["status"].as_str().unwrap_or("failed").to_owned()
449 } else {
450 "failed".to_owned()
451 };
452 let changes: Vec<(String, String)> = worktree::checkpoint(&root, &tmp)
453 .and_then(|after| worktree::diff(&root, &before, &after))
454 .unwrap_or_default()
455 .into_iter()
456 .map(|c| {
457 let kind = match c.kind {
458 worktree::ChangeKind::Added => "added",
459 worktree::ChangeKind::Modified => "modified",
460 worktree::ChangeKind::Deleted => "deleted",
461 };
462 (c.path, kind.to_owned())
463 })
464 .collect();
465 let report = SkillRunReport {
466 skill: skill.name.clone(),
467 run_id: outcome.run_id.to_string(),
468 summary: output["report"]["summary"].as_str().map(str::to_owned),
469 questions: output["report"]["questions"]
470 .as_array()
471 .map(|a| {
472 a.iter()
473 .filter_map(|q| q.as_str().map(str::to_owned))
474 .collect()
475 })
476 .unwrap_or_default(),
477 changes,
478 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
479 pending: output.get("pending").cloned(),
480 reason: output["reason"]
481 .as_str()
482 .map(str::to_owned)
483 .or_else(|| (!succeeded).then(|| format!("{:?}", outcome.status))),
484 usage: output.get("usage").cloned(),
485 status: status.clone(),
486 hooks: Vec::new(),
487 current: None,
488 };
489 let mut report = report;
492 if creates && succeeded {
493 let known: Vec<&str> = s.snapshot.features.iter().map(|f| f.dir.as_str()).collect();
494 let made = s
495 .format
496 .inspect(&root, None)
497 .map(|after| after.features)
498 .unwrap_or_default()
499 .into_iter()
500 .rfind(|f| !known.contains(&f.dir.as_str()));
501 if let Some(f) = made {
502 match s.format.set_current(&root, Some(&f.dir)) {
503 Ok(pointer) => {
504 report.current = Some(format!("{} (recorded in {pointer})", f.name));
505 }
506 Err(e) => report.current = Some(format!("{} (not recorded: {e})", f.name)),
507 }
508 }
509 } else if let Some(f) = s.snapshot.feature.as_ref() {
510 report.current = Some(format!(
511 "{} ({})",
512 f.feature.name,
513 crate::current::reason_text(s.snapshot.selection.reason, s.format)
514 ));
515 }
516 let tokens = |k: &str| {
517 report
518 .usage
519 .as_ref()
520 .and_then(|u| u[k].as_u64())
521 .unwrap_or(0)
522 };
523 let (code, message) = match status.as_str() {
524 "completed" => (
525 CommandStatus::Ok,
526 format!(
527 "{} done: {} file(s) changed in your checkout, uncommitted — review them (git diff, git status)",
528 skill.name,
529 report.changes.len()
530 ),
531 ),
532 "failed" => (
533 CommandStatus::RunFailed,
534 format!(
535 "{} failed: {}",
536 skill.name,
537 report.reason.clone().unwrap_or_default()
538 ),
539 ),
540 other => (
541 CommandStatus::NeedsHuman,
542 format!(
543 "{} stopped ({other}): {}",
544 skill.name,
545 report
547 .pending
548 .as_ref()
549 .filter(|p| p.is_object())
550 .map(|p| tools::policy::unasked(
551 p,
552 &format!(
553 "run it again with prompts (`speccoder spec {}` without `--no-prompt`) and approve it when asked",
554 skill
555 .name
556 .strip_prefix(s.format.planning().prefix)
557 .unwrap_or(&skill.name)
558 ),
559 ))
560 .or_else(|| report.reason.clone().filter(|r| !r.is_empty()))
561 .or_else(|| {
562 (!report.questions.is_empty())
563 .then(|| format!("open questions: {}", report.questions.join(" | ")))
564 })
565 .or_else(|| report
566 .pending
567 .as_ref()
568 .map(|p| p["reason"].as_str().unwrap_or_default().to_owned())
569 .filter(|r| !r.is_empty()))
570 .or_else(|| report
572 .summary
573 .clone()
574 .filter(|r| !r.trim().is_empty())
575 .map(|r| format!("the skill says: {r}")))
576 .unwrap_or_else(|| format!(
577 "the skill ended without saying why — run it again (speccoder spec {})",
578 skill
579 .name
580 .strip_prefix(s.format.planning().prefix)
581 .unwrap_or(&skill.name)
582 ))
583 ),
584 ),
585 };
586 crate::controls::record_skill_usage(
589 &s.plane_dir,
590 &skill.name,
591 [
592 tokens("input_tokens"),
593 tokens("output_tokens"),
594 tokens("cache_read_tokens"),
595 tokens("cache_write_tokens"),
596 ],
597 s.model.prices,
598 u64::from(turns.calls()),
599 );
600 live.event(&progress::Event::TaskFinished {
601 task: skill.name.clone(),
602 outcome: status,
603 message: redact(&message, secret),
604 run: None,
605 input_tokens: tokens("input_tokens"),
606 output_tokens: tokens("output_tokens"),
607 cache_read_tokens: tokens("cache_read_tokens"),
608 cache_write_tokens: tokens("cache_write_tokens"),
609 usd: None,
610 regression: None,
611 });
612 if code == CommandStatus::Ok
613 && let Some(e) = &event
614 {
615 match run_hooks_in(
616 &format!("after_{e}"),
617 arguments,
618 path,
619 feature,
620 state,
621 &provider,
622 secret,
623 options,
624 ) {
625 Ok(lines) => hook_lines.extend(lines),
626 Err(stop) => {
627 report.hooks = hook_lines;
628 return CommandResult::carried("skill", &stop)
629 .with_data(CommandData::Skill(Box::new(report)));
630 }
631 }
632 }
633 report.hooks = hook_lines;
634 CommandResult::new("skill", code, &redact(&message, secret))
635 .with_data(CommandData::Skill(Box::new(report)))
636}
637
638#[must_use]
640pub fn run(
641 path: &Path,
642 feature: Option<&str>,
643 name: &str,
644 arguments: &str,
645 options: &RunOptions,
646) -> CommandResult {
647 crate::with_driver("skill", path, None, |state, provider, key| {
648 run_in(
649 path,
650 feature,
651 name,
652 arguments,
653 state,
654 Arc::clone(provider),
655 Some(key),
656 options,
657 )
658 })
659}
660
661pub fn run_hooks(
665 event: &str,
666 arguments: &str,
667 path: &Path,
668 state: &Path,
669 provider: &Arc<dyn ModelProvider>,
670 secret: Option<&str>,
671 options: &RunOptions,
672) -> Result<Vec<String>, Box<CommandResult>> {
673 run_hooks_in(
674 event, arguments, path, None, state, provider, secret, options,
675 )
676}
677
678#[allow(clippy::too_many_arguments)]
680pub fn run_hooks_in(
681 event: &str,
682 arguments: &str,
683 path: &Path,
684 feature: Option<&str>,
685 state: &Path,
686 provider: &Arc<dyn ModelProvider>,
687 secret: Option<&str>,
688 options: &RunOptions,
689) -> Result<Vec<String>, Box<CommandResult>> {
690 let root = root_of(path)?;
691 let plan = crate::hooks::plan(&root, event)
692 .map_err(|e| Box::new(CommandResult::new("hooks", CommandStatus::ConfigError, &e)))?;
693 let mut lines: Vec<String> = Vec::new();
694 for hook in &plan.run {
695 let note = |text: String| {
696 if let Some(p) = &options.progress {
697 p.event(&progress::Event::Notice { text });
698 }
699 };
700 note(format!(
701 "{event} hook ({}): {}",
702 hook.extension, hook.command
703 ));
704 let res = run_in(
705 path,
706 feature,
707 &hook.skill(),
708 arguments,
709 state,
710 Arc::clone(provider),
711 secret,
712 options,
713 );
714 if res.status() != CommandStatus::Ok {
715 let status = if res.status() == CommandStatus::NotFound {
716 CommandStatus::ConfigError
717 } else {
718 res.status()
719 };
720 return Err(Box::new(CommandResult::new(
721 "hooks",
722 status,
723 &format!(
724 "mandatory {event} hook `{}` ({}) did not complete: {}",
725 hook.command,
726 hook.extension,
727 res.message()
728 ),
729 )));
730 }
731 lines.push(format!(
732 "ran {event} hook {} ({})",
733 hook.command, hook.extension
734 ));
735 }
736 for hook in &plan.offer {
737 let line = crate::hooks::offer_line(event, hook);
738 if let Some(p) = &options.progress {
739 p.event(&progress::Event::Notice { text: line.clone() });
740 }
741 lines.push(line);
742 }
743 for (hook, why) in &plan.skipped {
744 lines.push(format!(
745 "skipped {event} hook {} ({}): {why}",
746 hook.command, hook.extension
747 ));
748 }
749 Ok(lines)
750}
751
752fn core_event<'a>(planning: &Planning, skill: &'a str) -> Option<&'a str> {
754 let short = skill.strip_prefix(planning.prefix).unwrap_or(skill);
755 planning.core.contains(&short).then_some(short)
756}
757
758#[must_use]
762pub fn uncommitted(path: &Path, feature: Option<&str>) -> Vec<String> {
763 let format = crate::format::at(path);
764 let Some(snap) = format.open(path, feature) else {
765 return Vec::new();
766 };
767 let Some(f) = snap.feature.as_ref() else {
768 return Vec::new();
769 };
770 let (top, dir) = repo_dir(Path::new(&snap.root), &f.feature.dir);
771 crate::git::Git::at(&top)
772 .run(&[
773 "status",
774 "--porcelain",
775 "-z",
776 "--untracked-files=all",
777 "--",
778 &dir,
779 ])
780 .map(|out| {
781 String::from_utf8_lossy(&out)
782 .split('\0')
783 .filter(|e| e.len() > 3)
784 .map(|e| e[3..].to_owned())
785 .collect()
786 })
787 .unwrap_or_default()
788}
789
790#[must_use]
794pub fn commit_planning(path: &Path, feature: Option<&str>, paths: &[String]) -> CommandResult {
795 commit_planning_as(path, feature, paths, None)
796}
797
798#[must_use]
800pub fn commit_planning_as(
801 path: &Path,
802 feature: Option<&str>,
803 paths: &[String],
804 subject: Option<&str>,
805) -> CommandResult {
806 let refuse = |status, m: &str| CommandResult::new("commit", status, m);
807 let format = crate::format::at(path);
808 let Some(snap) = format.open(path, feature) else {
809 return refuse(
810 CommandStatus::NotAProject,
811 &(format.messages().not_a_project)(path),
812 );
813 };
814 let Some(f) = snap.feature.as_ref() else {
815 return refuse(
816 CommandStatus::SelectionNeeded,
817 "no current feature to commit for",
818 );
819 };
820 let (top, unit) = repo_dir(Path::new(&snap.root), &f.feature.dir);
821 let dir = format!("{unit}/");
822 if paths.is_empty() {
823 return refuse(CommandStatus::NotFound, "nothing to commit");
824 }
825 let inside = |p: &str| {
828 p.strip_prefix(&dir).is_some_and(|rest| {
829 rest.split('/')
830 .all(|c| !c.is_empty() && c != "." && c != "..")
831 })
832 };
833 if let Some(p) = paths.iter().find(|p| !inside(p)) {
834 return refuse(
835 CommandStatus::UsageError,
836 &format!(
837 "`{p}` is not in {}; only planning files are committed here",
838 f.feature.dir
839 ),
840 );
841 }
842 let git = crate::git::Git::at(&top);
843 let names: Vec<&str> = paths
844 .iter()
845 .map(|p| p.rsplit('/').next().unwrap_or(p))
846 .collect();
847 let message = subject.map_or_else(
848 || format!("spec({}): {}", f.feature.name, names.join(", ")),
849 |t| format!("spec({}): {t}", f.feature.name),
850 );
851 let mut add = vec!["add", "--"];
852 add.extend(paths.iter().map(String::as_str));
853 let mut commit = vec!["commit", "-q", "-m", &message, "--"];
854 commit.extend(paths.iter().map(String::as_str));
855 match git.run(&add).and_then(|_| git.run(&commit)) {
856 Ok(_) => {
857 let id = git
858 .line(&["rev-parse", "--short", "HEAD"])
859 .unwrap_or_default();
860 CommandResult::new(
861 "commit",
862 CommandStatus::Ok,
863 &format!("committed {} file(s) as {id} \"{message}\"", paths.len()),
864 )
865 }
866 Err(e) => refuse(
867 CommandStatus::GitPrecondition,
868 &format!("commit failed: {}", e.0),
869 ),
870 }
871}
872
873#[must_use]
877pub fn commit_exactly(root: &Path, paths: &[String], message: &str) -> CommandResult {
878 let refuse = |status, m: &str| CommandResult::new("commit", status, m);
879 if paths.is_empty() {
880 return refuse(CommandStatus::NotFound, "nothing to commit");
881 }
882 let (top, _) = repo_dir(root, "");
883 let git = crate::git::Git::at(&top);
884 let mut add = vec!["add", "--"];
885 add.extend(paths.iter().map(String::as_str));
886 let mut commit = vec!["commit", "-q", "-m", message, "--"];
887 commit.extend(paths.iter().map(String::as_str));
888 match git.run(&add).and_then(|_| git.run(&commit)) {
889 Ok(_) => {
890 let id = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
891 CommandResult::new("commit", CommandStatus::Ok, &id)
892 }
893 Err(e) => refuse(
894 CommandStatus::GitPrecondition,
895 &format!("commit failed: {}", e.0),
896 ),
897 }
898}
899
900fn repo_dir(root: &Path, unit_dir: &str) -> (std::path::PathBuf, String) {
903 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
904 let top = crate::git::Git::at(root)
905 .toplevel()
906 .ok()
907 .flatten()
908 .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
909 let prefix = canonical(root)
910 .strip_prefix(&top)
911 .map(|p| p.to_string_lossy().replace('\\', "/"))
912 .unwrap_or_default();
913 let dir = if prefix.is_empty() {
914 unit_dir.to_owned()
915 } else {
916 format!("{prefix}/{unit_dir}")
917 };
918 (top, dir)
919}
920
921#[derive(Debug, Default)]
923struct Turns(std::sync::atomic::AtomicU32);
924
925impl Turns {
926 fn calls(&self) -> u32 {
928 self.0.load(std::sync::atomic::Ordering::SeqCst) + 1
929 }
930}
931
932#[derive(Debug)]
934struct Counted {
935 turns: Arc<Turns>,
936 next: Option<Arc<dyn progress::Progress>>,
937}
938
939impl progress::Progress for Counted {
940 fn event(&self, event: &progress::Event) {
941 if let progress::Event::ToolCall { turn, .. } = event {
942 self.turns
943 .0
944 .fetch_max(*turn, std::sync::atomic::Ordering::SeqCst);
945 }
946 if let Some(n) = &self.next {
947 n.event(event);
948 }
949 }
950}