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() -> Result<Self> {
35 let repo = Repository::open(".").context("Not in a git repository")?;
36
37 Ok(Self { repo })
38 }
39
40 pub fn open_at<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
42 let repo = Repository::open(path).context("Failed to open git repository")?;
43
44 Ok(Self { repo })
45 }
46
47 pub fn get_working_directory_status(&self) -> Result<WorkingDirectoryStatus> {
49 let statuses = self
50 .repo
51 .statuses(None)
52 .context("Failed to get repository status")?;
53
54 let mut untracked_changes = Vec::new();
55
56 for entry in statuses.iter() {
57 if let Ok(path) = entry.path() {
58 let status_flags = entry.status();
59
60 if status_flags.contains(Status::IGNORED) {
62 continue;
63 }
64
65 let status_str = format_status_flags(status_flags);
66
67 untracked_changes.push(FileStatus {
68 status: status_str,
69 file: path.to_string(),
70 });
71 }
72 }
73
74 let clean = untracked_changes.is_empty();
75
76 Ok(WorkingDirectoryStatus {
77 clean,
78 untracked_changes,
79 })
80 }
81
82 pub fn is_working_directory_clean(&self) -> Result<bool> {
84 let status = self.get_working_directory_status()?;
85 Ok(status.clean)
86 }
87
88 pub fn path(&self) -> &std::path::Path {
90 self.repo.path()
91 }
92
93 pub fn workdir(&self) -> Option<&std::path::Path> {
95 self.repo.workdir()
96 }
97
98 pub fn repository(&self) -> &Repository {
100 &self.repo
101 }
102
103 pub fn get_current_branch(&self) -> Result<String> {
105 let head = self.repo.head().context("Failed to get HEAD reference")?;
106
107 if let Ok(name) = head.shorthand() {
108 if name != "HEAD" {
109 return Ok(name.to_string());
110 }
111 }
112
113 anyhow::bail!("Repository is in detached HEAD state")
114 }
115
116 pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
118 if self
120 .repo
121 .find_branch(branch_name, git2::BranchType::Local)
122 .is_ok()
123 {
124 return Ok(true);
125 }
126
127 if self
129 .repo
130 .find_branch(branch_name, git2::BranchType::Remote)
131 .is_ok()
132 {
133 return Ok(true);
134 }
135
136 if self.repo.revparse_single(branch_name).is_ok() {
138 return Ok(true);
139 }
140
141 Ok(false)
142 }
143
144 pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
146 let mut commits = Vec::new();
147
148 if range == "HEAD" {
149 let head = self.repo.head().context("Failed to get HEAD")?;
151 let commit = head
152 .peel_to_commit()
153 .context("Failed to peel HEAD to commit")?;
154 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
155 } else if range.contains("..") {
156 let parts: Vec<&str> = range.split("..").collect();
158 if parts.len() != 2 {
159 anyhow::bail!("Invalid range format: {range}");
160 }
161
162 let start_spec = parts[0];
163 let end_spec = parts[1];
164
165 let start_obj = self
167 .repo
168 .revparse_single(start_spec)
169 .with_context(|| format!("Failed to parse start commit: {start_spec}"))?;
170 let end_obj = self
171 .repo
172 .revparse_single(end_spec)
173 .with_context(|| format!("Failed to parse end commit: {end_spec}"))?;
174
175 let start_commit = start_obj
176 .peel_to_commit()
177 .context("Failed to peel start object to commit")?;
178 let end_commit = end_obj
179 .peel_to_commit()
180 .context("Failed to peel end object to commit")?;
181
182 let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
184 walker
185 .push(end_commit.id())
186 .context("Failed to push end commit")?;
187 walker
188 .hide(start_commit.id())
189 .context("Failed to hide start commit")?;
190
191 for oid in walker {
192 let oid = oid.context("Failed to get commit OID from walker")?;
193 let commit = self
194 .repo
195 .find_commit(oid)
196 .context("Failed to find commit")?;
197
198 if commit.parent_count() > 1 {
200 continue;
201 }
202
203 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
204 }
205
206 commits.reverse();
208 } else {
209 let obj = self
211 .repo
212 .revparse_single(range)
213 .with_context(|| format!("Failed to parse commit: {range}"))?;
214 let commit = obj
215 .peel_to_commit()
216 .context("Failed to peel object to commit")?;
217 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
218 }
219
220 Ok(commits)
221 }
222}
223
224fn format_status_flags(flags: Status) -> String {
226 let mut status = String::new();
227
228 if flags.contains(Status::INDEX_NEW) {
229 status.push('A');
230 } else if flags.contains(Status::INDEX_MODIFIED) {
231 status.push('M');
232 } else if flags.contains(Status::INDEX_DELETED) {
233 status.push('D');
234 } else if flags.contains(Status::INDEX_RENAMED) {
235 status.push('R');
236 } else if flags.contains(Status::INDEX_TYPECHANGE) {
237 status.push('T');
238 } else {
239 status.push(' ');
240 }
241
242 if flags.contains(Status::WT_NEW) {
243 status.push('?');
244 } else if flags.contains(Status::WT_MODIFIED) {
245 status.push('M');
246 } else if flags.contains(Status::WT_DELETED) {
247 status.push('D');
248 } else if flags.contains(Status::WT_TYPECHANGE) {
249 status.push('T');
250 } else if flags.contains(Status::WT_RENAMED) {
251 status.push('R');
252 } else {
253 status.push(' ');
254 }
255
256 status
257}
258
259impl GitRepository {
260 fn run_git(&self, args: &[&str]) -> Result<std::process::Output> {
268 let workdir = self
269 .repo
270 .workdir()
271 .context("Cannot run git command: repository has no working directory")?;
272
273 std::process::Command::new("git")
274 .current_dir(workdir)
275 .args(args)
276 .output()
277 .context("Failed to execute git command")
278 }
279
280 pub fn push_branch(&self, branch_name: &str, remote_name: &str) -> Result<()> {
282 info!(
283 "Pushing branch '{}' to remote '{}'",
284 branch_name, remote_name
285 );
286
287 debug!("Pushing via git CLI to '{}'", remote_name);
291 let output = self.run_git(&["push", "--set-upstream", remote_name, branch_name])?;
292
293 if output.status.success() {
294 info!(
295 "Successfully pushed branch '{}' to remote '{}'",
296 branch_name, remote_name
297 );
298 Ok(())
299 } else {
300 let stderr = String::from_utf8_lossy(&output.stderr);
301 let stderr = stderr.trim();
302 error!("Failed to push branch: {}", stderr);
303 anyhow::bail!(
304 "Failed to push branch '{branch_name}' to remote '{remote_name}': {stderr}"
305 )
306 }
307 }
308
309 pub fn branch_exists_on_remote(&self, branch_name: &str, remote_name: &str) -> Result<bool> {
311 debug!(
312 "Checking if branch '{}' exists on remote '{}'",
313 branch_name, remote_name
314 );
315
316 debug!("Listing remote refs via git CLI from '{}'", remote_name);
320 let output = self.run_git(&["ls-remote", "--heads", remote_name, branch_name])?;
321
322 if !output.status.success() {
323 let stderr = String::from_utf8_lossy(&output.stderr);
324 let stderr = stderr.trim();
325 error!("Failed to list remote refs: {}", stderr);
326 anyhow::bail!(
327 "Failed to check remote '{remote_name}' for branch '{branch_name}': {stderr}"
328 )
329 }
330
331 let remote_branch_ref = format!("refs/heads/{branch_name}");
336 let stdout = String::from_utf8_lossy(&output.stdout);
337 let exists = stdout
338 .lines()
339 .filter_map(|line| line.split('\t').nth(1))
340 .any(|reference| reference == remote_branch_ref);
341
342 if exists {
343 info!(
344 "Branch '{}' exists on remote '{}'",
345 branch_name, remote_name
346 );
347 } else {
348 info!(
349 "Branch '{}' does not exist on remote '{}'",
350 branch_name, remote_name
351 );
352 }
353 Ok(exists)
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
364 fn status_flags_new_index() {
365 let status = format_status_flags(Status::INDEX_NEW);
366 assert_eq!(status, "A ");
367 }
368
369 #[test]
370 fn status_flags_modified_index() {
371 let status = format_status_flags(Status::INDEX_MODIFIED);
372 assert_eq!(status, "M ");
373 }
374
375 #[test]
376 fn status_flags_deleted_index() {
377 let status = format_status_flags(Status::INDEX_DELETED);
378 assert_eq!(status, "D ");
379 }
380
381 #[test]
382 fn status_flags_wt_new() {
383 let status = format_status_flags(Status::WT_NEW);
384 assert_eq!(status, " ?");
385 }
386
387 #[test]
388 fn status_flags_wt_modified() {
389 let status = format_status_flags(Status::WT_MODIFIED);
390 assert_eq!(status, " M");
391 }
392
393 #[test]
394 fn status_flags_combined() {
395 let status = format_status_flags(Status::INDEX_NEW | Status::WT_MODIFIED);
396 assert_eq!(status, "AM");
397 }
398
399 #[test]
400 fn status_flags_empty() {
401 let status = format_status_flags(Status::empty());
402 assert_eq!(status, " ");
403 }
404
405 #[allow(clippy::unwrap_used)]
414 fn init_tmp_repo() -> tempfile::TempDir {
415 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
416 std::fs::create_dir_all(&tmp_root).unwrap();
417 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
418 git2::Repository::init(temp_dir.path()).unwrap();
419 temp_dir
420 }
421
422 #[test]
423 fn open_at_temp_repo() -> Result<()> {
424 let temp_dir = init_tmp_repo();
425 let repo = GitRepository::open_at(temp_dir.path())?;
426 assert!(repo.path().exists());
427 Ok(())
428 }
429
430 #[test]
431 fn working_directory_clean_empty_repo() -> Result<()> {
432 let temp_dir = init_tmp_repo();
433 let repo = GitRepository::open_at(temp_dir.path())?;
434 let status = repo.get_working_directory_status()?;
435 assert!(status.clean);
436 assert!(status.untracked_changes.is_empty());
437 Ok(())
438 }
439
440 #[test]
441 fn working_directory_dirty_with_file() -> Result<()> {
442 let temp_dir = init_tmp_repo();
443 std::fs::write(temp_dir.path().join("new_file.txt"), "content")?;
444 let repo = GitRepository::open_at(temp_dir.path())?;
445 let status = repo.get_working_directory_status()?;
446 assert!(!status.clean);
447 assert!(!status.untracked_changes.is_empty());
448 Ok(())
449 }
450
451 #[test]
452 fn is_working_directory_clean_delegator() -> Result<()> {
453 let temp_dir = init_tmp_repo();
454 let repo = GitRepository::open_at(temp_dir.path())?;
455 assert!(repo.is_working_directory_clean()?);
456 Ok(())
457 }
458
459 #[allow(clippy::unwrap_used)]
463 fn git_in(dir: &std::path::Path, args: &[&str]) {
464 let output = std::process::Command::new("git")
465 .current_dir(dir)
466 .args([
467 "-c",
468 "user.email=test@example.com",
469 "-c",
470 "user.name=Test",
471 "-c",
475 "commit.gpgsign=false",
476 "-c",
477 "tag.gpgsign=false",
478 ])
479 .args(args)
480 .output()
481 .unwrap();
482 let stderr = String::from_utf8_lossy(&output.stderr);
483 assert!(output.status.success(), "git {args:?} failed: {stderr}");
484 }
485
486 #[allow(clippy::unwrap_used)]
490 fn repo_with_bare_remote() -> (tempfile::TempDir, tempfile::TempDir, GitRepository) {
491 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
492 std::fs::create_dir_all(&tmp_root).unwrap();
493 let bare = tempfile::tempdir_in(&tmp_root).unwrap();
494 git_in(bare.path(), &["init", "--bare"]);
495
496 let work = init_tmp_repo();
497 std::fs::write(work.path().join("file.txt"), "content").unwrap();
498 git_in(work.path(), &["checkout", "-b", "feature-branch"]);
499 git_in(work.path(), &["add", "."]);
500 git_in(work.path(), &["commit", "-m", "initial"]);
501 git_in(
502 work.path(),
503 &["remote", "add", "origin", bare.path().to_str().unwrap()],
504 );
505
506 let repo = GitRepository::open_at(work.path()).unwrap();
507 (work, bare, repo)
508 }
509
510 #[test]
511 fn branch_absent_on_remote_before_push() -> Result<()> {
512 let (_work, _bare, repo) = repo_with_bare_remote();
513 assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
514 Ok(())
515 }
516
517 #[test]
518 fn push_branch_then_present_on_remote() -> Result<()> {
519 let (_work, _bare, repo) = repo_with_bare_remote();
520 repo.push_branch("feature-branch", "origin")?;
521 assert!(repo.branch_exists_on_remote("feature-branch", "origin")?);
522 assert!(!repo.branch_exists_on_remote("absent-branch", "origin")?);
523 Ok(())
524 }
525
526 #[test]
527 fn branch_exists_requires_exact_ref_match() -> Result<()> {
528 let (work, _bare, repo) = repo_with_bare_remote();
532 git_in(work.path(), &["checkout", "-b", "team/feature-branch"]);
533 repo.push_branch("team/feature-branch", "origin")?;
534 assert!(repo.branch_exists_on_remote("team/feature-branch", "origin")?);
535 assert!(!repo.branch_exists_on_remote("feature-branch", "origin")?);
536 Ok(())
537 }
538
539 #[test]
540 fn push_branch_reports_failure_for_unknown_remote() {
541 let (_work, _bare, repo) = repo_with_bare_remote();
542 let result = repo.push_branch("feature-branch", "nonexistent");
543 assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to push branch")));
544 }
545
546 #[test]
547 fn branch_exists_reports_failure_for_unknown_remote() {
548 let (_work, _bare, repo) = repo_with_bare_remote();
549 let result = repo.branch_exists_on_remote("feature-branch", "nonexistent");
550 assert!(matches!(&result, Err(e) if e.to_string().contains("Failed to check remote")));
551 }
552}