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 get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
139 let mut commits = Vec::new();
140
141 if range == "HEAD" {
142 let head = self.repo.head().context("Failed to get HEAD")?;
144 let commit = head
145 .peel_to_commit()
146 .context("Failed to peel HEAD to commit")?;
147 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
148 } else if range.contains("..") {
149 let parts: Vec<&str> = range.split("..").collect();
151 if parts.len() != 2 {
152 anyhow::bail!("Invalid range format: {range}");
153 }
154
155 let start_spec = parts[0];
156 let end_spec = parts[1];
157
158 let start_obj = self
160 .repo
161 .revparse_single(start_spec)
162 .with_context(|| format!("Failed to parse start commit: {start_spec}"))?;
163 let end_obj = self
164 .repo
165 .revparse_single(end_spec)
166 .with_context(|| format!("Failed to parse end commit: {end_spec}"))?;
167
168 let start_commit = start_obj
169 .peel_to_commit()
170 .context("Failed to peel start object to commit")?;
171 let end_commit = end_obj
172 .peel_to_commit()
173 .context("Failed to peel end object to commit")?;
174
175 let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
177 walker
178 .push(end_commit.id())
179 .context("Failed to push end commit")?;
180 walker
181 .hide(start_commit.id())
182 .context("Failed to hide start commit")?;
183
184 for oid in walker {
185 let oid = oid.context("Failed to get commit OID from walker")?;
186 let commit = self
187 .repo
188 .find_commit(oid)
189 .context("Failed to find commit")?;
190
191 if commit.parent_count() > 1 {
193 continue;
194 }
195
196 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
197 }
198
199 commits.reverse();
201 } else {
202 let obj = self
204 .repo
205 .revparse_single(range)
206 .with_context(|| format!("Failed to parse commit: {range}"))?;
207 let commit = obj
208 .peel_to_commit()
209 .context("Failed to peel object to commit")?;
210 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
211 }
212
213 Ok(commits)
214 }
215}
216
217fn format_status_flags(flags: Status) -> String {
219 let mut status = String::new();
220
221 if flags.contains(Status::INDEX_NEW) {
222 status.push('A');
223 } else if flags.contains(Status::INDEX_MODIFIED) {
224 status.push('M');
225 } else if flags.contains(Status::INDEX_DELETED) {
226 status.push('D');
227 } else if flags.contains(Status::INDEX_RENAMED) {
228 status.push('R');
229 } else if flags.contains(Status::INDEX_TYPECHANGE) {
230 status.push('T');
231 } else {
232 status.push(' ');
233 }
234
235 if flags.contains(Status::WT_NEW) {
236 status.push('?');
237 } else if flags.contains(Status::WT_MODIFIED) {
238 status.push('M');
239 } else if flags.contains(Status::WT_DELETED) {
240 status.push('D');
241 } else if flags.contains(Status::WT_TYPECHANGE) {
242 status.push('T');
243 } else if flags.contains(Status::WT_RENAMED) {
244 status.push('R');
245 } else {
246 status.push(' ');
247 }
248
249 status
250}
251
252impl GitRepository {
253 fn run_git(&self, args: &[&str]) -> Result<std::process::Output> {
261 let workdir = self
262 .repo
263 .workdir()
264 .context("Cannot run git command: repository has no working directory")?;
265
266 std::process::Command::new("git")
267 .current_dir(workdir)
268 .args(args)
269 .output()
270 .context("Failed to execute git command")
271 }
272
273 pub fn push_branch(&self, branch_name: &str, remote_name: &str) -> Result<()> {
275 info!(
276 "Pushing branch '{}' to remote '{}'",
277 branch_name, remote_name
278 );
279
280 debug!("Pushing via git CLI to '{}'", remote_name);
284 let output = self.run_git(&["push", "--set-upstream", remote_name, branch_name])?;
285
286 if output.status.success() {
287 info!(
288 "Successfully pushed branch '{}' to remote '{}'",
289 branch_name, remote_name
290 );
291 Ok(())
292 } else {
293 let stderr = String::from_utf8_lossy(&output.stderr);
294 let stderr = stderr.trim();
295 error!("Failed to push branch: {}", stderr);
296 anyhow::bail!(
297 "Failed to push branch '{branch_name}' to remote '{remote_name}': {stderr}"
298 )
299 }
300 }
301
302 pub fn branch_exists_on_remote(&self, branch_name: &str, remote_name: &str) -> Result<bool> {
304 debug!(
305 "Checking if branch '{}' exists on remote '{}'",
306 branch_name, remote_name
307 );
308
309 debug!("Listing remote refs via git CLI from '{}'", remote_name);
313 let output = self.run_git(&["ls-remote", "--heads", remote_name, branch_name])?;
314
315 if !output.status.success() {
316 let stderr = String::from_utf8_lossy(&output.stderr);
317 let stderr = stderr.trim();
318 error!("Failed to list remote refs: {}", stderr);
319 anyhow::bail!(
320 "Failed to check remote '{remote_name}' for branch '{branch_name}': {stderr}"
321 )
322 }
323
324 let remote_branch_ref = format!("refs/heads/{branch_name}");
329 let stdout = String::from_utf8_lossy(&output.stdout);
330 let exists = stdout
331 .lines()
332 .filter_map(|line| line.split('\t').nth(1))
333 .any(|reference| reference == remote_branch_ref);
334
335 if exists {
336 info!(
337 "Branch '{}' exists on remote '{}'",
338 branch_name, remote_name
339 );
340 } else {
341 info!(
342 "Branch '{}' does not exist on remote '{}'",
343 branch_name, remote_name
344 );
345 }
346 Ok(exists)
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
357 fn status_flags_new_index() {
358 let status = format_status_flags(Status::INDEX_NEW);
359 assert_eq!(status, "A ");
360 }
361
362 #[test]
363 fn status_flags_modified_index() {
364 let status = format_status_flags(Status::INDEX_MODIFIED);
365 assert_eq!(status, "M ");
366 }
367
368 #[test]
369 fn status_flags_deleted_index() {
370 let status = format_status_flags(Status::INDEX_DELETED);
371 assert_eq!(status, "D ");
372 }
373
374 #[test]
375 fn status_flags_wt_new() {
376 let status = format_status_flags(Status::WT_NEW);
377 assert_eq!(status, " ?");
378 }
379
380 #[test]
381 fn status_flags_wt_modified() {
382 let status = format_status_flags(Status::WT_MODIFIED);
383 assert_eq!(status, " M");
384 }
385
386 #[test]
387 fn status_flags_combined() {
388 let status = format_status_flags(Status::INDEX_NEW | Status::WT_MODIFIED);
389 assert_eq!(status, "AM");
390 }
391
392 #[test]
393 fn status_flags_empty() {
394 let status = format_status_flags(Status::empty());
395 assert_eq!(status, " ");
396 }
397
398 #[allow(clippy::unwrap_used)]
407 fn init_tmp_repo() -> tempfile::TempDir {
408 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
409 std::fs::create_dir_all(&tmp_root).unwrap();
410 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
411 git2::Repository::init(temp_dir.path()).unwrap();
412 temp_dir
413 }
414
415 #[test]
416 fn open_at_temp_repo() -> Result<()> {
417 let temp_dir = init_tmp_repo();
418 let repo = GitRepository::open_at(temp_dir.path())?;
419 assert!(repo.path().exists());
420 Ok(())
421 }
422
423 #[test]
424 fn working_directory_clean_empty_repo() -> Result<()> {
425 let temp_dir = init_tmp_repo();
426 let repo = GitRepository::open_at(temp_dir.path())?;
427 let status = repo.get_working_directory_status()?;
428 assert!(status.clean);
429 assert!(status.untracked_changes.is_empty());
430 Ok(())
431 }
432
433 #[test]
434 fn working_directory_dirty_with_file() -> Result<()> {
435 let temp_dir = init_tmp_repo();
436 std::fs::write(temp_dir.path().join("new_file.txt"), "content")?;
437 let repo = GitRepository::open_at(temp_dir.path())?;
438 let status = repo.get_working_directory_status()?;
439 assert!(!status.clean);
440 assert!(!status.untracked_changes.is_empty());
441 Ok(())
442 }
443
444 #[test]
445 fn is_working_directory_clean_delegator() -> Result<()> {
446 let temp_dir = init_tmp_repo();
447 let repo = GitRepository::open_at(temp_dir.path())?;
448 assert!(repo.is_working_directory_clean()?);
449 Ok(())
450 }
451
452 #[test]
453 fn current_branch_on_a_branch() -> Result<()> {
454 let temp_dir = init_tmp_repo();
455 let p = temp_dir.path();
456 std::fs::write(p.join("f.txt"), "x")?;
457 git_in(p, &["add", "."]);
458 git_in(p, &["commit", "-m", "init"]);
459 let repo = GitRepository::open_at(p)?;
460 assert_ne!(repo.get_current_branch()?, "HEAD");
463 Ok(())
464 }
465
466 #[test]
467 fn current_branch_errors_in_detached_head() -> Result<()> {
468 let temp_dir = init_tmp_repo();
471 let p = temp_dir.path();
472 std::fs::write(p.join("f.txt"), "x")?;
473 git_in(p, &["add", "."]);
474 git_in(p, &["commit", "-m", "init"]);
475 git_in(p, &["checkout", "--detach", "HEAD"]);
476 let repo = GitRepository::open_at(p)?;
477 let result = repo.get_current_branch();
478 assert!(
479 matches!(&result, Err(e) if e.to_string().contains("detached HEAD")),
480 "expected detached-HEAD error, got: {result:?}"
481 );
482 Ok(())
483 }
484
485 #[allow(clippy::unwrap_used)]
489 fn git_in(dir: &std::path::Path, args: &[&str]) {
490 let output = std::process::Command::new("git")
491 .current_dir(dir)
492 .args([
493 "-c",
494 "user.email=test@example.com",
495 "-c",
496 "user.name=Test",
497 "-c",
501 "commit.gpgsign=false",
502 "-c",
503 "tag.gpgsign=false",
504 ])
505 .args(args)
506 .output()
507 .unwrap();
508 let stderr = String::from_utf8_lossy(&output.stderr);
509 assert!(output.status.success(), "git {args:?} failed: {stderr}");
510 }
511
512 #[allow(clippy::unwrap_used)]
516 fn repo_with_bare_remote() -> (tempfile::TempDir, tempfile::TempDir, GitRepository) {
517 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
518 std::fs::create_dir_all(&tmp_root).unwrap();
519 let bare = tempfile::tempdir_in(&tmp_root).unwrap();
520 git_in(bare.path(), &["init", "--bare"]);
521
522 let work = init_tmp_repo();
523 std::fs::write(work.path().join("file.txt"), "content").unwrap();
524 git_in(work.path(), &["checkout", "-b", "feature-branch"]);
525 git_in(work.path(), &["add", "."]);
526 git_in(work.path(), &["commit", "-m", "initial"]);
527 git_in(
528 work.path(),
529 &["remote", "add", "origin", bare.path().to_str().unwrap()],
530 );
531
532 let repo = GitRepository::open_at(work.path()).unwrap();
533 (work, bare, repo)
534 }
535
536 #[test]
537 fn branch_absent_on_remote_before_push() -> Result<()> {
538 let (_work, _bare, repo) = repo_with_bare_remote();
539 assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
540 Ok(())
541 }
542
543 #[test]
544 fn push_branch_then_present_on_remote() -> Result<()> {
545 let (_work, _bare, repo) = repo_with_bare_remote();
546 repo.push_branch("feature-branch", "origin")?;
547 assert!(repo.branch_exists_on_remote("feature-branch", "origin")?);
548 assert!(!repo.branch_exists_on_remote("absent-branch", "origin")?);
549 Ok(())
550 }
551
552 #[test]
553 fn branch_exists_requires_exact_ref_match() -> Result<()> {
554 let (work, _bare, repo) = repo_with_bare_remote();
558 git_in(work.path(), &["checkout", "-b", "team/feature-branch"]);
559 repo.push_branch("team/feature-branch", "origin")?;
560 assert!(repo.branch_exists_on_remote("team/feature-branch", "origin")?);
561 assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
562 Ok(())
563 }
564
565 #[test]
566 fn push_branch_reports_failure_for_unknown_remote() {
567 let (_work, _bare, repo) = repo_with_bare_remote();
568 let result = repo.push_branch("feature-branch", "nonexistent");
569 assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to push branch")));
570 }
571
572 #[test]
573 fn branch_exists_reports_failure_for_unknown_remote() {
574 let (_work, _bare, repo) = repo_with_bare_remote();
575 let result = repo.branch_exists_on_remote("feature-branch", "nonexistent");
576 assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to check remote")));
577 }
578}