1use anyhow::{Context, Result};
4use git2::{Repository, Status};
5use tracing::{debug, error, info};
6
7use crate::git::CommitInfo;
8
9pub struct GitRepository {
11 repo: Repository,
12}
13
14#[derive(Debug)]
16pub struct WorkingDirectoryStatus {
17 pub clean: bool,
19 pub untracked_changes: Vec<FileStatus>,
21}
22
23#[derive(Debug)]
25pub struct FileStatus {
26 pub status: String,
28 pub file: String,
30}
31
32impl GitRepository {
33 pub fn open_at<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
35 let repo = Repository::open(path).context("Failed to open git repository")?;
36
37 Ok(Self { repo })
38 }
39
40 pub fn get_working_directory_status(&self) -> Result<WorkingDirectoryStatus> {
42 let statuses = self
43 .repo
44 .statuses(None)
45 .context("Failed to get repository status")?;
46
47 let mut untracked_changes = Vec::new();
48
49 for entry in statuses.iter() {
50 if let Ok(path) = entry.path() {
51 let status_flags = entry.status();
52
53 if status_flags.contains(Status::IGNORED) {
55 continue;
56 }
57
58 let status_str = format_status_flags(status_flags);
59
60 untracked_changes.push(FileStatus {
61 status: status_str,
62 file: path.to_string(),
63 });
64 }
65 }
66
67 let clean = untracked_changes.is_empty();
68
69 Ok(WorkingDirectoryStatus {
70 clean,
71 untracked_changes,
72 })
73 }
74
75 pub fn is_working_directory_clean(&self) -> Result<bool> {
77 let status = self.get_working_directory_status()?;
78 Ok(status.clean)
79 }
80
81 pub fn path(&self) -> &std::path::Path {
83 self.repo.path()
84 }
85
86 pub fn workdir(&self) -> Option<&std::path::Path> {
88 self.repo.workdir()
89 }
90
91 pub fn repository(&self) -> &Repository {
93 &self.repo
94 }
95
96 pub fn get_current_branch(&self) -> Result<String> {
98 let head = self.repo.head().context("Failed to get HEAD reference")?;
99
100 if let Ok(name) = head.shorthand() {
101 if name != "HEAD" {
102 return Ok(name.to_string());
103 }
104 }
105
106 anyhow::bail!("Repository is in detached HEAD state")
107 }
108
109 pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
111 if self
113 .repo
114 .find_branch(branch_name, git2::BranchType::Local)
115 .is_ok()
116 {
117 return Ok(true);
118 }
119
120 if self
122 .repo
123 .find_branch(branch_name, git2::BranchType::Remote)
124 .is_ok()
125 {
126 return Ok(true);
127 }
128
129 if self.repo.revparse_single(branch_name).is_ok() {
131 return Ok(true);
132 }
133
134 Ok(false)
135 }
136
137 pub fn resolve_default_base_branch(&self) -> Option<String> {
144 const CANDIDATES: [(&str, git2::BranchType); 4] = [
145 ("origin/main", git2::BranchType::Remote),
146 ("origin/master", git2::BranchType::Remote),
147 ("main", git2::BranchType::Local),
148 ("master", git2::BranchType::Local),
149 ];
150 CANDIDATES
151 .iter()
152 .find(|(name, kind)| self.repo.find_branch(name, *kind).is_ok())
153 .map(|(name, _)| (*name).to_string())
154 }
155
156 pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
158 let mut commits = Vec::new();
159
160 let main_tips = crate::git::main_branches::detect_main_branch_tips(&self.repo)?;
162
163 if range == "HEAD" {
164 let head = self.repo.head().context("Failed to get HEAD")?;
166 let commit = head
167 .peel_to_commit()
168 .context("Failed to peel HEAD to commit")?;
169 commits.push(CommitInfo::from_git_commit(
170 &self.repo, &commit, &main_tips,
171 )?);
172 } else if range.contains("..") {
173 let parts: Vec<&str> = range.split("..").collect();
175 if parts.len() != 2 {
176 anyhow::bail!("Invalid range format: {range}");
177 }
178
179 let start_spec = parts[0];
180 let end_spec = parts[1];
181
182 let start_obj = self
184 .repo
185 .revparse_single(start_spec)
186 .with_context(|| format!("Failed to parse start commit: {start_spec}"))?;
187 let end_obj = self
188 .repo
189 .revparse_single(end_spec)
190 .with_context(|| format!("Failed to parse end commit: {end_spec}"))?;
191
192 let start_commit = start_obj
193 .peel_to_commit()
194 .context("Failed to peel start object to commit")?;
195 let end_commit = end_obj
196 .peel_to_commit()
197 .context("Failed to peel end object to commit")?;
198
199 let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
201 walker
202 .push(end_commit.id())
203 .context("Failed to push end commit")?;
204 walker
205 .hide(start_commit.id())
206 .context("Failed to hide start commit")?;
207
208 for oid in walker {
209 let oid = oid.context("Failed to get commit OID from walker")?;
210 let commit = self
211 .repo
212 .find_commit(oid)
213 .context("Failed to find commit")?;
214
215 if commit.parent_count() > 1 {
217 continue;
218 }
219
220 commits.push(CommitInfo::from_git_commit(
221 &self.repo, &commit, &main_tips,
222 )?);
223 }
224
225 commits.reverse();
227 } else {
228 let obj = self
230 .repo
231 .revparse_single(range)
232 .with_context(|| format!("Failed to parse commit: {range}"))?;
233 let commit = obj
234 .peel_to_commit()
235 .context("Failed to peel object to commit")?;
236 commits.push(CommitInfo::from_git_commit(
237 &self.repo, &commit, &main_tips,
238 )?);
239 }
240
241 Ok(commits)
242 }
243}
244
245fn format_status_flags(flags: Status) -> String {
247 let mut status = String::new();
248
249 if flags.contains(Status::INDEX_NEW) {
250 status.push('A');
251 } else if flags.contains(Status::INDEX_MODIFIED) {
252 status.push('M');
253 } else if flags.contains(Status::INDEX_DELETED) {
254 status.push('D');
255 } else if flags.contains(Status::INDEX_RENAMED) {
256 status.push('R');
257 } else if flags.contains(Status::INDEX_TYPECHANGE) {
258 status.push('T');
259 } else {
260 status.push(' ');
261 }
262
263 if flags.contains(Status::WT_NEW) {
264 status.push('?');
265 } else if flags.contains(Status::WT_MODIFIED) {
266 status.push('M');
267 } else if flags.contains(Status::WT_DELETED) {
268 status.push('D');
269 } else if flags.contains(Status::WT_TYPECHANGE) {
270 status.push('T');
271 } else if flags.contains(Status::WT_RENAMED) {
272 status.push('R');
273 } else {
274 status.push(' ');
275 }
276
277 status
278}
279
280impl GitRepository {
281 fn run_git(&self, args: &[&str]) -> Result<std::process::Output> {
289 let workdir = self
290 .repo
291 .workdir()
292 .context("Cannot run git command: repository has no working directory")?;
293
294 std::process::Command::new("git")
295 .current_dir(workdir)
296 .args(args)
297 .output()
298 .context("Failed to execute git command")
299 }
300
301 pub fn push_branch(&self, branch_name: &str, remote_name: &str) -> Result<()> {
303 info!(
304 "Pushing branch '{}' to remote '{}'",
305 branch_name, remote_name
306 );
307
308 debug!("Pushing via git CLI to '{}'", remote_name);
312 let output = self.run_git(&["push", "--set-upstream", remote_name, branch_name])?;
313
314 if output.status.success() {
315 info!(
316 "Successfully pushed branch '{}' to remote '{}'",
317 branch_name, remote_name
318 );
319 Ok(())
320 } else {
321 let stderr = String::from_utf8_lossy(&output.stderr);
322 let stderr = stderr.trim();
323 error!("Failed to push branch: {}", stderr);
324 anyhow::bail!(
325 "Failed to push branch '{branch_name}' to remote '{remote_name}': {stderr}"
326 )
327 }
328 }
329
330 pub fn branch_exists_on_remote(&self, branch_name: &str, remote_name: &str) -> Result<bool> {
332 debug!(
333 "Checking if branch '{}' exists on remote '{}'",
334 branch_name, remote_name
335 );
336
337 debug!("Listing remote refs via git CLI from '{}'", remote_name);
341 let output = self.run_git(&["ls-remote", "--heads", remote_name, branch_name])?;
342
343 if !output.status.success() {
344 let stderr = String::from_utf8_lossy(&output.stderr);
345 let stderr = stderr.trim();
346 error!("Failed to list remote refs: {}", stderr);
347 anyhow::bail!(
348 "Failed to check remote '{remote_name}' for branch '{branch_name}': {stderr}"
349 )
350 }
351
352 let remote_branch_ref = format!("refs/heads/{branch_name}");
357 let stdout = String::from_utf8_lossy(&output.stdout);
358 let exists = stdout
359 .lines()
360 .filter_map(|line| line.split('\t').nth(1))
361 .any(|reference| reference == remote_branch_ref);
362
363 if exists {
364 info!(
365 "Branch '{}' exists on remote '{}'",
366 branch_name, remote_name
367 );
368 } else {
369 info!(
370 "Branch '{}' does not exist on remote '{}'",
371 branch_name, remote_name
372 );
373 }
374 Ok(exists)
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
385 fn status_flags_new_index() {
386 let status = format_status_flags(Status::INDEX_NEW);
387 assert_eq!(status, "A ");
388 }
389
390 #[test]
391 fn status_flags_modified_index() {
392 let status = format_status_flags(Status::INDEX_MODIFIED);
393 assert_eq!(status, "M ");
394 }
395
396 #[test]
397 fn status_flags_deleted_index() {
398 let status = format_status_flags(Status::INDEX_DELETED);
399 assert_eq!(status, "D ");
400 }
401
402 #[test]
403 fn status_flags_wt_new() {
404 let status = format_status_flags(Status::WT_NEW);
405 assert_eq!(status, " ?");
406 }
407
408 #[test]
409 fn status_flags_wt_modified() {
410 let status = format_status_flags(Status::WT_MODIFIED);
411 assert_eq!(status, " M");
412 }
413
414 #[test]
415 fn status_flags_combined() {
416 let status = format_status_flags(Status::INDEX_NEW | Status::WT_MODIFIED);
417 assert_eq!(status, "AM");
418 }
419
420 #[test]
421 fn status_flags_empty() {
422 let status = format_status_flags(Status::empty());
423 assert_eq!(status, " ");
424 }
425
426 #[allow(clippy::unwrap_used)]
435 fn init_tmp_repo() -> tempfile::TempDir {
436 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
437 std::fs::create_dir_all(&tmp_root).unwrap();
438 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
439 git2::Repository::init(temp_dir.path()).unwrap();
440 temp_dir
441 }
442
443 #[test]
444 fn open_at_temp_repo() -> Result<()> {
445 let temp_dir = init_tmp_repo();
446 let repo = GitRepository::open_at(temp_dir.path())?;
447 assert!(repo.path().exists());
448 Ok(())
449 }
450
451 #[test]
452 fn working_directory_clean_empty_repo() -> Result<()> {
453 let temp_dir = init_tmp_repo();
454 let repo = GitRepository::open_at(temp_dir.path())?;
455 let status = repo.get_working_directory_status()?;
456 assert!(status.clean);
457 assert!(status.untracked_changes.is_empty());
458 Ok(())
459 }
460
461 #[test]
462 fn working_directory_dirty_with_file() -> Result<()> {
463 let temp_dir = init_tmp_repo();
464 std::fs::write(temp_dir.path().join("new_file.txt"), "content")?;
465 let repo = GitRepository::open_at(temp_dir.path())?;
466 let status = repo.get_working_directory_status()?;
467 assert!(!status.clean);
468 assert!(!status.untracked_changes.is_empty());
469 Ok(())
470 }
471
472 #[test]
473 fn is_working_directory_clean_delegator() -> Result<()> {
474 let temp_dir = init_tmp_repo();
475 let repo = GitRepository::open_at(temp_dir.path())?;
476 assert!(repo.is_working_directory_clean()?);
477 Ok(())
478 }
479
480 #[test]
481 fn current_branch_on_a_branch() -> Result<()> {
482 let temp_dir = init_tmp_repo();
483 let p = temp_dir.path();
484 std::fs::write(p.join("f.txt"), "x")?;
485 git_in(p, &["add", "."]);
486 git_in(p, &["commit", "-m", "init"]);
487 let repo = GitRepository::open_at(p)?;
488 assert_ne!(repo.get_current_branch()?, "HEAD");
491 Ok(())
492 }
493
494 #[test]
495 fn current_branch_errors_in_detached_head() -> Result<()> {
496 let temp_dir = init_tmp_repo();
499 let p = temp_dir.path();
500 std::fs::write(p.join("f.txt"), "x")?;
501 git_in(p, &["add", "."]);
502 git_in(p, &["commit", "-m", "init"]);
503 git_in(p, &["checkout", "--detach", "HEAD"]);
504 let repo = GitRepository::open_at(p)?;
505 let result = repo.get_current_branch();
506 assert!(
507 matches!(&result, Err(e) if e.to_string().contains("detached HEAD")),
508 "expected detached-HEAD error, got: {result:?}"
509 );
510 Ok(())
511 }
512
513 #[allow(clippy::unwrap_used)]
517 fn git_in(dir: &std::path::Path, args: &[&str]) {
518 let output = std::process::Command::new("git")
519 .current_dir(dir)
520 .args([
521 "-c",
522 "user.email=test@example.com",
523 "-c",
524 "user.name=Test",
525 "-c",
529 "commit.gpgsign=false",
530 "-c",
531 "tag.gpgsign=false",
532 ])
533 .args(args)
534 .output()
535 .unwrap();
536 let stderr = String::from_utf8_lossy(&output.stderr);
537 assert!(output.status.success(), "git {args:?} failed: {stderr}");
538 }
539
540 #[allow(clippy::unwrap_used)]
544 fn repo_with_bare_remote() -> (tempfile::TempDir, tempfile::TempDir, GitRepository) {
545 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
546 std::fs::create_dir_all(&tmp_root).unwrap();
547 let bare = tempfile::tempdir_in(&tmp_root).unwrap();
548 git_in(bare.path(), &["init", "--bare"]);
549
550 let work = init_tmp_repo();
551 std::fs::write(work.path().join("file.txt"), "content").unwrap();
552 git_in(work.path(), &["checkout", "-b", "feature-branch"]);
553 git_in(work.path(), &["add", "."]);
554 git_in(work.path(), &["commit", "-m", "initial"]);
555 git_in(
556 work.path(),
557 &["remote", "add", "origin", bare.path().to_str().unwrap()],
558 );
559
560 let repo = GitRepository::open_at(work.path()).unwrap();
561 (work, bare, repo)
562 }
563
564 #[test]
565 fn branch_absent_on_remote_before_push() -> Result<()> {
566 let (_work, _bare, repo) = repo_with_bare_remote();
567 assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
568 Ok(())
569 }
570
571 #[test]
572 fn push_branch_then_present_on_remote() -> Result<()> {
573 let (_work, _bare, repo) = repo_with_bare_remote();
574 repo.push_branch("feature-branch", "origin")?;
575 assert!(repo.branch_exists_on_remote("feature-branch", "origin")?);
576 assert!(!repo.branch_exists_on_remote("absent-branch", "origin")?);
577 Ok(())
578 }
579
580 #[test]
581 fn branch_exists_requires_exact_ref_match() -> Result<()> {
582 let (work, _bare, repo) = repo_with_bare_remote();
586 git_in(work.path(), &["checkout", "-b", "team/feature-branch"]);
587 repo.push_branch("team/feature-branch", "origin")?;
588 assert!(repo.branch_exists_on_remote("team/feature-branch", "origin")?);
589 assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
590 Ok(())
591 }
592
593 #[test]
594 fn push_branch_reports_failure_for_unknown_remote() {
595 let (_work, _bare, repo) = repo_with_bare_remote();
596 let result = repo.push_branch("feature-branch", "nonexistent");
597 assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to push branch")));
598 }
599
600 #[test]
601 fn branch_exists_reports_failure_for_unknown_remote() {
602 let (_work, _bare, repo) = repo_with_bare_remote();
603 let result = repo.branch_exists_on_remote("feature-branch", "nonexistent");
604 assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to check remote")));
605 }
606
607 #[test]
610 fn resolve_default_base_prefers_origin_main_over_local() -> Result<()> {
611 let (work, _bare, repo) = repo_with_bare_remote();
612 git_in(work.path(), &["branch", "main"]);
613 repo.push_branch("main", "origin")?;
616 assert_eq!(
617 repo.resolve_default_base_branch(),
618 Some("origin/main".to_string())
619 );
620 Ok(())
621 }
622
623 #[test]
624 fn resolve_default_base_prefers_origin_master_over_local_main() -> Result<()> {
625 let (work, _bare, repo) = repo_with_bare_remote();
628 git_in(work.path(), &["branch", "main"]);
629 git_in(work.path(), &["branch", "master"]);
630 repo.push_branch("master", "origin")?;
631 assert_eq!(
632 repo.resolve_default_base_branch(),
633 Some("origin/master".to_string())
634 );
635 Ok(())
636 }
637
638 #[test]
639 fn resolve_default_base_uses_local_main_without_remote() -> Result<()> {
640 let temp_dir = init_tmp_repo();
641 let p = temp_dir.path();
642 std::fs::write(p.join("f.txt"), "x")?;
643 git_in(p, &["checkout", "-b", "main"]);
644 git_in(p, &["add", "."]);
645 git_in(p, &["commit", "-m", "init"]);
646 let repo = GitRepository::open_at(p)?;
647 assert_eq!(repo.resolve_default_base_branch(), Some("main".to_string()));
648 Ok(())
649 }
650
651 #[test]
652 fn resolve_default_base_falls_back_to_local_master() -> Result<()> {
653 let temp_dir = init_tmp_repo();
654 let p = temp_dir.path();
655 std::fs::write(p.join("f.txt"), "x")?;
656 git_in(p, &["checkout", "-b", "master"]);
657 git_in(p, &["add", "."]);
658 git_in(p, &["commit", "-m", "init"]);
659 let repo = GitRepository::open_at(p)?;
660 assert_eq!(
661 repo.resolve_default_base_branch(),
662 Some("master".to_string())
663 );
664 Ok(())
665 }
666
667 #[test]
668 fn resolve_default_base_none_without_mainline() -> Result<()> {
669 let temp_dir = init_tmp_repo();
670 let p = temp_dir.path();
671 std::fs::write(p.join("f.txt"), "x")?;
672 git_in(p, &["checkout", "-b", "dev"]);
673 git_in(p, &["add", "."]);
674 git_in(p, &["commit", "-m", "init"]);
675 let repo = GitRepository::open_at(p)?;
676 assert_eq!(repo.resolve_default_base_branch(), None);
677 Ok(())
678 }
679
680 #[test]
683 fn commits_in_range_report_main_branch_containment() -> Result<()> {
684 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
687 std::fs::create_dir_all(&tmp_root)?;
688 let bare = tempfile::tempdir_in(&tmp_root)?;
689 git_in(bare.path(), &["init", "--bare"]);
690
691 let work = init_tmp_repo();
692 let p = work.path();
693 git_in(p, &["checkout", "-b", "main"]);
694 std::fs::write(p.join("a.txt"), "pushed")?;
695 git_in(p, &["add", "."]);
696 git_in(p, &["commit", "-m", "pushed commit"]);
697 #[allow(clippy::unwrap_used)]
698 git_in(
699 p,
700 &["remote", "add", "origin", bare.path().to_str().unwrap()],
701 );
702 git_in(p, &["push", "origin", "main"]);
703 std::fs::write(p.join("b.txt"), "unpushed")?;
704 git_in(p, &["add", "."]);
705 git_in(p, &["commit", "-m", "unpushed commit"]);
706
707 let repo = GitRepository::open_at(p)?;
708 let pushed = repo.get_commits_in_range("HEAD~1")?;
710 assert_eq!(pushed.len(), 1);
711 assert_eq!(pushed[0].in_main_branches, vec!["origin/main".to_string()]);
712 let unpushed = repo.get_commits_in_range("HEAD~1..HEAD")?;
714 assert_eq!(unpushed.len(), 1);
715 assert!(unpushed[0].in_main_branches.is_empty());
716 Ok(())
717 }
718
719 #[test]
720 fn commits_in_range_empty_containment_without_remotes() -> Result<()> {
721 let work = init_tmp_repo();
722 let p = work.path();
723 std::fs::write(p.join("a.txt"), "x")?;
724 git_in(p, &["add", "."]);
725 git_in(p, &["commit", "-m", "local only"]);
726
727 let repo = GitRepository::open_at(p)?;
728 let commits = repo.get_commits_in_range("HEAD")?;
729 assert_eq!(commits.len(), 1);
730 assert!(commits[0].in_main_branches.is_empty());
731 Ok(())
732 }
733}