Skip to main content

ag_git/
repo.rs

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
9/// Returns the origin repository URL normalized to HTTPS form when possible.
10///
11/// # Arguments
12/// * `repo_path` - Path to a git repository or worktree
13///
14/// # Returns
15/// Ok(url) on success, Err([`GitError`]) on failure.
16///
17/// # Errors
18/// Returns an error if the remote URL cannot be read via `git remote get-url`.
19pub(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
34/// Resolves the main repository root for a repository or linked worktree.
35///
36/// Uses `git rev-parse --git-dir --git-common-dir`, normalizes both paths to
37/// absolute form, detects whether `repo_path` is a linked worktree (`git-dir`
38/// differs from `git-common-dir`), and then returns the shared repository
39/// root.
40///
41/// # Arguments
42/// * `repo_path` - Path to a git repository or worktree
43///
44/// # Returns
45/// Ok(path) containing the main repository root, Err([`GitError`]) on failure.
46///
47/// # Errors
48/// Returns an error if git metadata cannot be queried from `repo_path`.
49pub(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
53/// Resolves the main repository root for `repo_path` in synchronous code.
54pub(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
64/// Resolves the git directory path for a repository root or worktree root.
65pub(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
87/// Runs a git command in a blocking task and returns stdout text.
88///
89/// # Arguments
90/// * `repo_path` - Path to the git repository or worktree
91/// * `args` - Git command arguments
92/// * `error_context` - Prefix used for command failure messages
93///
94/// # Returns
95/// The command stdout on success.
96///
97/// # Errors
98/// Returns [`GitError::Join`] if the blocking task panics, or
99/// [`GitError::CommandFailed`] if spawning fails or the command exits with
100/// a non-zero status.
101pub(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
114/// Runs a git command in `repo_path` and returns stdout text.
115///
116/// # Arguments
117/// * `repo_path` - Path to the git repository or worktree
118/// * `args` - Git command arguments
119/// * `error_context` - Human-readable label prepended to the stderr detail on
120///   failure (e.g. `"Failed to read squash merge diff"`)
121///
122/// # Returns
123/// The command stdout on success.
124///
125/// # Errors
126/// Returns [`GitError::CommandFailed`] with the concrete git invocation in
127/// `command` and the `error_context` plus stderr/stdout detail in `stderr`.
128pub(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
147/// Formats the full git invocation string from command arguments.
148fn 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
156/// Runs a git command in `repo_path` and returns raw process output.
157///
158/// # Arguments
159/// * `repo_path` - Path to the git repository or worktree
160/// * `args` - Git command arguments
161///
162/// # Returns
163/// The process output, including status, stdout, and stderr.
164///
165/// # Errors
166/// Returns [`GitError::CommandFailed`] if spawning the command fails.
167pub(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
174/// Runs a git command in `repo_path` with environment overrides and returns
175/// raw process output.
176///
177/// Applies non-interactive defaults (`GIT_TERMINAL_PROMPT=0`,
178/// `GCM_INTERACTIVE=never`, and SSH batch mode) and closes stdin so
179/// credential failures do not block waiting for terminal input.
180/// Caller-provided `environment` pairs are then applied and can override
181/// these defaults.
182///
183/// # Arguments
184/// * `repo_path` - Path to the git repository or worktree
185/// * `args` - Git command arguments
186/// * `environment` - Environment variables applied to the git process
187///
188/// # Returns
189/// The process output, including status, stdout, and stderr.
190///
191/// # Errors
192/// Returns [`GitError::CommandFailed`] if spawning the command fails.
193pub(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
215/// Applies non-interactive defaults so git failures return immediately instead
216/// of waiting for terminal credential prompts.
217fn 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
229/// Extracts the best human-readable error detail from command output.
230pub(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
244/// Converts SSH-style GitHub remotes into HTTPS while preserving other URLs.
245fn 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
258/// Reads absolute git and common git directory paths for `repo_path`.
259fn 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
282/// Converts a git directory path (typically `.git`) into repository root.
283fn 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
312/// Normalizes a git metadata path into absolute form for path comparisons.
313fn 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        // Arrange
333        let mut command = Command::new("git");
334
335        // Act
336        apply_non_interactive_environment(&mut command);
337
338        // Assert
339        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        // Arrange
370
371        // Act
372        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
377        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        // Arrange
385
386        // Act
387        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
392        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        // Arrange
400        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        // Act
426        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
432        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        // Arrange
441        let temp_dir = tempdir().expect("failed to create temp dir");
442
443        // Act
444        let result = run_git_command_sync(
445            temp_dir.path(),
446            &["definitely-not-a-git-subcommand"],
447            "Git command failed",
448        );
449
450        // Assert
451        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        // Arrange
463        let temp_dir = tempdir().expect("failed to create temp dir");
464
465        // Act
466        let result = main_repo_root_sync(temp_dir.path());
467
468        // Assert
469        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        // Act / Assert
481        assert_eq!(format_git_invocation(&[]), "git");
482    }
483
484    #[test]
485    fn test_format_git_invocation_joins_args_after_git() {
486        // Act / Assert
487        assert_eq!(
488            format_git_invocation(&["diff", "--cached", "--quiet"]),
489            "git diff --cached --quiet"
490        );
491    }
492}