1use anyhow::{Context, Result};
8use std::fmt;
9use std::process::Command;
10
11fn run_git(args: &[&str]) -> Result<String> {
17 let output = Command::new("git")
18 .args(args)
19 .output()
20 .context(format!("Failed to run git {}", args.join(" ")))?;
21
22 if !output.status.success() {
23 let stderr = String::from_utf8_lossy(&output.stderr);
24 anyhow::bail!("git {} failed: {}", args.join(" "), stderr);
25 }
26
27 Ok(String::from_utf8_lossy(&output.stdout).to_string())
28}
29
30pub fn get_git_config(key: &str) -> Option<String> {
35 let output = Command::new("git").args(["config", key]).output().ok()?;
36
37 if !output.status.success() {
38 return None;
39 }
40
41 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
42 if value.is_empty() {
43 None
44 } else {
45 Some(value)
46 }
47}
48
49pub fn get_git_user_info() -> (Option<String>, Option<String>) {
53 (get_git_config("user.name"), get_git_config("user.email"))
54}
55
56pub fn get_current_branch() -> Result<String> {
59 let branch = run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
60 Ok(branch.trim().to_string())
61}
62
63pub fn branch_exists(branch_name: &str) -> Result<bool> {
65 let stdout = run_git(&["branch", "--list", branch_name])?;
66 Ok(!stdout.trim().is_empty())
67}
68
69pub fn is_branch_merged(branch_name: &str, target_branch: &str) -> Result<bool> {
80 let stdout = run_git(&["branch", "--merged", target_branch, "--list", branch_name])?;
82 Ok(!stdout.trim().is_empty())
83}
84
85pub fn checkout_branch(branch: &str, dry_run: bool) -> Result<()> {
88 if dry_run {
89 return Ok(());
90 }
91
92 run_git(&["checkout", branch]).with_context(|| format!("Failed to checkout {}", branch))?;
93
94 Ok(())
95}
96
97pub fn branches_have_diverged(spec_branch: &str) -> Result<bool> {
105 let output = Command::new("git")
106 .args(["merge-base", "--is-ancestor", "HEAD", spec_branch])
107 .output()
108 .context("Failed to check if branches have diverged")?;
109
110 Ok(!output.status.success())
113}
114
115#[derive(Debug)]
117pub struct MergeAttemptResult {
118 pub success: bool,
120 pub conflict_type: Option<ConflictType>,
122 pub conflicting_files: Vec<String>,
124 pub stderr: String,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum ConflictType {
131 Content,
133 FastForward,
135 Tree,
137 Unknown,
139}
140
141impl fmt::Display for ConflictType {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 match self {
144 ConflictType::Content => write!(f, "content"),
145 ConflictType::FastForward => write!(f, "fast-forward"),
146 ConflictType::Tree => write!(f, "tree"),
147 ConflictType::Unknown => write!(f, "unknown"),
148 }
149 }
150}
151
152pub fn merge_branch_ff_only(spec_branch: &str, dry_run: bool) -> Result<MergeAttemptResult> {
162 if dry_run {
163 return Ok(MergeAttemptResult {
164 success: true,
165 conflict_type: None,
166 conflicting_files: vec![],
167 stderr: String::new(),
168 });
169 }
170
171 let diverged = branches_have_diverged(spec_branch)?;
173
174 let merge_message = format!("Merge {}", spec_branch);
175
176 let mut cmd = Command::new("git");
177 if diverged {
178 cmd.args(["merge", "--no-ff", spec_branch, "-m", &merge_message]);
180 } else {
181 cmd.args(["merge", "--ff-only", spec_branch]);
183 }
184
185 let output = cmd.output().context("Failed to run git merge")?;
186
187 if !output.status.success() {
188 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
189
190 let status_output = Command::new("git")
192 .args(["status", "--porcelain"])
193 .output()
194 .ok()
195 .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
196
197 let conflict_type = classify_conflict_type(&stderr, status_output.as_deref());
198
199 let conflicting_files = status_output
200 .as_deref()
201 .map(parse_conflicting_files)
202 .unwrap_or_default();
203
204 let _ = Command::new("git").args(["merge", "--abort"]).output();
206
207 return Ok(MergeAttemptResult {
208 success: false,
209 conflict_type: Some(conflict_type),
210 conflicting_files,
211 stderr,
212 });
213 }
214
215 Ok(MergeAttemptResult {
216 success: true,
217 conflict_type: None,
218 conflicting_files: vec![],
219 stderr: String::new(),
220 })
221}
222
223pub fn classify_conflict_type(stderr: &str, status_output: Option<&str>) -> ConflictType {
225 let stderr_lower = stderr.to_lowercase();
226
227 if stderr_lower.contains("not possible to fast-forward")
228 || stderr_lower.contains("cannot fast-forward")
229 || stderr_lower.contains("refusing to merge unrelated histories")
230 {
231 return ConflictType::FastForward;
232 }
233
234 if stderr_lower.contains("conflict (rename/delete)")
235 || stderr_lower.contains("conflict (modify/delete)")
236 || stderr_lower.contains("deleted in")
237 || stderr_lower.contains("renamed in")
238 || stderr_lower.contains("conflict (add/add)")
239 {
240 return ConflictType::Tree;
241 }
242
243 if let Some(status) = status_output {
244 if status.lines().any(|line| {
245 let prefix = line.get(..2).unwrap_or("");
246 matches!(prefix, "DD" | "AU" | "UD" | "UA" | "DU")
247 }) {
248 return ConflictType::Tree;
249 }
250
251 if status.lines().any(|line| {
252 let prefix = line.get(..2).unwrap_or("");
253 matches!(prefix, "UU" | "AA")
254 }) {
255 return ConflictType::Content;
256 }
257 }
258
259 if stderr_lower.contains("conflict") || stderr_lower.contains("merge conflict") {
260 return ConflictType::Content;
261 }
262
263 ConflictType::Unknown
264}
265
266pub fn parse_conflicting_files(status_output: &str) -> Vec<String> {
268 let mut files = Vec::new();
269
270 for line in status_output.lines() {
271 if line.len() >= 3 {
272 let status = &line[0..2];
273 if status.contains('U') || status == "AA" || status == "DD" {
275 let file = line[3..].trim();
276 files.push(file.to_string());
277 }
278 }
279 }
280
281 files
282}
283
284pub fn remove_worktrees_for_branch(branch_name: &str) -> Result<()> {
287 let output = Command::new("git")
289 .args(["worktree", "list", "--porcelain"])
290 .output()
291 .context("Failed to list worktrees")?;
292
293 if !output.status.success() {
294 return Ok(());
296 }
297
298 let worktree_list = String::from_utf8_lossy(&output.stdout);
299 let mut current_path: Option<String> = None;
300 let mut worktrees_to_remove = Vec::new();
301
302 for line in worktree_list.lines() {
304 if line.starts_with("worktree ") {
305 current_path = Some(line.trim_start_matches("worktree ").to_string());
306 } else if line.starts_with("branch ") {
307 let branch = line
308 .trim_start_matches("branch ")
309 .trim_start_matches("refs/heads/");
310 if branch == branch_name {
311 if let Some(path) = current_path.take() {
312 worktrees_to_remove.push(path);
313 }
314 }
315 }
316 }
317
318 for path in worktrees_to_remove {
320 let _ = Command::new("git")
322 .args(["worktree", "remove", &path, "--force"])
323 .output();
324
325 let _ = std::fs::remove_dir_all(&path);
327 }
328
329 Ok(())
330}
331
332pub fn delete_branch(branch_name: &str, dry_run: bool) -> Result<()> {
335 if dry_run {
336 return Ok(());
337 }
338
339 remove_worktrees_for_branch(branch_name)?;
341
342 run_git(&["branch", "-d", branch_name])
343 .with_context(|| format!("Failed to delete branch {}", branch_name))?;
344
345 Ok(())
346}
347
348#[derive(Debug)]
350pub struct RebaseResult {
351 pub success: bool,
353 pub conflicting_files: Vec<String>,
355}
356
357pub fn rebase_branch(spec_branch: &str, onto_branch: &str) -> Result<RebaseResult> {
360 checkout_branch(spec_branch, false)?;
362
363 let output = Command::new("git")
365 .args(["rebase", onto_branch])
366 .output()
367 .context("Failed to run git rebase")?;
368
369 if output.status.success() {
370 return Ok(RebaseResult {
371 success: true,
372 conflicting_files: vec![],
373 });
374 }
375
376 let stderr = String::from_utf8_lossy(&output.stderr);
378 if stderr.contains("CONFLICT") || stderr.contains("conflict") {
379 let conflicting_files = get_conflicting_files()?;
381
382 let _ = Command::new("git").args(["rebase", "--abort"]).output();
384
385 return Ok(RebaseResult {
386 success: false,
387 conflicting_files,
388 });
389 }
390
391 let _ = Command::new("git").args(["rebase", "--abort"]).output();
393 anyhow::bail!("Rebase failed: {}", stderr);
394}
395
396pub fn get_conflicting_files() -> Result<Vec<String>> {
398 let output = Command::new("git")
399 .args(["status", "--porcelain"])
400 .output()
401 .context("Failed to run git status")?;
402
403 let stdout = String::from_utf8_lossy(&output.stdout);
404 let mut files = Vec::new();
405
406 for line in stdout.lines() {
407 if line.len() >= 3 {
409 let status = &line[0..2];
410 if status.contains('U') || status == "AA" || status == "DD" {
411 let file = line[3..].trim();
412 files.push(file.to_string());
413 }
414 }
415 }
416
417 Ok(files)
418}
419
420pub fn rebase_continue() -> Result<bool> {
422 let output = Command::new("git")
423 .args(["rebase", "--continue"])
424 .env("GIT_EDITOR", "true") .output()
426 .context("Failed to run git rebase --continue")?;
427
428 Ok(output.status.success())
429}
430
431pub fn rebase_abort() -> Result<()> {
433 let _ = Command::new("git").args(["rebase", "--abort"]).output();
434 Ok(())
435}
436
437pub fn stage_file(file_path: &str) -> Result<()> {
439 run_git(&["add", file_path]).with_context(|| format!("Failed to stage file {}", file_path))?;
440 Ok(())
441}
442
443pub fn can_fast_forward_merge(branch: &str, target: &str) -> Result<bool> {
446 let output = Command::new("git")
448 .args(["merge-base", target, branch])
449 .output()
450 .context("Failed to find merge base")?;
451
452 if !output.status.success() {
453 return Ok(false);
454 }
455
456 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
457
458 let output = Command::new("git")
460 .args(["rev-parse", target])
461 .output()
462 .context("Failed to get target commit")?;
463
464 if !output.status.success() {
465 return Ok(false);
466 }
467
468 let target_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
469
470 Ok(merge_base == target_commit)
472}
473
474pub fn is_branch_behind(branch: &str, target: &str) -> Result<bool> {
477 let output = Command::new("git")
479 .args(["merge-base", branch, target])
480 .output()
481 .context("Failed to find merge base")?;
482
483 if !output.status.success() {
484 return Ok(false);
485 }
486
487 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
488
489 let output = Command::new("git")
491 .args(["rev-parse", branch])
492 .output()
493 .context("Failed to get branch commit")?;
494
495 if !output.status.success() {
496 return Ok(false);
497 }
498
499 let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
500
501 Ok(merge_base == branch_commit)
503}
504
505pub fn count_commits(branch: &str) -> Result<usize> {
507 let output = Command::new("git")
508 .args(["rev-list", "--count", branch])
509 .output()
510 .context("Failed to count commits")?;
511
512 if !output.status.success() {
513 return Ok(0);
514 }
515
516 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
517 Ok(count_str.parse().unwrap_or(0))
518}
519
520#[derive(Debug, Clone)]
522pub struct CommitInfo {
523 pub hash: String,
524 pub message: String,
525 pub author: String,
526 pub timestamp: i64,
527}
528
529pub fn get_commits_in_range(from_ref: &str, to_ref: &str) -> Result<Vec<CommitInfo>> {
537 let range = format!("{}..{}", from_ref, to_ref);
538
539 let output = Command::new("git")
540 .args(["log", &range, "--format=%H|%an|%at|%s", "--reverse"])
541 .output()
542 .context("Failed to execute git log")?;
543
544 if !output.status.success() {
545 let stderr = String::from_utf8_lossy(&output.stderr);
546 anyhow::bail!("Invalid git refs {}: {}", range, stderr);
547 }
548
549 let stdout = String::from_utf8_lossy(&output.stdout);
550 let mut commits = Vec::new();
551
552 for line in stdout.lines() {
553 if line.is_empty() {
554 continue;
555 }
556
557 let parts: Vec<&str> = line.splitn(4, '|').collect();
558 if parts.len() != 4 {
559 continue;
560 }
561
562 commits.push(CommitInfo {
563 hash: parts[0].to_string(),
564 author: parts[1].to_string(),
565 timestamp: parts[2].parse().unwrap_or(0),
566 message: parts[3].to_string(),
567 });
568 }
569
570 Ok(commits)
571}
572
573pub fn get_commit_changed_files(hash: &str) -> Result<Vec<String>> {
580 let output = Command::new("git")
581 .args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
582 .output()
583 .context("Failed to execute git diff-tree")?;
584
585 if !output.status.success() {
586 let stderr = String::from_utf8_lossy(&output.stderr);
587 anyhow::bail!("Invalid commit hash {}: {}", hash, stderr);
588 }
589
590 let stdout = String::from_utf8_lossy(&output.stdout);
591 let files: Vec<String> = stdout
592 .lines()
593 .filter(|line| !line.is_empty())
594 .map(|line| line.to_string())
595 .collect();
596
597 Ok(files)
598}
599
600pub fn get_commit_files_with_status(hash: &str) -> Result<Vec<String>> {
607 let output = Command::new("git")
608 .args(["diff-tree", "--no-commit-id", "--name-status", "-r", hash])
609 .output()
610 .context("Failed to execute git diff-tree")?;
611
612 if !output.status.success() {
613 return Ok(Vec::new());
614 }
615
616 let stdout = String::from_utf8_lossy(&output.stdout);
617 let mut files = Vec::new();
618
619 for line in stdout.lines() {
620 let parts: Vec<&str> = line.split('\t').collect();
621 if parts.len() >= 2 {
622 files.push(format!("{}:{}", parts[0], parts[1]));
624 }
625 }
626
627 Ok(files)
628}
629
630pub fn get_file_at_commit(commit: &str, file: &str) -> Result<String> {
637 let output = Command::new("git")
638 .args(["show", &format!("{}:{}", commit, file)])
639 .output()
640 .context("Failed to get file at commit")?;
641
642 if !output.status.success() {
643 return Ok(String::new());
644 }
645
646 Ok(String::from_utf8_lossy(&output.stdout).to_string())
647}
648
649pub fn get_file_at_parent(commit: &str, file: &str) -> Result<String> {
656 let output = Command::new("git")
657 .args(["show", &format!("{}^:{}", commit, file)])
658 .output()
659 .context("Failed to get file at parent")?;
660
661 if !output.status.success() {
662 return Ok(String::new());
663 }
664
665 Ok(String::from_utf8_lossy(&output.stdout).to_string())
666}
667
668pub fn get_recent_commits(count: usize) -> Result<Vec<CommitInfo>> {
673 let count_str = count.to_string();
674
675 let output = Command::new("git")
676 .args(["log", "-n", &count_str, "--format=%H|%an|%at|%s"])
677 .output()
678 .context("Failed to execute git log")?;
679
680 if !output.status.success() {
681 let stderr = String::from_utf8_lossy(&output.stderr);
682 anyhow::bail!("Failed to get recent commits: {}", stderr);
683 }
684
685 let stdout = String::from_utf8_lossy(&output.stdout);
686 let mut commits = Vec::new();
687
688 for line in stdout.lines() {
689 if line.is_empty() {
690 continue;
691 }
692
693 let parts: Vec<&str> = line.splitn(4, '|').collect();
694 if parts.len() != 4 {
695 continue;
696 }
697
698 commits.push(CommitInfo {
699 hash: parts[0].to_string(),
700 author: parts[1].to_string(),
701 timestamp: parts[2].parse().unwrap_or(0),
702 message: parts[3].to_string(),
703 });
704 }
705
706 Ok(commits)
707}
708
709pub fn get_commits_for_path(path: &str) -> Result<Vec<CommitInfo>> {
717 let output = Command::new("git")
718 .args(["log", "--all", "--format=%H|%an|%at|%s", "--", path])
719 .output()
720 .context("Failed to execute git log")?;
721
722 if !output.status.success() {
723 let stderr = String::from_utf8_lossy(&output.stderr);
724 anyhow::bail!("git log failed: {}", stderr);
725 }
726
727 let stdout = String::from_utf8_lossy(&output.stdout);
728 let mut commits = Vec::new();
729
730 for line in stdout.lines() {
731 if line.is_empty() {
732 continue;
733 }
734
735 let parts: Vec<&str> = line.splitn(4, '|').collect();
736 if parts.len() != 4 {
737 continue;
738 }
739
740 commits.push(CommitInfo {
741 hash: parts[0].to_string(),
742 author: parts[1].to_string(),
743 timestamp: parts[2].parse().unwrap_or(0),
744 message: parts[3].to_string(),
745 });
746 }
747
748 Ok(commits)
749}