返回 CodeWhale
git_history.rs
根目录 / crates / tui / src / tools / git_history.rs
1 //! Git history tools: `git_log`, `git_show`, and `git_blame`.
2 //!
3 //! These tools provide read-only access to commit history and attribution
4 //! without exposing arbitrary shell execution.
5
6 use std::fs;
7 use std::path::{Path, PathBuf};
8 use std::process::Output;
9
10 use async_trait::async_trait;
11 use serde_json::{Value, json};
12
13 use super::spec::{
14 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
15 optional_bool, optional_str, optional_u64, required_str,
16 };
17 use crate::dependencies::ExternalTool;
18
19 const DEFAULT_LOG_MAX_COUNT: u64 = 20;
20 const MAX_LOG_MAX_COUNT: u64 = 200;
21 const DEFAULT_UNIFIED: u64 = 3;
22 const MAX_UNIFIED: u64 = 50;
23 const DEFAULT_BLAME_START_LINE: u64 = 1;
24 const DEFAULT_BLAME_MAX_LINES: u64 = 200;
25 const MAX_BLAME_MAX_LINES: u64 = 2_000;
26
27 /// Tool for reading recent commit history.
28 pub struct GitLogTool;
29
30 #[async_trait]
31 impl ToolSpec for GitLogTool {
32 fn name(&self) -> &'static str {
33 "git_log"
34 }
35
36 fn model_visible(&self) -> bool {
37 false
38 }
39
40 fn description(&self) -> &'static str {
41 "Run `git log` in the workspace with optional path and author/date filters."
42 }
43
44 fn input_schema(&self) -> Value {
45 json!({
46 "type": "object",
47 "properties": {
48 "path": {
49 "type": "string",
50 "description": "Optional subdirectory or file path to scope history to."
51 },
52 "max_count": {
53 "type": "integer",
54 "minimum": 1,
55 "maximum": MAX_LOG_MAX_COUNT,
56 "default": DEFAULT_LOG_MAX_COUNT,
57 "description": "Maximum number of commits to return."
58 },
59 "author": {
60 "type": "string",
61 "description": "Optional git author filter (same semantics as `git log --author`)."
62 },
63 "since": {
64 "type": "string",
65 "description": "Optional lower date bound, e.g. '2 weeks ago' or ISO date."
66 },
67 "until": {
68 "type": "string",
69 "description": "Optional upper date bound, e.g. 'yesterday' or ISO date."
70 }
71 },
72 "additionalProperties": false
73 })
74 }
75
76 fn capabilities(&self) -> Vec<ToolCapability> {
77 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
78 }
79
80 fn approval_requirement(&self) -> ApprovalRequirement {
81 ApprovalRequirement::Auto
82 }
83
84 fn supports_parallel(&self) -> bool {
85 true
86 }
87
88 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
89 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
90 let max_count =
91 optional_u64(&input, "max_count", DEFAULT_LOG_MAX_COUNT)?.clamp(1, MAX_LOG_MAX_COUNT);
92 let author = optional_str(&input, "author")?.map(ToOwned::to_owned);
93 let since = optional_str(&input, "since")?.map(ToOwned::to_owned);
94 let until = optional_str(&input, "until")?.map(ToOwned::to_owned);
95
96 let mut args = vec![
97 "log".to_string(),
98 "--no-color".to_string(),
99 format!("--max-count={max_count}"),
100 "--date=iso-strict".to_string(),
101 "--pretty=format:%H%nAuthor: %an <%ae>%nDate: %ad%nSubject: %s%n".to_string(),
102 ];
103 if let Some(author) = &author {
104 args.push(format!("--author={author}"));
105 }
106 if let Some(since) = &since {
107 args.push(format!("--since={since}"));
108 }
109 if let Some(until) = &until {
110 args.push(format!("--until={until}"));
111 }
112 if let Some(pathspec) = &git_ctx.pathspec {
113 args.push("--".to_string());
114 args.push(pathspec.display().to_string());
115 }
116
117 let command_str = format_command(&git_ctx.working_dir, &args);
118 let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
119 if !output.status.success() {
120 let stderr = String::from_utf8_lossy(&output.stderr);
121 return Ok(
122 ToolResult::error(format!("git log failed: {}", stderr.trim())).with_metadata(
123 json!({
124 "command": command_str,
125 "exit_code": output.status.code(),
126 "stderr": stderr.trim(),
127 }),
128 ),
129 );
130 }
131
132 let stdout = String::from_utf8_lossy(&output.stdout);
133 let content = stdout.into_owned();
134 Ok(ToolResult::success(content).with_metadata(json!({
135 "command": command_str,
136 "working_dir": git_ctx.working_dir,
137 "pathspec": git_ctx.pathspec,
138 "max_count": max_count,
139 "author": author,
140 "since": since,
141 "until": until,
142 })))
143 }
144 }
145
146 /// Tool for showing a specific commit with optional patch/stat output.
147 pub struct GitShowTool;
148
149 #[async_trait]
150 impl ToolSpec for GitShowTool {
151 fn name(&self) -> &'static str {
152 "git_show"
153 }
154
155 fn model_visible(&self) -> bool {
156 false
157 }
158
159 fn description(&self) -> &'static str {
160 "Run `git show` for a specific revision with optional patch and stats."
161 }
162
163 fn input_schema(&self) -> Value {
164 json!({
165 "type": "object",
166 "properties": {
167 "rev": {
168 "type": "string",
169 "description": "Revision to show (commit SHA, tag, branch, or ref expression)."
170 },
171 "path": {
172 "type": "string",
173 "description": "Optional subdirectory or file path to scope output."
174 },
175 "patch": {
176 "type": "boolean",
177 "default": true,
178 "description": "Include patch hunks (default true)."
179 },
180 "stat": {
181 "type": "boolean",
182 "default": true,
183 "description": "Include --stat summary (default true)."
184 },
185 "unified": {
186 "type": "integer",
187 "minimum": 0,
188 "maximum": MAX_UNIFIED,
189 "default": DEFAULT_UNIFIED,
190 "description": "Context lines for patch output when patch=true."
191 }
192 },
193 "required": ["rev"],
194 "additionalProperties": false
195 })
196 }
197
198 fn capabilities(&self) -> Vec<ToolCapability> {
199 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
200 }
201
202 fn approval_requirement(&self) -> ApprovalRequirement {
203 ApprovalRequirement::Auto
204 }
205
206 fn supports_parallel(&self) -> bool {
207 true
208 }
209
210 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
211 let rev = required_str(&input, "rev")?;
212 validate_git_rev(rev)?;
213 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
214 let patch = optional_bool(&input, "patch", true)?;
215 let stat = optional_bool(&input, "stat", true)?;
216 let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED)?.min(MAX_UNIFIED);
217
218 let mut args = vec!["show".to_string(), "--no-color".to_string()];
219 args.extend(crate::dependencies::Git::REVIEW_DIFF_ARGS.map(String::from));
220 if patch {
221 args.push(format!("--unified={unified}"));
222 } else {
223 args.push("--no-patch".to_string());
224 }
225 if stat {
226 args.push("--stat".to_string());
227 }
228 args.push(rev.to_string());
229 if let Some(pathspec) = &git_ctx.pathspec {
230 args.push("--".to_string());
231 args.push(pathspec.display().to_string());
232 }
233
234 let command_str = format_command(&git_ctx.working_dir, &args);
235 let working_dir = git_ctx.working_dir.clone();
236 let output =
237 tokio::task::spawn_blocking(move || super::git::run_git_command(&working_dir, &args))
238 .await
239 .map_err(|e| ToolError::execution_failed(format!("git task panicked: {e}")))??;
240 if !output.status.success() {
241 let stderr = String::from_utf8_lossy(&output.stderr);
242 return Ok(ToolResult::error(format!(
243 "git show failed for '{rev}': {}",
244 stderr.trim()
245 ))
246 .with_metadata(json!({
247 "command": command_str,
248 "exit_code": output.status.code(),
249 "stderr": stderr.trim(),
250 })));
251 }
252
253 let stdout = String::from_utf8_lossy(&output.stdout);
254 let content = stdout.into_owned();
255 Ok(ToolResult::success(content).with_metadata(json!({
256 "command": command_str,
257 "working_dir": git_ctx.working_dir,
258 "pathspec": git_ctx.pathspec,
259 "rev": rev,
260 "patch": patch,
261 "stat": stat,
262 "unified": if patch { Some(unified) } else { None },
263 })))
264 }
265 }
266
267 /// Tool for attributing lines in a file to commits and authors.
268 pub struct GitBlameTool;
269
270 #[async_trait]
271 impl ToolSpec for GitBlameTool {
272 fn name(&self) -> &'static str {
273 "git_blame"
274 }
275
276 fn model_visible(&self) -> bool {
277 false
278 }
279
280 fn description(&self) -> &'static str {
281 "Run `git blame` on a file with optional revision and line-range controls."
282 }
283
284 fn input_schema(&self) -> Value {
285 json!({
286 "type": "object",
287 "properties": {
288 "path": {
289 "type": "string",
290 "description": "Path to a tracked file within the workspace."
291 },
292 "rev": {
293 "type": "string",
294 "description": "Optional revision to blame against (default: HEAD)."
295 },
296 "start_line": {
297 "type": "integer",
298 "minimum": 1,
299 "default": DEFAULT_BLAME_START_LINE,
300 "description": "First line to include in blame output."
301 },
302 "max_lines": {
303 "type": "integer",
304 "minimum": 1,
305 "maximum": MAX_BLAME_MAX_LINES,
306 "default": DEFAULT_BLAME_MAX_LINES,
307 "description": "Maximum number of lines to include."
308 },
309 "porcelain": {
310 "type": "boolean",
311 "default": false,
312 "description": "When true, emit `--line-porcelain` output."
313 }
314 },
315 "required": ["path"],
316 "additionalProperties": false
317 })
318 }
319
320 fn capabilities(&self) -> Vec<ToolCapability> {
321 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
322 }
323
324 fn approval_requirement(&self) -> ApprovalRequirement {
325 ApprovalRequirement::Auto
326 }
327
328 fn supports_parallel(&self) -> bool {
329 true
330 }
331
332 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
333 let path_str = required_str(&input, "path")?;
334 let resolved_path = context.resolve_path(path_str)?;
335 let metadata = tokio::fs::metadata(&resolved_path).await.map_err(|e| {
336 ToolError::invalid_input(format!(
337 "Path does not exist or is not accessible: {path_str} ({e})"
338 ))
339 })?;
340 if !metadata.is_file() {
341 return Err(ToolError::invalid_input(format!(
342 "Path must point to a file: {path_str}"
343 )));
344 }
345
346 let working_dir = resolved_path.parent().ok_or_else(|| {
347 ToolError::invalid_input(format!("Path has no parent directory: {path_str}"))
348 })?;
349 let pathspec = pathspec_from(working_dir, &resolved_path);
350 let rev = optional_str(&input, "rev")?.unwrap_or("HEAD");
351 validate_git_rev(rev)?;
352 let start_line = optional_u64(&input, "start_line", DEFAULT_BLAME_START_LINE)?.max(1);
353 let max_lines = optional_u64(&input, "max_lines", DEFAULT_BLAME_MAX_LINES)?
354 .clamp(1, MAX_BLAME_MAX_LINES);
355 let end_line = start_line.saturating_add(max_lines.saturating_sub(1));
356 let porcelain = optional_bool(&input, "porcelain", false)?;
357
358 // Blame reads the working-tree file, so it runs under the review
359 // command (no clean filters) and skips textconv drivers.
360 let mut args = vec![
361 "blame".to_string(),
362 "--no-textconv".to_string(),
363 "--date=iso".to_string(),
364 format!("-L{start_line},{end_line}"),
365 ];
366 if porcelain {
367 args.push("--line-porcelain".to_string());
368 }
369 args.push(rev.to_string());
370 args.push("--".to_string());
371 args.push(pathspec.display().to_string());
372
373 let command_str = format_command(working_dir, &args);
374 let blame_dir = working_dir.to_path_buf();
375 let output =
376 tokio::task::spawn_blocking(move || super::git::run_git_command(&blame_dir, &args))
377 .await
378 .map_err(|e| ToolError::execution_failed(format!("git task panicked: {e}")))??;
379 if !output.status.success() {
380 let stderr = String::from_utf8_lossy(&output.stderr);
381 return Ok(ToolResult::error(format!(
382 "git blame failed for '{path_str}' at '{rev}': {}",
383 stderr.trim()
384 ))
385 .with_metadata(json!({
386 "command": command_str,
387 "exit_code": output.status.code(),
388 "stderr": stderr.trim(),
389 })));
390 }
391
392 let stdout = String::from_utf8_lossy(&output.stdout);
393 let content = stdout.into_owned();
394 Ok(ToolResult::success(content).with_metadata(json!({
395 "command": command_str,
396 "working_dir": working_dir,
397 "pathspec": pathspec,
398 "rev": rev,
399 "start_line": start_line,
400 "max_lines": max_lines,
401 "porcelain": porcelain,
402 })))
403 }
404 }
405
406 /// Tool for fetching remote refs: `git fetch <remote> [<refspec>...]`.
407 ///
408 /// The bounded verify-mode git surface (#6298): a verifier child cannot reach
409 /// raw shell, so `git fetch` arrives as a structured call instead of a shell
410 /// command. The bound is structural — fixed argv, argv-direct spawning (no
411 /// shell), a remote that must be a *configured* remote name (never a URL, so
412 /// an operator-supplied address cannot exfiltrate or redirect), and refspecs
413 /// that pass the option/whitespace/control gates. Only remote-tracking refs
414 /// (plus `FETCH_HEAD` and the fetched objects) move: never a checkout, merge,
415 /// or push. The execution envelope classes this as bounded fetch — shell plus
416 /// network authority, not write authority.
417 ///
418 /// Never interactive and always bounded: the spawn carries the shared no-prompt
419 /// environment (`GIT_TERMINAL_PROMPT=0`, BatchMode ssh), so a remote that wants
420 /// credentials, a passphrase or a host-key confirmation fails fast instead of
421 /// prompting on `/dev/tty` inside the raw-mode TUI; and the child runs under
422 /// [`GIT_FETCH_TIMEOUT`] with `kill_on_drop`, so a remote that never answers
423 /// ends the call with a clear error instead of freezing the turn.
424 pub struct GitFetchTool;
425
426 /// Upper bound on one `git_fetch`. Generous enough for a first fetch of a
427 /// large repository; short enough that a silent remote cannot pass for a hang.
428 const GIT_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
429
430 #[async_trait]
431 impl ToolSpec for GitFetchTool {
432 fn name(&self) -> &'static str {
433 "git_fetch"
434 }
435
436 fn model_visible(&self) -> bool {
437 false
438 }
439
440 fn description(&self) -> &'static str {
441 "Run `git fetch` against a configured remote. Updates remote-tracking refs only; never checks out, merges, or pushes."
442 }
443
444 fn input_schema(&self) -> Value {
445 json!({
446 "type": "object",
447 "properties": {
448 "remote": {
449 "type": "string",
450 "default": "origin",
451 "description": "Configured remote name to fetch from (default origin). Must be a name from `git remote`, never a URL."
452 },
453 "refspecs": {
454 "type": "array",
455 "items": { "type": "string" },
456 "description": "Optional refspecs to fetch (e.g. pull/123/head); a `src:dst` destination must be under refs/remotes/. Empty fetches the remote's defaults."
457 },
458 "path": {
459 "type": "string",
460 "description": "Optional subdirectory to run from."
461 }
462 },
463 "additionalProperties": false
464 })
465 }
466
467 fn capabilities(&self) -> Vec<ToolCapability> {
468 vec![ToolCapability::Network, ToolCapability::Sandboxable]
469 }
470
471 fn approval_requirement(&self) -> ApprovalRequirement {
472 ApprovalRequirement::Required
473 }
474
475 fn supports_parallel(&self) -> bool {
476 false
477 }
478
479 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
480 let remote = optional_str(&input, "remote")?.unwrap_or("origin");
481 validate_git_remote_name(remote)?;
482 let refspecs = parse_git_refspecs(&input)?;
483 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
484 require_configured_remote(&git_ctx.working_dir, remote).await?;
485
486 // Remote-tracking refs only (#6561 D03-01): a default fetch
487 // auto-follows tags into refs/tags/, and `tagOpt`/`pruneTags`
488 // config can add or delete local tags. The flags override both.
489 let mut args = vec![
490 "fetch".to_string(),
491 "--no-tags".to_string(),
492 "--no-prune-tags".to_string(),
493 remote.to_string(),
494 ];
495 args.extend(refspecs.clone());
496
497 let command_str = format_command(&git_ctx.working_dir, &args);
498 let Some(output) =
499 run_git_command_bounded(&git_ctx.working_dir, &args, GIT_FETCH_TIMEOUT).await?
500 else {
501 let seconds = GIT_FETCH_TIMEOUT.as_secs();
502 return Ok(ToolResult::error(format!(
503 "git fetch from remote '{remote}' timed out after {seconds}s and was stopped; \
504 the remote did not finish answering. No refs were changed by this call."
505 ))
506 .with_metadata(json!({
507 "command": command_str,
508 "timed_out": true,
509 "timeout_secs": seconds,
510 })));
511 };
512 if !output.status.success() {
513 let stderr = String::from_utf8_lossy(&output.stderr);
514 return Ok(ToolResult::error(format!(
515 "git fetch failed for remote '{remote}': {}",
516 stderr.trim()
517 ))
518 .with_metadata(json!({
519 "command": command_str,
520 "exit_code": output.status.code(),
521 "stderr": stderr.trim(),
522 })));
523 }
524
525 let stdout = String::from_utf8_lossy(&output.stdout);
526 let stderr = String::from_utf8_lossy(&output.stderr);
527 let combined = if stderr.trim().is_empty() {
528 stdout.to_string()
529 } else {
530 format!("{stdout}\n{stderr}")
531 };
532 let content = combined;
533 Ok(ToolResult::success(content).with_metadata(json!({
534 "command": command_str,
535 "working_dir": git_ctx.working_dir,
536 "remote": remote,
537 "refspecs": refspecs,
538 })))
539 }
540 }
541
542 /// Tool for computing a merge result without touching the working tree.
543 ///
544 /// `git merge-tree` is a pure read: it performs the merge in memory and
545 /// prints the resulting tree plus conflicted-file info, writing nothing, so
546 /// the envelope classes it Bounded like the other inspection actions. Uses
547 /// the modern two-revision form (git 2.38+, 2022); an explicit base arrives
548 /// via `--merge-base`, and otherwise git finds the bases itself — including
549 /// the multi-base virtual-base case a hand-rolled `merge-base` call cannot
550 /// express. Older gits fail with their own usage error, surfaced below.
551 pub struct GitMergeTreeTool;
552
553 #[async_trait]
554 impl ToolSpec for GitMergeTreeTool {
555 fn name(&self) -> &'static str {
556 "git_merge_tree"
557 }
558
559 fn model_visible(&self) -> bool {
560 false
561 }
562
563 fn description(&self) -> &'static str {
564 "Compute the merge result of two revisions without touching the working tree (`git merge-tree`). Pure read."
565 }
566
567 fn input_schema(&self) -> Value {
568 json!({
569 "type": "object",
570 "properties": {
571 "ours": {
572 "type": "string",
573 "description": "First revision (e.g. main)."
574 },
575 "theirs": {
576 "type": "string",
577 "description": "Second revision (e.g. the PR head)."
578 },
579 "base": {
580 "type": "string",
581 "description": "Optional merge base (--merge-base). Omit it and git finds the bases itself."
582 },
583 "path": {
584 "type": "string",
585 "description": "Optional subdirectory to run from."
586 }
587 },
588 "required": ["ours", "theirs"],
589 "additionalProperties": false
590 })
591 }
592
593 fn capabilities(&self) -> Vec<ToolCapability> {
594 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
595 }
596
597 fn approval_requirement(&self) -> ApprovalRequirement {
598 ApprovalRequirement::Auto
599 }
600
601 fn supports_parallel(&self) -> bool {
602 true
603 }
604
605 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
606 let ours = required_str(&input, "ours")?;
607 let theirs = required_str(&input, "theirs")?;
608 validate_git_rev(ours)?;
609 validate_git_rev(theirs)?;
610 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
611
612 let mut args = vec!["merge-tree".to_string()];
613 let base = match optional_str(&input, "base")? {
614 Some(base) => {
615 validate_git_rev(base)?;
616 // `--opt=value` form: a validated rev can never split into a
617 // second argv element, so no option injection through `base`.
618 args.push(format!("--merge-base={base}"));
619 Some(base.to_string())
620 }
621 None => None,
622 };
623 args.push(ours.to_string());
624 args.push(theirs.to_string());
625 let command_str = format_command(&git_ctx.working_dir, &args);
626 let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
627 // merge-tree exits 1 both for conflicts (a successful report on
628 // stdout) and for real failures (empty stdout, reason on stderr).
629 // The report is the answer; only the empty case is an error.
630 let stdout = String::from_utf8_lossy(&output.stdout);
631 if !output.status.success() && stdout.trim().is_empty() {
632 let stderr = String::from_utf8_lossy(&output.stderr);
633 return Ok(ToolResult::error(format!(
634 "git merge-tree failed for '{ours}' + '{theirs}': {}",
635 stderr.trim()
636 ))
637 .with_metadata(json!({
638 "command": command_str,
639 "exit_code": output.status.code(),
640 "stderr": stderr.trim(),
641 })));
642 }
643
644 let conflicts = !output.status.success();
645 let content = stdout.into_owned();
646 Ok(ToolResult::success(content).with_metadata(json!({
647 "command": command_str,
648 "working_dir": git_ctx.working_dir,
649 "ours": ours,
650 "theirs": theirs,
651 "base": base,
652 "conflicts": conflicts,
653 })))
654 }
655 }
656
657 struct GitContext {
658 working_dir: PathBuf,
659 pathspec: Option<PathBuf>,
660 }
661
662 fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result<GitContext, ToolError> {
663 let workspace = canonical_or_workspace(&context.workspace);
664 let mut working_dir = workspace.clone();
665 let mut pathspec = None;
666
667 if let Some(raw) = path {
668 let resolved = context.resolve_path(raw)?;
669 let metadata = fs::metadata(&resolved).map_err(|e| {
670 ToolError::invalid_input(format!(
671 "Path does not exist or is not accessible: {raw} ({e})"
672 ))
673 })?;
674
675 if metadata.is_dir() {
676 working_dir = resolved;
677 pathspec = Some(PathBuf::from("."));
678 } else {
679 let parent = resolved.parent().ok_or_else(|| {
680 ToolError::invalid_input(format!("Path has no parent directory: {raw}"))
681 })?;
682 working_dir = parent.to_path_buf();
683 pathspec = Some(pathspec_from(&working_dir, &resolved));
684 }
685 }
686
687 if !working_dir.exists() {
688 return Err(ToolError::invalid_input(format!(
689 "Working directory does not exist: {}",
690 working_dir.display()
691 )));
692 }
693
694 Ok(GitContext {
695 working_dir,
696 pathspec,
697 })
698 }
699
700 fn validate_git_rev(rev: &str) -> Result<(), ToolError> {
701 let trimmed = rev.trim();
702 if trimmed.is_empty() {
703 return Err(ToolError::invalid_input(
704 "git revision must not be empty".to_string(),
705 ));
706 }
707 if trimmed.starts_with('-') {
708 return Err(ToolError::invalid_input(
709 "git revision must not start with '-'".to_string(),
710 ));
711 }
712 if trimmed.chars().any(char::is_whitespace) {
713 return Err(ToolError::invalid_input(
714 "git revision must not contain whitespace".to_string(),
715 ));
716 }
717 if trimmed
718 .chars()
719 .any(|ch| ch == '\0' || ch.is_ascii_control())
720 {
721 return Err(ToolError::invalid_input(
722 "git revision must not contain control characters".to_string(),
723 ));
724 }
725 Ok(())
726 }
727
728 /// A fetch remote is a configured remote *name*, never a URL: URLs and paths
729 /// fail the membership check below, but rejecting their shapes here keeps the
730 /// refusal precise (`:` kills `https://`, `user@host:path`, and `file://`;
731 /// `/` kills paths) instead of "unknown remote".
732 fn validate_git_remote_name(remote: &str) -> Result<(), ToolError> {
733 let trimmed = remote.trim();
734 if trimmed.is_empty() {
735 return Err(ToolError::invalid_input(
736 "git remote must not be empty".to_string(),
737 ));
738 }
739 if trimmed.starts_with('-') {
740 return Err(ToolError::invalid_input(
741 "git remote must not start with '-'".to_string(),
742 ));
743 }
744 if trimmed.chars().any(char::is_whitespace) {
745 return Err(ToolError::invalid_input(
746 "git remote must not contain whitespace".to_string(),
747 ));
748 }
749 if trimmed
750 .chars()
751 .any(|ch| ch == '\0' || ch.is_ascii_control() || ch == ':' || ch == '/')
752 {
753 return Err(ToolError::invalid_input(
754 "git remote must be a configured remote name (from `git remote`), never a URL or path"
755 .to_string(),
756 ));
757 }
758 Ok(())
759 }
760
761 /// Parse the optional `refspecs` array: `[+]<src>[:<dst>]`, each side passing
762 /// the revision gates. A wrong type is an error, never a silent default.
763 fn parse_git_refspecs(input: &Value) -> Result<Vec<String>, ToolError> {
764 let items = match input.get("refspecs") {
765 None | Some(Value::Null) => return Ok(Vec::new()),
766 Some(Value::Array(items)) => items,
767 Some(other) => {
768 return Err(super::spec::type_mismatch(
769 "refspecs",
770 other,
771 "an array of strings",
772 ));
773 }
774 };
775 let mut refspecs = Vec::with_capacity(items.len());
776 for (index, item) in items.iter().enumerate() {
777 let Some(refspec) = item.as_str() else {
778 return Err(super::spec::type_mismatch(
779 &format!("refspecs[{index}]"),
780 item,
781 "a string",
782 ));
783 };
784 validate_git_refspec(refspec)?;
785 refspecs.push(refspec.to_string());
786 }
787 Ok(refspecs)
788 }
789
790 fn validate_git_refspec(refspec: &str) -> Result<(), ToolError> {
791 let body = refspec.strip_prefix('+').unwrap_or(refspec);
792 let parts: Vec<&str> = body.split(':').collect();
793 if parts.len() > 2 {
794 return Err(ToolError::invalid_input(format!(
795 "git refspec '{refspec}' must have at most one ':'"
796 )));
797 }
798 for side in &parts {
799 validate_git_refspec_side(refspec, side)?;
800 }
801 // `git fetch <remote> tag <name>` is shorthand for
802 // `refs/tags/<name>:refs/tags/<name>`, a local tag write.
803 if refspec == "tag" {
804 return Err(ToolError::invalid_input(
805 "git refspec 'tag' is not accepted: the `tag <name>` form writes a local tag; \
806 fetch refs/tags/<name> into FETCH_HEAD or under refs/remotes/ instead",
807 ));
808 }
809 // The tool updates remote-tracking refs only: a destination under
810 // `refs/heads/` or `refs/tags/` (or a bare name git would resolve there)
811 // could rewrite local branches and tags.
812 if let Some(dst) = parts.get(1)
813 && (!dst.starts_with("refs/remotes/") || dst.split('/').any(|part| part == ".."))
814 {
815 return Err(ToolError::invalid_input(format!(
816 "git refspec '{refspec}' must write under refs/remotes/; omit the destination to fetch into FETCH_HEAD"
817 )));
818 }
819 Ok(())
820 }
821
822 fn validate_git_refspec_side(refspec: &str, side: &str) -> Result<(), ToolError> {
823 if side.is_empty() {
824 return Err(ToolError::invalid_input(format!(
825 "git refspec '{refspec}' has an empty side"
826 )));
827 }
828 validate_git_rev(side).map_err(|_| {
829 ToolError::invalid_input(format!(
830 "git refspec '{refspec}' is not a plain refspec (no options, whitespace, or control characters)"
831 ))
832 })
833 }
834
835 /// The remote must already be configured on this repository. A name git never
836 /// heard of fails here — before any network — so a typo cannot become a fetch
837 /// from somewhere else.
838 async fn require_configured_remote(working_dir: &Path, remote: &str) -> Result<(), ToolError> {
839 let output =
840 run_git_command_async(working_dir.to_path_buf(), vec!["remote".to_string()]).await?;
841 if !output.status.success() {
842 let stderr = String::from_utf8_lossy(&output.stderr);
843 return Err(ToolError::execution_failed(format!(
844 "git remote failed: {}",
845 stderr.trim()
846 )));
847 }
848 let stdout = String::from_utf8_lossy(&output.stdout);
849 let configured: Vec<&str> = stdout
850 .lines()
851 .map(str::trim)
852 .filter(|line| !line.is_empty())
853 .collect();
854 if configured.contains(&remote) {
855 Ok(())
856 } else {
857 Err(ToolError::invalid_input(format!(
858 "unknown git remote '{remote}'; configured remotes: {}",
859 if configured.is_empty() {
860 "(none)".to_string()
861 } else {
862 configured.join(", ")
863 }
864 )))
865 }
866 }
867
868 fn canonical_or_workspace(workspace: &Path) -> PathBuf {
869 workspace
870 .canonicalize()
871 .unwrap_or_else(|_| workspace.to_path_buf())
872 }
873
874 fn pathspec_from(working_dir: &Path, resolved: &Path) -> PathBuf {
875 match resolved.strip_prefix(working_dir) {
876 Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
877 Ok(rel) => rel.to_path_buf(),
878 Err(_) => PathBuf::from("."),
879 }
880 }
881
882 /// History reads share the read-only runner in `git.rs`, which disables the
883 /// workspace's fsmonitor, hooks and filters.
884 fn run_git_command(working_dir: &Path, args: &[String]) -> Result<Output, ToolError> {
885 super::git::run_git_command(working_dir, args)
886 }
887
888 /// Async wrapper that offloads the blocking `git` invocation onto a
889 /// blocking-capable thread so the tokio worker is not stalled.
890 async fn run_git_command_async(
891 working_dir: PathBuf,
892 args: Vec<String>,
893 ) -> Result<Output, ToolError> {
894 tokio::task::spawn_blocking(move || run_git_command(&working_dir, &args))
895 .await
896 .map_err(|e| ToolError::execution_failed(format!("git task panicked: {e}")))?
897 }
898
899 /// Run git under a hard deadline. `Ok(None)` means the deadline passed and the
900 /// child was killed, so a timed-out fetch never lingers.
901 ///
902 /// git runs contained ([`crate::process_tree::contained_output`]) and the whole
903 /// tree is killed at the deadline or when the call is cancelled: git hands the
904 /// network to a transport child (`git-remote-http`, `ssh`) that survives a
905 /// SIGKILL to git alone and would otherwise keep the stalled connection open
906 /// indefinitely.
907 async fn run_git_command_bounded(
908 working_dir: &Path,
909 args: &[String],
910 timeout: std::time::Duration,
911 ) -> Result<Option<Output>, ToolError> {
912 let Some(mut cmd) = crate::dependencies::Git::tokio_command() else {
913 return Err(ToolError::not_available(
914 "git is not installed or not in PATH",
915 ));
916 };
917 cmd.args(args).current_dir(working_dir);
918 match tokio::time::timeout(timeout, crate::process_tree::contained_output(&mut cmd)).await {
919 Ok(Ok(output)) => Ok(Some(output)),
920 Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Err(ToolError::not_available(
921 "git is not installed or not in PATH",
922 )),
923 Ok(Err(e)) => Err(ToolError::execution_failed(format!(
924 "Failed to run git: {e}"
925 ))),
926 // The elapsed deadline dropped the contained run, which killed git's
927 // whole tree.
928 Err(_) => Ok(None),
929 }
930 }
931
932 fn format_command(working_dir: &Path, args: &[String]) -> String {
933 format!(
934 "git -C {} {}",
935 working_dir.display(),
936 args.iter()
937 .map(String::as_str)
938 .collect::<Vec<_>>()
939 .join(" ")
940 )
941 }
942
943 #[cfg(test)]
944 mod tests {
945 use super::*;
946 use std::fs;
947 use std::path::Path;
948 use tempfile::tempdir;
949
950 fn git_available() -> bool {
951 crate::dependencies::Git::available()
952 }
953
954 fn run_git(root: &Path, args: &[&str]) {
955 let status = crate::dependencies::Git::status(args, root).expect("git should spawn");
956 assert!(status.success(), "git {args:?} failed");
957 }
958
959 fn init_git_repo(root: &Path) {
960 run_git(root, &["init", "-q"]);
961 run_git(root, &["config", "core.autocrlf", "false"]);
962 run_git(root, &["config", "user.email", "test@example.com"]);
963 run_git(root, &["config", "user.name", "Test User"]);
964 }
965
966 fn commit_all(root: &Path, message: &str) {
967 run_git(root, &["add", "."]);
968 run_git(root, &["commit", "-q", "-m", message]);
969 }
970
971 #[tokio::test]
972 async fn git_log_lists_recent_commits() {
973 if !git_available() {
974 return;
975 }
976
977 let tmp = tempdir().expect("tempdir");
978 init_git_repo(tmp.path());
979 fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
980 commit_all(tmp.path(), "first");
981 fs::write(tmp.path().join("file.txt"), "two\n").expect("write");
982 commit_all(tmp.path(), "second");
983
984 let ctx = ToolContext::new(tmp.path());
985 let result = GitLogTool
986 .execute(json!({ "max_count": 1 }), &ctx)
987 .await
988 .expect("execute");
989 assert!(result.success);
990 assert!(result.content.contains("Subject: second"));
991 }
992
993 #[tokio::test]
994 async fn git_show_returns_patch_for_revision() {
995 if !git_available() {
996 return;
997 }
998
999 let tmp = tempdir().expect("tempdir");
1000 init_git_repo(tmp.path());
1001 fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
1002 commit_all(tmp.path(), "first");
1003 fs::write(tmp.path().join("file.txt"), "one\ntwo\n").expect("write");
1004 commit_all(tmp.path(), "second");
1005
1006 let ctx = ToolContext::new(tmp.path());
1007 let result = GitShowTool
1008 .execute(json!({ "rev": "HEAD", "stat": false }), &ctx)
1009 .await
1010 .expect("execute");
1011 assert!(result.success);
1012 assert!(result.content.contains("diff --git"));
1013 assert!(result.content.contains("+two"));
1014 }
1015
1016 #[tokio::test]
1017 async fn git_show_returns_a_large_patch_whole() {
1018 // #6508: no 40,000-character per-tool cut; the end of the patch
1019 // reaches the caller.
1020 if !git_available() {
1021 return;
1022 }
1023 let tmp = tempdir().expect("tempdir");
1024 init_git_repo(tmp.path());
1025 fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
1026 commit_all(tmp.path(), "first");
1027 fs::write(
1028 tmp.path().join("file.txt"),
1029 format!("{}FINAL LINE\n", "line of patch\n".repeat(4_000)),
1030 )
1031 .expect("write");
1032 commit_all(tmp.path(), "second");
1033
1034 let result = GitShowTool
1035 .execute(
1036 json!({ "rev": "HEAD", "stat": false }),
1037 &ToolContext::new(tmp.path()),
1038 )
1039 .await
1040 .expect("execute");
1041 assert!(result.success);
1042 assert!(result.content.chars().count() > 40_000);
1043 assert!(result.content.contains("+FINAL LINE"));
1044 assert!(!result.content.contains("output truncated"));
1045 }
1046
1047 #[tokio::test]
1048 async fn git_show_rejects_option_like_revision() {
1049 let tmp = tempdir().expect("tempdir");
1050 let ctx = ToolContext::new(tmp.path());
1051 let err = GitShowTool
1052 .execute(json!({ "rev": "--stat" }), &ctx)
1053 .await
1054 .expect_err("option-shaped rev should fail before git runs");
1055 assert!(matches!(err, ToolError::InvalidInput { .. }));
1056 assert!(err.to_string().contains("must not start with '-'"));
1057 }
1058
1059 #[tokio::test]
1060 async fn git_show_rejects_whitespace_revision_payload() {
1061 let tmp = tempdir().expect("tempdir");
1062 let ctx = ToolContext::new(tmp.path());
1063 let err = GitShowTool
1064 .execute(
1065 json!({ "rev": "HEAD --output=/tmp/codewhale-git-show" }),
1066 &ctx,
1067 )
1068 .await
1069 .expect_err("whitespace rev payload should fail before git runs");
1070 assert!(matches!(err, ToolError::InvalidInput { .. }));
1071 assert!(err.to_string().contains("must not contain whitespace"));
1072 }
1073
1074 #[tokio::test]
1075 async fn git_blame_reports_author_for_range() {
1076 if !git_available() {
1077 return;
1078 }
1079
1080 let tmp = tempdir().expect("tempdir");
1081 init_git_repo(tmp.path());
1082 let src = tmp.path().join("src");
1083 fs::create_dir_all(&src).expect("mkdir");
1084 let file = src.join("lib.rs");
1085 fs::write(&file, "pub fn one() -> i32 { 1 }\n").expect("write");
1086 commit_all(tmp.path(), "first");
1087 fs::write(&file, "pub fn one() -> i32 { 2 }\n").expect("write");
1088 commit_all(tmp.path(), "second");
1089
1090 let ctx = ToolContext::new(tmp.path());
1091 let result = GitBlameTool
1092 .execute(
1093 json!({
1094 "path": "src/lib.rs",
1095 "start_line": 1,
1096 "max_lines": 1
1097 }),
1098 &ctx,
1099 )
1100 .await
1101 .expect("execute");
1102 assert!(result.success);
1103 assert!(result.content.contains("Test User"));
1104 }
1105
1106 #[tokio::test]
1107 async fn git_blame_rejects_option_like_revision() {
1108 let tmp = tempdir().expect("tempdir");
1109 let file = tmp.path().join("file.txt");
1110 fs::write(&file, "one\n").expect("write");
1111 let ctx = ToolContext::new(tmp.path());
1112 let err = GitBlameTool
1113 .execute(
1114 json!({ "path": "file.txt", "rev": "--contents=/tmp/x" }),
1115 &ctx,
1116 )
1117 .await
1118 .expect_err("option-shaped rev should fail before git runs");
1119 assert!(matches!(err, ToolError::InvalidInput { .. }));
1120 assert!(err.to_string().contains("must not start with '-'"));
1121 }
1122
1123 #[tokio::test]
1124 async fn git_blame_rejects_whitespace_revision_payload() {
1125 let tmp = tempdir().expect("tempdir");
1126 let file = tmp.path().join("file.txt");
1127 fs::write(&file, "one\n").expect("write");
1128 let ctx = ToolContext::new(tmp.path());
1129 let err = GitBlameTool
1130 .execute(
1131 json!({ "path": "file.txt", "rev": "HEAD --contents=/tmp/codewhale-git-blame" }),
1132 &ctx,
1133 )
1134 .await
1135 .expect_err("whitespace rev payload should fail before git runs");
1136 assert!(matches!(err, ToolError::InvalidInput { .. }));
1137 assert!(err.to_string().contains("must not contain whitespace"));
1138 }
1139
1140 #[tokio::test]
1141 async fn git_blame_errors_for_non_file_path() {
1142 if !git_available() {
1143 return;
1144 }
1145
1146 let tmp = tempdir().expect("tempdir");
1147 init_git_repo(tmp.path());
1148
1149 let ctx = ToolContext::new(tmp.path());
1150 let result = GitBlameTool
1151 .execute(json!({ "path": "." }), &ctx)
1152 .await
1153 .expect_err("directory path should fail");
1154 assert!(matches!(result, ToolError::InvalidInput { .. }));
1155 }
1156
1157 #[tokio::test]
1158 async fn git_fetch_rejects_url_and_option_shaped_remotes() {
1159 let tmp = tempdir().expect("tempdir");
1160 let ctx = ToolContext::new(tmp.path());
1161 for remote in [
1162 "https://example.com/repo.git",
1163 "git@example.com:org/repo.git",
1164 "/tmp/other-checkout",
1165 "--upload-pack=evil",
1166 "origin --prune",
1167 ] {
1168 let err = GitFetchTool
1169 .execute(json!({ "remote": remote }), &ctx)
1170 .await
1171 .expect_err("non-name remote should fail before git runs");
1172 assert!(
1173 matches!(err, ToolError::InvalidInput { .. }),
1174 "{remote}: {err}"
1175 );
1176 }
1177 }
1178
1179 #[tokio::test]
1180 async fn git_fetch_rejects_unknown_remote_before_network() {
1181 if !git_available() {
1182 return;
1183 }
1184
1185 let tmp = tempdir().expect("tempdir");
1186 init_git_repo(tmp.path());
1187 let ctx = ToolContext::new(tmp.path());
1188 let err = GitFetchTool
1189 .execute(json!({ "remote": "origin" }), &ctx)
1190 .await
1191 .expect_err("unconfigured remote must be refused");
1192 let message = err.to_string();
1193 assert!(message.contains("unknown git remote 'origin'"), "{message}");
1194 assert!(message.contains("(none)"), "{message}");
1195 }
1196
1197 #[tokio::test]
1198 async fn git_fetch_rejects_malformed_refspecs() {
1199 if !git_available() {
1200 return;
1201 }
1202
1203 let tmp = tempdir().expect("tempdir");
1204 init_git_repo(tmp.path());
1205 let ctx = ToolContext::new(tmp.path());
1206 for refspec in ["a:b:c", "src:", ":dst", "--prune", "a b"] {
1207 let err = GitFetchTool
1208 .execute(json!({ "refspecs": [refspec] }), &ctx)
1209 .await
1210 .expect_err("malformed refspec should fail before git runs");
1211 assert!(
1212 matches!(err, ToolError::InvalidInput { .. }),
1213 "{refspec}: {err}"
1214 );
1215 }
1216 for refspec in [
1217 "+main:refs/heads/main",
1218 "v1:refs/tags/v1",
1219 "main:other",
1220 "main:refs/remotes/../heads/main",
1221 ] {
1222 let err = validate_git_refspec(refspec)
1223 .expect_err("a destination outside refs/remotes/ must be refused");
1224 assert!(
1225 err.to_string().contains("refs/remotes/"),
1226 "{refspec}: {err}"
1227 );
1228 }
1229 // `tag <name>` arrives as two array elements, each a plain rev.
1230 let err = GitFetchTool
1231 .execute(json!({ "refspecs": ["tag", "v1"] }), &ctx)
1232 .await
1233 .expect_err("the tag shorthand writes a local tag");
1234 assert!(
1235 matches!(err, ToolError::InvalidInput { .. }) && err.to_string().contains("tag"),
1236 "{err}"
1237 );
1238 for refspec in [
1239 "pull/123/head",
1240 "refs/tags/v1",
1241 "refs/heads/x:refs/remotes/origin/x",
1242 "+refs/heads/*:refs/remotes/origin/*",
1243 ] {
1244 validate_git_refspec(refspec).expect(refspec);
1245 }
1246 let err = GitFetchTool
1247 .execute(json!({ "refspecs": "pull/1/head" }), &ctx)
1248 .await
1249 .expect_err("wrongly typed refspecs should fail");
1250 assert!(err.to_string().contains("an array of strings"), "{err}");
1251 }
1252
1253 #[tokio::test]
1254 async fn git_fetch_brings_remote_refs_without_checkout() {
1255 if !git_available() {
1256 return;
1257 }
1258
1259 let origin = tempdir().expect("tempdir");
1260 init_git_repo(origin.path());
1261 fs::write(origin.path().join("file.txt"), "one\n").expect("write");
1262 commit_all(origin.path(), "first");
1263 // An annotated tag on fetched history: a default `git fetch`
1264 // auto-follows it into the local refs/tags/ namespace.
1265 run_git(origin.path(), &["tag", "-a", "v1", "-m", "v1"]);
1266
1267 let work = tempdir().expect("tempdir");
1268 init_git_repo(work.path());
1269 run_git(
1270 work.path(),
1271 &[
1272 "remote",
1273 "add",
1274 "origin",
1275 &origin.path().display().to_string(),
1276 ],
1277 );
1278
1279 let ctx = ToolContext::new(work.path());
1280 let result = GitFetchTool
1281 .execute(json!({ "remote": "origin" }), &ctx)
1282 .await
1283 .expect("execute");
1284 assert!(result.success, "{}", result.content);
1285
1286 // #6561 D03-01: remote-tracking refs only, so no local tag appeared.
1287 let tags = crate::dependencies::Git::output(&["tag", "--list"], work.path())
1288 .expect("git should spawn");
1289 assert!(tags.status.success());
1290 assert_eq!(String::from_utf8_lossy(&tags.stdout).trim(), "");
1291
1292 // The refs arrived, but nothing was checked out: the work tree has no
1293 // file.txt and no local branch moved.
1294 let refs = crate::dependencies::Git::output(&["branch", "-r"], work.path())
1295 .expect("git should spawn");
1296 assert!(refs.status.success());
1297 let refs = String::from_utf8_lossy(&refs.stdout);
1298 assert!(refs.contains("origin/"), "{refs}");
1299 assert!(!work.path().join("file.txt").exists());
1300 }
1301
1302 /// A loopback HTTP "remote" that answers every request with `response`.
1303 /// Returns its URL.
1304 fn spawn_fake_http_remote(response: &'static str) -> String {
1305 use std::io::{Read, Write};
1306 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1307 let addr = listener.local_addr().expect("addr");
1308 std::thread::spawn(move || {
1309 for stream in listener.incoming().take(16) {
1310 let Ok(mut stream) = stream else { continue };
1311 let mut buf = [0u8; 4096];
1312 let _ = stream.read(&mut buf);
1313 let _ = stream.write_all(response.as_bytes());
1314 }
1315 });
1316 format!("http://{addr}/repo.git")
1317 }
1318
1319 fn init_repo_with_remote(root: &Path, url: &str) {
1320 init_git_repo(root);
1321 run_git(root, &["remote", "add", "origin", url]);
1322 // Isolate from the developer's credential helpers and askpass so the
1323 // only thing standing between git and a prompt is our environment.
1324 run_git(root, &["config", "credential.helper", ""]);
1325 run_git(root, &["config", "core.askPass", ""]);
1326 }
1327
1328 #[tokio::test]
1329 async fn git_fetch_fails_fast_when_remote_requires_credentials() {
1330 if !git_available() {
1331 return;
1332 }
1333 let url = spawn_fake_http_remote(
1334 "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"codewhale\"\r\n\
1335 Content-Length: 0\r\nConnection: close\r\n\r\n",
1336 );
1337 let work = tempdir().expect("tempdir");
1338 init_repo_with_remote(work.path(), &url);
1339
1340 let ctx = ToolContext::new(work.path());
1341 let started = std::time::Instant::now();
1342 let result = tokio::time::timeout(
1343 std::time::Duration::from_secs(60),
1344 GitFetchTool.execute(json!({ "remote": "origin" }), &ctx),
1345 )
1346 .await
1347 .expect("git_fetch must not wait on a credential prompt")
1348 .expect("execute");
1349 assert!(!result.success, "{}", result.content);
1350 assert!(
1351 result
1352 .content
1353 .contains("git fetch failed for remote 'origin'"),
1354 "{}",
1355 result.content
1356 );
1357 // Git names the refusal to prompt rather than blocking on /dev/tty.
1358 let lower = result.content.to_lowercase();
1359 assert!(
1360 lower.contains("terminal prompts disabled") || lower.contains("authentication"),
1361 "{}",
1362 result.content
1363 );
1364 assert!(started.elapsed() < std::time::Duration::from_secs(30));
1365 }
1366
1367 #[tokio::test]
1368 async fn git_fetch_runner_kills_a_remote_that_never_answers() {
1369 if !git_available() {
1370 return;
1371 }
1372 // Accept one connection and hand it back, never answering.
1373 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1374 let url = format!("http://{}/repo.git", listener.local_addr().expect("addr"));
1375 let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
1376 std::thread::spawn(move || {
1377 if let Ok((stream, _)) = listener.accept() {
1378 let _ = accepted_tx.send(stream);
1379 }
1380 });
1381 let work = tempdir().expect("tempdir");
1382 init_repo_with_remote(work.path(), &url);
1383
1384 let started = std::time::Instant::now();
1385 let outcome = run_git_command_bounded(
1386 work.path(),
1387 &["fetch".to_string(), "origin".to_string()],
1388 std::time::Duration::from_secs(2),
1389 )
1390 .await
1391 .expect("git spawns");
1392 assert!(outcome.is_none(), "a silent remote must hit the deadline");
1393 assert!(started.elapsed() < std::time::Duration::from_secs(15));
1394
1395 // The connection belongs to git's transport child, not git itself.
1396 // It must close too: a lingering helper would hold the stall open.
1397 #[cfg(unix)]
1398 {
1399 use std::io::Read;
1400 let mut stream = accepted_rx
1401 .recv_timeout(std::time::Duration::from_secs(5))
1402 .expect("git connected to the fake remote");
1403 stream
1404 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1405 .expect("read timeout");
1406 let mut buf = [0u8; 4096];
1407 loop {
1408 match stream.read(&mut buf) {
1409 Ok(0) => break,
1410 Ok(_) => continue, // the request itself
1411 Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => break,
1412 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1413 Err(e) => panic!("transport child outlived the deadline: {e}"),
1414 }
1415 }
1416 }
1417 #[cfg(not(unix))]
1418 drop(accepted_rx);
1419 }
1420
1421 #[tokio::test]
1422 async fn git_merge_tree_reports_conflicts_without_touching_tree() {
1423 if !git_available() {
1424 return;
1425 }
1426
1427 let tmp = tempdir().expect("tempdir");
1428 init_git_repo(tmp.path());
1429 fs::write(tmp.path().join("file.txt"), "base\n").expect("write");
1430 commit_all(tmp.path(), "base");
1431 let main = current_branch(tmp.path());
1432 run_git(tmp.path(), &["checkout", "-qb", "side"]);
1433 fs::write(tmp.path().join("file.txt"), "side\n").expect("write");
1434 commit_all(tmp.path(), "side");
1435 run_git(tmp.path(), &["checkout", "-q", main.as_str()]);
1436 fs::write(tmp.path().join("file.txt"), "base\nmain\n").expect("write");
1437 commit_all(tmp.path(), "main");
1438
1439 let ctx = ToolContext::new(tmp.path());
1440 let before = fs::read(tmp.path().join("file.txt")).expect("read");
1441 let result = GitMergeTreeTool
1442 .execute(json!({ "ours": main, "theirs": "side" }), &ctx)
1443 .await
1444 .expect("execute");
1445 assert!(result.success, "{}", result.content);
1446 // Modern merge-tree shape: result tree plus the conflicted path in
1447 // the stage table and the CONFLICT notice.
1448 assert!(result.content.contains("file.txt"), "{}", result.content);
1449 assert!(result.content.contains("CONFLICT"), "{}", result.content);
1450 assert_eq!(
1451 fs::read(tmp.path().join("file.txt")).expect("read"),
1452 before,
1453 "merge-tree must not touch the working tree"
1454 );
1455 }
1456
1457 #[tokio::test]
1458 async fn git_merge_tree_rejects_option_shaped_revisions() {
1459 let tmp = tempdir().expect("tempdir");
1460 let ctx = ToolContext::new(tmp.path());
1461 let err = GitMergeTreeTool
1462 .execute(json!({ "ours": "--merge-base=x", "theirs": "HEAD" }), &ctx)
1463 .await
1464 .expect_err("option-shaped rev should fail before git runs");
1465 assert!(matches!(err, ToolError::InvalidInput { .. }));
1466 assert!(err.to_string().contains("must not start with '-'"));
1467 }
1468
1469 fn current_branch(root: &Path) -> String {
1470 let output = crate::dependencies::Git::output(&["branch", "--show-current"], root)
1471 .expect("git should spawn");
1472 assert!(output.status.success());
1473 String::from_utf8_lossy(&output.stdout).trim().to_string()
1474 }
1475
1476 /// A cancelled call kills git's whole tree, not only at the deadline.
1477 #[cfg(unix)]
1478 #[tokio::test]
1479 async fn cancelled_bounded_git_run_kills_what_git_started() {
1480 if !git_available() {
1481 return;
1482 }
1483 let tmp = tempdir().expect("tempdir");
1484 init_git_repo(tmp.path());
1485 let pid_file = tmp.path().join("hang.pid");
1486 let args = [
1487 "-c".to_string(),
1488 "alias.hang=!sleep 300 & echo $! > hang.pid; wait".to_string(),
1489 "hang".to_string(),
1490 ];
1491 let run = run_git_command_bounded(tmp.path(), &args, std::time::Duration::from_secs(600));
1492 let grandchild = crate::process_tree::drop_once_pid_written(run, &pid_file).await;
1493 assert!(
1494 crate::process_tree::wait_for_pid_exit(grandchild, std::time::Duration::from_secs(5)),
1495 "a process git started outlived the cancelled call"
1496 );
1497 }
1498 }
1499
1499 lines RUST