Skip to main content

commit_wizard/infra/git/
mod.rs

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/// A tag boundary with all commits that fall in the range `(prev_tag, tag]`.
19/// The `tag` field is either a version string or `"Unreleased"` for commits
20/// that have not yet been tagged.
21#[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            // workdir() returns None for bare repos; for all others it returns
49            // the working tree root (may have a trailing slash, so canonicalize).
50            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    /// Returns commits grouped by tag boundary, ordered newest-first.
474    ///
475    /// Each entry contains the tag name (or `"Unreleased"` for the leading
476    /// segment) and the commits that fall in that range.  The ranges are
477    /// computed as half-open intervals `(prev_tag, tag]` so every commit
478    /// appears in exactly one entry.
479    ///
480    /// `to` is the tip of the walk (usually `"HEAD"`).
481    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        // Unreleased: commits since last tag up to `to`
486        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        // One entry per tag (newest first), each covering (prev_tag, this_tag]
495        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                // Peel annotated tags down to the underlying commit object
831                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}