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 tracked_files(&self) -> Result<Vec<String>> {
87 let index = self.repo.index().context("Failed to open git index")?;
88 let mut files: Vec<String> = index
89 .iter()
90 .map(|entry| String::from_utf8_lossy(&entry.path).into_owned())
91 .collect();
92 files.sort();
93 files.dedup();
94 Ok(files)
95 }
96
97 pub fn path(&self) -> &std::path::Path {
99 self.repo.path()
100 }
101
102 pub fn workdir(&self) -> Option<&std::path::Path> {
104 self.repo.workdir()
105 }
106
107 pub fn repository(&self) -> &Repository {
109 &self.repo
110 }
111
112 pub fn get_current_branch(&self) -> Result<String> {
114 let head = self.repo.head().context("Failed to get HEAD reference")?;
115
116 if let Ok(name) = head.shorthand() {
117 if name != "HEAD" {
118 return Ok(name.to_string());
119 }
120 }
121
122 anyhow::bail!("Repository is in detached HEAD state")
123 }
124
125 pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
127 if self
129 .repo
130 .find_branch(branch_name, git2::BranchType::Local)
131 .is_ok()
132 {
133 return Ok(true);
134 }
135
136 if self
138 .repo
139 .find_branch(branch_name, git2::BranchType::Remote)
140 .is_ok()
141 {
142 return Ok(true);
143 }
144
145 if self.repo.revparse_single(branch_name).is_ok() {
147 return Ok(true);
148 }
149
150 Ok(false)
151 }
152
153 pub fn resolve_default_base_branch(&self) -> Option<String> {
160 const CANDIDATES: [(&str, git2::BranchType); 4] = [
161 ("origin/main", git2::BranchType::Remote),
162 ("origin/master", git2::BranchType::Remote),
163 ("main", git2::BranchType::Local),
164 ("master", git2::BranchType::Local),
165 ];
166 CANDIDATES
167 .iter()
168 .find(|(name, kind)| self.repo.find_branch(name, *kind).is_ok())
169 .map(|(name, _)| (*name).to_string())
170 }
171
172 pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
174 let mut commits = Vec::new();
175
176 let main_tips = crate::git::main_branches::detect_main_branch_tips(&self.repo)?;
178
179 if range == "HEAD" {
180 let head = self.repo.head().context("Failed to get HEAD")?;
182 let commit = head
183 .peel_to_commit()
184 .context("Failed to peel HEAD to commit")?;
185 commits.push(CommitInfo::from_git_commit(
186 &self.repo, &commit, &main_tips,
187 )?);
188 } else if range.contains("..") {
189 let parts: Vec<&str> = range.split("..").collect();
191 if parts.len() != 2 {
192 anyhow::bail!("Invalid range format: {range}");
193 }
194
195 let start_spec = parts[0];
196 let end_spec = parts[1];
197
198 let start_obj = self
200 .repo
201 .revparse_single(start_spec)
202 .with_context(|| format!("Failed to parse start commit: {start_spec}"))?;
203 let end_obj = self
204 .repo
205 .revparse_single(end_spec)
206 .with_context(|| format!("Failed to parse end commit: {end_spec}"))?;
207
208 let start_commit = start_obj
209 .peel_to_commit()
210 .context("Failed to peel start object to commit")?;
211 let end_commit = end_obj
212 .peel_to_commit()
213 .context("Failed to peel end object to commit")?;
214
215 let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
217 walker
218 .push(end_commit.id())
219 .context("Failed to push end commit")?;
220 walker
221 .hide(start_commit.id())
222 .context("Failed to hide start commit")?;
223
224 commits = self.collect_walk(walker, &main_tips, None)?;
225 } else {
226 let obj = self
228 .repo
229 .revparse_single(range)
230 .with_context(|| format!("Failed to parse commit: {range}"))?;
231 let commit = obj
232 .peel_to_commit()
233 .context("Failed to peel object to commit")?;
234 commits.push(CommitInfo::from_git_commit(
235 &self.repo, &commit, &main_tips,
236 )?);
237 }
238
239 Ok(commits)
240 }
241
242 pub fn get_commits_from_head(&self, max_count: Option<usize>) -> Result<Vec<CommitInfo>> {
249 let main_tips = crate::git::main_branches::detect_main_branch_tips(&self.repo)?;
250 let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
251 walker.push_head().context("Failed to push HEAD")?;
252 self.collect_walk(walker, &main_tips, max_count)
253 }
254
255 fn collect_walk(
262 &self,
263 walker: git2::Revwalk<'_>,
264 main_tips: &[crate::git::main_branches::MainBranchTip],
265 max_count: Option<usize>,
266 ) -> Result<Vec<CommitInfo>> {
267 let mut commits = Vec::new();
268
269 for oid in walker {
270 let oid = oid.context("Failed to get commit OID from walker")?;
271 let commit = self
272 .repo
273 .find_commit(oid)
274 .context("Failed to find commit")?;
275
276 if commit.parent_count() > 1 {
278 continue;
279 }
280
281 commits.push(CommitInfo::from_git_commit(&self.repo, &commit, main_tips)?);
282
283 if max_count.is_some_and(|n| commits.len() >= n) {
284 break;
285 }
286 }
287
288 commits.reverse();
290 Ok(commits)
291 }
292}
293
294fn format_status_flags(flags: Status) -> String {
296 let mut status = String::new();
297
298 if flags.contains(Status::INDEX_NEW) {
299 status.push('A');
300 } else if flags.contains(Status::INDEX_MODIFIED) {
301 status.push('M');
302 } else if flags.contains(Status::INDEX_DELETED) {
303 status.push('D');
304 } else if flags.contains(Status::INDEX_RENAMED) {
305 status.push('R');
306 } else if flags.contains(Status::INDEX_TYPECHANGE) {
307 status.push('T');
308 } else {
309 status.push(' ');
310 }
311
312 if flags.contains(Status::WT_NEW) {
313 status.push('?');
314 } else if flags.contains(Status::WT_MODIFIED) {
315 status.push('M');
316 } else if flags.contains(Status::WT_DELETED) {
317 status.push('D');
318 } else if flags.contains(Status::WT_TYPECHANGE) {
319 status.push('T');
320 } else if flags.contains(Status::WT_RENAMED) {
321 status.push('R');
322 } else {
323 status.push(' ');
324 }
325
326 status
327}
328
329impl GitRepository {
330 fn run_git(&self, args: &[&str]) -> Result<std::process::Output> {
338 let workdir = self
339 .repo
340 .workdir()
341 .context("Cannot run git command: repository has no working directory")?;
342
343 std::process::Command::new("git")
344 .current_dir(workdir)
345 .args(args)
346 .output()
347 .context("Failed to execute git command")
348 }
349
350 pub fn push_branch(&self, branch_name: &str, remote_name: &str) -> Result<()> {
352 info!(
353 "Pushing branch '{}' to remote '{}'",
354 branch_name, remote_name
355 );
356
357 debug!("Pushing via git CLI to '{}'", remote_name);
361 let output = self.run_git(&["push", "--set-upstream", remote_name, branch_name])?;
362
363 if output.status.success() {
364 info!(
365 "Successfully pushed branch '{}' to remote '{}'",
366 branch_name, remote_name
367 );
368 Ok(())
369 } else {
370 let stderr = String::from_utf8_lossy(&output.stderr);
371 let stderr = stderr.trim();
372 error!("Failed to push branch: {}", stderr);
373 anyhow::bail!(
374 "Failed to push branch '{branch_name}' to remote '{remote_name}': {stderr}"
375 )
376 }
377 }
378
379 pub fn branch_exists_on_remote(&self, branch_name: &str, remote_name: &str) -> Result<bool> {
381 debug!(
382 "Checking if branch '{}' exists on remote '{}'",
383 branch_name, remote_name
384 );
385
386 debug!("Listing remote refs via git CLI from '{}'", remote_name);
390 let output = self.run_git(&["ls-remote", "--heads", remote_name, branch_name])?;
391
392 if !output.status.success() {
393 let stderr = String::from_utf8_lossy(&output.stderr);
394 let stderr = stderr.trim();
395 error!("Failed to list remote refs: {}", stderr);
396 anyhow::bail!(
397 "Failed to check remote '{remote_name}' for branch '{branch_name}': {stderr}"
398 )
399 }
400
401 let remote_branch_ref = format!("refs/heads/{branch_name}");
406 let stdout = String::from_utf8_lossy(&output.stdout);
407 let exists = stdout
408 .lines()
409 .filter_map(|line| line.split('\t').nth(1))
410 .any(|reference| reference == remote_branch_ref);
411
412 if exists {
413 info!(
414 "Branch '{}' exists on remote '{}'",
415 branch_name, remote_name
416 );
417 } else {
418 info!(
419 "Branch '{}' does not exist on remote '{}'",
420 branch_name, remote_name
421 );
422 }
423 Ok(exists)
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
434 fn status_flags_new_index() {
435 let status = format_status_flags(Status::INDEX_NEW);
436 assert_eq!(status, "A ");
437 }
438
439 #[test]
440 fn status_flags_modified_index() {
441 let status = format_status_flags(Status::INDEX_MODIFIED);
442 assert_eq!(status, "M ");
443 }
444
445 #[test]
446 fn status_flags_deleted_index() {
447 let status = format_status_flags(Status::INDEX_DELETED);
448 assert_eq!(status, "D ");
449 }
450
451 #[test]
452 fn status_flags_wt_new() {
453 let status = format_status_flags(Status::WT_NEW);
454 assert_eq!(status, " ?");
455 }
456
457 #[test]
458 fn status_flags_wt_modified() {
459 let status = format_status_flags(Status::WT_MODIFIED);
460 assert_eq!(status, " M");
461 }
462
463 #[test]
464 fn status_flags_combined() {
465 let status = format_status_flags(Status::INDEX_NEW | Status::WT_MODIFIED);
466 assert_eq!(status, "AM");
467 }
468
469 #[test]
470 fn status_flags_empty() {
471 let status = format_status_flags(Status::empty());
472 assert_eq!(status, " ");
473 }
474
475 #[allow(clippy::unwrap_used)]
484 fn init_tmp_repo() -> tempfile::TempDir {
485 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
486 std::fs::create_dir_all(&tmp_root).unwrap();
487 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
488 git2::Repository::init(temp_dir.path()).unwrap();
489 temp_dir
490 }
491
492 #[test]
493 fn open_at_temp_repo() -> Result<()> {
494 let temp_dir = init_tmp_repo();
495 let repo = GitRepository::open_at(temp_dir.path())?;
496 assert!(repo.path().exists());
497 Ok(())
498 }
499
500 #[test]
501 fn working_directory_clean_empty_repo() -> Result<()> {
502 let temp_dir = init_tmp_repo();
503 let repo = GitRepository::open_at(temp_dir.path())?;
504 let status = repo.get_working_directory_status()?;
505 assert!(status.clean);
506 assert!(status.untracked_changes.is_empty());
507 Ok(())
508 }
509
510 #[test]
511 fn working_directory_dirty_with_file() -> Result<()> {
512 let temp_dir = init_tmp_repo();
513 std::fs::write(temp_dir.path().join("new_file.txt"), "content")?;
514 let repo = GitRepository::open_at(temp_dir.path())?;
515 let status = repo.get_working_directory_status()?;
516 assert!(!status.clean);
517 assert!(!status.untracked_changes.is_empty());
518 Ok(())
519 }
520
521 #[test]
522 fn is_working_directory_clean_delegator() -> Result<()> {
523 let temp_dir = init_tmp_repo();
524 let repo = GitRepository::open_at(temp_dir.path())?;
525 assert!(repo.is_working_directory_clean()?);
526 Ok(())
527 }
528
529 #[test]
530 fn current_branch_on_a_branch() -> Result<()> {
531 let temp_dir = init_tmp_repo();
532 let p = temp_dir.path();
533 std::fs::write(p.join("f.txt"), "x")?;
534 git_in(p, &["add", "."]);
535 git_in(p, &["commit", "-m", "init"]);
536 let repo = GitRepository::open_at(p)?;
537 assert_ne!(repo.get_current_branch()?, "HEAD");
540 Ok(())
541 }
542
543 #[test]
544 fn current_branch_errors_in_detached_head() -> Result<()> {
545 let temp_dir = init_tmp_repo();
548 let p = temp_dir.path();
549 std::fs::write(p.join("f.txt"), "x")?;
550 git_in(p, &["add", "."]);
551 git_in(p, &["commit", "-m", "init"]);
552 git_in(p, &["checkout", "--detach", "HEAD"]);
553 let repo = GitRepository::open_at(p)?;
554 let result = repo.get_current_branch();
555 assert!(
556 matches!(&result, Err(e) if e.to_string().contains("detached HEAD")),
557 "expected detached-HEAD error, got: {result:?}"
558 );
559 Ok(())
560 }
561
562 #[allow(clippy::unwrap_used)]
566 fn git_in(dir: &std::path::Path, args: &[&str]) {
567 let output = std::process::Command::new("git")
568 .current_dir(dir)
569 .args([
570 "-c",
571 "user.email=test@example.com",
572 "-c",
573 "user.name=Test",
574 "-c",
578 "commit.gpgsign=false",
579 "-c",
580 "tag.gpgsign=false",
581 ])
582 .args(args)
583 .output()
584 .unwrap();
585 let stderr = String::from_utf8_lossy(&output.stderr);
586 assert!(output.status.success(), "git {args:?} failed: {stderr}");
587 }
588
589 #[allow(clippy::unwrap_used)]
593 fn repo_with_bare_remote() -> (tempfile::TempDir, tempfile::TempDir, GitRepository) {
594 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
595 std::fs::create_dir_all(&tmp_root).unwrap();
596 let bare = tempfile::tempdir_in(&tmp_root).unwrap();
597 git_in(bare.path(), &["init", "--bare"]);
598
599 let work = init_tmp_repo();
600 std::fs::write(work.path().join("file.txt"), "content").unwrap();
601 git_in(work.path(), &["checkout", "-b", "feature-branch"]);
602 git_in(work.path(), &["add", "."]);
603 git_in(work.path(), &["commit", "-m", "initial"]);
604 git_in(
605 work.path(),
606 &["remote", "add", "origin", bare.path().to_str().unwrap()],
607 );
608
609 let repo = GitRepository::open_at(work.path()).unwrap();
610 (work, bare, repo)
611 }
612
613 #[test]
614 fn branch_absent_on_remote_before_push() -> Result<()> {
615 let (_work, _bare, repo) = repo_with_bare_remote();
616 assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
617 Ok(())
618 }
619
620 #[test]
621 fn push_branch_then_present_on_remote() -> Result<()> {
622 let (_work, _bare, repo) = repo_with_bare_remote();
623 repo.push_branch("feature-branch", "origin")?;
624 assert!(repo.branch_exists_on_remote("feature-branch", "origin")?);
625 assert!(!repo.branch_exists_on_remote("absent-branch", "origin")?);
626 Ok(())
627 }
628
629 #[test]
630 fn branch_exists_requires_exact_ref_match() -> Result<()> {
631 let (work, _bare, repo) = repo_with_bare_remote();
635 git_in(work.path(), &["checkout", "-b", "team/feature-branch"]);
636 repo.push_branch("team/feature-branch", "origin")?;
637 assert!(repo.branch_exists_on_remote("team/feature-branch", "origin")?);
638 assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
639 Ok(())
640 }
641
642 #[test]
643 fn push_branch_reports_failure_for_unknown_remote() {
644 let (_work, _bare, repo) = repo_with_bare_remote();
645 let result = repo.push_branch("feature-branch", "nonexistent");
646 assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to push branch")));
647 }
648
649 #[test]
650 fn branch_exists_reports_failure_for_unknown_remote() {
651 let (_work, _bare, repo) = repo_with_bare_remote();
652 let result = repo.branch_exists_on_remote("feature-branch", "nonexistent");
653 assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to check remote")));
654 }
655
656 #[test]
659 fn resolve_default_base_prefers_origin_main_over_local() -> Result<()> {
660 let (work, _bare, repo) = repo_with_bare_remote();
661 git_in(work.path(), &["branch", "main"]);
662 repo.push_branch("main", "origin")?;
665 assert_eq!(
666 repo.resolve_default_base_branch(),
667 Some("origin/main".to_string())
668 );
669 Ok(())
670 }
671
672 #[test]
673 fn resolve_default_base_prefers_origin_master_over_local_main() -> Result<()> {
674 let (work, _bare, repo) = repo_with_bare_remote();
677 git_in(work.path(), &["branch", "main"]);
678 git_in(work.path(), &["branch", "master"]);
679 repo.push_branch("master", "origin")?;
680 assert_eq!(
681 repo.resolve_default_base_branch(),
682 Some("origin/master".to_string())
683 );
684 Ok(())
685 }
686
687 #[test]
688 fn resolve_default_base_uses_local_main_without_remote() -> Result<()> {
689 let temp_dir = init_tmp_repo();
690 let p = temp_dir.path();
691 std::fs::write(p.join("f.txt"), "x")?;
692 git_in(p, &["checkout", "-b", "main"]);
693 git_in(p, &["add", "."]);
694 git_in(p, &["commit", "-m", "init"]);
695 let repo = GitRepository::open_at(p)?;
696 assert_eq!(repo.resolve_default_base_branch(), Some("main".to_string()));
697 Ok(())
698 }
699
700 #[test]
701 fn resolve_default_base_falls_back_to_local_master() -> Result<()> {
702 let temp_dir = init_tmp_repo();
703 let p = temp_dir.path();
704 std::fs::write(p.join("f.txt"), "x")?;
705 git_in(p, &["checkout", "-b", "master"]);
706 git_in(p, &["add", "."]);
707 git_in(p, &["commit", "-m", "init"]);
708 let repo = GitRepository::open_at(p)?;
709 assert_eq!(
710 repo.resolve_default_base_branch(),
711 Some("master".to_string())
712 );
713 Ok(())
714 }
715
716 #[test]
717 fn resolve_default_base_none_without_mainline() -> Result<()> {
718 let temp_dir = init_tmp_repo();
719 let p = temp_dir.path();
720 std::fs::write(p.join("f.txt"), "x")?;
721 git_in(p, &["checkout", "-b", "dev"]);
722 git_in(p, &["add", "."]);
723 git_in(p, &["commit", "-m", "init"]);
724 let repo = GitRepository::open_at(p)?;
725 assert_eq!(repo.resolve_default_base_branch(), None);
726 Ok(())
727 }
728
729 #[test]
732 fn commits_in_range_report_main_branch_containment() -> Result<()> {
733 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
736 std::fs::create_dir_all(&tmp_root)?;
737 let bare = tempfile::tempdir_in(&tmp_root)?;
738 git_in(bare.path(), &["init", "--bare"]);
739
740 let work = init_tmp_repo();
741 let p = work.path();
742 git_in(p, &["checkout", "-b", "main"]);
743 std::fs::write(p.join("a.txt"), "pushed")?;
744 git_in(p, &["add", "."]);
745 git_in(p, &["commit", "-m", "pushed commit"]);
746 #[allow(clippy::unwrap_used)]
747 git_in(
748 p,
749 &["remote", "add", "origin", bare.path().to_str().unwrap()],
750 );
751 git_in(p, &["push", "origin", "main"]);
752 std::fs::write(p.join("b.txt"), "unpushed")?;
753 git_in(p, &["add", "."]);
754 git_in(p, &["commit", "-m", "unpushed commit"]);
755
756 let repo = GitRepository::open_at(p)?;
757 let pushed = repo.get_commits_in_range("HEAD~1")?;
759 assert_eq!(pushed.len(), 1);
760 assert_eq!(pushed[0].in_main_branches, vec!["origin/main".to_string()]);
761 let unpushed = repo.get_commits_in_range("HEAD~1..HEAD")?;
763 assert_eq!(unpushed.len(), 1);
764 assert!(unpushed[0].in_main_branches.is_empty());
765 Ok(())
766 }
767
768 #[test]
769 fn commits_in_range_empty_containment_without_remotes() -> Result<()> {
770 let work = init_tmp_repo();
771 let p = work.path();
772 std::fs::write(p.join("a.txt"), "x")?;
773 git_in(p, &["add", "."]);
774 git_in(p, &["commit", "-m", "local only"]);
775
776 let repo = GitRepository::open_at(p)?;
777 let commits = repo.get_commits_in_range("HEAD")?;
778 assert_eq!(commits.len(), 1);
779 assert!(commits[0].in_main_branches.is_empty());
780 Ok(())
781 }
782
783 #[allow(clippy::unwrap_used)]
788 fn repo_with_linear_commits(n: usize) -> (tempfile::TempDir, std::path::PathBuf) {
789 let temp_dir = init_tmp_repo();
790 let p = temp_dir.path().to_path_buf();
791 for i in 0..n {
792 std::fs::write(p.join("f.txt"), format!("content {i}")).unwrap();
793 git_in(&p, &["add", "."]);
794 git_in(&p, &["commit", "-m", &format!("commit {i}")]);
795 }
796 (temp_dir, p)
797 }
798
799 #[test]
800 fn commits_from_head_no_cap_returns_all_in_chronological_order() -> Result<()> {
801 let (_tmp, p) = repo_with_linear_commits(3);
802 let repo = GitRepository::open_at(&p)?;
803 let commits = repo.get_commits_from_head(None)?;
804 let subjects: Vec<&str> = commits.iter().map(|c| c.original_message.trim()).collect();
805 assert_eq!(subjects, vec!["commit 0", "commit 1", "commit 2"]);
806 Ok(())
807 }
808
809 #[test]
810 fn commits_from_head_max_count_caps_to_newest() -> Result<()> {
811 let (_tmp, p) = repo_with_linear_commits(5);
812 let repo = GitRepository::open_at(&p)?;
813 let commits = repo.get_commits_from_head(Some(2))?;
814 let subjects: Vec<&str> = commits.iter().map(|c| c.original_message.trim()).collect();
815 assert_eq!(subjects, vec!["commit 3", "commit 4"]);
817 Ok(())
818 }
819
820 #[test]
821 fn commits_from_head_unborn_head_errors() -> Result<()> {
822 let temp_dir = init_tmp_repo();
827 let repo = GitRepository::open_at(temp_dir.path())?;
828 let result = repo.get_commits_from_head(None);
829 assert!(result.is_err(), "unborn HEAD is expected to error");
830 Ok(())
831 }
832
833 #[test]
834 fn commits_from_head_skips_merge_commits() -> Result<()> {
835 let (_tmp, p) = repo_with_linear_commits(1);
836 git_in(&p, &["checkout", "-b", "feature"]);
837 std::fs::write(p.join("g.txt"), "feature")?;
838 git_in(&p, &["add", "."]);
839 git_in(&p, &["commit", "-m", "feature commit"]);
840 git_in(&p, &["checkout", "-"]);
843 git_in(&p, &["merge", "--no-ff", "feature", "-m", "merge feature"]);
844
845 let repo = GitRepository::open_at(&p)?;
846 let commits = repo.get_commits_from_head(None)?;
847 let subjects: Vec<&str> = commits.iter().map(|c| c.original_message.trim()).collect();
848 assert!(
849 !subjects.contains(&"merge feature"),
850 "merge commit must be excluded, got: {subjects:?}"
851 );
852 assert!(subjects.contains(&"commit 0"));
853 assert!(subjects.contains(&"feature commit"));
854 Ok(())
855 }
856
857 #[test]
860 fn tracked_files_includes_committed_and_staged() -> Result<()> {
861 let work = init_tmp_repo();
862 let p = work.path();
863 std::fs::write(p.join("a.txt"), "committed")?;
864 git_in(p, &["add", "."]);
865 git_in(p, &["commit", "-m", "init"]);
866 std::fs::write(p.join("b.txt"), "staged only")?;
867 git_in(p, &["add", "b.txt"]);
868
869 let repo = GitRepository::open_at(p)?;
870 let files = repo.tracked_files()?;
871 assert!(files.contains(&"a.txt".to_string()));
872 assert!(files.contains(&"b.txt".to_string()));
873 Ok(())
874 }
875
876 #[test]
877 fn tracked_files_excludes_untracked() -> Result<()> {
878 let work = init_tmp_repo();
879 let p = work.path();
880 std::fs::write(p.join("a.txt"), "committed")?;
881 git_in(p, &["add", "."]);
882 git_in(p, &["commit", "-m", "init"]);
883 std::fs::write(p.join("untracked.txt"), "never added")?;
884
885 let repo = GitRepository::open_at(p)?;
886 let files = repo.tracked_files()?;
887 assert!(!files.contains(&"untracked.txt".to_string()));
888 Ok(())
889 }
890
891 #[test]
892 fn tracked_files_sorted() -> Result<()> {
893 let work = init_tmp_repo();
894 let p = work.path();
895 std::fs::write(p.join("zeta.txt"), "z")?;
896 std::fs::write(p.join("alpha.txt"), "a")?;
897 git_in(p, &["add", "."]);
898 git_in(p, &["commit", "-m", "init"]);
899
900 let repo = GitRepository::open_at(p)?;
901 let files = repo.tracked_files()?;
902 let mut sorted = files.clone();
903 sorted.sort();
904 assert_eq!(files, sorted);
905 Ok(())
906 }
907
908 #[test]
909 fn tracked_files_empty_repo() -> Result<()> {
910 let temp_dir = init_tmp_repo();
911 let repo = GitRepository::open_at(temp_dir.path())?;
912 assert!(repo.tracked_files()?.is_empty());
913 Ok(())
914 }
915}