1use std::{
2 collections::{BTreeMap, HashMap},
3 io::Write as _,
4 path::{Path, PathBuf},
5 process::{Command, Output, Stdio},
6};
7
8use git2::{
9 DiffFormat, DiffOptions, IndexAddOption, ObjectType, Oid, Repository, Sort, Status,
10 StatusEntry, StatusOptions,
11};
12
13use crate::engine::{
14 Error, ErrorCode,
15 models::git::{Change, CommitSummary, FileStatus},
16};
17
18#[derive(Debug, Clone)]
22pub struct TaggedCommits {
23 pub tag: String,
24 pub commits: Vec<CommitSummary>,
25}
26
27#[derive(Debug, Clone)]
28pub struct Git {
29 cwd: PathBuf,
30}
31
32impl Git {
33 pub fn new(cwd: impl Into<PathBuf>) -> Self {
34 Self { cwd: cwd.into() }
35 }
36
37 pub fn is_installed(&self) -> bool {
38 self.run(["--version"])
39 .is_some_and(|output| output.status.success())
40 }
41
42 pub fn is_inside_work_tree(&self) -> bool {
43 Repository::discover(&self.cwd).is_ok()
44 }
45
46 pub fn repo_root(&self) -> Option<PathBuf> {
47 Repository::discover(&self.cwd).ok().and_then(|repo| {
48 repo.workdir().and_then(|p| std::fs::canonicalize(p).ok())
51 })
52 }
53
54 pub fn commit(&self, message: &str, allow_empty: bool) -> Result<(), Error> {
55 let mut cmd = Command::new("git");
56 cmd.current_dir(&self.cwd);
57 cmd.arg("commit");
58
59 if allow_empty {
60 cmd.arg("--allow-empty");
61 }
62
63 cmd.arg("-F").arg("-");
64
65 let mut child = cmd
66 .stdin(Stdio::piped())
67 .stdout(Stdio::inherit())
68 .stderr(Stdio::inherit())
69 .spawn()
70 .map_err(|err| {
71 ErrorCode::ProcessFailure
72 .error()
73 .with_context("command", "git commit -F -")
74 .with_context("cwd", self.cwd.display().to_string())
75 .with_context("error", err.to_string())
76 })?;
77
78 if let Some(stdin) = child.stdin.as_mut() {
79 stdin.write_all(message.as_bytes()).map_err(|err| {
80 ErrorCode::ProcessFailure
81 .error()
82 .with_context("command", "git commit -F -")
83 .with_context("cwd", self.cwd.display().to_string())
84 .with_context("error", err.to_string())
85 })?;
86 }
87
88 let status = child.wait().map_err(|err| {
89 ErrorCode::ProcessFailure
90 .error()
91 .with_context("command", "git commit -F -")
92 .with_context("cwd", self.cwd.display().to_string())
93 .with_context("error", err.to_string())
94 })?;
95
96 if status.success() {
97 Ok(())
98 } else {
99 Err(ErrorCode::ProcessFailure
100 .error()
101 .with_context("command", "git commit -F -")
102 .with_context("cwd", self.cwd.display().to_string())
103 .with_context("status", status.to_string()))
104 }
105 }
106
107 pub fn list_commits(&self, from: Option<&str>, to: &str) -> Result<Vec<CommitSummary>, Error> {
108 let repo = self.repo()?;
109 let to_oid = self.revparse_oid(&repo, to)?;
110 let from_oid = match from {
111 Some(value) => Some(self.revparse_oid(&repo, value)?),
112 None => None,
113 };
114
115 let mut revwalk = repo
116 .revwalk()
117 .map_err(|err| self.git2_error("revwalk", err))?;
118 revwalk
119 .push(to_oid)
120 .map_err(|err| self.git2_error("revwalk.push", err))?;
121
122 if let Some(oid) = from_oid {
123 revwalk
124 .hide(oid)
125 .map_err(|err| self.git2_error("revwalk.hide", err))?;
126 }
127
128 revwalk
129 .set_sorting(Sort::TOPOLOGICAL | Sort::TIME)
130 .map_err(|err| self.git2_error("revwalk.set_sorting", err))?;
131
132 let mut out = Vec::new();
133 for oid in revwalk {
134 let oid = oid.map_err(|err| self.git2_error("revwalk.next", err))?;
135 let commit = repo
136 .find_commit(oid)
137 .map_err(|err| self.git2_error("find_commit", err))?;
138
139 out.push(CommitSummary {
140 hash: commit.id().to_string(),
141 summary: commit.summary().unwrap_or("").to_string(),
142 full_message: commit.message().map(|m| m.to_string()),
143 });
144 }
145
146 Ok(out)
147 }
148
149 pub fn latest_tag(&self) -> Result<Option<String>, Error> {
150 let repo = self.repo()?;
151 let tags = repo
152 .tag_names(None)
153 .map_err(|err| self.git2_error("tag_names", err))?;
154
155 let mut latest: Option<(String, git2::Time)> = None;
156
157 for tag_name in tags.iter().flatten() {
158 let tag_ref = format!("refs/tags/{tag_name}");
159 let Ok(reference) = repo.find_reference(&tag_ref) else {
160 continue;
161 };
162 let Ok(obj) = reference.peel(ObjectType::Commit) else {
163 continue;
164 };
165 let Some(commit) = obj.as_commit() else {
166 continue;
167 };
168
169 let time = commit.time();
170
171 match &latest {
172 Some((_, prev)) if time.seconds() <= prev.seconds() => {}
173 _ => latest = Some((tag_name.to_string(), time)),
174 }
175 }
176
177 Ok(latest.map(|(name, _)| name))
178 }
179
180 pub fn tag_date(&self, tag: &str) -> Result<Option<String>, Error> {
181 let repo = self.repo()?;
182 let reference = match repo.find_reference(&format!("refs/tags/{tag}")) {
183 Ok(reference) => reference,
184 Err(_) => return Ok(None),
185 };
186
187 let obj = match reference.peel(ObjectType::Commit) {
188 Ok(obj) => obj,
189 Err(_) => return Ok(None),
190 };
191
192 let Some(commit) = obj.as_commit() else {
193 return Ok(None);
194 };
195
196 let ts = commit.time().seconds();
197 let Some(dt) = chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0) else {
198 return Ok(None);
199 };
200
201 Ok(Some(dt.format("%Y-%m-%d").to_string()))
202 }
203
204 pub fn create_tag(&self, name: &str, message: &str) -> Result<(), Error> {
205 let repo = self.repo()?;
206 let obj = repo
207 .revparse_single("HEAD")
208 .map_err(|err| self.git2_error("revparse_single HEAD", err))?;
209 let sig = repo
210 .signature()
211 .map_err(|err| self.git2_error("signature", err))?;
212
213 repo.tag(name, &obj, &sig, message, false)
214 .map_err(|err| self.git2_error("tag", err))?;
215
216 Ok(())
217 }
218
219 pub fn create_signed_tag(&self, name: &str, message: &str) -> Result<(), Error> {
220 let output = self
221 .run_dynamic(["tag", "-s", "-m", message, name])
222 .ok_or_else(|| {
223 ErrorCode::ProcessFailure
224 .error()
225 .with_context("command", "git tag -s")
226 .with_context("cwd", self.cwd.display().to_string())
227 })?;
228
229 self.ensure_success("git tag -s", output)
230 }
231
232 pub fn create_branch(&self, name: &str, from: Option<&str>) -> Result<(), Error> {
233 let repo = self.repo()?;
234 let from_ref = from.unwrap_or("HEAD");
235 let target_oid = self.revparse_oid(&repo, from_ref)?;
236 let commit = repo
237 .find_commit(target_oid)
238 .map_err(|err| self.git2_error("find_commit", err))?;
239 repo.branch(name, &commit, false)
240 .map_err(|err| self.git2_error("branch", err))?;
241 Ok(())
242 }
243
244 pub fn checkout_branch(&self, name: &str) -> Result<(), Error> {
245 let output = self.run_dynamic(["checkout", name]).ok_or_else(|| {
246 ErrorCode::ProcessFailure
247 .error()
248 .with_context("command", "git checkout <branch>")
249 .with_context("cwd", self.cwd.display().to_string())
250 })?;
251 self.ensure_success("git checkout <branch>", output)
252 }
253
254 pub fn list_branches(&self) -> Result<Vec<String>, Error> {
255 let repo = self.repo()?;
256 let mut names = Vec::new();
257 for branch in repo
258 .branches(Some(git2::BranchType::Local))
259 .map_err(|err| self.git2_error("branches", err))?
260 {
261 let (b, _) = branch.map_err(|err| self.git2_error("branch_iter", err))?;
262 if let Some(name) = b
263 .name()
264 .map_err(|err| self.git2_error("branch.name", err))?
265 {
266 names.push(name.to_string());
267 }
268 }
269 Ok(names)
270 }
271
272 pub fn rename_branch(&self, old_name: &str, new_name: &str) -> Result<(), Error> {
273 let output = self
274 .run_dynamic(["branch", "-m", old_name, new_name])
275 .ok_or_else(|| {
276 ErrorCode::ProcessFailure
277 .error()
278 .with_context("command", "git branch -m <old> <new>")
279 .with_context("cwd", self.cwd.display().to_string())
280 })?;
281 self.ensure_success("git branch -m <old> <new>", output)
282 }
283
284 pub fn delete_branch(&self, name: &str, force: bool) -> Result<(), Error> {
285 let flag = if force { "-D" } else { "-d" };
286 let output = self.run_dynamic(["branch", flag, name]).ok_or_else(|| {
287 ErrorCode::ProcessFailure
288 .error()
289 .with_context("command", format!("git branch {} <name>", flag))
290 .with_context("cwd", self.cwd.display().to_string())
291 })?;
292 self.ensure_success(&format!("git branch {} <name>", flag), output)
293 }
294
295 pub fn fetch(&self, remote: &str) -> Result<(), Error> {
296 let output = self.run_dynamic(["fetch", remote]).ok_or_else(|| {
297 ErrorCode::ProcessFailure
298 .error()
299 .with_context("command", "git fetch <remote>")
300 .with_context("cwd", self.cwd.display().to_string())
301 })?;
302 self.ensure_success("git fetch <remote>", output)
303 }
304
305 pub fn rebase(&self, onto: &str) -> Result<(), Error> {
306 let output = self.run_dynamic(["rebase", onto]).ok_or_else(|| {
307 ErrorCode::ProcessFailure
308 .error()
309 .with_context("command", "git rebase <onto>")
310 .with_context("cwd", self.cwd.display().to_string())
311 })?;
312 self.ensure_success("git rebase <onto>", output)
313 }
314
315 pub fn merge(&self, branch: &str) -> Result<(), Error> {
316 let output = self.run_dynamic(["merge", branch]).ok_or_else(|| {
317 ErrorCode::ProcessFailure
318 .error()
319 .with_context("command", "git merge <branch>")
320 .with_context("cwd", self.cwd.display().to_string())
321 })?;
322 self.ensure_success("git merge <branch>", output)
323 }
324
325 pub fn prune_remote_tracking(&self, remote: &str) -> Result<(), Error> {
326 let output = self
327 .run_dynamic(["remote", "prune", remote])
328 .ok_or_else(|| {
329 ErrorCode::ProcessFailure
330 .error()
331 .with_context("command", "git remote prune <remote>")
332 .with_context("cwd", self.cwd.display().to_string())
333 })?;
334 self.ensure_success("git remote prune <remote>", output)
335 }
336
337 pub fn is_branch_merged(&self, name: &str) -> Result<bool, Error> {
338 let output = self.run_dynamic(["branch", "--merged"]).ok_or_else(|| {
339 ErrorCode::ProcessFailure
340 .error()
341 .with_context("command", "git branch --merged")
342 .with_context("cwd", self.cwd.display().to_string())
343 })?;
344 if !output.status.success() {
345 return Ok(false);
346 }
347 let stdout = String::from_utf8_lossy(&output.stdout);
348 Ok(stdout
349 .lines()
350 .any(|line| line.trim().trim_start_matches('*').trim() == name))
351 }
352
353 pub fn push_branch(&self, remote: &str, branch: &str) -> Result<(), Error> {
354 let output = self.run_dynamic(["push", remote, branch]).ok_or_else(|| {
355 ErrorCode::ProcessFailure
356 .error()
357 .with_context("command", "git push <remote> <branch>")
358 .with_context("cwd", self.cwd.display().to_string())
359 })?;
360
361 self.ensure_success("git push <remote> <branch>", output)
362 }
363
364 pub fn push_tag(&self, remote: &str, tag: &str) -> Result<(), Error> {
365 let output = self.run_dynamic(["push", remote, tag]).ok_or_else(|| {
366 ErrorCode::ProcessFailure
367 .error()
368 .with_context("command", "git push <remote> <tag>")
369 .with_context("cwd", self.cwd.display().to_string())
370 })?;
371
372 self.ensure_success("git push <remote> <tag>", output)
373 }
374
375 pub fn stage_path(&self, path: &str) -> Result<(), Error> {
376 self.stage_paths(&[path.to_string()])
377 }
378
379 pub fn unstage_path(&self, path: &str) -> Result<(), Error> {
380 self.unstage_paths(&[path.to_string()])
381 }
382
383 pub fn list_changes(&self) -> Result<Vec<Change>, Error> {
384 let repo = self.repo()?;
385 let mut opts = StatusOptions::new();
386 opts.include_untracked(true)
387 .include_ignored(false)
388 .include_unmodified(false)
389 .renames_head_to_index(true)
390 .renames_index_to_workdir(true)
391 .renames_from_rewrites(true)
392 .recurse_untracked_dirs(true)
393 .show(git2::StatusShow::IndexAndWorkdir);
394
395 let statuses = repo
396 .statuses(Some(&mut opts))
397 .map_err(|err| self.git2_error("statuses", err))?;
398
399 let mut seen: BTreeMap<String, Change> = BTreeMap::new();
400
401 for entry in statuses.iter() {
402 let st = entry.status();
403 let staged = st.is_index_new()
404 || st.is_index_modified()
405 || st.is_index_deleted()
406 || st.is_index_renamed()
407 || st.is_index_typechange();
408
409 let status = Self::map_status(&entry);
410 let path = match &status {
411 FileStatus::Renamed { new, .. } => new.clone(),
412 _ => Self::best_new_path(&entry)
413 .unwrap_or_else(|| entry.path().unwrap_or_default().to_string()),
414 };
415
416 seen.entry(path.clone()).or_insert(Change {
417 path,
418 staged,
419 status,
420 });
421 }
422
423 Ok(seen.into_values().collect())
424 }
425
426 pub fn list_tags_sorted(&self) -> Result<Vec<String>, Error> {
427 let output = self
428 .run(["tag", "--list", "--sort=creatordate"])
429 .ok_or_else(|| {
430 ErrorCode::ProcessFailure
431 .error()
432 .with_context("command", "git tag --list --sort=creatordate")
433 .with_context("cwd", self.cwd.display().to_string())
434 })?;
435
436 if !output.status.success() {
437 return Err(ErrorCode::ProcessFailure
438 .error()
439 .with_context("command", "git tag --list --sort=creatordate")
440 .with_context("cwd", self.cwd.display().to_string())
441 .with_context("status", output.status.to_string())
442 .with_context(
443 "stderr",
444 String::from_utf8_lossy(&output.stderr).trim().to_string(),
445 ));
446 }
447
448 Ok(String::from_utf8_lossy(&output.stdout)
449 .lines()
450 .map(|s| s.trim().to_string())
451 .filter(|s| !s.is_empty())
452 .collect())
453 }
454
455 pub fn latest_tag_ancestor_of(&self, rev: &str) -> Result<Option<String>, Error> {
456 let output = self
457 .run_dynamic(["describe", "--tags", "--abbrev=0", rev])
458 .ok_or_else(|| {
459 ErrorCode::ProcessFailure
460 .error()
461 .with_context("command", "git describe --tags --abbrev=0 <rev>")
462 .with_context("cwd", self.cwd.display().to_string())
463 })?;
464
465 if !output.status.success() {
466 return Ok(None);
467 }
468
469 let tag = String::from_utf8_lossy(&output.stdout).trim().to_string();
470 Ok(if tag.is_empty() { None } else { Some(tag) })
471 }
472
473 pub fn commits_by_tag(&self, to: &str) -> Result<Vec<TaggedCommits>, Error> {
482 let tags = self.list_tags_sorted().unwrap_or_default();
483 let mut result: Vec<TaggedCommits> = Vec::new();
484
485 let unreleased = self.list_commits(tags.last().map(|s| s.as_str()), to)?;
487 if !unreleased.is_empty() {
488 result.push(TaggedCommits {
489 tag: "Unreleased".to_string(),
490 commits: unreleased,
491 });
492 }
493
494 for (idx, tag) in tags.iter().enumerate().rev() {
496 let prev = if idx > 0 {
497 Some(tags[idx - 1].as_str())
498 } else {
499 None
500 };
501 let commits = self.list_commits(prev, tag)?;
502 if !commits.is_empty() {
503 result.push(TaggedCommits {
504 tag: tag.clone(),
505 commits,
506 });
507 }
508 }
509
510 Ok(result)
511 }
512
513 pub fn stage_all(&self) -> Result<(), Error> {
514 let repo = self.repo()?;
515 let mut index = repo.index().map_err(|err| self.git2_error("index", err))?;
516 index
517 .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
518 .map_err(|err| self.git2_error("index.add_all", err))?;
519 index
520 .write()
521 .map_err(|err| self.git2_error("index.write", err))?;
522 Ok(())
523 }
524
525 pub fn unstage_all(&self) -> Result<(), Error> {
526 let repo = self.repo()?;
527 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
528 let mut index = repo.index().map_err(|err| self.git2_error("index", err))?;
529
530 if let Some(tree) = head_tree {
531 index
532 .read_tree(&tree)
533 .map_err(|err| self.git2_error("index.read_tree", err))?;
534 } else {
535 index
536 .clear()
537 .map_err(|err| self.git2_error("index.clear", err))?;
538 }
539
540 index
541 .write()
542 .map_err(|err| self.git2_error("index.write", err))?;
543
544 Ok(())
545 }
546
547 pub fn stage_paths(&self, paths: &[String]) -> Result<(), Error> {
548 if paths.is_empty() {
549 return Ok(());
550 }
551
552 let repo = self.repo()?;
553 let mut index = repo.index().map_err(|err| self.git2_error("index", err))?;
554
555 let mut opts = StatusOptions::new();
556 opts.include_untracked(true)
557 .include_ignored(false)
558 .include_unmodified(false)
559 .renames_head_to_index(true)
560 .renames_index_to_workdir(true)
561 .renames_from_rewrites(true)
562 .recurse_untracked_dirs(true)
563 .show(git2::StatusShow::IndexAndWorkdir);
564
565 let statuses = repo
566 .statuses(Some(&mut opts))
567 .map_err(|err| self.git2_error("statuses", err))?;
568
569 let mut info: HashMap<String, (Status, Option<(String, String)>)> = HashMap::new();
570
571 for entry in statuses.iter() {
572 let st = entry.status();
573 let path = Self::best_new_path(&entry)
574 .or_else(|| entry.path().map(|s| s.to_string()))
575 .unwrap_or_default();
576
577 let renamed = if st.intersects(Status::INDEX_RENAMED | Status::WT_RENAMED) {
578 Self::renamed_paths(&entry)
579 } else {
580 None
581 };
582
583 info.insert(path, (st, renamed));
584 }
585
586 for p in paths {
587 let path = Path::new(p);
588
589 if path.is_dir() {
590 index
591 .add_all([p.as_str()].iter(), IndexAddOption::DEFAULT, None)
592 .map_err(|err| self.git2_error("index.add_all(dir)", err))?;
593 continue;
594 }
595
596 if path.exists() {
597 index
598 .add_path(path)
599 .map_err(|err| self.git2_error("index.add_path", err))?;
600 continue;
601 }
602
603 match info.get(p) {
604 Some((st, renamed)) => {
605 if st.intersects(Status::WT_DELETED | Status::INDEX_DELETED) {
606 index
607 .remove_path(path)
608 .map_err(|err| self.git2_error("index.remove_path", err))?;
609 continue;
610 }
611
612 if let Some((old, new)) = renamed {
613 let new_path = Path::new(new);
614
615 if new_path.is_dir() {
616 index
617 .add_all([new.as_str()].iter(), IndexAddOption::DEFAULT, None)
618 .map_err(|err| self.git2_error("index.add_all(rename dir)", err))?;
619 } else if new_path.exists() {
620 index.add_path(new_path).map_err(|err| {
621 self.git2_error("index.add_path(rename new)", err)
622 })?;
623 } else {
624 index
625 .add_all([new.as_str()].iter(), IndexAddOption::DEFAULT, None)
626 .map_err(|err| {
627 self.git2_error("index.add_all(rename fallback)", err)
628 })?;
629 }
630
631 index
632 .remove_path(Path::new(old))
633 .map_err(|err| self.git2_error("index.remove_path(rename old)", err))?;
634 continue;
635 }
636
637 index
638 .add_all([p.as_str()].iter(), IndexAddOption::DEFAULT, None)
639 .map_err(|err| self.git2_error("index.add_all(fallback)", err))?;
640 }
641 None => {
642 index
643 .add_all([p.as_str()].iter(), IndexAddOption::DEFAULT, None)
644 .map_err(|err| self.git2_error("index.add_all(missing)", err))?;
645 }
646 }
647 }
648
649 index
650 .write()
651 .map_err(|err| self.git2_error("index.write", err))?;
652
653 Ok(())
654 }
655
656 pub fn unstage_paths(&self, paths: &[String]) -> Result<(), Error> {
657 if paths.is_empty() {
658 return Ok(());
659 }
660
661 let repo = self.repo()?;
662 let head_exists = repo.head().is_ok();
663
664 if head_exists {
665 let mut chunk: Vec<&str> = Vec::new();
666 const CHUNK_SIZE: usize = 200;
667
668 for path in paths {
669 chunk.push(path.as_str());
670
671 if chunk.len() >= CHUNK_SIZE {
672 self.git_reset_head_paths(&chunk)?;
673 chunk.clear();
674 }
675 }
676
677 if !chunk.is_empty() {
678 self.git_reset_head_paths(&chunk)?;
679 }
680
681 Ok(())
682 } else {
683 let mut index = repo.index().map_err(|err| self.git2_error("index", err))?;
684
685 for path in paths {
686 index
687 .remove_all([path.as_str()].iter(), None)
688 .map_err(|err| {
689 ErrorCode::ProcessFailure
690 .error()
691 .with_context("command", "git rm --cached / index.remove_all")
692 .with_context("cwd", self.cwd.display().to_string())
693 .with_context("path", path.clone())
694 .with_context("error", err.to_string())
695 })?;
696 }
697
698 index
699 .write()
700 .map_err(|err| self.git2_error("index.write", err))?;
701
702 Ok(())
703 }
704 }
705
706 pub fn diff_unstaged(&self, path: &str) -> Result<Option<String>, Error> {
707 let repo = self.repo()?;
708 let mut opts = DiffOptions::new();
709 opts.pathspec(path);
710
711 let diff = repo
712 .diff_index_to_workdir(None, Some(&mut opts))
713 .map_err(|err| self.git2_error("diff_index_to_workdir", err))?;
714
715 if diff.deltas().len() == 0 {
716 return Ok(None);
717 }
718
719 let mut buf = String::new();
720 diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
721 let origin = line.origin();
722 let text = std::str::from_utf8(line.content()).unwrap_or("");
723
724 match origin {
725 '+' | '-' | ' ' => {
726 buf.push(origin);
727 buf.push_str(text);
728 }
729 _ => buf.push_str(text),
730 }
731
732 true
733 })
734 .map_err(|err| self.git2_error("diff.print", err))?;
735
736 Ok(Some(buf))
737 }
738
739 pub fn show_unstaged_diff(&self, path: &str) -> Result<String, Error> {
740 Ok(self.diff_unstaged(path)?.unwrap_or_default())
741 }
742
743 pub fn github_web_url(&self) -> Result<Option<String>, Error> {
744 let repo = self.repo()?;
745 let remotes = repo
746 .remotes()
747 .map_err(|err| self.git2_error("remotes", err))?;
748
749 let remote = remotes.iter().flatten().find(|r| *r == "origin");
750 let Some(remote) = remote else {
751 return Ok(None);
752 };
753
754 let remote_obj = repo
755 .find_remote(remote)
756 .map_err(|err| self.git2_error("find_remote", err))?;
757 let Some(url) = remote_obj.url() else {
758 return Ok(None);
759 };
760
761 let https = if url.starts_with("git@github.com:") {
762 url.replacen("git@github.com:", "https://github.com/", 1)
763 .trim_end_matches(".git")
764 .to_string()
765 } else if url.starts_with("https://github.com/") {
766 url.trim_end_matches(".git").to_string()
767 } else {
768 return Ok(None);
769 };
770
771 Ok(Some(https))
772 }
773
774 pub fn current_branch(&self) -> Result<String, Error> {
775 let repo = self.repo()?;
776 let head = repo.head().map_err(|err| {
777 ErrorCode::ProcessFailure
778 .error()
779 .with_context("command", "git symbolic-ref --short HEAD")
780 .with_context("cwd", self.cwd.display().to_string())
781 .with_context("error", err.to_string())
782 .with_context("reason", "failed to read HEAD")
783 })?;
784
785 if head.is_branch() {
786 head.shorthand().map(|s| s.to_string()).ok_or_else(|| {
787 ErrorCode::ProcessFailure
788 .error()
789 .with_context("command", "git symbolic-ref --short HEAD")
790 .with_context("cwd", self.cwd.display().to_string())
791 .with_context("reason", "unable to determine branch name from HEAD")
792 })
793 } else {
794 let oid = head
795 .target()
796 .map(|id| id.to_string())
797 .unwrap_or_else(|| "<unknown>".to_string());
798
799 Err(ErrorCode::ProcessFailure
800 .error()
801 .with_context("command", "git symbolic-ref --short HEAD")
802 .with_context("cwd", self.cwd.display().to_string())
803 .with_context("reason", "detached HEAD")
804 .with_context("head", oid))
805 }
806 }
807
808 fn repo(&self) -> Result<Repository, Error> {
809 Repository::discover(&self.cwd).map_err(|err| {
810 ErrorCode::ProcessFailure
811 .error()
812 .with_context("command", "git rev-parse --git-dir")
813 .with_context("cwd", self.cwd.display().to_string())
814 .with_context("error", err.to_string())
815 .with_context("reason", "failed to discover git repository")
816 })
817 }
818
819 fn revparse_oid(&self, repo: &Repository, rev: &str) -> Result<Oid, Error> {
820 repo.revparse_single(rev)
821 .map_err(|err| {
822 ErrorCode::ProcessFailure
823 .error()
824 .with_context("command", "git rev-parse <rev>")
825 .with_context("cwd", self.cwd.display().to_string())
826 .with_context("rev", rev.to_string())
827 .with_context("error", err.to_string())
828 })
829 .and_then(|obj| {
830 obj.peel(ObjectType::Commit).map(|c| c.id()).map_err(|err| {
832 ErrorCode::ProcessFailure
833 .error()
834 .with_context("command", "git rev-parse <rev>")
835 .with_context("cwd", self.cwd.display().to_string())
836 .with_context("rev", rev.to_string())
837 .with_context("error", err.to_string())
838 })
839 })
840 }
841
842 fn git_reset_head_paths(&self, paths: &[&str]) -> Result<(), Error> {
843 let mut args = vec!["reset", "-q", "HEAD", "--"];
844 args.extend(paths.iter().copied());
845
846 let output = Command::new("git")
847 .current_dir(&self.cwd)
848 .args(&args)
849 .output()
850 .map_err(|err| {
851 ErrorCode::ProcessFailure
852 .error()
853 .with_context("command", format!("git {}", args.join(" ")))
854 .with_context("cwd", self.cwd.display().to_string())
855 .with_context("error", err.to_string())
856 })?;
857
858 if output.status.success() {
859 Ok(())
860 } else {
861 Err(ErrorCode::ProcessFailure
862 .error()
863 .with_context("command", format!("git {}", args.join(" ")))
864 .with_context("cwd", self.cwd.display().to_string())
865 .with_context("status", output.status.to_string())
866 .with_context(
867 "stderr",
868 String::from_utf8_lossy(&output.stderr).trim().to_string(),
869 ))
870 }
871 }
872
873 fn git2_error(&self, operation: &str, err: git2::Error) -> Error {
874 ErrorCode::ProcessFailure
875 .error()
876 .with_context("operation", operation.to_string())
877 .with_context("cwd", self.cwd.display().to_string())
878 .with_context("error", err.to_string())
879 }
880
881 fn ensure_success(&self, command: &str, output: Output) -> Result<(), Error> {
882 if output.status.success() {
883 return Ok(());
884 }
885
886 Err(ErrorCode::ProcessFailure
887 .error()
888 .with_context("command", command.to_string())
889 .with_context("cwd", self.cwd.display().to_string())
890 .with_context("status", output.status.to_string())
891 .with_context(
892 "stderr",
893 String::from_utf8_lossy(&output.stderr).trim().to_string(),
894 ))
895 }
896
897 fn is_staged_bits(status: Status) -> bool {
898 status.intersects(
899 Status::INDEX_NEW
900 | Status::INDEX_MODIFIED
901 | Status::INDEX_DELETED
902 | Status::INDEX_RENAMED
903 | Status::INDEX_TYPECHANGE,
904 )
905 }
906
907 fn renamed_paths(entry: &StatusEntry<'_>) -> Option<(String, String)> {
908 if let Some(delta) = entry.head_to_index() {
909 let old = delta.old_file().path()?.to_string_lossy().into_owned();
910 let new = delta.new_file().path()?.to_string_lossy().into_owned();
911 return Some((old, new));
912 }
913
914 if let Some(delta) = entry.index_to_workdir() {
915 let old = delta.old_file().path()?.to_string_lossy().into_owned();
916 let new = delta.new_file().path()?.to_string_lossy().into_owned();
917 return Some((old, new));
918 }
919
920 None
921 }
922
923 fn best_new_path(entry: &StatusEntry<'_>) -> Option<String> {
924 if let Some(delta) = entry.head_to_index()
925 && let Some(path) = delta.new_file().path()
926 {
927 return Some(path.to_string_lossy().into_owned());
928 }
929
930 if let Some(delta) = entry.index_to_workdir()
931 && let Some(path) = delta.new_file().path()
932 {
933 return Some(path.to_string_lossy().into_owned());
934 }
935
936 entry.path().map(|p| p.to_string())
937 }
938
939 fn map_status(entry: &StatusEntry<'_>) -> FileStatus {
940 let status = entry.status();
941
942 if status.contains(Status::IGNORED) {
943 return FileStatus::Unknown;
944 }
945
946 let staged = Self::is_staged_bits(status);
947
948 if status.contains(Status::WT_NEW) && !staged {
949 return FileStatus::Untracked;
950 }
951
952 if status.intersects(Status::INDEX_RENAMED | Status::WT_RENAMED) {
953 if let Some((old, new)) = Self::renamed_paths(entry) {
954 return FileStatus::Renamed { old, new };
955 }
956 return FileStatus::Unknown;
957 }
958
959 if status.intersects(Status::INDEX_DELETED | Status::WT_DELETED) {
960 return FileStatus::Deleted;
961 }
962
963 if status.intersects(Status::INDEX_TYPECHANGE | Status::WT_TYPECHANGE) {
964 return FileStatus::TypeChange;
965 }
966
967 if status.intersects(Status::INDEX_MODIFIED | Status::WT_MODIFIED) {
968 return FileStatus::Modified;
969 }
970
971 if status.intersects(Status::INDEX_NEW | Status::WT_NEW) {
972 return FileStatus::Added;
973 }
974
975 FileStatus::Unknown
976 }
977
978 fn run<const N: usize>(&self, args: [&str; N]) -> Option<Output> {
979 Command::new("git")
980 .current_dir(&self.cwd)
981 .args(args)
982 .output()
983 .ok()
984 }
985
986 fn run_dynamic<const N: usize>(&self, args: [&str; N]) -> Option<Output> {
987 Command::new("git")
988 .current_dir(&self.cwd)
989 .args(args)
990 .output()
991 .ok()
992 }
993}