Skip to main content

diffler_core/
git.rs

1//! git2 backend for the [`Vcs`] trait.
2
3use std::cell::Cell;
4use std::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::diffalgo::{DiffAlgorithm, DiffSettings, histogram_hunks};
9use crate::model::{
10    DiffLine, DiffModel, FileDiff, FileStatus, Hunk, HunkId, LineKind, disambiguated_hunk_id,
11};
12use crate::vcs::{
13    BlameSpan, BranchInfo, HeadInfo, LogEntry, NetworkOp, StatusModel, Vcs, VcsError,
14};
15
16pub const DEFAULT_CONTEXT_LINES: u32 = 3;
17
18pub const DEFAULT_INDENT_HEURISTIC: bool = true;
19
20pub struct GitVcs {
21    repo: git2::Repository,
22    context_lines: u32,
23    /// A `Cell` so `Vcs::set_diff_algorithm` works through `&self`.
24    algorithm: Cell<DiffAlgorithm>,
25    indent_heuristic: Cell<bool>,
26}
27
28impl GitVcs {
29    pub fn open(root: &Path) -> Result<Self, VcsError> {
30        Self::open_with_settings(root, &DiffSettings::default())
31    }
32
33    pub fn open_with_settings(root: &Path, settings: &DiffSettings) -> Result<Self, VcsError> {
34        let repo = git2::Repository::open(root)?;
35        if repo.workdir().is_none() {
36            return Err(VcsError::NoWorkdir);
37        }
38        Ok(Self {
39            repo,
40            context_lines: settings.context_lines,
41            algorithm: Cell::new(settings.algorithm),
42            indent_heuristic: Cell::new(settings.indent_heuristic),
43        })
44    }
45
46    /// `None` on an unborn branch.
47    fn head_tree(&self) -> Result<Option<git2::Tree<'_>>, VcsError> {
48        match self.repo.head() {
49            Ok(head) => Ok(Some(head.peel_to_tree()?)),
50            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
51            Err(err) => Err(err.into()),
52        }
53    }
54
55    fn workdir_path(&self) -> Result<&Path, VcsError> {
56        self.repo.workdir().ok_or(VcsError::NoWorkdir)
57    }
58
59    /// `base` tree vs index + worktree, untracked included, renames found.
60    /// `None` is the empty tree.
61    fn workdir_diff(&self, base: Option<&git2::Tree<'_>>) -> Result<DiffModel, VcsError> {
62        let mut diff = self
63            .repo
64            .diff_tree_to_workdir_with_index(base, Some(&mut self.workdir_diff_options()))?;
65        let mut find = git2::DiffFindOptions::new();
66        find.renames(true);
67        diff.find_similar(Some(&mut find))?;
68        self.diff_to_model(&mut diff)
69    }
70
71    fn plain_diff_options(&self) -> git2::DiffOptions {
72        let mut opts = git2::DiffOptions::new();
73        opts.context_lines(self.context_lines);
74        apply_git_algorithm(&mut opts, self.algorithm.get(), self.indent_heuristic.get());
75        opts
76    }
77
78    fn workdir_diff_options(&self) -> git2::DiffOptions {
79        let mut opts = self.plain_diff_options();
80        opts.include_untracked(true)
81            .recurse_untracked_dirs(true)
82            .show_untracked_content(true);
83        opts
84    }
85
86    /// Leaves `.emphasis` empty; the TUI enriches a file when it draws it.
87    fn diff_to_model(&self, diff: &mut git2::Diff<'_>) -> Result<DiffModel, VcsError> {
88        let mut files = Vec::new();
89        for idx in 0..diff.deltas().len() {
90            if let Some(file) = self.build_file(diff, idx)? {
91                files.push(file);
92            }
93        }
94        Ok(DiffModel { files })
95    }
96
97    /// A modified text file's hunks under histogram/structural, which libgit2
98    /// lacks; `None` keeps git2's own. Model building and hunk staging both
99    /// call this so their hunk ids agree.
100    fn imara_hunks(
101        &self,
102        delta: &git2::DiffDelta<'_>,
103        old: Option<&str>,
104        new: Option<&str>,
105        path: &str,
106    ) -> Option<Vec<Hunk>> {
107        if !self.algorithm.get().is_imara() {
108            return None;
109        }
110        let (old, new) = (old?, new?);
111        // we read the worktree side raw, so when a clean filter (autocrlf,
112        // ident) makes git compare other bytes we keep git's own hunks
113        let hashed = git2::Oid::hash_object(git2::ObjectType::Blob, new.as_bytes()).ok()?;
114        if hashed != delta.new_file().id() {
115            return None;
116        }
117        Some(histogram_hunks(
118            old,
119            new,
120            path,
121            self.context_lines,
122            self.indent_heuristic.get(),
123        ))
124    }
125
126    fn walk_entries(
127        &self,
128        walk: git2::Revwalk<'_>,
129        limit: usize,
130    ) -> Result<Vec<LogEntry>, VcsError> {
131        let mut entries = Vec::new();
132        for oid in walk.take(limit) {
133            let oid = oid?;
134            let commit = self.repo.find_commit(oid)?;
135            let full = oid.to_string();
136            entries.push(LogEntry {
137                oid7: short7(&full),
138                oid: full,
139                refs: Vec::new(),
140                subject: commit.summary()?.unwrap_or_default().to_owned(),
141                author: commit.author().name().unwrap_or_default().to_owned(),
142                time_unix: commit.time().seconds(),
143            });
144        }
145        Ok(entries)
146    }
147
148    /// Whether `git stash` would save anything; untracked files don't count.
149    fn has_tracked_changes(&self) -> Result<bool, VcsError> {
150        let mut opts = git2::StatusOptions::new();
151        opts.include_untracked(false).include_ignored(false);
152        let statuses = self.repo.statuses(Some(&mut opts))?;
153        Ok(statuses.iter().next().is_some())
154    }
155}
156
157impl Vcs for GitVcs {
158    fn has_index(&self) -> bool {
159        true
160    }
161
162    fn native_git_checkout(&self) -> bool {
163        true
164    }
165
166    fn git_dir(&self) -> Result<PathBuf, VcsError> {
167        Ok(self.repo.path().to_path_buf())
168    }
169
170    fn head(&self) -> Result<HeadInfo, VcsError> {
171        match self.repo.head() {
172            Ok(head) => {
173                let branch = if head.is_branch() {
174                    Some(head.shorthand()?.to_owned())
175                } else {
176                    None
177                };
178                let commit = head.peel_to_commit()?;
179                let tracking = branch.as_deref().and_then(|name| {
180                    let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
181                    let upstream = local.upstream().ok()?;
182                    let name = upstream.name().ok().flatten()?.to_owned();
183                    Some((name, upstream.get().target()))
184                });
185                let (upstream, ahead, behind) = match tracking {
186                    Some((name, Some(target))) => {
187                        let (ahead, behind) = self
188                            .repo
189                            .graph_ahead_behind(commit.id(), target)
190                            .unwrap_or((0, 0));
191                        (Some(name), ahead, behind)
192                    }
193                    Some((name, None)) => (Some(name), 0, 0),
194                    None => (None, 0, 0),
195                };
196                Ok(HeadInfo {
197                    branch,
198                    oid7: short7(&commit.id().to_string()),
199                    subject: commit.summary()?.unwrap_or_default().to_owned(),
200                    upstream,
201                    ahead,
202                    behind,
203                })
204            }
205            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
206                let branch = self
207                    .repo
208                    .find_reference("HEAD")
209                    .ok()
210                    .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
211                    .and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
212                Ok(HeadInfo {
213                    branch,
214                    oid7: String::new(),
215                    subject: String::new(),
216                    upstream: None,
217                    ahead: 0,
218                    behind: 0,
219                })
220            }
221            Err(err) => Err(err.into()),
222        }
223    }
224
225    fn status(&self) -> Result<StatusModel, VcsError> {
226        // we diff against the index so a staged new file lands in staged only
227        let mut workdir = self
228            .repo
229            .diff_index_to_workdir(None, Some(&mut self.workdir_diff_options()))?;
230        let workdir_model = self.diff_to_model(&mut workdir)?;
231        let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
232            .files
233            .into_iter()
234            .partition(|f| f.status == FileStatus::Untracked);
235
236        let head_tree = self.head_tree()?;
237        let mut staged = self.repo.diff_tree_to_index(
238            head_tree.as_ref(),
239            None,
240            Some(&mut self.plain_diff_options()),
241        )?;
242        let staged = self.diff_to_model(&mut staged)?;
243
244        Ok(StatusModel {
245            untracked: DiffModel { files: untracked },
246            unstaged: DiffModel { files: unstaged },
247            staged,
248        })
249    }
250
251    fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
252        self.workdir_diff(self.head_tree()?.as_ref())
253    }
254
255    fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
256        let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
257        self.workdir_diff(Some(&base.tree()?))
258    }
259
260    fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
261        let oid = git2::Oid::from_str(oid)?;
262        let commit = self.repo.find_commit(oid)?;
263        let tree = commit.tree()?;
264        let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
265        let mut diff = self.repo.diff_tree_to_tree(
266            parent_tree.as_ref(),
267            Some(&tree),
268            Some(&mut self.plain_diff_options()),
269        )?;
270        self.diff_to_model(&mut diff)
271    }
272
273    fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
274        let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
275        let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
276        let mut diff = self.repo.diff_tree_to_tree(
277            Some(&base.tree()?),
278            Some(&newest.tree()?),
279            Some(&mut self.plain_diff_options()),
280        )?;
281        self.diff_to_model(&mut diff)
282    }
283
284    fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
285        let base = self
286            .repo
287            .merge_base(git2::Oid::from_str(a)?, git2::Oid::from_str(b)?)?;
288        Ok(base.to_string())
289    }
290
291    fn resolve(&self, revision: &str) -> Result<String, VcsError> {
292        let object = self.repo.revparse_single(revision)?;
293        let commit = object.peel_to_commit()?;
294        Ok(commit.id().to_string())
295    }
296
297    fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
298        let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
299        let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
300        let newest_tree = newest.tree()?;
301        let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
302        let mut diff = self.repo.diff_tree_to_tree(
303            base_tree.as_ref(),
304            Some(&newest_tree),
305            Some(&mut self.plain_diff_options()),
306        )?;
307        self.diff_to_model(&mut diff)
308    }
309
310    fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError> {
311        if self.head_tree()?.is_none() {
312            return Ok(Vec::new());
313        }
314        let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
315        for reference in self.repo.references()?.flatten() {
316            let Ok(name) = reference.shorthand().map(str::to_owned) else {
317                continue;
318            };
319            let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
320                continue;
321            };
322            refs_by_oid.entry(target).or_default().push(name);
323        }
324
325        let mut walk = self.repo.revwalk()?;
326        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
327        walk.push_head()?;
328        let mut entries = Vec::new();
329        for oid in walk.take(limit) {
330            let oid = oid?;
331            let commit = self.repo.find_commit(oid)?;
332            let full = oid.to_string();
333            entries.push(LogEntry {
334                oid7: short7(&full),
335                oid: full,
336                refs: refs_by_oid.get(&oid).cloned().unwrap_or_default(),
337                subject: commit.summary()?.unwrap_or_default().to_owned(),
338                author: commit.author().name().unwrap_or_default().to_owned(),
339                time_unix: commit.time().seconds(),
340            });
341        }
342        Ok(entries)
343    }
344
345    fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
346        let head_ref = format!("refs/remotes/{remote}/HEAD");
347        let prefix = format!("refs/remotes/{remote}/");
348        if let Ok(reference) = self.repo.find_reference(&head_ref)
349            && let Ok(Some(target)) = reference.symbolic_target()
350            // we keep the whole remainder so `release/2.x` survives
351            && let Some(name) = target.strip_prefix(prefix.as_str())
352        {
353            return Ok(Some(name.to_owned()));
354        }
355        for name in ["main", "master"] {
356            if self
357                .repo
358                .find_branch(name, git2::BranchType::Local)
359                .or_else(|_| {
360                    self.repo
361                        .find_branch(&format!("{remote}/{name}"), git2::BranchType::Remote)
362                })
363                .is_ok()
364            {
365                return Ok(Some(name.to_owned()));
366            }
367        }
368        Ok(None)
369    }
370
371    fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
372        let (base, head) = (
373            self.repo.revparse_single(base)?,
374            self.repo.revparse_single(head)?,
375        );
376        let mut walk = self.repo.revwalk()?;
377        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
378        walk.push(head.id())?;
379        walk.hide(base.id())?;
380        self.walk_entries(walk, usize::MAX)
381    }
382
383    fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError> {
384        let Ok(head) = self.repo.head() else {
385            return Ok(None);
386        };
387        let mut walk = self.repo.revwalk()?;
388        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
389        walk.push(head.peel_to_commit()?.id())?;
390        let mut remotes = 0;
391        for reference in self.repo.references()? {
392            let reference = reference?;
393            // a symbolic ref (origin/HEAD) has no target, and its destination
394            // is hidden anyway
395            if let (true, Some(oid)) = (reference.is_remote(), reference.target()) {
396                remotes += 1;
397                walk.hide(oid)?;
398            }
399        }
400        if remotes == 0 {
401            return Ok(None);
402        }
403        self.walk_entries(walk, limit).map(Some)
404    }
405
406    fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
407        let mut options = git2::BlameOptions::new();
408        options.track_copies_same_file(true);
409        let blame = self.repo.blame_file(rel, Some(&mut options))?;
410        // blame_file only knows committed content, so we remap the spans onto
411        // the worktree with blame_buffer
412        let workdir = self.workdir()?.join(rel);
413        let blame = match fs::read(&workdir) {
414            Ok(bytes) => blame.blame_buffer(&bytes)?,
415            Err(_) => blame,
416        };
417
418        let mut out = Vec::new();
419        for hunk in blame.iter() {
420            let oid = hunk.final_commit_id();
421            let commit = self.repo.find_commit(oid).ok();
422            let full = oid.to_string();
423            out.push(BlameSpan {
424                start_line: u32::try_from(hunk.final_start_line()).unwrap_or(u32::MAX),
425                line_count: u32::try_from(hunk.lines_in_hunk()).unwrap_or(u32::MAX),
426                oid7: short7(&full),
427                oid: full,
428                author: commit
429                    .as_ref()
430                    .map(|c| c.author().name().unwrap_or_default().to_owned())
431                    .unwrap_or_default(),
432                time_unix: commit.as_ref().map_or(0, |c| c.time().seconds()),
433                summary: commit
434                    .as_ref()
435                    .and_then(|c| c.summary().ok().flatten().map(str::to_owned))
436                    .unwrap_or_default(),
437                committed: !oid.is_zero(),
438            });
439        }
440        out.sort_by_key(|span| span.start_line);
441        Ok(out)
442    }
443
444    fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError> {
445        let commit = self.repo.revparse_single(rev)?.peel_to_commit()?;
446        match commit.tree()?.get_path(Path::new(path)) {
447            Ok(entry) => Ok(blob_text(&self.repo, entry.id())),
448            Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
449            Err(err) => Err(err.into()),
450        }
451    }
452
453    fn read_blob(&self, oid: &str) -> Result<Option<Vec<u8>>, VcsError> {
454        let oid = git2::Oid::from_str(oid)?;
455        match self.repo.find_blob(oid) {
456            Ok(blob) => Ok(Some(blob.content().to_vec())),
457            Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
458            Err(err) => Err(err.into()),
459        }
460    }
461
462    fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError> {
463        let index = self.repo.index()?;
464        let mut out: Vec<PathBuf> = index
465            .iter()
466            .filter_map(|entry| {
467                std::str::from_utf8(&entry.path)
468                    .ok()
469                    .map(|path| PathBuf::from(path.to_owned()))
470            })
471            .collect();
472        out.sort_unstable();
473        out.dedup();
474        Ok(out)
475    }
476
477    fn attr(&self, rel: &Path, name: &str) -> bool {
478        let Ok(value) = self.repo.get_attr(rel, name, git2::AttrCheckFlags::empty()) else {
479            return false;
480        };
481        // a bare `linguist-generated` is git's boolean, `=true` is a string
482        matches!(
483            git2::AttrValue::from_string(value),
484            git2::AttrValue::True | git2::AttrValue::String("true")
485        )
486    }
487
488    fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
489        let mut out = Vec::new();
490        for entry in self.repo.branches(Some(git2::BranchType::Local))? {
491            let (branch, _) = entry?;
492            let Some(name) = branch.name()?.map(str::to_owned) else {
493                continue;
494            };
495            let tip_unix = branch
496                .get()
497                .peel_to_commit()
498                .ok()
499                .map_or(0, |commit| commit.time().seconds());
500            out.push(BranchInfo {
501                name,
502                is_head: branch.is_head(),
503                tip_unix,
504                divergence: None,
505            });
506        }
507        Ok(out)
508    }
509
510    fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError> {
511        let branch = self.repo.find_branch(branch, git2::BranchType::Local)?;
512        let Some(tip) = branch.get().peel_to_commit().ok() else {
513            return Ok(None);
514        };
515        let Some(target) = branch.upstream().ok().and_then(|up| up.get().target()) else {
516            return Ok(None);
517        };
518        Ok(Some(self.repo.graph_ahead_behind(tip.id(), target)?))
519    }
520
521    fn all_branches(&self) -> Result<Vec<String>, VcsError> {
522        let mut out = Vec::new();
523        for entry in self.repo.branches(None)? {
524            let (branch, _) = entry?;
525            let Some(name) = branch.name()?.map(str::to_owned) else {
526                continue;
527            };
528            // refs/remotes/<remote>/HEAD is a symbolic alias
529            if name.ends_with("/HEAD") {
530                continue;
531            }
532            out.push(name);
533        }
534        Ok(out)
535    }
536
537    fn stage(&self, rel: &Path) -> Result<(), VcsError> {
538        let root = self.workdir_path()?;
539        let mut index = self.repo.index()?;
540        if root.join(rel).exists() {
541            index.add_path(rel)?;
542        } else {
543            index.remove_path(rel)?;
544        }
545        index.write()?;
546        Ok(())
547    }
548
549    fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
550        let diff = self
551            .repo
552            .diff_index_to_workdir(None, Some(&mut self.workdir_diff_options()))?;
553        let patch = self.synthesize_patch(&diff, rel, hunk, false)?;
554        let diff = git2::Diff::from_buffer(&patch)?;
555        self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
556        Ok(())
557    }
558
559    fn stage_everything(&self) -> Result<(), VcsError> {
560        let mut index = self.repo.index()?;
561        // update_all catches deletions, add_all catches untracked files
562        index.update_all(["*"], None)?;
563        index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
564        index.write()?;
565        Ok(())
566    }
567
568    fn unstage_everything(&self) -> Result<(), VcsError> {
569        match self.repo.head() {
570            Ok(head) => {
571                let target = head.peel(git2::ObjectType::Commit)?;
572                self.repo.reset_default(Some(&target), ["*"])?;
573            }
574            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
575                let mut index = self.repo.index()?;
576                index.clear()?;
577                index.write()?;
578            }
579            Err(err) => return Err(err.into()),
580        }
581        Ok(())
582    }
583
584    fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
585        match self.repo.head() {
586            Ok(head) => {
587                let target = head.peel(git2::ObjectType::Commit)?;
588                self.repo.reset_default(Some(&target), [rel])?;
589            }
590            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
591                let mut index = self.repo.index()?;
592                index.remove_path(rel)?;
593                index.write()?;
594            }
595            Err(err) => return Err(err.into()),
596        }
597        Ok(())
598    }
599
600    fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
601        let head_tree = self.head_tree()?;
602        let diff = self.repo.diff_tree_to_index(
603            head_tree.as_ref(),
604            None,
605            Some(&mut self.plain_diff_options()),
606        )?;
607        let patch = self.synthesize_patch(&diff, rel, hunk, true)?;
608        let diff = git2::Diff::from_buffer(&patch)?;
609        self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
610        Ok(())
611    }
612
613    fn discard(&self, rel: &Path) -> Result<(), VcsError> {
614        let head_tree = self.head_tree()?;
615        let mut opts = git2::DiffOptions::new();
616        opts.pathspec(rel).disable_pathspec_match(true);
617        let staged = self
618            .repo
619            .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
620        if staged.deltas().len() > 0 {
621            return Err(VcsError::Rejected(
622                "file has staged changes; unstage first".into(),
623            ));
624        }
625        let status = self.repo.status_file(rel)?;
626        if status.contains(git2::Status::WT_NEW) {
627            fs::remove_file(self.workdir_path()?.join(rel))?;
628            return Ok(());
629        }
630        // we update the index stat cache too: under autocrlf the checkout
631        // grows the file, and a stale cached size makes git report a phantom
632        // modification. Nothing is staged here, so only metadata changes.
633        let mut checkout = git2::build::CheckoutBuilder::new();
634        checkout.path(rel).force().update_index(true);
635        self.repo.checkout_head(Some(&mut checkout))?;
636        Ok(())
637    }
638
639    fn commit(&self, message: &str) -> Result<String, VcsError> {
640        if message.trim().is_empty() {
641            return Err(VcsError::Rejected("empty commit message".into()));
642        }
643        let mut index = self.repo.index()?;
644        let tree_id = index.write_tree()?;
645        let tree = self.repo.find_tree(tree_id)?;
646        let signature = self.repo.signature()?;
647        let parent = match self.repo.head() {
648            Ok(head) => Some(head.peel_to_commit()?),
649            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
650            Err(err) => return Err(err.into()),
651        };
652        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
653        let oid = self.repo.commit(
654            Some("HEAD"),
655            &signature,
656            &signature,
657            message,
658            &tree,
659            &parents,
660        )?;
661        Ok(oid.to_string())
662    }
663
664    fn head_message(&self) -> Result<String, VcsError> {
665        let commit = self.repo.head()?.peel_to_commit()?;
666        Ok(commit.message().unwrap_or_default().to_owned())
667    }
668
669    fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
670        if let Some(message) = message
671            && message.trim().is_empty()
672        {
673            return Err(VcsError::Rejected("empty commit message".into()));
674        }
675        let head = self.repo.head()?.peel_to_commit()?;
676        let tree = if use_index {
677            let mut index = self.repo.index()?;
678            let tree_id = index.write_tree()?;
679            self.repo.find_tree(tree_id)?
680        } else {
681            head.tree()?
682        };
683        let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
684        Ok(oid.to_string())
685    }
686
687    fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
688        let head = self.repo.head()?.peel_to_commit()?;
689        self.repo.branch(name, &head, false)?;
690        if checkout {
691            self.checkout(name)?;
692        }
693        Ok(())
694    }
695
696    fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
697        let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
698        if branch.is_head() {
699            return Err(VcsError::Rejected(
700                "cannot delete the checked-out branch".into(),
701            ));
702        }
703        branch.delete()?;
704        Ok(())
705    }
706
707    fn checkout(&self, name: &str) -> Result<(), VcsError> {
708        let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
709        let target = branch.get().peel(git2::ObjectType::Commit)?;
710        // a non-force checkout refuses to clobber local modifications
711        self.repo.checkout_tree(&target, None)?;
712        self.repo.set_head(&format!("refs/heads/{name}"))?;
713        Ok(())
714    }
715
716    fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError> {
717        if !self.has_tracked_changes()? {
718            return Err(VcsError::Rejected("nothing to stash".into()));
719        }
720        // git2's stash needs `&mut Repository` and the trait is `&self`, so we
721        // open a fresh handle
722        let mut repo = git2::Repository::open(self.workdir_path()?)?;
723        let signature = repo.signature()?;
724        repo.stash_save2(&signature, message, None)?;
725        Ok(())
726    }
727
728    fn stash_pop(&self) -> Result<(), VcsError> {
729        let mut repo = git2::Repository::open(self.workdir_path()?)?;
730        match repo.stash_pop(0, None) {
731            Ok(()) => Ok(()),
732            Err(err) if err.code() == git2::ErrorCode::NotFound => {
733                Err(VcsError::Rejected("no stash to pop".into()))
734            }
735            // a conflicting pop leaves the merge in the worktree and keeps the
736            // stash entry
737            Err(err) if err.code() == git2::ErrorCode::Conflict => Err(VcsError::Rejected(
738                "stash applied with conflicts; resolve them (the stash was kept)".into(),
739            )),
740            Err(err) => Err(err.into()),
741        }
742    }
743
744    fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError> {
745        let args: Vec<String> = match op {
746            NetworkOp::Fetch => vec!["fetch".into()],
747            NetworkOp::FetchAll => vec!["fetch".into(), "--all".into()],
748            NetworkOp::Push => vec!["push".into()],
749            NetworkOp::PushSetUpstream { remote } => {
750                vec!["push".into(), "-u".into(), remote, "HEAD".into()]
751            }
752            NetworkOp::Pull => vec!["pull".into()],
753            NetworkOp::PullFrom { remote, branch } => vec!["pull".into(), remote, branch],
754            NetworkOp::PullRebase => vec!["pull".into(), "--rebase".into()],
755            NetworkOp::PullMerge => vec!["pull".into(), "--no-rebase".into()],
756        };
757        Ok(std::iter::once("git".to_owned()).chain(args).collect())
758    }
759
760    fn workdir(&self) -> Result<PathBuf, VcsError> {
761        Ok(self.workdir_path()?.to_path_buf())
762    }
763
764    fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
765        match self.repo.find_remote(name) {
766            // url() errs only on a non-UTF-8 URL, which we treat as none
767            Ok(remote) => Ok(remote.url().ok().map(str::to_owned)),
768            Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
769            Err(err) => Err(err.into()),
770        }
771    }
772
773    fn remotes(&self) -> Result<Vec<String>, VcsError> {
774        let array = self.repo.remotes()?;
775        let mut names = Vec::new();
776        for i in 0..array.len() {
777            if let Ok(Some(name)) = array.get(i) {
778                names.push(name.to_owned());
779            }
780        }
781        Ok(names)
782    }
783
784    fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
785        self.algorithm.set(algorithm);
786        self.indent_heuristic.set(indent_heuristic);
787    }
788}
789
790/// Histogram and structural have no git2 flag; [`GitVcs::imara_hunks`]
791/// computes those.
792fn apply_git_algorithm(
793    opts: &mut git2::DiffOptions,
794    algorithm: DiffAlgorithm,
795    indent_heuristic: bool,
796) {
797    match algorithm {
798        DiffAlgorithm::Minimal => {
799            opts.minimal(true);
800        }
801        DiffAlgorithm::Patience => {
802            opts.patience(true);
803        }
804        DiffAlgorithm::Myers | DiffAlgorithm::Histogram | DiffAlgorithm::Structural => {}
805    }
806    opts.indent_heuristic(indent_heuristic);
807}
808
809impl GitVcs {
810    /// Render one hunk of `rel` as a unified patch libgit2 can apply to the
811    /// index. `reverse` flips the patch so applying it undoes a staged hunk.
812    fn synthesize_patch(
813        &self,
814        diff: &git2::Diff<'_>,
815        rel: &Path,
816        target: &HunkId,
817        reverse: bool,
818    ) -> Result<Vec<u8>, VcsError> {
819        let rel = rel.to_string_lossy();
820        for idx in 0..diff.deltas().len() {
821            let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
822                continue;
823            };
824            // we take `delta` from the patch, since loading the patch fills in
825            // the worktree side's id that `imara_hunks` checks
826            let delta = patch.delta();
827            if delta.flags().is_binary() || delta_new_path(&delta) != rel {
828                continue;
829            }
830            if self.algorithm.get().is_imara() {
831                let old_text = blob_text(&self.repo, delta.old_file().id());
832                let new_text = new_side_text(&self.repo, &delta, &rel);
833                let (old, new) = (old_text.as_deref(), new_text.as_deref());
834                if let Some(hunks) = self.imara_hunks(&delta, old, new, &rel)
835                    && let (Some(old), Some(new)) = (old, new)
836                {
837                    if let Some(hunk) = hunks.iter().find(|h| h.id == *target) {
838                        return Ok(render_hunk_patch_from_model(hunk, &rel, reverse, old, new));
839                    }
840                    continue;
841                }
842            }
843            let mut seen = HashMap::new();
844            for h in 0..patch.num_hunks() {
845                let lines = hunk_model_lines(&patch, h)?;
846                if disambiguated_hunk_id(&rel, &lines, &mut seen) == *target {
847                    return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
848                }
849            }
850        }
851        Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
852    }
853
854    fn build_file(
855        &self,
856        diff: &mut git2::Diff<'_>,
857        idx: usize,
858    ) -> Result<Option<FileDiff>, VcsError> {
859        let repo = &self.repo;
860        let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
861            return Ok(build_binary_file(diff, idx));
862        };
863        let delta = patch.delta();
864        if delta.flags().is_binary() {
865            return Ok(build_binary_file(diff, idx));
866        }
867        let file_path = delta_new_path(&delta);
868        let status = map_status(delta.status());
869        let old_path = if status == FileStatus::Renamed {
870            delta
871                .old_file()
872                .path()
873                .map(|p| p.to_string_lossy().into_owned())
874        } else {
875            None
876        };
877
878        let old_text = blob_text(repo, delta.old_file().id());
879        let new_text = new_side_text(repo, &delta, &file_path);
880
881        let imara = self.imara_hunks(&delta, old_text.as_deref(), new_text.as_deref(), &file_path);
882        let hunks = match imara {
883            Some(hunks) => hunks,
884            None => patch_hunks(&patch, &file_path)?,
885        };
886
887        Ok(Some(FileDiff {
888            path: file_path,
889            old_path,
890            status,
891            binary: false,
892            old_text,
893            new_text,
894            hunks,
895            hashes: crate::model::HashCache::default(),
896            blobs: crate::model::BlobIds::default(),
897        }))
898    }
899}
900
901fn render_hunk_patch(
902    patch: &git2::Patch<'_>,
903    h: usize,
904    rel: &str,
905    status: git2::Delta,
906    reverse: bool,
907) -> Result<Vec<u8>, VcsError> {
908    let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
909    let deleted = status == git2::Delta::Deleted;
910    let mut out = Vec::new();
911    out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
912    // whole-file adds and deletes keep that identity (sides swapped under
913    // reverse) so applying creates or drops the index entry; otherwise git
914    // leaves an empty blob behind
915    if (added && !reverse) || (deleted && reverse) {
916        out.extend_from_slice(
917            format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
918        );
919    } else if (deleted && !reverse) || (added && reverse) {
920        out.extend_from_slice(
921            format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
922        );
923    } else {
924        out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
925    }
926    let (hunk, line_count) = patch.hunk(h)?;
927    let mut lines: Vec<(LineKind, Vec<u8>)> = Vec::with_capacity(line_count);
928    for l in 0..line_count {
929        let line = patch.line_in_hunk(h, l)?;
930        let kind = match line.origin() {
931            ' ' => LineKind::Context,
932            '+' => LineKind::Added,
933            '-' => LineKind::Deleted,
934            // an EOF-newline marker carries the "\ No newline" text and the
935            // newline ending the previous line, so we append it to that line
936            '=' | '>' | '<' => {
937                if let Some((_, text)) = lines.last_mut() {
938                    text.extend_from_slice(line.content());
939                }
940                continue;
941            }
942            _ => continue,
943        };
944        lines.push((kind, line.content().to_vec()));
945    }
946    write_hunk(
947        &mut out,
948        (hunk.old_start(), hunk.old_lines()),
949        (hunk.new_start(), hunk.new_lines()),
950        &lines,
951        reverse,
952    );
953    Ok(out)
954}
955
956/// One hunk's `@@` header and lines. `old`/`new` are `(start, count)`; each
957/// line's bytes are already terminated.
958fn write_hunk(
959    out: &mut Vec<u8>,
960    old: (u32, u32),
961    new: (u32, u32),
962    lines: &[(LineKind, Vec<u8>)],
963    reverse: bool,
964) {
965    let ((minus_start, minus_lines), (_, plus_lines)) =
966        if reverse { (new, old) } else { (old, new) };
967    // libgit2 applies a hunk at its `+` start, and we send this hunk without
968    // the ones before it, so we point that start at the minus side's
969    // position; git's header gives an empty side the line before the change
970    let plus_start = if minus_lines == 0 {
971        minus_start + 1
972    } else {
973        minus_start
974    };
975    out.extend_from_slice(
976        format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
977    );
978    // minus lines go first in a change run even under `reverse`: libgit2
979    // rejects a `+` line marked "No newline at end of file" before a `-` one
980    let (minus_kind, plus_kind) = if reverse {
981        (LineKind::Added, LineKind::Deleted)
982    } else {
983        (LineKind::Deleted, LineKind::Added)
984    };
985    let is_context = |kind: LineKind| kind == LineKind::Context;
986    for run in lines.chunk_by(|a, b| is_context(a.0) == is_context(b.0)) {
987        for (origin, kind) in [
988            (b' ', LineKind::Context),
989            (b'-', minus_kind),
990            (b'+', plus_kind),
991        ] {
992            for (_, text) in run.iter().filter(|(k, _)| *k == kind) {
993                out.push(origin);
994                out.extend_from_slice(text);
995            }
996        }
997    }
998}
999
1000/// [`render_hunk_patch`] for an imara-diff hunk. We copy each line's raw
1001/// bytes from `old_text`/`new_text`, since the model's text has CR and LF
1002/// stripped.
1003fn render_hunk_patch_from_model(
1004    hunk: &Hunk,
1005    rel: &str,
1006    reverse: bool,
1007    old_text: &str,
1008    new_text: &str,
1009) -> Vec<u8> {
1010    let old_raw: Vec<&str> = old_text.split_inclusive('\n').collect();
1011    let new_raw: Vec<&str> = new_text.split_inclusive('\n').collect();
1012    let raw = |line: &DiffLine| -> Vec<u8> {
1013        let (side, number) = match line.kind {
1014            LineKind::Added => (&new_raw, line.new_no),
1015            LineKind::Deleted | LineKind::Context => (&old_raw, line.old_no),
1016        };
1017        let text = number
1018            .and_then(|n| n.checked_sub(1))
1019            .and_then(|i| side.get(i as usize))
1020            .copied()
1021            .unwrap_or_default();
1022        let mut bytes = text.as_bytes().to_vec();
1023        if !text.ends_with('\n') {
1024            bytes.extend_from_slice(b"\n\\ No newline at end of file\n");
1025        }
1026        bytes
1027    };
1028    let lines: Vec<(LineKind, Vec<u8>)> = hunk.lines.iter().map(|l| (l.kind, raw(l))).collect();
1029
1030    let mut out = format!("diff --git a/{rel} b/{rel}\n--- a/{rel}\n+++ b/{rel}\n").into_bytes();
1031    write_hunk(
1032        &mut out,
1033        (hunk.old_start, hunk.old_lines),
1034        (hunk.new_start, hunk.new_lines),
1035        &lines,
1036        reverse,
1037    );
1038    out
1039}
1040
1041fn short7(oid: &str) -> String {
1042    oid.get(..7).unwrap_or(oid).to_owned()
1043}
1044
1045/// Re-diff a file's own old/new text at `context` lines (`u32::MAX` for the
1046/// whole file). `None` for binary files or a missing side.
1047pub fn rehunk_file(
1048    file: &FileDiff,
1049    context: u32,
1050    algorithm: DiffAlgorithm,
1051    indent_heuristic: bool,
1052) -> Option<Vec<Hunk>> {
1053    if file.binary {
1054        return None;
1055    }
1056    let (old, new) = (file.old_text.as_deref()?, file.new_text.as_deref()?);
1057    if algorithm.is_imara() {
1058        return Some(histogram_hunks(
1059            old,
1060            new,
1061            &file.path,
1062            context,
1063            indent_heuristic,
1064        ));
1065    }
1066    let as_path = Path::new(&file.path);
1067    // libgit2's context math overflows on u32::MAX and yields zero context on
1068    // some platforms, so we cap it at the line count
1069    let cap = u32::try_from(old.lines().count().max(new.lines().count())).unwrap_or(u32::MAX);
1070    let mut opts = git2::DiffOptions::new();
1071    opts.context_lines(context.min(cap));
1072    apply_git_algorithm(&mut opts, algorithm, indent_heuristic);
1073    let patch = git2::Patch::from_buffers(
1074        old.as_bytes(),
1075        Some(as_path),
1076        new.as_bytes(),
1077        Some(as_path),
1078        Some(&mut opts),
1079    )
1080    .ok()?;
1081    patch_hunks(&patch, &file.path).ok()
1082}
1083
1084/// Shared by the initial diff and the context re-diff so their ids agree.
1085fn patch_hunks(patch: &git2::Patch<'_>, file_path: &str) -> Result<Vec<Hunk>, VcsError> {
1086    let mut hunks = Vec::with_capacity(patch.num_hunks());
1087    let mut seen = HashMap::new();
1088    for h in 0..patch.num_hunks() {
1089        let (hunk, _) = patch.hunk(h)?;
1090        let lines = hunk_model_lines(patch, h)?;
1091        let id = disambiguated_hunk_id(file_path, &lines, &mut seen);
1092        hunks.push(Hunk {
1093            id,
1094            old_start: hunk.old_start(),
1095            old_lines: hunk.old_lines(),
1096            new_start: hunk.new_start(),
1097            new_lines: hunk.new_lines(),
1098            context: hunk_context(&hunk),
1099            lines,
1100        });
1101    }
1102    Ok(hunks)
1103}
1104
1105/// The text after the header's second `@@`, empty when git emits none.
1106fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
1107    let header = String::from_utf8_lossy(hunk.header());
1108    match header.split_once(" @@") {
1109        Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
1110        None => String::new(),
1111    }
1112}
1113
1114/// Shared by model building and hunk lookup so their hunk ids agree.
1115fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
1116    let (_, line_count) = patch.hunk(h)?;
1117    let mut lines = Vec::with_capacity(line_count);
1118    for l in 0..line_count {
1119        let line = patch.line_in_hunk(h, l)?;
1120        let kind = match line.origin() {
1121            '-' => LineKind::Deleted,
1122            '+' => LineKind::Added,
1123            ' ' => LineKind::Context,
1124            _ => continue,
1125        };
1126        let text = String::from_utf8_lossy(line.content())
1127            .trim_end_matches(['\n', '\r'])
1128            .to_owned();
1129        lines.push(DiffLine::new(
1130            kind,
1131            line.old_lineno(),
1132            line.new_lineno(),
1133            text,
1134        ));
1135    }
1136    Ok(lines)
1137}
1138
1139fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
1140    let delta = diff.get_delta(idx)?;
1141    let blob = |file: git2::DiffFile<'_>| (!file.id().is_zero()).then(|| file.id().to_string());
1142    let blobs = crate::model::BlobIds {
1143        old: blob(delta.old_file()),
1144        new: blob(delta.new_file()),
1145    };
1146    Some(FileDiff {
1147        path: delta_new_path(&delta),
1148        old_path: None,
1149        status: map_status(delta.status()),
1150        binary: true,
1151        old_text: None,
1152        new_text: None,
1153        hunks: Vec::new(),
1154        hashes: crate::model::HashCache::default(),
1155        blobs,
1156    })
1157}
1158
1159fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
1160    delta
1161        .new_file()
1162        .path()
1163        .or_else(|| delta.old_file().path())
1164        .map(|p| p.to_string_lossy().into_owned())
1165        .unwrap_or_default()
1166}
1167
1168fn map_status(status: git2::Delta) -> FileStatus {
1169    match status {
1170        git2::Delta::Added => FileStatus::Added,
1171        git2::Delta::Deleted => FileStatus::Deleted,
1172        git2::Delta::Renamed => FileStatus::Renamed,
1173        git2::Delta::Untracked => FileStatus::Untracked,
1174        _ => FileStatus::Modified,
1175    }
1176}
1177
1178fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
1179    if oid.is_zero() {
1180        return None;
1181    }
1182    let blob = repo.find_blob(oid).ok()?;
1183    if blob.is_binary() {
1184        return None;
1185    }
1186    String::from_utf8(blob.content().to_vec()).ok()
1187}
1188
1189/// The recorded blob when the diff target is a tree or the index, else the
1190/// worktree file.
1191fn new_side_text(
1192    repo: &git2::Repository,
1193    delta: &git2::DiffDelta<'_>,
1194    rel: &str,
1195) -> Option<String> {
1196    if delta.status() == git2::Delta::Deleted {
1197        return None;
1198    }
1199    if let Some(text) = blob_text(repo, delta.new_file().id()) {
1200        return Some(text);
1201    }
1202    let root = repo.workdir()?;
1203    fs::read_to_string(root.join(rel)).ok()
1204}