1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Output, Stdio};
4
5use tokio::task::spawn_blocking;
6
7use super::error::GitError;
8
9pub(crate) async fn repo_url(repo_path: PathBuf) -> Result<String, GitError> {
20 let remote = run_git_command(
21 repo_path,
22 vec![
23 "remote".to_string(),
24 "get-url".to_string(),
25 "origin".to_string(),
26 ],
27 "Git remote get-url failed".to_string(),
28 )
29 .await?;
30
31 Ok(normalize_repo_url(remote.trim()))
32}
33
34pub(crate) async fn main_repo_root(repo_path: PathBuf) -> Result<PathBuf, GitError> {
50 spawn_blocking(move || main_repo_root_sync(&repo_path)).await?
51}
52
53pub(super) fn main_repo_root_sync(repo_path: &Path) -> Result<PathBuf, GitError> {
55 let (git_dir, git_common_dir) = git_directory_paths(repo_path)?;
56
57 if git_dir == git_common_dir {
58 return repo_root_from_git_dir(repo_path, &git_dir);
59 }
60
61 repo_root_from_git_dir(repo_path, &git_common_dir)
62}
63
64pub(super) fn resolve_git_dir(repo_dir: &Path) -> Option<PathBuf> {
66 let dot_git = repo_dir.join(".git");
67 if dot_git.is_dir() {
68 return Some(dot_git);
69 }
70
71 if dot_git.is_file() {
72 let content = fs::read_to_string(&dot_git).ok()?;
73 let git_dir_line = content.lines().find(|line| line.starts_with("gitdir:"))?;
74 let git_dir_path = git_dir_line.trim_start_matches("gitdir:").trim();
75 let git_dir = PathBuf::from(git_dir_path);
76
77 if git_dir.is_absolute() {
78 return Some(git_dir);
79 }
80
81 return Some(repo_dir.join(git_dir));
82 }
83
84 None
85}
86
87pub(super) async fn run_git_command(
102 repo_path: PathBuf,
103 args: Vec<String>,
104 error_context: String,
105) -> Result<String, GitError> {
106 spawn_blocking(move || {
107 let argument_refs: Vec<&str> = args.iter().map(String::as_str).collect();
108
109 run_git_command_sync(&repo_path, &argument_refs, &error_context)
110 })
111 .await?
112}
113
114pub(super) fn run_git_command_sync(
129 repo_path: &Path,
130 args: &[&str],
131 error_context: &str,
132) -> Result<String, GitError> {
133 let output = run_git_command_output_sync(repo_path, args)?;
134 if !output.status.success() {
135 let detail = command_output_detail(&output.stdout, &output.stderr);
136 let git_invocation = format_git_invocation(args);
137
138 return Err(GitError::CommandFailed {
139 command: git_invocation,
140 stderr: format!("{error_context}: {detail}"),
141 });
142 }
143
144 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
145}
146
147fn format_git_invocation(args: &[&str]) -> String {
149 if args.is_empty() {
150 return "git".to_string();
151 }
152
153 format!("git {}", args.join(" "))
154}
155
156pub(super) fn run_git_command_output_sync(
168 repo_path: &Path,
169 args: &[&str],
170) -> Result<Output, GitError> {
171 run_git_command_output_with_env_sync(repo_path, args, &[])
172}
173
174pub(super) fn run_git_command_output_with_env_sync(
194 repo_path: &Path,
195 args: &[&str],
196 environment: &[(&str, &str)],
197) -> Result<Output, GitError> {
198 let mut command = Command::new("git");
199 command
200 .args(args)
201 .current_dir(repo_path)
202 .stdin(Stdio::null());
203 apply_non_interactive_environment(&mut command);
204
205 for (key, value) in environment {
206 command.env(key, value);
207 }
208
209 command.output().map_err(|error| GitError::CommandFailed {
210 command: format_git_invocation(args),
211 stderr: error.to_string(),
212 })
213}
214
215fn apply_non_interactive_environment(command: &mut Command) {
218 let git_ssh_command = std::env::var("GIT_SSH_COMMAND").map_or_else(
219 |_| "ssh -o BatchMode=yes".to_string(),
220 |configured_command| format!("{configured_command} -o BatchMode=yes"),
221 );
222
223 command
224 .env("GIT_TERMINAL_PROMPT", "0")
225 .env("GCM_INTERACTIVE", "never")
226 .env("GIT_SSH_COMMAND", git_ssh_command);
227}
228
229pub(super) fn command_output_detail(stdout: &[u8], stderr: &[u8]) -> String {
231 let stderr_text = String::from_utf8_lossy(stderr).trim().to_string();
232 if !stderr_text.is_empty() {
233 return stderr_text;
234 }
235
236 let stdout_text = String::from_utf8_lossy(stdout).trim().to_string();
237 if !stdout_text.is_empty() {
238 return stdout_text;
239 }
240
241 "Unknown git error".to_string()
242}
243
244fn normalize_repo_url(remote: &str) -> String {
246 let trimmed = remote.trim_end_matches(".git");
247 if let Some(path) = trimmed.strip_prefix("git@github.com:") {
248 return format!("https://github.com/{path}");
249 }
250
251 if let Some(path) = trimmed.strip_prefix("ssh://git@github.com/") {
252 return format!("https://github.com/{path}");
253 }
254
255 trimmed.to_string()
256}
257
258fn git_directory_paths(repo_path: &Path) -> Result<(PathBuf, PathBuf), GitError> {
260 let stdout = run_git_command_sync(
261 repo_path,
262 &["rev-parse", "--git-dir", "--git-common-dir"],
263 "Git rev-parse failed",
264 )?;
265 let mut lines = stdout
266 .lines()
267 .map(str::trim)
268 .filter(|line| !line.is_empty());
269 let git_dir = lines
270 .next()
271 .ok_or_else(|| GitError::OutputParse("Git rev-parse output missing git-dir".to_string()))?;
272 let git_common_dir = lines.next().ok_or_else(|| {
273 GitError::OutputParse("Git rev-parse output missing git-common-dir".to_string())
274 })?;
275
276 Ok((
277 normalize_git_dir_path(repo_path, git_dir),
278 normalize_git_dir_path(repo_path, git_common_dir),
279 ))
280}
281
282fn repo_root_from_git_dir(repo_path: &Path, git_dir: &Path) -> Result<PathBuf, GitError> {
284 if git_dir
285 .file_name()
286 .and_then(|name| name.to_str())
287 .is_some_and(|name| name == ".git")
288 {
289 return git_dir.parent().map(Path::to_path_buf).ok_or_else(|| {
290 GitError::OutputParse(format!(
291 "Git directory has no parent: {}",
292 git_dir.display()
293 ))
294 });
295 }
296
297 let root = run_git_command_sync(
298 repo_path,
299 &["rev-parse", "--show-toplevel"],
300 "Git rev-parse --show-toplevel failed",
301 )?;
302 let root = root.trim().to_string();
303 if root.is_empty() {
304 return Err(GitError::OutputParse(
305 "Git rev-parse --show-toplevel returned empty output".to_string(),
306 ));
307 }
308
309 Ok(PathBuf::from(root))
310}
311
312fn normalize_git_dir_path(repo_path: &Path, git_path: &str) -> PathBuf {
314 let git_path = PathBuf::from(git_path);
315 let git_path = if git_path.is_absolute() {
316 git_path
317 } else {
318 repo_path.join(git_path)
319 };
320
321 std::fs::canonicalize(&git_path).unwrap_or(git_path)
322}
323
324#[cfg(test)]
325mod tests {
326 use tempfile::tempdir;
327
328 use super::*;
329
330 #[test]
331 fn test_apply_non_interactive_environment_sets_git_prompt_controls() {
332 let mut command = Command::new("git");
334
335 apply_non_interactive_environment(&mut command);
337
338 let env_pairs: Vec<(String, String)> = command
340 .get_envs()
341 .filter_map(|(key, value)| {
342 value.map(|resolved_value| {
343 (
344 key.to_string_lossy().to_string(),
345 resolved_value.to_string_lossy().to_string(),
346 )
347 })
348 })
349 .collect();
350 assert!(
351 env_pairs
352 .iter()
353 .any(|(key, value)| key == "GIT_TERMINAL_PROMPT" && value == "0")
354 );
355 assert!(
356 env_pairs
357 .iter()
358 .any(|(key, value)| key == "GCM_INTERACTIVE" && value == "never")
359 );
360 assert!(
361 env_pairs
362 .iter()
363 .any(|(key, value)| key == "GIT_SSH_COMMAND" && value.contains("BatchMode=yes"))
364 );
365 }
366
367 #[test]
368 fn test_command_output_detail_prefers_stderr_then_stdout_then_unknown() {
369 let stderr_detail = command_output_detail(b"stdout detail", b"stderr detail");
373 let stdout_detail = command_output_detail(b"stdout detail", b"");
374 let unknown_detail = command_output_detail(b"", b"");
375
376 assert_eq!(stderr_detail, "stderr detail");
378 assert_eq!(stdout_detail, "stdout detail");
379 assert_eq!(unknown_detail, "Unknown git error");
380 }
381
382 #[test]
383 fn test_normalize_repo_url_converts_supported_github_formats() {
384 let ssh_short = normalize_repo_url("git@github.com:agentty-xyz/agentty.git");
388 let ssh_long = normalize_repo_url("ssh://git@github.com/agentty-xyz/agentty.git");
389 let passthrough = normalize_repo_url("https://example.com/agentty-xyz/agentty.git");
390
391 assert_eq!(ssh_short, "https://github.com/agentty-xyz/agentty");
393 assert_eq!(ssh_long, "https://github.com/agentty-xyz/agentty");
394 assert_eq!(passthrough, "https://example.com/agentty-xyz/agentty");
395 }
396
397 #[test]
398 fn test_resolve_git_dir_supports_directories_and_gitdir_files() {
399 let temp_dir = tempdir().expect("failed to create temp dir");
401 let repo_with_directory = temp_dir.path().join("repo-directory");
402 let repo_with_absolute_file = temp_dir.path().join("repo-absolute");
403 let repo_with_relative_file = temp_dir.path().join("repo-relative");
404 let relative_git_dir = repo_with_relative_file.join(".actual-git");
405 let malformed_repo = temp_dir.path().join("repo-malformed");
406 fs::create_dir_all(repo_with_directory.join(".git"))
407 .expect("failed to create .git directory repo");
408 fs::create_dir_all(&repo_with_absolute_file).expect("failed to create absolute repo");
409 fs::create_dir_all(&repo_with_relative_file).expect("failed to create relative repo");
410 fs::create_dir_all(&relative_git_dir).expect("failed to create relative git dir");
411 fs::create_dir_all(&malformed_repo).expect("failed to create malformed repo");
412 fs::write(
413 repo_with_absolute_file.join(".git"),
414 format!("gitdir: {}", temp_dir.path().join("absolute-git").display()),
415 )
416 .expect("failed to write absolute gitdir file");
417 fs::write(
418 repo_with_relative_file.join(".git"),
419 "gitdir: .actual-git\n",
420 )
421 .expect("failed to write relative gitdir file");
422 fs::write(malformed_repo.join(".git"), "not-a-gitdir-file")
423 .expect("failed to write malformed gitdir file");
424
425 let directory_git_dir = resolve_git_dir(&repo_with_directory);
427 let absolute_git_dir = resolve_git_dir(&repo_with_absolute_file);
428 let relative_git_dir_resolved = resolve_git_dir(&repo_with_relative_file);
429 let malformed_git_dir = resolve_git_dir(&malformed_repo);
430
431 assert_eq!(directory_git_dir, Some(repo_with_directory.join(".git")));
433 assert_eq!(absolute_git_dir, Some(temp_dir.path().join("absolute-git")));
434 assert_eq!(relative_git_dir_resolved, Some(relative_git_dir));
435 assert_eq!(malformed_git_dir, None);
436 }
437
438 #[test]
439 fn test_run_git_command_sync_returns_command_failed_on_invalid_subcommand() {
440 let temp_dir = tempdir().expect("failed to create temp dir");
442
443 let result = run_git_command_sync(
445 temp_dir.path(),
446 &["definitely-not-a-git-subcommand"],
447 "Git command failed",
448 );
449
450 let error = result.expect_err("invalid git command should fail");
452 assert!(
453 matches!(&error, GitError::CommandFailed { command, stderr }
454 if command == "git definitely-not-a-git-subcommand"
455 && stderr.contains("Git command failed")),
456 "unexpected error: {error:?}"
457 );
458 }
459
460 #[test]
461 fn test_main_repo_root_sync_returns_command_failed_outside_git_repository() {
462 let temp_dir = tempdir().expect("failed to create temp dir");
464
465 let result = main_repo_root_sync(temp_dir.path());
467
468 let error = result.expect_err("non-repo should fail");
470 assert!(
471 matches!(&error, GitError::CommandFailed { command, stderr }
472 if command.starts_with("git rev-parse")
473 && stderr.contains("Git rev-parse failed")),
474 "unexpected error: {error:?}"
475 );
476 }
477
478 #[test]
479 fn test_format_git_invocation_returns_bare_git_for_empty_args() {
480 assert_eq!(format_git_invocation(&[]), "git");
482 }
483
484 #[test]
485 fn test_format_git_invocation_joins_args_after_git() {
486 assert_eq!(
488 format_git_invocation(&["diff", "--cached", "--quiet"]),
489 "git diff --cached --quiet"
490 );
491 }
492}