1use crate::infra::error::GitError;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7pub 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
42pub 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
67pub 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
76pub 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
95pub 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
113pub 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
131pub 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
149pub 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
167pub 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
190pub 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
218pub 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
255pub 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
287pub 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
335pub 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
372pub 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
428pub 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
472pub 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}