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