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