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}