Skip to main content

git_claw/infra/
git.rs

1//! Git CLI command wrappers for repository discovery, merge, and path resolution.
2
3use crate::infra::error::GitError;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7/// Resolves the Git common directory (`git rev-parse --git-common-dir`)
8/// for repository-wide state like `claw/slots.json` and `claw/slots.lock`.
9pub fn resolve_git_common_dir(repo_root: Option<&Path>) -> Result<PathBuf, GitError> {
10    let mut cmd = Command::new("git");
11    if let Some(root) = repo_root {
12        cmd.current_dir(root);
13    }
14    cmd.args(["rev-parse", "--git-common-dir"]);
15
16    let output = cmd.output().map_err(GitError::ProcessFailed)?;
17    if !output.status.success() {
18        let stderr = String::from_utf8_lossy(&output.stderr);
19        if stderr.contains("not a git repository") {
20            return Err(GitError::NotAGitRepository);
21        }
22        return Err(GitError::CommandFailed {
23            command: "git rev-parse --git-common-dir".to_string(),
24            message: stderr.trim().to_string(),
25        });
26    }
27
28    let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
29    let path = PathBuf::from(path_str);
30    let resolved = if path.is_absolute() {
31        path
32    } else {
33        let base = repo_root
34            .map(|p| p.to_path_buf())
35            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
36        base.join(path)
37    };
38
39    Ok(resolved.canonicalize().unwrap_or(resolved))
40}
41
42/// Resolves the top-level repository working tree directory (`git rev-parse --show-toplevel`).
43pub fn resolve_toplevel(repo_root: Option<&Path>) -> Result<PathBuf, GitError> {
44    let mut cmd = Command::new("git");
45    if let Some(root) = repo_root {
46        cmd.current_dir(root);
47    }
48    cmd.args(["rev-parse", "--show-toplevel"]);
49
50    let output = cmd.output().map_err(GitError::ProcessFailed)?;
51    if !output.status.success() {
52        let stderr = String::from_utf8_lossy(&output.stderr);
53        if stderr.contains("not a git repository") {
54            return Err(GitError::NotAGitRepository);
55        }
56        return Err(GitError::CommandFailed {
57            command: "git rev-parse --show-toplevel".to_string(),
58            message: stderr.trim().to_string(),
59        });
60    }
61
62    let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
63    let path = PathBuf::from(path_str);
64    Ok(path.canonicalize().unwrap_or(path))
65}
66
67/// Resolves the primary repository root directory even when invoked from inside a linked worktree.
68///
69/// In standard Git repositories and linked worktrees, `git rev-parse --git-common-dir` points
70/// to `<primary_root>/.git`. If the common directory ends in `.git`, its parent directory is
71/// returned as the primary repository root. Otherwise, it falls back cleanly to `resolve_toplevel(repo_root)`.
72///
73/// # Parameters
74///
75/// * `repo_root` - Optional path to run Git resolution from.
76///
77/// # Returns
78///
79/// Returns `Ok(PathBuf)` containing the canonical path to the primary repository root,
80/// or `Err(GitError)` if resolution fails or the path is not a Git repository.
81///
82/// # Example
83///
84/// ```no_run
85/// use std::path::Path;
86/// use git_claw::infra::git::resolve_primary_repo_root;
87///
88/// # fn run() -> Result<(), git_claw::infra::error::GitError> {
89/// let root = resolve_primary_repo_root(Some(Path::new(".")))?;
90/// println!("Primary repository root: {:?}", root);
91/// # Ok(())
92/// # }
93/// ```
94pub fn resolve_primary_repo_root(repo_root: Option<&Path>) -> Result<PathBuf, GitError> {
95    let common_dir = resolve_git_common_dir(repo_root)?;
96    if common_dir.file_name().map(|n| n == ".git").unwrap_or(false) {
97        if let Some(parent) = common_dir.parent() {
98            return Ok(parent.to_path_buf());
99        }
100    }
101    resolve_toplevel(repo_root)
102}
103
104/// Extracts repository name from top-level directory.
105pub fn resolve_repo_name(toplevel: &Path) -> String {
106    toplevel
107        .file_name()
108        .and_then(|n| n.to_str())
109        .unwrap_or("repo")
110        .to_string()
111}
112
113/// Runs `git worktree prune` to clean internal Git worktree tracking.
114pub fn git_worktree_prune(repo_root: Option<&Path>) -> Result<(), GitError> {
115    let mut cmd = Command::new("git");
116    if let Some(root) = repo_root {
117        cmd.current_dir(root);
118    }
119    cmd.args(["worktree", "prune"]);
120
121    let output = cmd.output().map_err(GitError::ProcessFailed)?;
122    if !output.status.success() {
123        let stderr = String::from_utf8_lossy(&output.stderr);
124        return Err(GitError::CommandFailed {
125            command: "git worktree prune".to_string(),
126            message: stderr.trim().to_string(),
127        });
128    }
129    Ok(())
130}
131
132/// Checks whether the Git repository working tree has any unstaged or staged modifications.
133pub fn git_is_clean(repo_root: &Path) -> Result<bool, GitError> {
134    let mut cmd = Command::new("git");
135    cmd.current_dir(repo_root);
136    cmd.args(["status", "--porcelain"]);
137
138    let output = cmd.output().map_err(GitError::ProcessFailed)?;
139    if !output.status.success() {
140        let stderr = String::from_utf8_lossy(&output.stderr);
141        return Err(GitError::CommandFailed {
142            command: "git status --porcelain".to_string(),
143            message: stderr.trim().to_string(),
144        });
145    }
146
147    Ok(output.stdout.is_empty())
148}
149
150/// Merges `branch` into the currently checked-out branch.
151pub fn git_merge(repo_root: &Path, branch: &str) -> Result<(), GitError> {
152    let mut cmd = Command::new("git");
153    cmd.current_dir(repo_root);
154    cmd.args(["merge", branch]);
155
156    let output = cmd.output().map_err(GitError::ProcessFailed)?;
157    if !output.status.success() {
158        let stderr = String::from_utf8_lossy(&output.stderr);
159        return Err(GitError::CommandFailed {
160            command: format!("git merge {}", branch),
161            message: stderr.trim().to_string(),
162        });
163    }
164
165    Ok(())
166}
167
168/// Switches to the specified branch.
169pub fn git_checkout(repo_root: &Path, branch: &str) -> Result<(), GitError> {
170    let mut cmd = Command::new("git");
171    cmd.current_dir(repo_root);
172    cmd.args(["checkout", branch]);
173
174    let output = cmd.output().map_err(GitError::ProcessFailed)?;
175    if !output.status.success() {
176        let stderr = String::from_utf8_lossy(&output.stderr);
177        return Err(GitError::CommandFailed {
178            command: format!("git checkout {}", branch),
179            message: stderr.trim().to_string(),
180        });
181    }
182
183    Ok(())
184}
185
186/// Resolves the abbreviated HEAD commit hash for a worktree.
187pub fn git_head_commit(worktree_path: &Path) -> Result<String, GitError> {
188    let mut cmd = Command::new("git");
189    cmd.current_dir(worktree_path);
190    cmd.args(["rev-parse", "--short", "HEAD"]);
191
192    let output = cmd.output().map_err(GitError::ProcessFailed)?;
193    if !output.status.success() {
194        let stderr = String::from_utf8_lossy(&output.stderr);
195        return Err(GitError::CommandFailed {
196            command: "git rev-parse --short HEAD".to_string(),
197            message: stderr.trim().to_string(),
198        });
199    }
200
201    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
202}
203
204/// Creates an annotated Git tag on `target_ref`.
205pub fn git_create_annotated_tag(
206    repo_root: &Path,
207    tag: &str,
208    target_ref: &str,
209    message: &str,
210) -> Result<(), GitError> {
211    let mut cmd = Command::new("git");
212    cmd.current_dir(repo_root);
213    cmd.args(["tag", "-a", tag, target_ref, "-m", message]);
214
215    let output = cmd.output().map_err(GitError::ProcessFailed)?;
216    if !output.status.success() {
217        let stderr = String::from_utf8_lossy(&output.stderr);
218        return Err(GitError::CommandFailed {
219            command: format!("git tag -a {} {} -m {}", tag, target_ref, message),
220            message: stderr.trim().to_string(),
221        });
222    }
223
224    Ok(())
225}
226
227/// Checks if a branch exists locally in the repository.
228///
229/// # Example
230///
231/// ```no_run
232/// use std::path::Path;
233/// use git_claw::infra::git::git_branch_exists;
234///
235/// # fn run() -> Result<(), git_claw::infra::error::GitError> {
236/// let exists = git_branch_exists(Path::new("."), "main")?;
237/// assert!(exists);
238/// # Ok(())
239/// # }
240/// ```
241pub fn git_branch_exists(repo_root: &Path, branch: &str) -> Result<bool, GitError> {
242    let mut cmd = Command::new("git");
243    cmd.current_dir(repo_root);
244    let ref_spec = if branch.starts_with("refs/heads/") {
245        branch.to_string()
246    } else {
247        format!("refs/heads/{}", branch)
248    };
249    cmd.args(["rev-parse", "--verify", &ref_spec]);
250
251    let output = cmd.output().map_err(GitError::ProcessFailed)?;
252    Ok(output.status.success())
253}
254
255/// Returns the list of configured Git remotes in the repository.
256///
257/// # Example
258///
259/// ```no_run
260/// use std::path::Path;
261/// use git_claw::infra::git::git_get_remotes;
262///
263/// # fn run() -> Result<(), git_claw::infra::error::GitError> {
264/// let remotes = git_get_remotes(Path::new("."))?;
265/// assert!(remotes.contains(&"origin".to_string()));
266/// # Ok(())
267/// # }
268/// ```
269pub fn git_get_remotes(repo_root: &Path) -> Result<Vec<String>, GitError> {
270    let mut cmd = Command::new("git");
271    cmd.current_dir(repo_root);
272    cmd.arg("remote");
273
274    let output = cmd.output().map_err(GitError::ProcessFailed)?;
275    if !output.status.success() {
276        let stderr = String::from_utf8_lossy(&output.stderr);
277        return Err(GitError::CommandFailed {
278            command: "git remote".to_string(),
279            message: stderr.trim().to_string(),
280        });
281    }
282
283    let remotes = String::from_utf8_lossy(&output.stdout)
284        .lines()
285        .map(|s| s.trim().to_string())
286        .filter(|s| !s.is_empty())
287        .collect();
288
289    Ok(remotes)
290}
291
292/// Pushes the branch to the specified remote repository and configures upstream tracking.
293///
294/// Executes `git push -u <remote> <branch>`.
295///
296/// # Example
297///
298/// ```no_run
299/// use std::path::Path;
300/// use git_claw::infra::git::git_push_upstream;
301///
302/// # fn run() -> Result<(), git_claw::infra::error::GitError> {
303/// git_push_upstream(Path::new("."), "origin", "feature/login")?;
304/// # Ok(())
305/// # }
306/// ```
307pub fn git_push_upstream(repo_root: &Path, remote: &str, branch: &str) -> Result<(), GitError> {
308    let mut cmd = Command::new("git");
309    cmd.current_dir(repo_root);
310    cmd.args(["push", "-u", remote, branch]);
311
312    let output = cmd.output().map_err(GitError::ProcessFailed)?;
313    if !output.status.success() {
314        let stderr = String::from_utf8_lossy(&output.stderr);
315        return Err(GitError::CommandFailed {
316            command: format!("git push -u {} {}", remote, branch),
317            message: stderr.trim().to_string(),
318        });
319    }
320
321    Ok(())
322}
323
324/// Checks whether a branch exists on the specified remote repository.
325///
326/// Executes `git ls-remote --heads <remote> <branch>`.
327///
328/// # Example
329///
330/// ```no_run
331/// use std::path::Path;
332/// use git_claw::infra::git::git_remote_branch_exists;
333///
334/// # fn run() -> Result<(), git_claw::infra::error::GitError> {
335/// let exists = git_remote_branch_exists(Path::new("."), "origin", "feature/login")?;
336/// assert!(exists);
337/// # Ok(())
338/// # }
339/// ```
340pub fn git_remote_branch_exists(
341    repo_root: &Path,
342    remote: &str,
343    branch: &str,
344) -> Result<bool, GitError> {
345    let mut cmd = Command::new("git");
346    cmd.current_dir(repo_root);
347    cmd.args(["ls-remote", "--heads", remote, branch]);
348
349    let output = cmd.output().map_err(GitError::ProcessFailed)?;
350    if !output.status.success() {
351        let stderr = String::from_utf8_lossy(&output.stderr);
352        return Err(GitError::CommandFailed {
353            command: format!("git ls-remote --heads {} {}", remote, branch),
354            message: stderr.trim().to_string(),
355        });
356    }
357
358    let expected_ref = if branch.starts_with("refs/heads/") {
359        branch.to_string()
360    } else {
361        format!("refs/heads/{}", branch)
362    };
363
364    let stdout = String::from_utf8_lossy(&output.stdout);
365    let exists = stdout
366        .lines()
367        .any(|line| line.split_whitespace().nth(1) == Some(&expected_ref));
368
369    Ok(exists)
370}
371
372/// Deletes a branch from the specified remote repository.
373///
374/// Executes `git push <remote> --delete <branch>`.
375///
376/// # Example
377///
378/// ```no_run
379/// use std::path::Path;
380/// use git_claw::infra::git::git_delete_remote_branch;
381///
382/// # fn run() -> Result<(), git_claw::infra::error::GitError> {
383/// git_delete_remote_branch(Path::new("."), "origin", "feature/login")?;
384/// # Ok(())
385/// # }
386/// ```
387pub fn git_delete_remote_branch(
388    repo_root: &Path,
389    remote: &str,
390    branch: &str,
391) -> Result<(), GitError> {
392    let mut cmd = Command::new("git");
393    cmd.current_dir(repo_root);
394    let branch_clean = branch.strip_prefix("refs/heads/").unwrap_or(branch);
395    cmd.args(["push", remote, "--delete", branch_clean]);
396
397    let output = cmd.output().map_err(GitError::ProcessFailed)?;
398    if !output.status.success() {
399        let stderr = String::from_utf8_lossy(&output.stderr);
400        return Err(GitError::CommandFailed {
401            command: format!("git push {} --delete {}", remote, branch_clean),
402            message: stderr.trim().to_string(),
403        });
404    }
405
406    Ok(())
407}
408
409/// Adds specified files to the Git index and records a commit with the given message.
410///
411/// Executes `git add <files...>` followed by `git commit -m <message>`.
412///
413/// # Arguments
414///
415/// * `repo_root` - Path to the repository root directory.
416/// * `files` - List of file paths relative to `repo_root` to stage and commit.
417/// * `message` - Commit message text.
418///
419/// # Example
420///
421/// ```no_run
422/// use std::path::Path;
423/// use git_claw::infra::git::git_commit_files;
424///
425/// # fn run() -> Result<(), git_claw::infra::error::GitError> {
426/// git_commit_files(Path::new("."), &["Cargo.toml"], "chore: release 0.2.0")?;
427/// # Ok(())
428/// # }
429/// ```
430pub fn git_commit_files(repo_root: &Path, files: &[&str], message: &str) -> Result<(), GitError> {
431    if files.is_empty() {
432        return Ok(());
433    }
434
435    let mut add_cmd = Command::new("git");
436    add_cmd.current_dir(repo_root);
437    add_cmd.arg("add");
438    add_cmd.args(files);
439
440    let add_output = add_cmd.output().map_err(GitError::ProcessFailed)?;
441    if !add_output.status.success() {
442        let stderr = String::from_utf8_lossy(&add_output.stderr);
443        return Err(GitError::CommandFailed {
444            command: format!("git add {}", files.join(" ")),
445            message: stderr.trim().to_string(),
446        });
447    }
448
449    let mut commit_cmd = Command::new("git");
450    commit_cmd.current_dir(repo_root);
451    commit_cmd.args(["commit", "-m", message]);
452
453    let commit_output = commit_cmd.output().map_err(GitError::ProcessFailed)?;
454    if !commit_output.status.success() {
455        let stderr = String::from_utf8_lossy(&commit_output.stderr);
456        return Err(GitError::CommandFailed {
457            command: format!("git commit -m {}", message),
458            message: stderr.trim().to_string(),
459        });
460    }
461
462    Ok(())
463}
464
465/// Returns the latest tag reachable from the current commit, or `None` if no tags exist.
466///
467/// Executes `git describe --tags --abbrev=0`.
468///
469/// # Arguments
470///
471/// * `repo_root` - Path to the repository root directory.
472///
473/// # Example
474///
475/// ```no_run
476/// use std::path::Path;
477/// use git_claw::infra::git::git_get_latest_tag;
478///
479/// # fn run() -> Result<(), git_claw::infra::error::GitError> {
480/// let latest = git_get_latest_tag(Path::new("."))?;
481/// if let Some(tag) = latest {
482///     println!("Latest tag: {}", tag);
483/// }
484/// # Ok(())
485/// # }
486/// ```
487pub fn git_get_latest_tag(repo_root: &Path) -> Result<Option<String>, GitError> {
488    let mut cmd = Command::new("git");
489    cmd.current_dir(repo_root);
490    cmd.args(["describe", "--tags", "--abbrev=0"]);
491
492    let output = cmd.output().map_err(GitError::ProcessFailed)?;
493    if output.status.success() {
494        let tag = String::from_utf8_lossy(&output.stdout).trim().to_string();
495        if tag.is_empty() {
496            Ok(None)
497        } else {
498            Ok(Some(tag))
499        }
500    } else {
501        let stderr = String::from_utf8_lossy(&output.stderr);
502        if stderr.contains("not a git repository") {
503            return Err(GitError::NotAGitRepository);
504        }
505        Ok(None)
506    }
507}
508
509/// Retrieves all commit messages since the given tag (or all commits up to HEAD if `tag` is `None`).
510///
511/// Executes `git log <tag>..HEAD --format=%B%x1e` or `git log HEAD --format=%B%x1e`.
512/// Splits the output on ASCII 0x1E records, trims whitespace, and filters empty entries.
513///
514/// # Arguments
515///
516/// * `repo_root` - Path to the repository root directory.
517/// * `tag` - Optional starting tag reference.
518///
519/// # Example
520///
521/// ```no_run
522/// use std::path::Path;
523/// use git_claw::infra::git::git_get_commits_since;
524///
525/// # fn run() -> Result<(), git_claw::infra::error::GitError> {
526/// let commits = git_get_commits_since(Path::new("."), Some("v1.0.0"))?;
527/// for c in commits {
528///     println!("Commit: {}", c);
529/// }
530/// # Ok(())
531/// # }
532/// ```
533pub fn git_get_commits_since(repo_root: &Path, tag: Option<&str>) -> Result<Vec<String>, GitError> {
534    let mut cmd = Command::new("git");
535    cmd.current_dir(repo_root);
536    let range = match tag {
537        Some(t) => format!("{}..HEAD", t),
538        None => "HEAD".to_string(),
539    };
540    cmd.args(["log", &range, "--format=%B%x1e"]);
541
542    let output = cmd.output().map_err(GitError::ProcessFailed)?;
543    if !output.status.success() {
544        let stderr = String::from_utf8_lossy(&output.stderr);
545        if stderr.contains("not a git repository") {
546            return Err(GitError::NotAGitRepository);
547        }
548        if stderr.contains("does not have any commits yet") {
549            return Ok(Vec::new());
550        }
551        return Err(GitError::CommandFailed {
552            command: format!("git log {}", range),
553            message: stderr.trim().to_string(),
554        });
555    }
556
557    let stdout = String::from_utf8_lossy(&output.stdout);
558    let commits = stdout
559        .split('\x1e')
560        .map(|s| s.trim().to_string())
561        .filter(|s| !s.is_empty())
562        .collect();
563
564    Ok(commits)
565}