返回 CodeWhale
git.rs
根目录 / crates / tui / src / tools / git.rs
1 //! Git power tools: `git_status`, `git_diff`, and the propose-only
2 //! `git_commit_plan`.
3 //!
4 //! These tools are read-only wrappers around common git inspection commands,
5 //! scoped to the workspace and optionally to a sub-path within it. The commit
6 //! planner (#3999) is read-only too: it proposes an ordered atomic split and
7 //! leaves staging and committing to the ordinary shell write path.
8
9 use std::collections::{BTreeMap, BTreeSet, HashSet};
10 use std::fs;
11 use std::path::{Path, PathBuf};
12
13 use async_trait::async_trait;
14 use serde_json::{Value, json};
15
16 use crate::dependencies::ExternalTool;
17
18 use super::spec::{
19 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
20 optional_bool, optional_str, optional_u64,
21 };
22
23 const MAX_OUTPUT_CHARS: usize = 40_000;
24 const DEFAULT_UNIFIED: u64 = 3;
25 const MAX_UNIFIED: u64 = 50;
26
27 /// Resolve untrusted revision text before using it as an argument to another
28 /// Git command. Only a verified commit ID crosses that option boundary.
29 pub(super) async fn resolve_commit_ref(workspace: &Path, base: &str) -> Result<String, ToolError> {
30 let workspace = workspace.to_path_buf();
31 let revision = format!("{base}^{{commit}}");
32 let output = tokio::task::spawn_blocking(move || {
33 run_git_command(
34 &workspace,
35 &[
36 "rev-parse".to_string(),
37 "--verify".to_string(),
38 "--end-of-options".to_string(),
39 revision,
40 ],
41 )
42 })
43 .await
44 .map_err(|error| {
45 ToolError::execution_failed(format!("git resolve task panicked: {error}"))
46 })??;
47 if !output.status.success() {
48 return Err(ToolError::invalid_input(format!(
49 "Invalid git base ref '{base}': {}",
50 String::from_utf8_lossy(&output.stderr).trim()
51 )));
52 }
53 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
54 if !matches!(commit.len(), 40 | 64) || !commit.bytes().all(|byte| byte.is_ascii_hexdigit()) {
55 return Err(ToolError::execution_failed(
56 "git resolved base to an invalid commit id",
57 ));
58 }
59 Ok(commit)
60 }
61
62 // === GitStatusTool ===
63
64 /// Tool for reading the concise git status of the workspace.
65 pub struct GitStatusTool;
66
67 #[async_trait]
68 impl ToolSpec for GitStatusTool {
69 fn name(&self) -> &'static str {
70 "git_status"
71 }
72
73 fn model_visible(&self) -> bool {
74 false
75 }
76
77 fn description(&self) -> &'static str {
78 "Run `git status --porcelain=v1 -b` in the workspace (optionally scoped to a path)."
79 }
80
81 fn input_schema(&self) -> Value {
82 json!({
83 "type": "object",
84 "properties": {
85 "path": {
86 "type": "string",
87 "description": "Optional subdirectory or file to scope the status to (must be within the workspace)."
88 }
89 },
90 "additionalProperties": false
91 })
92 }
93
94 fn capabilities(&self) -> Vec<ToolCapability> {
95 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
96 }
97
98 fn approval_requirement(&self) -> ApprovalRequirement {
99 ApprovalRequirement::Auto
100 }
101
102 fn supports_parallel(&self) -> bool {
103 true
104 }
105
106 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
107 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
108
109 let mut args = vec![
110 "-c".to_string(),
111 "core.quotepath=false".to_string(),
112 "status".to_string(),
113 "--porcelain=v1".to_string(),
114 "-b".to_string(),
115 ];
116 if let Some(pathspec) = &git_ctx.pathspec {
117 args.push("--".to_string());
118 args.push(pathspec.display().to_string());
119 }
120
121 let command_str = format_command(&git_ctx.working_dir, &args);
122 let output = run_git_command(&git_ctx.working_dir, &args)?;
123
124 if !output.status.success() {
125 let stderr = String::from_utf8_lossy(&output.stderr);
126 let message = format!("git status failed: {}", stderr.trim());
127 return Ok(ToolResult::error(message).with_metadata(json!({
128 "command": command_str,
129 "exit_code": output.status.code(),
130 "stderr": stderr.trim(),
131 })));
132 }
133
134 let stdout = String::from_utf8_lossy(&output.stdout);
135 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
136
137 Ok(ToolResult::success(content).with_metadata(json!({
138 "command": command_str,
139 "working_dir": git_ctx.working_dir,
140 "pathspec": git_ctx.pathspec,
141 "truncated": truncated,
142 "omitted_chars": omitted_chars,
143 })))
144 }
145 }
146
147 // === GitDiffTool ===
148
149 /// Tool for reading git diffs in the workspace.
150 pub struct GitDiffTool;
151
152 #[async_trait]
153 impl ToolSpec for GitDiffTool {
154 fn name(&self) -> &'static str {
155 "git_diff"
156 }
157
158 fn model_visible(&self) -> bool {
159 false
160 }
161
162 fn description(&self) -> &'static str {
163 "Run `git diff` in the workspace with sensible defaults and safe truncation."
164 }
165
166 fn input_schema(&self) -> Value {
167 json!({
168 "type": "object",
169 "properties": {
170 "path": {
171 "type": "string",
172 "description": "Optional subdirectory or file to scope the diff to (must be within the workspace)."
173 },
174 "cached": {
175 "type": "boolean",
176 "description": "When true, diff staged changes (`--cached`)."
177 },
178 "unified": {
179 "type": "integer",
180 "minimum": 0,
181 "maximum": MAX_UNIFIED,
182 "default": DEFAULT_UNIFIED,
183 "description": "Number of context lines to include around changes."
184 }
185 },
186 "additionalProperties": false
187 })
188 }
189
190 fn capabilities(&self) -> Vec<ToolCapability> {
191 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
192 }
193
194 fn approval_requirement(&self) -> ApprovalRequirement {
195 ApprovalRequirement::Auto
196 }
197
198 fn supports_parallel(&self) -> bool {
199 true
200 }
201
202 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
203 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
204 let cached = optional_bool(&input, "cached", false)?;
205 let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED)?.min(MAX_UNIFIED);
206
207 let mut args = vec![
208 "-c".to_string(),
209 "core.quotepath=false".to_string(),
210 "diff".to_string(),
211 "--no-color".to_string(),
212 "--no-ext-diff".to_string(),
213 format!("--unified={unified}"),
214 ];
215 if cached {
216 args.push("--cached".to_string());
217 }
218 if let Some(pathspec) = &git_ctx.pathspec {
219 args.push("--".to_string());
220 args.push(pathspec.display().to_string());
221 }
222
223 let command_str = format_command(&git_ctx.working_dir, &args);
224 let output = run_git_command(&git_ctx.working_dir, &args)?;
225
226 if !output.status.success() {
227 let stderr = String::from_utf8_lossy(&output.stderr);
228 let message = format!("git diff failed: {}", stderr.trim());
229 return Ok(ToolResult::error(message).with_metadata(json!({
230 "command": command_str,
231 "exit_code": output.status.code(),
232 "stderr": stderr.trim(),
233 })));
234 }
235
236 let stdout = String::from_utf8_lossy(&output.stdout);
237 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
238
239 Ok(ToolResult::success(content).with_metadata(json!({
240 "command": command_str,
241 "working_dir": git_ctx.working_dir,
242 "pathspec": git_ctx.pathspec,
243 "cached": cached,
244 "unified": unified,
245 "truncated": truncated,
246 "omitted_chars": omitted_chars,
247 })))
248 }
249 }
250
251 // === GitCommitPlanTool ===
252
253 /// Propose-only planner that splits the working tree into ordered atomic
254 /// commits (#3999).
255 ///
256 /// The planner reads `git diff HEAD` plus the untracked-file list, groups
257 /// whole files into logical commits, orders the groups so a commit that
258 /// defines a symbol lands before the commit that uses it, and refuses the
259 /// whole plan when that dependency graph has a cycle. It never touches the
260 /// index or the object store — no `git add -N`, no `git apply --cached`, no
261 /// `git commit`. The model lands each proposed commit through the ordinary
262 /// `git add` / `git commit` shell path, which is where the approval gate
263 /// already lives: one commit authority, not two.
264 pub struct GitCommitPlanTool;
265
266 #[async_trait]
267 impl ToolSpec for GitCommitPlanTool {
268 fn name(&self) -> &'static str {
269 "git_commit_plan"
270 }
271
272 fn model_visible(&self) -> bool {
273 false
274 }
275
276 fn description(&self) -> &'static str {
277 "Propose how to split the working tree into ordered atomic commits. Read-only: returns the plan and writes nothing."
278 }
279
280 fn input_schema(&self) -> Value {
281 json!({
282 "type": "object",
283 "properties": {
284 "path": {
285 "type": "string",
286 "description": "Optional subdirectory or file to scope the plan to (must be within the workspace)."
287 }
288 },
289 "additionalProperties": false
290 })
291 }
292
293 fn capabilities(&self) -> Vec<ToolCapability> {
294 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
295 }
296
297 fn approval_requirement(&self) -> ApprovalRequirement {
298 ApprovalRequirement::Auto
299 }
300
301 fn supports_parallel(&self) -> bool {
302 true
303 }
304
305 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
306 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
307 let working_dir = &git_ctx.working_dir;
308
309 let root_args = vec!["rev-parse".to_string(), "--show-toplevel".to_string()];
310 let repo_root = match git_stdout(working_dir, &root_args)? {
311 Ok(stdout) => PathBuf::from(String::from_utf8_lossy(&stdout).trim_end()),
312 Err(failure) => return Ok(failure),
313 };
314
315 let mut diff_args = vec![
316 "-c".to_string(),
317 "core.quotepath=false".to_string(),
318 "diff".to_string(),
319 "HEAD".to_string(),
320 "--no-color".to_string(),
321 "--no-ext-diff".to_string(),
322 "-U3".to_string(),
323 ];
324 if let Some(pathspec) = &git_ctx.pathspec {
325 diff_args.push("--".to_string());
326 diff_args.push(pathspec.display().to_string());
327 }
328 let command = format_command(working_dir, &diff_args);
329 let mut files = match git_stdout(working_dir, &diff_args)? {
330 Ok(stdout) => parse_diff(&String::from_utf8_lossy(&stdout)),
331 Err(failure) => return Ok(failure),
332 };
333
334 // Untracked files are listed by path and read for symbol analysis.
335 // Never `git add -N` them: intent-to-add mutates the index, and a
336 // planner that mutates the index is not propose-only.
337 let mut untracked_args = vec![
338 "-c".to_string(),
339 "core.quotepath=false".to_string(),
340 "ls-files".to_string(),
341 "--others".to_string(),
342 "--exclude-standard".to_string(),
343 "--full-name".to_string(),
344 "-z".to_string(),
345 ];
346 if let Some(pathspec) = &git_ctx.pathspec {
347 untracked_args.push("--".to_string());
348 untracked_args.push(pathspec.display().to_string());
349 }
350 match git_stdout(working_dir, &untracked_args)? {
351 Ok(stdout) => {
352 for path in String::from_utf8_lossy(&stdout)
353 .split('\0')
354 .filter(|path| !path.is_empty())
355 {
356 files.push(ChangedFile {
357 path: path.to_string(),
358 hunks: untracked_hunk(&repo_root, path).into_iter().collect(),
359 untracked: true,
360 });
361 }
362 }
363 Err(failure) => return Ok(failure),
364 }
365
366 if files.is_empty() {
367 return Ok(
368 ToolResult::success("No changes to plan: the working tree matches HEAD.")
369 .with_metadata(json!({
370 "command": command,
371 "propose_only": true,
372 "commits": [],
373 })),
374 );
375 }
376
377 let staged_args = vec![
378 "diff".to_string(),
379 "--cached".to_string(),
380 "--quiet".to_string(),
381 ];
382 let index_has_staged_changes =
383 run_git_command(working_dir, &staged_args)?.status.code() == Some(1);
384
385 let commits = match plan_commits(files) {
386 Ok(commits) => commits,
387 Err(cycle) => {
388 let message = format!(
389 "Dependency cycle detected among changes in: {}. Atomic commit split rejected; nothing was written.\nCycle edges:\n{}",
390 cycle.files.join(", "),
391 cycle
392 .edges
393 .iter()
394 .map(|edge| format!(" {edge}"))
395 .collect::<Vec<_>>()
396 .join("\n")
397 );
398 return Ok(ToolResult::error(message).with_metadata(json!({
399 "command": command,
400 "propose_only": true,
401 "cycle_detected": true,
402 "cyclic_files": cycle.files,
403 "cycle_edges": cycle.edges,
404 })));
405 }
406 };
407
408 let content = render_commit_plan(&repo_root, index_has_staged_changes, &commits);
409 let (content, truncated, omitted_chars) = truncate_with_note(&content, MAX_OUTPUT_CHARS);
410 let metadata_commits: Vec<Value> = commits
411 .iter()
412 .enumerate()
413 .map(|(idx, commit)| {
414 json!({
415 "order": idx + 1,
416 "message": commit.message,
417 "files": commit.files.iter().filter(|f| !f.untracked).map(|f| &f.path).collect::<Vec<_>>(),
418 "untracked": commit.files.iter().filter(|f| f.untracked).map(|f| &f.path).collect::<Vec<_>>(),
419 "hunks": commit.files.iter().flat_map(|f| f.hunks.iter().map(|h| json!({"file": h.file_path, "header": h.header}))).collect::<Vec<_>>(),
420 "defines": commit.defines,
421 "depends_on": commit.depends_on.iter().map(|(order, reason)| json!({"order": order, "reason": reason})).collect::<Vec<_>>(),
422 })
423 })
424 .collect();
425
426 Ok(ToolResult::success(content).with_metadata(json!({
427 "command": command,
428 "repo_root": repo_root.display().to_string(),
429 "propose_only": true,
430 "cycle_detected": false,
431 "index_has_staged_changes": index_has_staged_changes,
432 "commits": metadata_commits,
433 "truncated": truncated,
434 "omitted_chars": omitted_chars,
435 })))
436 }
437 }
438
439 // === Helpers ===
440
441 struct GitContext {
442 working_dir: PathBuf,
443 pathspec: Option<PathBuf>,
444 }
445
446 fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result<GitContext, ToolError> {
447 let workspace = canonical_or_workspace(&context.workspace);
448 let mut working_dir = workspace.clone();
449 let mut pathspec = None;
450
451 if let Some(raw) = path {
452 let resolved = context.resolve_path(raw)?;
453 let metadata = fs::metadata(&resolved).map_err(|e| {
454 ToolError::invalid_input(format!(
455 "Path does not exist or is not accessible: {raw} ({e})"
456 ))
457 })?;
458
459 if metadata.is_dir() {
460 working_dir = resolved;
461 pathspec = Some(PathBuf::from("."));
462 } else {
463 // For file paths, run from the parent and scope to the file name.
464 let parent = resolved.parent().ok_or_else(|| {
465 ToolError::invalid_input(format!("Path has no parent directory: {raw}"))
466 })?;
467 working_dir = parent.to_path_buf();
468 pathspec = Some(pathspec_from(&working_dir, &resolved));
469 }
470 }
471
472 if !working_dir.exists() {
473 return Err(ToolError::invalid_input(format!(
474 "Working directory does not exist: {}",
475 working_dir.display()
476 )));
477 }
478
479 Ok(GitContext {
480 working_dir,
481 pathspec,
482 })
483 }
484
485 fn canonical_or_workspace(workspace: &Path) -> PathBuf {
486 workspace
487 .canonicalize()
488 .unwrap_or_else(|_| workspace.to_path_buf())
489 }
490
491 fn pathspec_from(working_dir: &Path, resolved: &Path) -> PathBuf {
492 match resolved.strip_prefix(working_dir) {
493 Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
494 Ok(rel) => rel.to_path_buf(),
495 Err(_) => PathBuf::from("."),
496 }
497 }
498
499 fn run_git_command(working_dir: &Path, args: &[String]) -> Result<std::process::Output, ToolError> {
500 let Some(mut cmd) = crate::dependencies::Git::command() else {
501 return Err(ToolError::not_available(
502 "git is not installed or not in PATH",
503 ));
504 };
505 cmd.args(args).current_dir(working_dir);
506 cmd.output().map_err(|e| {
507 if e.kind() == std::io::ErrorKind::NotFound {
508 ToolError::not_available("git is not installed or not in PATH")
509 } else {
510 ToolError::execution_failed(format!("Failed to run git: {e}"))
511 }
512 })
513 }
514
515 fn format_command(working_dir: &Path, args: &[String]) -> String {
516 // `[String]::join` produces the same string as collecting `&str` first, so
517 // join the slice directly and skip the intermediate `Vec<&str>` allocation.
518 format!("git -C {} {}", working_dir.display(), args.join(" "))
519 }
520
521 fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool, usize) {
522 if text.chars().count() <= max_chars {
523 return (text.to_string(), false, 0);
524 }
525 let end = char_boundary_index(text, max_chars);
526 let truncated = &text[..end];
527 let omitted_chars = text
528 .chars()
529 .count()
530 .saturating_sub(truncated.chars().count());
531 let note = format!(
532 "\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
533 );
534 (format!("{truncated}{note}"), true, omitted_chars)
535 }
536
537 fn char_boundary_index(text: &str, max_chars: usize) -> usize {
538 if max_chars == 0 {
539 return 0;
540 }
541 for (count, (idx, _)) in text.char_indices().enumerate() {
542 if count == max_chars {
543 return idx;
544 }
545 }
546 text.len()
547 }
548
549 // === Commit Split Specific Types & Helpers ===
550
551 // === Commit plan: types, parsing, grouping, ordering ===
552
553 /// Largest untracked file the planner reads for symbol analysis. Bigger or
554 /// binary files are still listed by path; they just carry no hunks.
555 const MAX_UNTRACKED_BYTES: u64 = 1 << 20;
556
557 /// One hunk of a unified diff, kept for symbol analysis and for the plan's
558 /// per-file hunk listing. The `@@` ranges stay in `header`: nothing rebuilds
559 /// a patch from them anymore, so parsed copies would be dead weight.
560 #[derive(Debug, Clone)]
561 pub struct Hunk {
562 pub file_path: String,
563 pub header: String,
564 pub lines: Vec<String>,
565 }
566
567 /// One file the working tree changed relative to HEAD. Tracked binary and
568 /// mode-only changes carry no hunks; untracked files carry a synthesized
569 /// all-additions hunk when they are readable text.
570 #[derive(Debug, Clone)]
571 pub struct ChangedFile {
572 pub path: String,
573 pub hunks: Vec<Hunk>,
574 pub untracked: bool,
575 }
576
577 /// One proposed commit in dependency order.
578 #[derive(Debug, Clone)]
579 pub struct PlannedCommit {
580 pub message: String,
581 /// Sorted by path; every hunk of a file stays in the same commit.
582 pub files: Vec<ChangedFile>,
583 pub defines: Vec<String>,
584 /// `(order, reason)` pairs naming the earlier commits this one builds on.
585 pub depends_on: Vec<(usize, String)>,
586 }
587
588 /// Why a plan was refused: the files on the cycle and the edges that close it.
589 #[derive(Debug, Clone)]
590 pub struct CycleDiagnostic {
591 pub files: Vec<String>,
592 pub edges: Vec<String>,
593 }
594
595 struct CommitGroup {
596 files: BTreeMap<String, ChangedFile>,
597 defined_symbols: BTreeSet<String>,
598 referenced_symbols: BTreeSet<String>,
599 }
600
601 impl CommitGroup {
602 fn from_file(file: ChangedFile) -> Self {
603 let mut defined_symbols = BTreeSet::new();
604 let mut referenced_symbols = BTreeSet::new();
605 for hunk in &file.hunks {
606 defined_symbols.extend(extract_defined_symbols(hunk));
607 referenced_symbols.extend(extract_referenced_symbols(hunk));
608 }
609 let mut group = Self {
610 files: BTreeMap::from([(file.path.clone(), file)]),
611 defined_symbols,
612 referenced_symbols,
613 };
614 group.drop_self_references();
615 group
616 }
617
618 fn absorb(&mut self, other: CommitGroup) {
619 self.files.extend(other.files);
620 self.defined_symbols.extend(other.defined_symbols);
621 self.referenced_symbols.extend(other.referenced_symbols);
622 self.drop_self_references();
623 }
624
625 fn drop_self_references(&mut self) {
626 for symbol in &self.defined_symbols {
627 self.referenced_symbols.remove(symbol);
628 }
629 }
630
631 fn has_source_file(&self) -> bool {
632 self.files.keys().any(|path| is_source_file(path))
633 }
634
635 fn first_path(&self) -> &str {
636 self.files.keys().next().map_or("", String::as_str)
637 }
638 }
639
640 /// Run git and hand back stdout, or the operator-facing failure result.
641 fn git_stdout(
642 working_dir: &Path,
643 args: &[String],
644 ) -> Result<Result<Vec<u8>, ToolResult>, ToolError> {
645 let output = run_git_command(working_dir, args)?;
646 if output.status.success() {
647 return Ok(Ok(output.stdout));
648 }
649 let stderr = String::from_utf8_lossy(&output.stderr);
650 Ok(Err(ToolResult::error(format!(
651 "{} failed: {}",
652 format_command(working_dir, args),
653 stderr.trim()
654 ))))
655 }
656
657 /// Parse `git diff` output into per-file hunks. Every `diff --git` section
658 /// yields a file even when it has no hunks (binary or mode-only change), so
659 /// no changed file can silently drop out of the plan.
660 fn parse_diff(diff_output: &str) -> Vec<ChangedFile> {
661 let mut files: Vec<ChangedFile> = Vec::new();
662 let mut current_hunk: Option<Hunk> = None;
663
664 let flush = |files: &mut Vec<ChangedFile>, hunk: Option<Hunk>| {
665 if let (Some(hunk), Some(file)) = (hunk, files.last_mut()) {
666 file.hunks.push(hunk);
667 }
668 };
669
670 for line in diff_output.lines() {
671 if let Some(rest) = line.strip_prefix("diff --git ") {
672 flush(&mut files, current_hunk.take());
673 let path = rest
674 .rfind(" b/")
675 .map(|pos| &rest[pos + 3..])
676 .unwrap_or(rest)
677 .trim_matches('"')
678 .to_string();
679 files.push(ChangedFile {
680 path,
681 hunks: Vec::new(),
682 untracked: false,
683 });
684 } else if line.starts_with("@@ ") {
685 flush(&mut files, current_hunk.take());
686 let Some(file) = files.last() else { continue };
687 current_hunk = Some(Hunk {
688 file_path: file.path.clone(),
689 header: line.to_string(),
690 lines: Vec::new(),
691 });
692 } else if let Some(hunk) = current_hunk.as_mut() {
693 hunk.lines.push(line.to_string());
694 }
695 }
696 flush(&mut files, current_hunk.take());
697 files
698 }
699
700 /// Synthesize an all-additions hunk for an untracked text file so its
701 /// symbols take part in grouping. Binary, oversized, or unreadable files
702 /// yield `None` and are listed by path only.
703 fn untracked_hunk(repo_root: &Path, path: &str) -> Option<Hunk> {
704 let full = repo_root.join(path);
705 if fs::metadata(&full).ok()?.len() > MAX_UNTRACKED_BYTES {
706 return None;
707 }
708 let bytes = fs::read(&full).ok()?;
709 if bytes.iter().take(8000).any(|byte| *byte == 0) {
710 return None;
711 }
712 let text = String::from_utf8(bytes).ok()?;
713 let lines: Vec<String> = text.lines().map(|line| format!("+{line}")).collect();
714 Some(Hunk {
715 file_path: path.to_string(),
716 header: format!("@@ -0,0 +1,{} @@", lines.len()),
717 lines,
718 })
719 }
720
721 /// Group changed files into commits and order them by dependency.
722 ///
723 /// Pure: reads nothing from git and writes nothing anywhere. Returns the
724 /// cycle diagnostic instead of a plan when the dependency graph is not a DAG.
725 fn plan_commits(files: Vec<ChangedFile>) -> Result<Vec<PlannedCommit>, CycleDiagnostic> {
726 let (lock_files, files): (Vec<ChangedFile>, Vec<ChangedFile>) =
727 files.into_iter().partition(|file| is_lock_file(&file.path));
728
729 let mut groups: Vec<CommitGroup> = files.into_iter().map(CommitGroup::from_file).collect();
730
731 // Lock files are excluded from symbol analysis and ride with the manifest
732 // change that moved them; a lock file with no manifest change stands alone.
733 for lock in lock_files {
734 let lock_path = lock.path.clone();
735 let mut group = CommitGroup::from_file(lock);
736 group.defined_symbols.clear();
737 group.referenced_symbols.clear();
738 match groups.iter_mut().find(|existing| {
739 existing
740 .files
741 .keys()
742 .any(|manifest| matches_lock_file(manifest, &lock_path))
743 }) {
744 Some(manifest_group) => manifest_group.files.extend(group.files),
745 None => groups.push(group),
746 }
747 }
748
749 // Merge files with closely related names (source with its test/spec).
750 let mut merged: Vec<CommitGroup> = Vec::new();
751 for group in groups {
752 let related = merged.iter_mut().find(|existing| {
753 group
754 .files
755 .keys()
756 .any(|a| existing.files.keys().any(|b| are_files_related(a, b)))
757 });
758 match related {
759 Some(existing) => existing.absorb(group),
760 None => merged.push(group),
761 }
762 }
763 let groups = merged;
764
765 // Dependency graph: `edges[j]` lists the groups that must land after j.
766 let n = groups.len();
767 let mut edges: Vec<Vec<(usize, String)>> = vec![Vec::new(); n];
768 let mut in_degree = vec![0usize; n];
769 for i in 0..n {
770 for j in 0..n {
771 if i == j {
772 continue;
773 }
774 let reason = groups[i]
775 .referenced_symbols
776 .iter()
777 .find(|symbol| groups[j].defined_symbols.contains(*symbol))
778 .map(|symbol| format!("uses `{symbol}`"))
779 .or_else(|| {
780 // Tests, docs, and configs follow the source change that
781 // lives beside them.
782 (!groups[i].has_source_file() && groups[j].has_source_file())
783 .then(|| {
784 groups[i]
785 .files
786 .keys()
787 .any(|a| groups[j].files.keys().any(|b| share_context(a, b)))
788 })
789 .filter(|shares| *shares)
790 .map(|_| "follows the source change in the same directory".to_string())
791 });
792 if let Some(reason) = reason {
793 edges[j].push((i, reason));
794 in_degree[i] += 1;
795 }
796 }
797 }
798
799 // Kahn's algorithm; among ready groups, source changes land first, then
800 // path order, so the plan is deterministic.
801 let mut ready: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
802 let mut order: Vec<usize> = Vec::with_capacity(n);
803 while !ready.is_empty() {
804 ready.sort_by(|&a, &b| {
805 groups[b]
806 .has_source_file()
807 .cmp(&groups[a].has_source_file())
808 .then_with(|| groups[a].first_path().cmp(groups[b].first_path()))
809 });
810 let current = ready.remove(0);
811 order.push(current);
812 for (next, _) in &edges[current] {
813 in_degree[*next] -= 1;
814 if in_degree[*next] == 0 {
815 ready.push(*next);
816 }
817 }
818 }
819
820 if order.len() < n {
821 let cyclic: Vec<usize> = (0..n).filter(|&i| in_degree[i] > 0).collect();
822 let files = cyclic
823 .iter()
824 .flat_map(|&i| groups[i].files.keys().cloned())
825 .collect();
826 let mut cycle_edges = Vec::new();
827 for &j in &cyclic {
828 for (i, reason) in &edges[j] {
829 if cyclic.contains(i) {
830 cycle_edges.push(format!(
831 "{} -> {} ({reason})",
832 groups[*i].first_path(),
833 groups[j].first_path()
834 ));
835 }
836 }
837 }
838 return Err(CycleDiagnostic {
839 files,
840 edges: cycle_edges,
841 });
842 }
843
844 let mut position = vec![0usize; n];
845 for (idx, &group) in order.iter().enumerate() {
846 position[group] = idx + 1;
847 }
848 let mut depends_on: Vec<Vec<(usize, String)>> = vec![Vec::new(); n];
849 for (j, outgoing) in edges.iter().enumerate() {
850 for (i, reason) in outgoing {
851 depends_on[*i].push((position[j], reason.clone()));
852 }
853 }
854
855 Ok(order
856 .into_iter()
857 .map(|idx| {
858 let mut deps = std::mem::take(&mut depends_on[idx]);
859 deps.sort();
860 let group = &groups[idx];
861 PlannedCommit {
862 message: generate_commit_message(group),
863 files: group.files.values().cloned().collect(),
864 defines: group.defined_symbols.iter().cloned().collect(),
865 depends_on: deps,
866 }
867 })
868 .collect())
869 }
870
871 fn render_commit_plan(
872 repo_root: &Path,
873 index_has_staged_changes: bool,
874 commits: &[PlannedCommit],
875 ) -> String {
876 let mut out = format!(
877 "Commit plan for {}: {} commit{} (propose-only; nothing was staged or committed).\n\
878 Groups are whole files. Land each in order from the repo root with \
879 `git add -- <files>` then `git commit -m '<message>'`; those commands go \
880 through the normal shell approval gate.\n",
881 repo_root.display(),
882 commits.len(),
883 if commits.len() == 1 { "" } else { "s" }
884 );
885 if index_has_staged_changes {
886 out.push_str(
887 "WARNING: the index already holds staged changes. Run `git reset` before \
888 staging commit 1, or those hunks will ride into it.\n",
889 );
890 }
891 for (idx, commit) in commits.iter().enumerate() {
892 out.push_str(&format!("\n{}. {}\n", idx + 1, commit.message));
893 for file in &commit.files {
894 let hunks = match file.hunks.len() {
895 0 if file.untracked => "untracked; listed by path".to_string(),
896 0 => "no text hunks (binary or mode change)".to_string(),
897 1 => format!("1 hunk: {}", file.hunks[0].header),
898 count => format!(
899 "{count} hunks: {}",
900 file.hunks
901 .iter()
902 .map(|hunk| hunk.header.as_str())
903 .collect::<Vec<_>>()
904 .join(" ")
905 ),
906 };
907 let flag = if file.untracked { " (untracked)" } else { "" };
908 out.push_str(&format!(" {}{flag} — {hunks}\n", file.path));
909 }
910 if !commit.defines.is_empty() {
911 out.push_str(&format!(" defines: {}\n", commit.defines.join(", ")));
912 }
913 if commit.depends_on.is_empty() {
914 out.push_str(" depends on: none\n");
915 } else {
916 let deps: Vec<String> = commit
917 .depends_on
918 .iter()
919 .map(|(order, reason)| format!("{order} ({reason})"))
920 .collect();
921 out.push_str(&format!(" depends on: {}\n", deps.join(", ")));
922 }
923 }
924 out
925 }
926
927 fn is_lock_file(path: &str) -> bool {
928 let name = file_name(path);
929 name.ends_with(".lock")
930 || matches!(
931 name,
932 "go.sum" | "package-lock.json" | "pnpm-lock.yaml" | "yarn.lock"
933 )
934 }
935
936 fn is_manifest_file(path: &str) -> bool {
937 matches!(file_name(path), "Cargo.toml" | "package.json" | "go.mod")
938 }
939
940 fn file_name(path: &str) -> &str {
941 Path::new(path)
942 .file_name()
943 .and_then(|n| n.to_str())
944 .unwrap_or(path)
945 }
946
947 fn file_stem(path: &str) -> &str {
948 Path::new(path)
949 .file_stem()
950 .and_then(|s| s.to_str())
951 .unwrap_or(path)
952 }
953
954 fn matches_lock_file(manifest: &str, lock: &str) -> bool {
955 let m_path = Path::new(manifest);
956 let l_path = Path::new(lock);
957 if m_path.parent() != l_path.parent() {
958 return false;
959 }
960 match (file_name(manifest), file_name(lock)) {
961 ("Cargo.toml", "Cargo.lock") | ("go.mod", "go.sum") => true,
962 ("package.json", "package-lock.json" | "yarn.lock" | "pnpm-lock.yaml") => true,
963 (m_name, l_name) => {
964 file_stem(manifest) == file_stem(lock)
965 || (m_name.ends_with(".json") && l_name.ends_with(".json"))
966 }
967 }
968 }
969
970 /// Lowercased file stem with test/spec markers removed — the name two
971 /// related files share (`math.rs` and `math_test.rs` both reduce to `math`).
972 fn related_stem(path: &str) -> String {
973 file_stem(path)
974 .to_lowercase()
975 .replace("_test", "")
976 .replace("test_", "")
977 .replace("_spec", "")
978 .replace("spec_", "")
979 .replace("test", "")
980 }
981
982 fn are_files_related(f1: &str, f2: &str) -> bool {
983 let stem1 = file_stem(f1).to_lowercase();
984 let stem2 = file_stem(f2).to_lowercase();
985 if stem1 == stem2 {
986 return true;
987 }
988 let clean1 = related_stem(f1);
989 !clean1.is_empty() && clean1 == related_stem(f2)
990 }
991
992 fn is_source_file(path: &str) -> bool {
993 let p = Path::new(path);
994 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
995 let name = file_name(path).to_lowercase();
996 if name.contains("test") || name.contains("spec") || name.contains("mock") {
997 return false;
998 }
999 matches!(
1000 ext,
1001 "rs" | "py" | "go" | "js" | "ts" | "cpp" | "h" | "c" | "java" | "cs" | "rb" | "php"
1002 )
1003 }
1004
1005 fn is_doc_file(path: &str) -> bool {
1006 matches!(
1007 Path::new(path).extension().and_then(|e| e.to_str()),
1008 Some("md" | "rst" | "txt" | "adoc")
1009 )
1010 }
1011
1012 fn share_context(f1: &str, f2: &str) -> bool {
1013 Path::new(f1).parent() == Path::new(f2).parent()
1014 }
1015
1016 const DEFINING_KEYWORDS: &[&str] = &[
1017 "fn",
1018 "func",
1019 "def",
1020 "function",
1021 "struct",
1022 "enum",
1023 "trait",
1024 "class",
1025 "interface",
1026 "type",
1027 "const",
1028 "let",
1029 "mod",
1030 ];
1031
1032 fn extract_defined_symbols(hunk: &Hunk) -> HashSet<String> {
1033 let mut symbols = HashSet::new();
1034 for content in added_lines(hunk) {
1035 let tokens = tokenize(content);
1036 for pair in tokens.windows(2) {
1037 if DEFINING_KEYWORDS.contains(&pair[0].as_str()) && is_valid_identifier(&pair[1]) {
1038 symbols.insert(pair[1].clone());
1039 }
1040 }
1041 }
1042 symbols
1043 }
1044
1045 fn extract_referenced_symbols(hunk: &Hunk) -> HashSet<String> {
1046 let mut symbols = HashSet::new();
1047 for content in added_lines(hunk) {
1048 for token in tokenize(content) {
1049 if is_valid_identifier(&token) && !is_keyword(&token) {
1050 symbols.insert(token);
1051 }
1052 }
1053 }
1054 symbols
1055 }
1056
1057 fn added_lines(hunk: &Hunk) -> impl Iterator<Item = &str> {
1058 hunk.lines
1059 .iter()
1060 .filter(|line| line.starts_with('+') && !line.starts_with("+++"))
1061 .map(|line| &line[1..])
1062 }
1063
1064 fn tokenize(s: &str) -> Vec<String> {
1065 s.split(|c: char| !(c.is_alphanumeric() || c == '_'))
1066 .filter(|token| !token.is_empty())
1067 .map(str::to_string)
1068 .collect()
1069 }
1070
1071 fn is_valid_identifier(s: &str) -> bool {
1072 let mut chars = s.chars();
1073 chars
1074 .next()
1075 .is_some_and(|first| first.is_alphabetic() || first == '_')
1076 && chars.all(|c| c.is_alphanumeric() || c == '_')
1077 }
1078
1079 fn is_keyword(s: &str) -> bool {
1080 matches!(
1081 s,
1082 "if" | "else"
1083 | "while"
1084 | "for"
1085 | "return"
1086 | "import"
1087 | "use"
1088 | "pub"
1089 | "impl"
1090 | "crate"
1091 | "self"
1092 | "true"
1093 | "false"
1094 | "let"
1095 | "mut"
1096 | "match"
1097 | "var"
1098 | "void"
1099 | "int"
1100 | "string"
1101 | "bool"
1102 | "float"
1103 | "double"
1104 | "public"
1105 | "private"
1106 | "protected"
1107 | "static"
1108 | "final"
1109 | "class"
1110 | "fn"
1111 | "struct"
1112 | "enum"
1113 | "trait"
1114 | "interface"
1115 | "type"
1116 | "const"
1117 | "mod"
1118 | "def"
1119 | "func"
1120 | "function"
1121 | "and"
1122 | "or"
1123 | "not"
1124 | "in"
1125 | "as"
1126 | "break"
1127 | "continue"
1128 | "new"
1129 | "this"
1130 | "super"
1131 )
1132 }
1133
1134 /// A conventional-commit proposal for one group. The model is expected to
1135 /// refine it; the point is that it names the group's single concern rather
1136 /// than "wip".
1137 fn generate_commit_message(group: &CommitGroup) -> String {
1138 let paths: Vec<&str> = group.files.keys().map(String::as_str).collect();
1139 let scope = match paths.as_slice() {
1140 [single] => file_stem(single).to_string(),
1141 _ => {
1142 // A test or spec rides with the source it names; when every file
1143 // in the group shares that stem, the stem is the subject. An
1144 // unrelated group falls back to its directory name.
1145 let shared = related_stem(paths[0]);
1146 if !shared.is_empty() && paths.iter().all(|path| related_stem(path) == shared) {
1147 shared
1148 } else {
1149 paths
1150 .iter()
1151 .map(|path| Path::new(path).parent())
1152 .reduce(|a, b| if a == b { a } else { None })
1153 .flatten()
1154 .and_then(|dir| dir.file_name().and_then(|n| n.to_str()))
1155 .map_or_else(|| "repo".to_string(), str::to_string)
1156 }
1157 }
1158 };
1159 if !group.defined_symbols.is_empty() {
1160 let shown: Vec<&str> = group
1161 .defined_symbols
1162 .iter()
1163 .take(3)
1164 .map(String::as_str)
1165 .collect();
1166 let more = group.defined_symbols.len().saturating_sub(shown.len());
1167 let suffix = if more > 0 {
1168 format!(" (+{more} more)")
1169 } else {
1170 String::new()
1171 };
1172 return format!("feat({scope}): add {}{suffix}", shown.join(", "));
1173 }
1174 let names: Vec<&str> = paths.iter().map(|path| file_name(path)).collect();
1175 let kind = if paths
1176 .iter()
1177 .all(|path| is_lock_file(path) || is_manifest_file(path))
1178 {
1179 return format!("chore(deps): update {}", names.join(", "));
1180 } else if paths.iter().all(|path| is_doc_file(path)) {
1181 "docs"
1182 } else if paths
1183 .iter()
1184 .all(|path| !is_source_file(path) && file_name(path).to_lowercase().contains("test"))
1185 {
1186 "test"
1187 } else {
1188 "chore"
1189 };
1190 format!("{kind}({scope}): update {}", names.join(", "))
1191 }
1192
1193 #[cfg(test)]
1194 mod tests {
1195 use super::*;
1196 use std::fs;
1197 use tempfile::tempdir;
1198
1199 fn git_available() -> bool {
1200 crate::dependencies::Git::available()
1201 }
1202
1203 fn init_git_repo(root: &Path) {
1204 let run = |args: &[&str]| {
1205 let status = crate::dependencies::Git::status(args, root).expect("git should spawn");
1206 assert!(status.success(), "git {args:?} failed");
1207 };
1208
1209 run(&["init", "-q"]);
1210 run(&["config", "core.autocrlf", "false"]);
1211 run(&["config", "user.email", "test@example.com"]);
1212 run(&["config", "user.name", "Test User"]);
1213 }
1214
1215 fn commit_all(root: &Path, message: &str) {
1216 let run = |args: &[&str]| {
1217 let status = crate::dependencies::Git::status(args, root).expect("git should spawn");
1218 assert!(status.success(), "git {args:?} failed");
1219 };
1220 run(&["add", "."]);
1221 run(&["commit", "-q", "-m", message]);
1222 }
1223
1224 #[tokio::test]
1225 async fn git_status_reports_branch_and_changes() {
1226 if !git_available() {
1227 return;
1228 }
1229 let tmp = tempdir().expect("tempdir");
1230 init_git_repo(tmp.path());
1231
1232 let file = tmp.path().join("file.txt");
1233 fs::write(&file, "hello\n").expect("write");
1234 commit_all(tmp.path(), "init");
1235
1236 fs::write(&file, "hello\nworld\n").expect("modify");
1237
1238 let ctx = ToolContext::new(tmp.path());
1239 let tool = GitStatusTool;
1240 let result = tool.execute(json!({}), &ctx).await.expect("execute");
1241 assert!(result.success);
1242 assert!(result.content.contains("##"));
1243 assert!(result.content.contains("file.txt"));
1244 }
1245
1246 #[tokio::test]
1247 async fn git_status_reports_unquoted_unicode_paths() {
1248 if !git_available() {
1249 return;
1250 }
1251
1252 let tmp = tempdir().expect("tempdir");
1253 init_git_repo(tmp.path());
1254
1255 let file = tmp.path().join("中文-данные.txt");
1256 fs::write(&file, "hello\n").expect("write");
1257 commit_all(tmp.path(), "init");
1258
1259 fs::write(&file, "hello\nworld\n").expect("modify");
1260
1261 let ctx = ToolContext::new(tmp.path());
1262 let tool = GitStatusTool;
1263 let result = tool.execute(json!({}), &ctx).await.expect("execute");
1264 assert!(result.success);
1265 assert!(
1266 result
1267 .metadata
1268 .as_ref()
1269 .and_then(|m| m.get("command"))
1270 .and_then(Value::as_str)
1271 .is_some_and(|command| command.contains("-c core.quotepath=false"))
1272 );
1273 assert!(result.content.contains("中文-данные.txt"));
1274 assert!(!result.content.contains("\\344"));
1275 assert!(!result.content.contains("\\320"));
1276 }
1277
1278 #[tokio::test]
1279 async fn git_diff_supports_cached_and_path_scoping() {
1280 if !git_available() {
1281 return;
1282 }
1283 let tmp = tempdir().expect("tempdir");
1284 init_git_repo(tmp.path());
1285
1286 let subdir = tmp.path().join("src");
1287 fs::create_dir_all(&subdir).expect("mkdir");
1288 let file = subdir.join("lib.rs");
1289 fs::write(&file, "pub fn one() -> i32 { 1 }\n").expect("write");
1290 commit_all(tmp.path(), "init");
1291
1292 fs::write(&file, "pub fn one() -> i32 { 2 }\n").expect("modify");
1293
1294 let ctx = ToolContext::new(tmp.path());
1295 let tool = GitDiffTool;
1296
1297 let uncached = tool
1298 .execute(json!({ "path": "src" }), &ctx)
1299 .await
1300 .expect("diff");
1301 assert!(uncached.success);
1302 assert!(uncached.content.contains("diff --git"));
1303 assert!(uncached.content.contains("lib.rs"));
1304
1305 let _ =
1306 crate::dependencies::Git::status(&["add", "src/lib.rs"], tmp.path()).expect("git add");
1307
1308 let cached = tool
1309 .execute(json!({ "path": "src", "cached": true }), &ctx)
1310 .await
1311 .expect("diff cached");
1312 assert!(cached.success);
1313 assert!(cached.content.contains("diff --git"));
1314 assert!(
1315 cached
1316 .metadata
1317 .as_ref()
1318 .and_then(|m| m.get("cached"))
1319 .and_then(Value::as_bool)
1320 .unwrap_or(false)
1321 );
1322 }
1323
1324 #[tokio::test]
1325 async fn git_diff_reports_unquoted_unicode_paths() {
1326 if !git_available() {
1327 return;
1328 }
1329
1330 let tmp = tempdir().expect("tempdir");
1331 init_git_repo(tmp.path());
1332
1333 let unicode_name = "\u{4e2d}\u{6587}-\u{0434}\u{0430}\u{043d}\u{043d}\u{044b}\u{0435}.txt";
1334 let file = tmp.path().join(unicode_name);
1335 fs::write(&file, "hello\n").expect("write");
1336 commit_all(tmp.path(), "init");
1337
1338 fs::write(&file, "hello\nworld\n").expect("modify");
1339
1340 let ctx = ToolContext::new(tmp.path());
1341 let tool = GitDiffTool;
1342 let result = tool.execute(json!({}), &ctx).await.expect("execute");
1343
1344 assert!(result.success);
1345 assert!(
1346 result
1347 .metadata
1348 .as_ref()
1349 .and_then(|m| m.get("command"))
1350 .and_then(Value::as_str)
1351 .is_some_and(|command| command.contains("-c core.quotepath=false"))
1352 );
1353 assert!(result.content.contains(unicode_name));
1354 assert!(!result.content.contains("\\344"));
1355 assert!(!result.content.contains("\\320"));
1356 }
1357
1358 #[test]
1359 fn format_command_joins_args_without_intermediate_vec() {
1360 let args = vec![
1361 "-c".to_string(),
1362 "core.quotepath=false".to_string(),
1363 "status".to_string(),
1364 "--porcelain=v1".to_string(),
1365 "-b".to_string(),
1366 ];
1367 let rendered = format_command(Path::new("/tmp/repo"), &args);
1368 assert_eq!(
1369 rendered,
1370 "git -C /tmp/repo -c core.quotepath=false status --porcelain=v1 -b"
1371 );
1372
1373 assert_eq!(
1374 format_command(Path::new("/tmp/repo"), &[]),
1375 "git -C /tmp/repo "
1376 );
1377 }
1378
1379 #[test]
1380 fn truncation_adds_note() {
1381 let long = "a".repeat(MAX_OUTPUT_CHARS + 100);
1382 let (truncated, did_truncate, omitted) = truncate_with_note(&long, MAX_OUTPUT_CHARS);
1383 assert!(did_truncate);
1384 assert!(omitted > 0);
1385 assert!(truncated.contains("output truncated"));
1386 }
1387
1388 // === Commit plan (#3999) ===
1389
1390 #[test]
1391 fn test_parse_diff() {
1392 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1393 index e69de29..4b2a8d3 100644
1394 --- a/src/lib.rs
1395 +++ b/src/lib.rs
1396 @@ -1,3 +1,4 @@
1397 line1
1398 -line2
1399 +line2 modified
1400 line3
1401 +line4 added
1402 diff --git a/image.png b/image.png
1403 index 1111111..2222222 100644
1404 Binary files a/image.png and b/image.png differ
1405 "#;
1406 let files = parse_diff(diff);
1407 assert_eq!(files.len(), 2);
1408 let hunks = &files[0].hunks;
1409 assert_eq!(hunks.len(), 1);
1410 assert_eq!(hunks[0].file_path, "src/lib.rs");
1411 assert_eq!(hunks[0].header, "@@ -1,3 +1,4 @@");
1412 assert_eq!(hunks[0].lines.len(), 5);
1413 // A binary change has no hunks but must not vanish from the plan.
1414 assert_eq!(files[1].path, "image.png");
1415 assert!(files[1].hunks.is_empty());
1416 }
1417
1418 #[test]
1419 fn test_dependency_extraction() {
1420 let hunk = Hunk {
1421 file_path: "src/lib.rs".to_string(),
1422 header: "@@ -1 +1,2 @@".to_string(),
1423 lines: vec![
1424 " pub fn add(a: i32, b: i32) -> i32 {".to_string(),
1425 "+ let sum = a + b;".to_string(),
1426 "+ struct Answer;".to_string(),
1427 " sum".to_string(),
1428 ],
1429 };
1430 let defined = extract_defined_symbols(&hunk);
1431 let referenced = extract_referenced_symbols(&hunk);
1432
1433 assert!(defined.contains("Answer"));
1434 assert!(defined.contains("sum"));
1435 assert!(referenced.contains("sum"));
1436 assert!(referenced.contains("Answer"));
1437 }
1438
1439 fn text_file(path: &str, added: &[&str]) -> ChangedFile {
1440 ChangedFile {
1441 path: path.to_string(),
1442 hunks: vec![Hunk {
1443 file_path: path.to_string(),
1444 header: format!("@@ -1 +1,{} @@", added.len()),
1445 lines: added.iter().map(|line| format!("+{line}")).collect(),
1446 }],
1447 untracked: false,
1448 }
1449 }
1450
1451 fn paths(commit: &PlannedCommit) -> Vec<&str> {
1452 commit.files.iter().map(|f| f.path.as_str()).collect()
1453 }
1454
1455 #[test]
1456 fn plan_orders_definition_before_use_and_tests_after_source() {
1457 let commits = plan_commits(vec![
1458 text_file("src/main.rs", &["fn main() { let y = math::sub(3, 4); }"]),
1459 text_file(
1460 "src/math.rs",
1461 &["pub fn sub(a: i32, b: i32) -> i32 { a - b }"],
1462 ),
1463 text_file("src/math_test.rs", &["#[test] fn sub_works() {}"]),
1464 text_file("docs/notes.md", &["Some notes"]),
1465 ])
1466 .expect("acyclic");
1467
1468 assert_eq!(commits.len(), 3, "{commits:#?}");
1469 // The test rides with the source file it names.
1470 assert_eq!(paths(&commits[0]), vec!["src/math.rs", "src/math_test.rs"]);
1471 assert!(commits[0].depends_on.is_empty());
1472 assert_eq!(paths(&commits[1]), vec!["src/main.rs"]);
1473 assert_eq!(commits[1].depends_on, vec![(1, "uses `sub`".to_string())]);
1474 assert_eq!(paths(&commits[2]), vec!["docs/notes.md"]);
1475 assert!(
1476 commits[0].message.starts_with("feat(math): add sub"),
1477 "{}",
1478 commits[0].message
1479 );
1480 assert_eq!(commits[2].message, "docs(notes): update notes.md");
1481 }
1482
1483 #[test]
1484 fn plan_rejects_cycles_with_a_diagnostic() {
1485 let cycle = plan_commits(vec![
1486 text_file("a.rs", &["pub fn func_a2() { b::func_b2(); }"]),
1487 text_file("b.rs", &["pub fn func_b2() { a::func_a2(); }"]),
1488 ])
1489 .expect_err("cycle");
1490 assert_eq!(cycle.files, vec!["a.rs", "b.rs"]);
1491 assert!(
1492 cycle
1493 .edges
1494 .iter()
1495 .any(|edge| edge.contains("uses `func_b2`")),
1496 "{:?}",
1497 cycle.edges
1498 );
1499 }
1500
1501 #[test]
1502 fn lock_file_rides_with_its_manifest_and_stays_out_of_analysis() {
1503 let mut lock = text_file("Cargo.lock", &["name = \"serde\"", "fn sub() {}"]);
1504 lock.hunks[0].file_path = "Cargo.lock".to_string();
1505 let commits = plan_commits(vec![
1506 text_file("Cargo.toml", &["serde = \"1\""]),
1507 lock,
1508 text_file("src/math.rs", &["pub fn sub() {}"]),
1509 ])
1510 .expect("acyclic");
1511 assert_eq!(commits.len(), 2, "{commits:#?}");
1512 let deps = commits
1513 .iter()
1514 .find(|c| paths(c).contains(&"Cargo.lock"))
1515 .expect("lock group");
1516 assert_eq!(paths(deps), vec!["Cargo.lock", "Cargo.toml"]);
1517 assert_eq!(deps.message, "chore(deps): update Cargo.lock, Cargo.toml");
1518 // The lock file's tokens never create a dependency edge.
1519 assert!(
1520 commits.iter().all(|c| c.depends_on.is_empty()),
1521 "{commits:#?}"
1522 );
1523 }
1524
1525 fn git_out(root: &Path, args: &[&str]) -> String {
1526 let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
1527 let output = run_git_command(root, &args).expect("git");
1528 assert!(output.status.success(), "git {args:?} failed");
1529 String::from_utf8_lossy(&output.stdout).to_string()
1530 }
1531
1532 #[tokio::test]
1533 async fn commit_plan_proposes_without_touching_the_index() {
1534 if !git_available() {
1535 return;
1536 }
1537 let tmp = tempdir().expect("tempdir");
1538 init_git_repo(tmp.path());
1539
1540 let math_file = tmp.path().join("math.rs");
1541 let main_file = tmp.path().join("main.rs");
1542 fs::write(&math_file, "pub fn add(a: i32, b: i32) -> i32 { a + b }\n").expect("write");
1543 fs::write(&main_file, "fn main() { let x = math::add(1, 2); }\n").expect("write");
1544 commit_all(tmp.path(), "init");
1545
1546 fs::write(
1547 &math_file,
1548 "pub fn add(a: i32, b: i32) -> i32 { a + b }\npub fn sub(a: i32, b: i32) -> i32 { a - b }\n",
1549 )
1550 .expect("modify");
1551 fs::write(
1552 &main_file,
1553 "fn main() { let x = math::add(1, 2); let y = math::sub(3, 4); }\n",
1554 )
1555 .expect("modify");
1556 fs::write(tmp.path().join("NOTES.md"), "untracked notes\n").expect("write");
1557
1558 let ctx = ToolContext::new(tmp.path());
1559 let result = GitCommitPlanTool
1560 .execute(json!({}), &ctx)
1561 .await
1562 .expect("execute");
1563 assert!(result.success, "{}", result.content);
1564 assert!(
1565 result.content.contains("propose-only"),
1566 "{}",
1567 result.content
1568 );
1569 let metadata = result.metadata.expect("metadata");
1570 let commits = metadata["commits"].as_array().expect("commits");
1571 assert_eq!(commits.len(), 3, "{}", result.content);
1572 assert_eq!(commits[0]["files"], json!(["math.rs"]));
1573 assert_eq!(commits[1]["files"], json!(["main.rs"]));
1574 assert_eq!(commits[1]["depends_on"][0]["order"], json!(1));
1575 assert_eq!(commits[2]["untracked"], json!(["NOTES.md"]));
1576 assert_eq!(metadata["index_has_staged_changes"], json!(false));
1577
1578 // Nothing was staged, intent-added, or committed.
1579 assert_eq!(
1580 git_out(tmp.path(), &["diff", "--cached", "--name-only"]),
1581 ""
1582 );
1583 assert_eq!(
1584 git_out(tmp.path(), &["rev-list", "--count", "HEAD"]).trim(),
1585 "1"
1586 );
1587 assert_eq!(
1588 git_out(tmp.path(), &["ls-files", "--others", "--exclude-standard"]).trim(),
1589 "NOTES.md"
1590 );
1591
1592 // The plan lands through the ordinary write path, in order.
1593 for commit in commits {
1594 let mut add = vec!["add", "--"];
1595 let files: Vec<String> = commit["files"]
1596 .as_array()
1597 .unwrap()
1598 .iter()
1599 .chain(commit["untracked"].as_array().unwrap())
1600 .map(|f| f.as_str().unwrap().to_string())
1601 .collect();
1602 add.extend(files.iter().map(String::as_str));
1603 git_out(tmp.path(), &add);
1604 git_out(
1605 tmp.path(),
1606 &["commit", "-q", "-m", commit["message"].as_str().unwrap()],
1607 );
1608 }
1609 assert_eq!(
1610 git_out(tmp.path(), &["rev-list", "--count", "HEAD"]).trim(),
1611 "4"
1612 );
1613 assert_eq!(git_out(tmp.path(), &["status", "--porcelain"]), "");
1614 }
1615
1616 #[tokio::test]
1617 async fn commit_plan_rejects_cycles_and_writes_nothing() {
1618 if !git_available() {
1619 return;
1620 }
1621 let tmp = tempdir().expect("tempdir");
1622 init_git_repo(tmp.path());
1623
1624 let a_file = tmp.path().join("a.rs");
1625 let b_file = tmp.path().join("b.rs");
1626 fs::write(&a_file, "pub fn func_a() {}\n").expect("write a");
1627 fs::write(&b_file, "pub fn func_b() {}\n").expect("write b");
1628 commit_all(tmp.path(), "init");
1629
1630 fs::write(
1631 &a_file,
1632 "pub fn func_a() {}\npub fn func_a2() { b::func_b2(); }\n",
1633 )
1634 .expect("modify a");
1635 fs::write(
1636 &b_file,
1637 "pub fn func_b() {}\npub fn func_b2() { a::func_a2(); }\n",
1638 )
1639 .expect("modify b");
1640
1641 let ctx = ToolContext::new(tmp.path());
1642 let result = GitCommitPlanTool
1643 .execute(json!({}), &ctx)
1644 .await
1645 .expect("execute");
1646 assert!(!result.success);
1647 assert!(
1648 result.content.contains("Dependency cycle detected"),
1649 "{}",
1650 result.content
1651 );
1652 assert!(
1653 result.content.contains("nothing was written"),
1654 "{}",
1655 result.content
1656 );
1657 assert_eq!(result.metadata.unwrap()["cycle_detected"], json!(true));
1658 assert_eq!(
1659 git_out(tmp.path(), &["diff", "--cached", "--name-only"]),
1660 ""
1661 );
1662 assert_eq!(
1663 git_out(tmp.path(), &["rev-list", "--count", "HEAD"]).trim(),
1664 "1"
1665 );
1666 }
1667
1668 #[tokio::test]
1669 async fn commit_plan_warns_when_the_index_already_holds_changes() {
1670 if !git_available() {
1671 return;
1672 }
1673 let tmp = tempdir().expect("tempdir");
1674 init_git_repo(tmp.path());
1675 let file = tmp.path().join("a.rs");
1676 fs::write(&file, "pub fn a() {}\n").expect("write");
1677 commit_all(tmp.path(), "init");
1678 fs::write(&file, "pub fn a() {}\npub fn a2() {}\n").expect("modify");
1679 git_out(tmp.path(), &["add", "a.rs"]);
1680
1681 let ctx = ToolContext::new(tmp.path());
1682 let result = GitCommitPlanTool
1683 .execute(json!({}), &ctx)
1684 .await
1685 .expect("execute");
1686 assert!(result.success, "{}", result.content);
1687 assert!(
1688 result
1689 .content
1690 .contains("WARNING: the index already holds staged changes")
1691 );
1692 assert_eq!(
1693 result.metadata.unwrap()["index_has_staged_changes"],
1694 json!(true)
1695 );
1696 // Still staged exactly as the user left it.
1697 assert_eq!(
1698 git_out(tmp.path(), &["diff", "--cached", "--name-only"]).trim(),
1699 "a.rs"
1700 );
1701 }
1702
1703 #[tokio::test]
1704 async fn commit_plan_reports_a_clean_tree() {
1705 if !git_available() {
1706 return;
1707 }
1708 let tmp = tempdir().expect("tempdir");
1709 init_git_repo(tmp.path());
1710 fs::write(tmp.path().join("a.rs"), "pub fn a() {}\n").expect("write");
1711 commit_all(tmp.path(), "init");
1712
1713 let ctx = ToolContext::new(tmp.path());
1714 let result = GitCommitPlanTool
1715 .execute(json!({}), &ctx)
1716 .await
1717 .expect("execute");
1718 assert!(result.success);
1719 assert!(
1720 result.content.contains("No changes to plan"),
1721 "{}",
1722 result.content
1723 );
1724 }
1725 }
1726
1726 lines RUST