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_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
104pub 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
113pub 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
132pub 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
150pub 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
168pub 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
186pub 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
204pub 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
227pub 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
255pub 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
292pub 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
324pub 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
372pub 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
409pub 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
465pub 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
509pub 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}