1use anyhow::{Context, Result};
8use std::fmt;
9use std::process::Command;
10
11pub fn get_git_config(key: &str) -> Option<String> {
16 let output = Command::new("git").args(["config", key]).output().ok()?;
17
18 if !output.status.success() {
19 return None;
20 }
21
22 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
23 if value.is_empty() {
24 None
25 } else {
26 Some(value)
27 }
28}
29
30pub fn get_git_user_info() -> (Option<String>, Option<String>) {
34 (get_git_config("user.name"), get_git_config("user.email"))
35}
36
37pub fn get_current_branch() -> Result<String> {
40 let output = Command::new("git")
41 .args(["rev-parse", "--abbrev-ref", "HEAD"])
42 .output()
43 .context("Failed to run git rev-parse")?;
44
45 if !output.status.success() {
46 anyhow::bail!("Failed to get current branch");
47 }
48
49 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
50 Ok(branch)
51}
52
53pub fn branch_exists(branch_name: &str) -> Result<bool> {
55 let output = Command::new("git")
56 .args(["branch", "--list", branch_name])
57 .output()
58 .context("Failed to check if branch exists")?;
59
60 if !output.status.success() {
61 anyhow::bail!("Failed to check if branch exists");
62 }
63
64 let stdout = String::from_utf8_lossy(&output.stdout);
65 Ok(!stdout.trim().is_empty())
66}
67
68pub fn is_branch_merged(branch_name: &str, target_branch: &str) -> Result<bool> {
79 let output = Command::new("git")
81 .args(["branch", "--merged", target_branch, "--list", branch_name])
82 .output()
83 .context("Failed to check if branch is merged")?;
84
85 if !output.status.success() {
86 anyhow::bail!("Failed to check if branch is merged");
87 }
88
89 let stdout = String::from_utf8_lossy(&output.stdout);
90 Ok(!stdout.trim().is_empty())
91}
92
93pub fn checkout_branch(branch: &str, dry_run: bool) -> Result<()> {
96 if dry_run {
97 return Ok(());
98 }
99
100 let output = Command::new("git")
101 .args(["checkout", branch])
102 .output()
103 .context("Failed to run git checkout")?;
104
105 if !output.status.success() {
106 let stderr = String::from_utf8_lossy(&output.stderr);
107 anyhow::bail!("Failed to checkout {}: {}", branch, stderr);
108 }
109
110 Ok(())
111}
112
113pub fn branches_have_diverged(spec_branch: &str) -> Result<bool> {
121 let output = Command::new("git")
122 .args(["merge-base", "--is-ancestor", "HEAD", spec_branch])
123 .output()
124 .context("Failed to check if branches have diverged")?;
125
126 Ok(!output.status.success())
129}
130
131#[derive(Debug)]
133pub struct MergeAttemptResult {
134 pub success: bool,
136 pub conflict_type: Option<ConflictType>,
138 pub conflicting_files: Vec<String>,
140 pub stderr: String,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ConflictType {
147 Content,
149 FastForward,
151 Tree,
153 Unknown,
155}
156
157impl fmt::Display for ConflictType {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 match self {
160 ConflictType::Content => write!(f, "content"),
161 ConflictType::FastForward => write!(f, "fast-forward"),
162 ConflictType::Tree => write!(f, "tree"),
163 ConflictType::Unknown => write!(f, "unknown"),
164 }
165 }
166}
167
168pub fn merge_branch_ff_only(spec_branch: &str, dry_run: bool) -> Result<MergeAttemptResult> {
178 if dry_run {
179 return Ok(MergeAttemptResult {
180 success: true,
181 conflict_type: None,
182 conflicting_files: vec![],
183 stderr: String::new(),
184 });
185 }
186
187 let diverged = branches_have_diverged(spec_branch)?;
189
190 let merge_message = format!("Merge {}", spec_branch);
191
192 let mut cmd = Command::new("git");
193 if diverged {
194 cmd.args(["merge", "--no-ff", spec_branch, "-m", &merge_message]);
196 } else {
197 cmd.args(["merge", "--ff-only", spec_branch]);
199 }
200
201 let output = cmd.output().context("Failed to run git merge")?;
202
203 if !output.status.success() {
204 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
205
206 let status_output = Command::new("git")
208 .args(["status", "--porcelain"])
209 .output()
210 .ok()
211 .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
212
213 let conflict_type = classify_conflict_type(&stderr, status_output.as_deref());
214
215 let conflicting_files = status_output
216 .as_deref()
217 .map(parse_conflicting_files)
218 .unwrap_or_default();
219
220 let _ = Command::new("git").args(["merge", "--abort"]).output();
222
223 return Ok(MergeAttemptResult {
224 success: false,
225 conflict_type: Some(conflict_type),
226 conflicting_files,
227 stderr,
228 });
229 }
230
231 Ok(MergeAttemptResult {
232 success: true,
233 conflict_type: None,
234 conflicting_files: vec![],
235 stderr: String::new(),
236 })
237}
238
239pub fn classify_conflict_type(stderr: &str, status_output: Option<&str>) -> ConflictType {
241 let stderr_lower = stderr.to_lowercase();
242
243 if stderr_lower.contains("not possible to fast-forward")
244 || stderr_lower.contains("cannot fast-forward")
245 || stderr_lower.contains("refusing to merge unrelated histories")
246 {
247 return ConflictType::FastForward;
248 }
249
250 if stderr_lower.contains("conflict (rename/delete)")
251 || stderr_lower.contains("conflict (modify/delete)")
252 || stderr_lower.contains("deleted in")
253 || stderr_lower.contains("renamed in")
254 || stderr_lower.contains("conflict (add/add)")
255 {
256 return ConflictType::Tree;
257 }
258
259 if let Some(status) = status_output {
260 if status.lines().any(|line| {
261 let prefix = line.get(..2).unwrap_or("");
262 matches!(prefix, "DD" | "AU" | "UD" | "UA" | "DU")
263 }) {
264 return ConflictType::Tree;
265 }
266
267 if status.lines().any(|line| {
268 let prefix = line.get(..2).unwrap_or("");
269 matches!(prefix, "UU" | "AA")
270 }) {
271 return ConflictType::Content;
272 }
273 }
274
275 if stderr_lower.contains("conflict") || stderr_lower.contains("merge conflict") {
276 return ConflictType::Content;
277 }
278
279 ConflictType::Unknown
280}
281
282pub fn parse_conflicting_files(status_output: &str) -> Vec<String> {
284 let mut files = Vec::new();
285
286 for line in status_output.lines() {
287 if line.len() >= 3 {
288 let status = &line[0..2];
289 if status.contains('U') || status == "AA" || status == "DD" {
291 let file = line[3..].trim();
292 files.push(file.to_string());
293 }
294 }
295 }
296
297 files
298}
299
300pub fn remove_worktrees_for_branch(branch_name: &str) -> Result<()> {
303 let output = Command::new("git")
305 .args(["worktree", "list", "--porcelain"])
306 .output()
307 .context("Failed to list worktrees")?;
308
309 if !output.status.success() {
310 return Ok(());
312 }
313
314 let worktree_list = String::from_utf8_lossy(&output.stdout);
315 let mut current_path: Option<String> = None;
316 let mut worktrees_to_remove = Vec::new();
317
318 for line in worktree_list.lines() {
320 if line.starts_with("worktree ") {
321 current_path = Some(line.trim_start_matches("worktree ").to_string());
322 } else if line.starts_with("branch ") {
323 let branch = line
324 .trim_start_matches("branch ")
325 .trim_start_matches("refs/heads/");
326 if branch == branch_name {
327 if let Some(path) = current_path.take() {
328 worktrees_to_remove.push(path);
329 }
330 }
331 }
332 }
333
334 for path in worktrees_to_remove {
336 let _ = Command::new("git")
338 .args(["worktree", "remove", &path, "--force"])
339 .output();
340
341 let _ = std::fs::remove_dir_all(&path);
343 }
344
345 Ok(())
346}
347
348pub fn delete_branch(branch_name: &str, dry_run: bool) -> Result<()> {
351 if dry_run {
352 return Ok(());
353 }
354
355 remove_worktrees_for_branch(branch_name)?;
357
358 let output = Command::new("git")
359 .args(["branch", "-d", branch_name])
360 .output()
361 .context("Failed to run git branch -d")?;
362
363 if !output.status.success() {
364 let stderr = String::from_utf8_lossy(&output.stderr);
365 anyhow::bail!("Failed to delete branch {}: {}", branch_name, stderr);
366 }
367
368 Ok(())
369}
370
371#[derive(Debug)]
373pub struct RebaseResult {
374 pub success: bool,
376 pub conflicting_files: Vec<String>,
378}
379
380pub fn rebase_branch(spec_branch: &str, onto_branch: &str) -> Result<RebaseResult> {
383 checkout_branch(spec_branch, false)?;
385
386 let output = Command::new("git")
388 .args(["rebase", onto_branch])
389 .output()
390 .context("Failed to run git rebase")?;
391
392 if output.status.success() {
393 return Ok(RebaseResult {
394 success: true,
395 conflicting_files: vec![],
396 });
397 }
398
399 let stderr = String::from_utf8_lossy(&output.stderr);
401 if stderr.contains("CONFLICT") || stderr.contains("conflict") {
402 let conflicting_files = get_conflicting_files()?;
404
405 let _ = Command::new("git").args(["rebase", "--abort"]).output();
407
408 return Ok(RebaseResult {
409 success: false,
410 conflicting_files,
411 });
412 }
413
414 let _ = Command::new("git").args(["rebase", "--abort"]).output();
416 anyhow::bail!("Rebase failed: {}", stderr);
417}
418
419pub fn get_conflicting_files() -> Result<Vec<String>> {
421 let output = Command::new("git")
422 .args(["status", "--porcelain"])
423 .output()
424 .context("Failed to run git status")?;
425
426 let stdout = String::from_utf8_lossy(&output.stdout);
427 let mut files = Vec::new();
428
429 for line in stdout.lines() {
430 if line.len() >= 3 {
432 let status = &line[0..2];
433 if status.contains('U') || status == "AA" || status == "DD" {
434 let file = line[3..].trim();
435 files.push(file.to_string());
436 }
437 }
438 }
439
440 Ok(files)
441}
442
443pub fn rebase_continue() -> Result<bool> {
445 let output = Command::new("git")
446 .args(["rebase", "--continue"])
447 .env("GIT_EDITOR", "true") .output()
449 .context("Failed to run git rebase --continue")?;
450
451 Ok(output.status.success())
452}
453
454pub fn rebase_abort() -> Result<()> {
456 let _ = Command::new("git").args(["rebase", "--abort"]).output();
457 Ok(())
458}
459
460pub fn stage_file(file_path: &str) -> Result<()> {
462 let output = Command::new("git")
463 .args(["add", file_path])
464 .output()
465 .context("Failed to run git add")?;
466
467 if !output.status.success() {
468 let stderr = String::from_utf8_lossy(&output.stderr);
469 anyhow::bail!("Failed to stage file {}: {}", file_path, stderr);
470 }
471
472 Ok(())
473}
474
475pub fn can_fast_forward_merge(branch: &str, target: &str) -> Result<bool> {
478 let output = Command::new("git")
480 .args(["merge-base", target, branch])
481 .output()
482 .context("Failed to find merge base")?;
483
484 if !output.status.success() {
485 return Ok(false);
486 }
487
488 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
489
490 let output = Command::new("git")
492 .args(["rev-parse", target])
493 .output()
494 .context("Failed to get target commit")?;
495
496 if !output.status.success() {
497 return Ok(false);
498 }
499
500 let target_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
501
502 Ok(merge_base == target_commit)
504}
505
506pub fn is_branch_behind(branch: &str, target: &str) -> Result<bool> {
509 let output = Command::new("git")
511 .args(["merge-base", branch, target])
512 .output()
513 .context("Failed to find merge base")?;
514
515 if !output.status.success() {
516 return Ok(false);
517 }
518
519 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
520
521 let output = Command::new("git")
523 .args(["rev-parse", branch])
524 .output()
525 .context("Failed to get branch commit")?;
526
527 if !output.status.success() {
528 return Ok(false);
529 }
530
531 let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
532
533 Ok(merge_base == branch_commit)
535}
536
537pub fn count_commits(branch: &str) -> Result<usize> {
539 let output = Command::new("git")
540 .args(["rev-list", "--count", branch])
541 .output()
542 .context("Failed to count commits")?;
543
544 if !output.status.success() {
545 return Ok(0);
546 }
547
548 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
549 Ok(count_str.parse().unwrap_or(0))
550}
551
552#[derive(Debug, Clone)]
554pub struct CommitInfo {
555 pub hash: String,
556 pub message: String,
557 pub author: String,
558 pub timestamp: i64,
559}
560
561pub fn get_commits_in_range(from_ref: &str, to_ref: &str) -> Result<Vec<CommitInfo>> {
569 let range = format!("{}..{}", from_ref, to_ref);
570
571 let output = Command::new("git")
572 .args(["log", &range, "--format=%H|%an|%at|%s", "--reverse"])
573 .output()
574 .context("Failed to execute git log")?;
575
576 if !output.status.success() {
577 let stderr = String::from_utf8_lossy(&output.stderr);
578 anyhow::bail!("Invalid git refs {}: {}", range, stderr);
579 }
580
581 let stdout = String::from_utf8_lossy(&output.stdout);
582 let mut commits = Vec::new();
583
584 for line in stdout.lines() {
585 if line.is_empty() {
586 continue;
587 }
588
589 let parts: Vec<&str> = line.splitn(4, '|').collect();
590 if parts.len() != 4 {
591 continue;
592 }
593
594 commits.push(CommitInfo {
595 hash: parts[0].to_string(),
596 author: parts[1].to_string(),
597 timestamp: parts[2].parse().unwrap_or(0),
598 message: parts[3].to_string(),
599 });
600 }
601
602 Ok(commits)
603}
604
605pub fn get_commit_changed_files(hash: &str) -> Result<Vec<String>> {
612 let output = Command::new("git")
613 .args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
614 .output()
615 .context("Failed to execute git diff-tree")?;
616
617 if !output.status.success() {
618 let stderr = String::from_utf8_lossy(&output.stderr);
619 anyhow::bail!("Invalid commit hash {}: {}", hash, stderr);
620 }
621
622 let stdout = String::from_utf8_lossy(&output.stdout);
623 let files: Vec<String> = stdout
624 .lines()
625 .filter(|line| !line.is_empty())
626 .map(|line| line.to_string())
627 .collect();
628
629 Ok(files)
630}
631
632pub fn get_commit_files_with_status(hash: &str) -> Result<Vec<String>> {
639 let output = Command::new("git")
640 .args(["diff-tree", "--no-commit-id", "--name-status", "-r", hash])
641 .output()
642 .context("Failed to execute git diff-tree")?;
643
644 if !output.status.success() {
645 return Ok(Vec::new());
646 }
647
648 let stdout = String::from_utf8_lossy(&output.stdout);
649 let mut files = Vec::new();
650
651 for line in stdout.lines() {
652 let parts: Vec<&str> = line.split('\t').collect();
653 if parts.len() >= 2 {
654 files.push(format!("{}:{}", parts[0], parts[1]));
656 }
657 }
658
659 Ok(files)
660}
661
662pub fn get_file_at_commit(commit: &str, file: &str) -> Result<String> {
669 let output = Command::new("git")
670 .args(["show", &format!("{}:{}", commit, file)])
671 .output()
672 .context("Failed to get file at commit")?;
673
674 if !output.status.success() {
675 return Ok(String::new());
676 }
677
678 Ok(String::from_utf8_lossy(&output.stdout).to_string())
679}
680
681pub fn get_file_at_parent(commit: &str, file: &str) -> Result<String> {
688 let output = Command::new("git")
689 .args(["show", &format!("{}^:{}", commit, file)])
690 .output()
691 .context("Failed to get file at parent")?;
692
693 if !output.status.success() {
694 return Ok(String::new());
695 }
696
697 Ok(String::from_utf8_lossy(&output.stdout).to_string())
698}
699
700pub fn get_recent_commits(count: usize) -> Result<Vec<CommitInfo>> {
705 let count_str = count.to_string();
706
707 let output = Command::new("git")
708 .args(["log", "-n", &count_str, "--format=%H|%an|%at|%s"])
709 .output()
710 .context("Failed to execute git log")?;
711
712 if !output.status.success() {
713 let stderr = String::from_utf8_lossy(&output.stderr);
714 anyhow::bail!("Failed to get recent commits: {}", stderr);
715 }
716
717 let stdout = String::from_utf8_lossy(&output.stdout);
718 let mut commits = Vec::new();
719
720 for line in stdout.lines() {
721 if line.is_empty() {
722 continue;
723 }
724
725 let parts: Vec<&str> = line.splitn(4, '|').collect();
726 if parts.len() != 4 {
727 continue;
728 }
729
730 commits.push(CommitInfo {
731 hash: parts[0].to_string(),
732 author: parts[1].to_string(),
733 timestamp: parts[2].parse().unwrap_or(0),
734 message: parts[3].to_string(),
735 });
736 }
737
738 Ok(commits)
739}
740
741pub fn get_commits_for_path(path: &str) -> Result<Vec<CommitInfo>> {
749 let output = Command::new("git")
750 .args(["log", "--all", "--format=%H|%an|%at|%s", "--", path])
751 .output()
752 .context("Failed to execute git log")?;
753
754 if !output.status.success() {
755 let stderr = String::from_utf8_lossy(&output.stderr);
756 anyhow::bail!("git log failed: {}", stderr);
757 }
758
759 let stdout = String::from_utf8_lossy(&output.stdout);
760 let mut commits = Vec::new();
761
762 for line in stdout.lines() {
763 if line.is_empty() {
764 continue;
765 }
766
767 let parts: Vec<&str> = line.splitn(4, '|').collect();
768 if parts.len() != 4 {
769 continue;
770 }
771
772 commits.push(CommitInfo {
773 hash: parts[0].to_string(),
774 author: parts[1].to_string(),
775 timestamp: parts[2].parse().unwrap_or(0),
776 message: parts[3].to_string(),
777 });
778 }
779
780 Ok(commits)
781}