Skip to main content

diffler_core/
git.rs

1//! git2 backend for the [`Vcs`] trait.
2
3use std::cell::Cell;
4use std::collections::{HashMap, HashSet};
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, FileVersion, HeadInfo, LogEntry, NetworkOp, StatusModel, Vcs, VcsError,
14    Worktree, WorktreeOp,
15};
16
17pub const DEFAULT_CONTEXT_LINES: u32 = 3;
18
19/// Commits a file history walks at most, so a file untouched for years in
20/// a huge repo still answers quickly.
21const HISTORY_WALK_LIMIT: usize = 20_000;
22
23pub const DEFAULT_INDENT_HEURISTIC: bool = true;
24
25pub struct GitVcs {
26    repo: git2::Repository,
27    context_lines: u32,
28    /// A `Cell` so `Vcs::set_diff_algorithm` works through `&self`.
29    algorithm: Cell<DiffAlgorithm>,
30    indent_heuristic: Cell<bool>,
31    /// False for a jj repo's own store, which has no index to read.
32    indexed: bool,
33    /// The commit every read of HEAD means. A jj store's own HEAD never
34    /// moves, so jj sets this to `@-`.
35    base: Cell<Base>,
36}
37
38#[derive(Debug, Clone, Copy)]
39enum Base {
40    FollowHead,
41    Unborn,
42    At(git2::Oid),
43}
44
45impl GitVcs {
46    pub fn open(root: &Path) -> Result<Self, VcsError> {
47        Self::open_with_settings(root, &DiffSettings::default())
48    }
49
50    pub fn open_with_settings(root: &Path, settings: &DiffSettings) -> Result<Self, VcsError> {
51        let repo = git2::Repository::open(root)?;
52        if repo.workdir().is_none() {
53            return Err(VcsError::NoWorkdir);
54        }
55        Ok(Self {
56            repo,
57            context_lines: settings.context_lines,
58            algorithm: Cell::new(settings.algorithm),
59            indent_heuristic: Cell::new(settings.indent_heuristic),
60            indexed: true,
61            base: Cell::new(Base::FollowHead),
62        })
63    }
64
65    /// The bare git store of a jj repo with no `.git` of its own, its
66    /// worktree set to `root`.
67    pub fn open_jj_store(
68        git_dir: &Path,
69        root: &Path,
70        settings: &DiffSettings,
71    ) -> Result<Self, VcsError> {
72        let repo = git2::Repository::open_bare(git_dir)?;
73        repo.set_workdir(root, false)?;
74        // jj keeps its own state in `.jj` and, with no `.git`, writes no ignore file there
75        repo.add_ignore_rule(".jj/")?;
76        Ok(Self {
77            repo,
78            context_lines: settings.context_lines,
79            algorithm: Cell::new(settings.algorithm),
80            indent_heuristic: Cell::new(settings.indent_heuristic),
81            indexed: false,
82            base: Cell::new(Base::Unborn),
83        })
84    }
85
86    /// Read every later "HEAD" as `base`, `None` being an unborn one.
87    pub(crate) fn set_base(&self, base: Option<&str>) -> Result<(), VcsError> {
88        self.base.set(match base {
89            Some(oid) => Base::At(git2::Oid::from_str(oid)?),
90            None => Base::Unborn,
91        });
92        Ok(())
93    }
94
95    /// `None` on an unborn branch or base.
96    fn head_commit(&self) -> Result<Option<git2::Commit<'_>>, VcsError> {
97        match self.base.get() {
98            Base::At(oid) => Ok(Some(self.repo.find_commit(oid)?)),
99            Base::Unborn => Ok(None),
100            Base::FollowHead => match self.repo.head() {
101                Ok(head) => Ok(Some(head.peel_to_commit()?)),
102                Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
103                Err(err) => Err(err.into()),
104            },
105        }
106    }
107
108    fn head_commit_or_unborn(&self) -> Result<git2::Commit<'_>, VcsError> {
109        self.head_commit()?.ok_or_else(|| {
110            git2::Error::new(
111                git2::ErrorCode::UnbornBranch,
112                git2::ErrorClass::Reference,
113                "HEAD has no commit yet",
114            )
115            .into()
116        })
117    }
118
119    /// `rev`, with a leading `HEAD` (`HEAD`, `HEAD~2`, `HEAD^`) read as the
120    /// base when one is set.
121    fn revparse(&self, rev: &str) -> Result<git2::Object<'_>, VcsError> {
122        let head_relative = rev
123            .strip_prefix("HEAD")
124            .filter(|rest| rest.is_empty() || rest.starts_with(['~', '^']));
125        match (self.base.get(), head_relative) {
126            (Base::At(_) | Base::Unborn, Some(rest)) => {
127                let oid = self.head_commit_or_unborn()?.id();
128                Ok(self.repo.revparse_single(&format!("{oid}{rest}"))?)
129            }
130            _ => Ok(self.repo.revparse_single(rev)?),
131        }
132    }
133
134    fn tree_files(&self) -> Result<Vec<PathBuf>, VcsError> {
135        let Some(tree) = self.head_tree()? else {
136            return Ok(Vec::new());
137        };
138        let mut out = Vec::new();
139        tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
140            if entry.kind() == Some(git2::ObjectType::Blob)
141                && let Ok(name) = entry.name()
142            {
143                out.push(PathBuf::from(format!("{dir}{name}")));
144            }
145            git2::TreeWalkResult::Ok
146        })?;
147        out.sort_unstable();
148        Ok(out)
149    }
150
151    /// `None` on an unborn branch.
152    fn head_tree(&self) -> Result<Option<git2::Tree<'_>>, VcsError> {
153        Ok(self
154            .head_commit()?
155            .map(|commit| commit.tree())
156            .transpose()?)
157    }
158
159    fn workdir_path(&self) -> Result<&Path, VcsError> {
160        self.repo.workdir().ok_or(VcsError::NoWorkdir)
161    }
162
163    /// `base` tree vs index + worktree, untracked included, renames found.
164    /// `None` is the empty tree.
165    fn workdir_diff(&self, base: Option<&git2::Tree<'_>>) -> Result<DiffModel, VcsError> {
166        let mut options = self.workdir_diff_options();
167        let mut diff = if self.indexed {
168            self.repo
169                .diff_tree_to_workdir_with_index(base, Some(&mut options))?
170        } else {
171            self.repo.diff_tree_to_workdir(base, Some(&mut options))?
172        };
173        let sparse = self.sparse_paths();
174        // ponytail: a sparse checkout gets no rename detection, since it would read
175        // every file outside the cone, which a partial clone never downloaded
176        if sparse.is_empty() {
177            let mut find = git2::DiffFindOptions::new();
178            find.renames(true);
179            diff.find_similar(Some(&mut find))?;
180        }
181        let mut model = self.diff_to_model_skipping(&mut diff, &sparse)?;
182        if !self.indexed {
183            // jj tracks every file of the working copy that no ignore rule names
184            for file in &mut model.files {
185                if file.status == FileStatus::Untracked {
186                    file.status = FileStatus::Added;
187                }
188            }
189        }
190        Ok(model)
191    }
192
193    /// The index entries a sparse checkout leaves out of the worktree. libgit2
194    /// ignores the skip-worktree bit, so we have to drop the deletions it
195    /// reports for them; git shows none.
196    fn sparse_paths(&self) -> HashSet<Vec<u8>> {
197        let Ok(index) = self.repo.index() else {
198            return HashSet::new();
199        };
200        index
201            .iter()
202            .filter(|entry| {
203                git2::IndexEntryExtendedFlag::from_bits_truncate(entry.flags_extended)
204                    .is_skip_worktree()
205            })
206            .map(|entry| entry.path)
207            .collect()
208    }
209
210    fn plain_diff_options(&self) -> git2::DiffOptions {
211        let mut opts = git2::DiffOptions::new();
212        opts.context_lines(self.context_lines);
213        apply_git_algorithm(&mut opts, self.algorithm.get(), self.indent_heuristic.get());
214        opts
215    }
216
217    fn workdir_diff_options(&self) -> git2::DiffOptions {
218        let mut opts = self.plain_diff_options();
219        opts.include_untracked(true)
220            .recurse_untracked_dirs(true)
221            .show_untracked_content(true);
222        opts
223    }
224
225    /// Leaves `.emphasis` empty; the TUI enriches a file when it draws it.
226    fn diff_to_model(&self, diff: &mut git2::Diff<'_>) -> Result<DiffModel, VcsError> {
227        self.diff_to_model_skipping(diff, &HashSet::new())
228    }
229
230    /// [`Self::diff_to_model`] for a worktree diff, leaving out the deletions
231    /// of the `sparse` paths a sparse checkout never wrote to disk.
232    fn diff_to_model_skipping(
233        &self,
234        diff: &mut git2::Diff<'_>,
235        sparse: &HashSet<Vec<u8>>,
236    ) -> Result<DiffModel, VcsError> {
237        let mut files = Vec::new();
238        for idx in 0..diff.deltas().len() {
239            let outside_cone = diff.get_delta(idx).is_some_and(|delta| {
240                delta.status() == git2::Delta::Deleted
241                    && sparse.contains(delta.old_file().path_bytes().unwrap_or_default())
242            });
243            if outside_cone {
244                continue;
245            }
246            if let Some(file) = self.build_file(diff, idx)? {
247                files.push(file);
248            }
249        }
250        Ok(DiffModel { files })
251    }
252
253    /// A modified text file's hunks under histogram/structural, which libgit2
254    /// lacks; `None` keeps git2's own. Model building and hunk staging both
255    /// call this so their hunk ids agree.
256    fn imara_hunks(
257        &self,
258        delta: &git2::DiffDelta<'_>,
259        old: Option<&str>,
260        new: Option<&str>,
261        path: &str,
262    ) -> Option<Vec<Hunk>> {
263        if !self.algorithm.get().is_imara() {
264            return None;
265        }
266        let (old, new) = (old?, new?);
267        // we read the worktree side raw, so when a clean filter (autocrlf,
268        // ident) makes git compare other bytes we keep git's own hunks
269        let hashed = git2::Oid::hash_object(git2::ObjectType::Blob, new.as_bytes()).ok()?;
270        if hashed != delta.new_file().id() {
271            return None;
272        }
273        Some(histogram_hunks(
274            old,
275            new,
276            path,
277            self.context_lines,
278            self.indent_heuristic.get(),
279        ))
280    }
281
282    fn walk_entries(
283        &self,
284        walk: git2::Revwalk<'_>,
285        limit: usize,
286    ) -> Result<Vec<LogEntry>, VcsError> {
287        let mut entries = Vec::new();
288        for oid in walk.take(limit) {
289            entries.push(log_entry(&self.repo.find_commit(oid?)?)?);
290        }
291        Ok(entries)
292    }
293
294    /// Where `commit` took `path` from its first parent: the same path, or
295    /// the one a rename moved it from. `None` when the parent has neither.
296    fn path_in_parent(
297        &self,
298        commit: &git2::Commit<'_>,
299        parent: &git2::Commit<'_>,
300        path: &str,
301    ) -> Result<Option<String>, VcsError> {
302        if blob_at(parent, path)?.is_some() {
303            return Ok(Some(path.to_owned()));
304        }
305        let mut diff = self.repo.diff_tree_to_tree(
306            Some(&parent.tree()?),
307            Some(&commit.tree()?),
308            Some(&mut git2::DiffOptions::new()),
309        )?;
310        diff.find_similar(Some(git2::DiffFindOptions::new().renames(true)))?;
311        Ok(diff.deltas().find_map(|delta| {
312            let renamed = delta.status() == git2::Delta::Renamed
313                && delta.new_file().path() == Some(Path::new(path));
314            renamed
315                .then(|| {
316                    delta
317                        .old_file()
318                        .path()
319                        .map(|old| old.to_string_lossy().into_owned())
320                })
321                .flatten()
322        }))
323    }
324
325    fn blame_spans(&self, blame: &git2::Blame<'_>) -> Vec<BlameSpan> {
326        let mut out = Vec::new();
327        for hunk in blame.iter() {
328            let oid = hunk.final_commit_id();
329            let commit = self.repo.find_commit(oid).ok();
330            let full = oid.to_string();
331            out.push(BlameSpan {
332                start_line: u32::try_from(hunk.final_start_line()).unwrap_or(u32::MAX),
333                line_count: u32::try_from(hunk.lines_in_hunk()).unwrap_or(u32::MAX),
334                oid7: short7(&full),
335                oid: full,
336                author: commit
337                    .as_ref()
338                    .map(|c| c.author().name().unwrap_or_default().to_owned())
339                    .unwrap_or_default(),
340                time_unix: commit.as_ref().map_or(0, |c| c.time().seconds()),
341                summary: commit
342                    .as_ref()
343                    .and_then(|c| c.summary().ok().flatten().map(str::to_owned))
344                    .unwrap_or_default(),
345                committed: !oid.is_zero(),
346            });
347        }
348        out.sort_by_key(|span| span.start_line);
349        out
350    }
351
352    /// [`Vcs::log`], with `all_branches` also walking `tips`, the commits jj
353    /// bookmarks that the git store holds no ref for.
354    pub(crate) fn log_from(
355        &self,
356        limit: usize,
357        all_branches: bool,
358        tips: &[&str],
359    ) -> Result<Vec<LogEntry>, VcsError> {
360        let Some(head) = self.head_commit()? else {
361            return Ok(Vec::new());
362        };
363        let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
364        for reference in self.repo.references()?.flatten() {
365            let Ok(name) = reference.shorthand().map(str::to_owned) else {
366                continue;
367            };
368            let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
369                continue;
370            };
371            refs_by_oid.entry(target).or_default().push(name);
372        }
373
374        let mut walk = self.repo.revwalk()?;
375        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
376        walk.push(head.id())?;
377        if all_branches {
378            for glob in ["refs/heads", "refs/remotes", "refs/tags"] {
379                walk.push_glob(glob)?;
380            }
381            for tip in tips {
382                walk.push(git2::Oid::from_str(tip)?)?;
383            }
384        }
385        let mut entries = self.walk_entries(walk, limit)?;
386        for entry in &mut entries {
387            if let Some(refs) = git2::Oid::from_str(&entry.oid)
388                .ok()
389                .and_then(|oid| refs_by_oid.remove(&oid))
390            {
391                entry.refs = refs;
392            }
393        }
394        Ok(entries)
395    }
396
397    /// `(ahead, behind)` of commit `local` against commit `upstream`.
398    pub(crate) fn ahead_behind(
399        &self,
400        local: &str,
401        upstream: &str,
402    ) -> Result<(usize, usize), VcsError> {
403        Ok(self
404            .repo
405            .graph_ahead_behind(git2::Oid::from_str(local)?, git2::Oid::from_str(upstream)?)?)
406    }
407
408    /// Whether `git stash` would save anything; untracked files don't count.
409    fn has_tracked_changes(&self) -> Result<bool, VcsError> {
410        let mut opts = git2::StatusOptions::new();
411        opts.include_untracked(false).include_ignored(false);
412        let statuses = self.repo.statuses(Some(&mut opts))?;
413        let sparse = self.sparse_paths();
414        Ok(statuses.iter().any(|entry| {
415            !(entry.status() == git2::Status::WT_DELETED && sparse.contains(entry.path_bytes()))
416        }))
417    }
418}
419
420impl Vcs for GitVcs {
421    fn has_index(&self) -> bool {
422        true
423    }
424
425    fn native_git_checkout(&self) -> bool {
426        true
427    }
428
429    fn git_dir(&self) -> Result<PathBuf, VcsError> {
430        Ok(self.repo.path().to_path_buf())
431    }
432
433    fn head(&self) -> Result<HeadInfo, VcsError> {
434        if !matches!(self.base.get(), Base::FollowHead) {
435            let commit = self.head_commit()?;
436            return Ok(HeadInfo {
437                branch: None,
438                oid7: commit
439                    .as_ref()
440                    .map(|c| short7(&c.id().to_string()))
441                    .unwrap_or_default(),
442                subject: commit
443                    .as_ref()
444                    .and_then(|c| c.summary().ok().flatten().map(str::to_owned))
445                    .unwrap_or_default(),
446                upstream: None,
447                ahead: 0,
448                behind: 0,
449            });
450        }
451        match self.repo.head() {
452            Ok(head) => {
453                let branch = if head.is_branch() {
454                    Some(head.shorthand()?.to_owned())
455                } else {
456                    None
457                };
458                let commit = head.peel_to_commit()?;
459                let tracking = branch.as_deref().and_then(|name| {
460                    let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
461                    let upstream = local.upstream().ok()?;
462                    let name = upstream.name().ok().flatten()?.to_owned();
463                    Some((name, upstream.get().target()))
464                });
465                let (upstream, ahead, behind) = match tracking {
466                    Some((name, Some(target))) => {
467                        let (ahead, behind) = self
468                            .repo
469                            .graph_ahead_behind(commit.id(), target)
470                            .unwrap_or((0, 0));
471                        (Some(name), ahead, behind)
472                    }
473                    Some((name, None)) => (Some(name), 0, 0),
474                    None => (None, 0, 0),
475                };
476                Ok(HeadInfo {
477                    branch,
478                    oid7: short7(&commit.id().to_string()),
479                    subject: commit.summary()?.unwrap_or_default().to_owned(),
480                    upstream,
481                    ahead,
482                    behind,
483                })
484            }
485            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
486                let branch = self
487                    .repo
488                    .find_reference("HEAD")
489                    .ok()
490                    .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
491                    .and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
492                Ok(HeadInfo {
493                    branch,
494                    oid7: String::new(),
495                    subject: String::new(),
496                    upstream: None,
497                    ahead: 0,
498                    behind: 0,
499                })
500            }
501            Err(err) => Err(err.into()),
502        }
503    }
504
505    fn status(&self) -> Result<StatusModel, VcsError> {
506        // we diff against the index so a staged new file lands in staged only
507        let mut workdir = self
508            .repo
509            .diff_index_to_workdir(None, Some(&mut self.workdir_diff_options()))?;
510        let workdir_model = self.diff_to_model_skipping(&mut workdir, &self.sparse_paths())?;
511        let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
512            .files
513            .into_iter()
514            .partition(|f| f.status == FileStatus::Untracked);
515
516        let head_tree = self.head_tree()?;
517        let mut staged = self.repo.diff_tree_to_index(
518            head_tree.as_ref(),
519            None,
520            Some(&mut self.plain_diff_options()),
521        )?;
522        let staged = self.diff_to_model(&mut staged)?;
523
524        Ok(StatusModel {
525            untracked: DiffModel { files: untracked },
526            unstaged: DiffModel { files: unstaged },
527            staged,
528        })
529    }
530
531    fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
532        self.workdir_diff(self.head_tree()?.as_ref())
533    }
534
535    fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
536        let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
537        self.workdir_diff(Some(&base.tree()?))
538    }
539
540    fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
541        let oid = git2::Oid::from_str(oid)?;
542        let commit = self.repo.find_commit(oid)?;
543        let tree = commit.tree()?;
544        let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
545        let mut diff = self.repo.diff_tree_to_tree(
546            parent_tree.as_ref(),
547            Some(&tree),
548            Some(&mut self.plain_diff_options()),
549        )?;
550        self.diff_to_model(&mut diff)
551    }
552
553    fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
554        let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
555        let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
556        let mut diff = self.repo.diff_tree_to_tree(
557            Some(&base.tree()?),
558            Some(&newest.tree()?),
559            Some(&mut self.plain_diff_options()),
560        )?;
561        self.diff_to_model(&mut diff)
562    }
563
564    fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
565        let base = self
566            .repo
567            .merge_base(git2::Oid::from_str(a)?, git2::Oid::from_str(b)?)?;
568        Ok(base.to_string())
569    }
570
571    fn resolve(&self, revision: &str) -> Result<String, VcsError> {
572        let object = self.revparse(revision)?;
573        let commit = object.peel_to_commit()?;
574        Ok(commit.id().to_string())
575    }
576
577    fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
578        let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
579        let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
580        let newest_tree = newest.tree()?;
581        let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
582        let mut diff = self.repo.diff_tree_to_tree(
583            base_tree.as_ref(),
584            Some(&newest_tree),
585            Some(&mut self.plain_diff_options()),
586        )?;
587        self.diff_to_model(&mut diff)
588    }
589
590    fn log(&self, limit: usize, all_branches: bool) -> Result<Vec<LogEntry>, VcsError> {
591        self.log_from(limit, all_branches, &[])
592    }
593
594    fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
595        let head_ref = format!("refs/remotes/{remote}/HEAD");
596        let prefix = format!("refs/remotes/{remote}/");
597        if let Ok(reference) = self.repo.find_reference(&head_ref)
598            && let Ok(Some(target)) = reference.symbolic_target()
599            // we keep the whole remainder so `release/2.x` survives
600            && let Some(name) = target.strip_prefix(prefix.as_str())
601        {
602            return Ok(Some(name.to_owned()));
603        }
604        for name in ["main", "master"] {
605            if self
606                .repo
607                .find_branch(name, git2::BranchType::Local)
608                .or_else(|_| {
609                    self.repo
610                        .find_branch(&format!("{remote}/{name}"), git2::BranchType::Remote)
611                })
612                .is_ok()
613            {
614                return Ok(Some(name.to_owned()));
615            }
616        }
617        Ok(None)
618    }
619
620    fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
621        let (base, head) = (self.revparse(base)?, self.revparse(head)?);
622        let mut walk = self.repo.revwalk()?;
623        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
624        walk.push(head.id())?;
625        walk.hide(base.id())?;
626        self.walk_entries(walk, usize::MAX)
627    }
628
629    fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError> {
630        let Some(head) = self.head_commit().ok().flatten() else {
631            return Ok(None);
632        };
633        let mut walk = self.repo.revwalk()?;
634        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
635        walk.push(head.id())?;
636        let mut remotes = 0;
637        for reference in self.repo.references()? {
638            let reference = reference?;
639            // a symbolic ref (origin/HEAD) has no target, and its destination
640            // is hidden anyway
641            if let (true, Some(oid)) = (reference.is_remote(), reference.target()) {
642                remotes += 1;
643                walk.hide(oid)?;
644            }
645        }
646        if remotes == 0 {
647            return Ok(None);
648        }
649        self.walk_entries(walk, limit).map(Some)
650    }
651
652    fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
653        let mut options = git2::BlameOptions::new();
654        options.track_copies_same_file(true);
655        if !matches!(self.base.get(), Base::FollowHead) {
656            options.newest_commit(self.head_commit_or_unborn()?.id());
657        }
658        let blame = self.repo.blame_file(rel, Some(&mut options))?;
659        // blame_file only knows committed content, so we remap the spans onto
660        // the worktree with blame_buffer
661        let workdir = self.workdir()?.join(rel);
662        let blame = match fs::read(&workdir) {
663            Ok(bytes) => blame.blame_buffer(&bytes)?,
664            Err(_) => blame,
665        };
666        Ok(self.blame_spans(&blame))
667    }
668
669    fn blame_at(&self, rev: &str, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
670        let commit = self.revparse(rev)?.peel_to_commit()?;
671        let mut options = git2::BlameOptions::new();
672        options
673            .track_copies_same_file(true)
674            .newest_commit(commit.id());
675        let blame = self.repo.blame_file(rel, Some(&mut options))?;
676        Ok(self.blame_spans(&blame))
677    }
678
679    fn file_history(&self, rel: &str, limit: usize) -> Result<Vec<FileVersion>, VcsError> {
680        let mut versions = Vec::new();
681        let Some(mut commit) = self.head_commit()? else {
682            return Ok(versions);
683        };
684        let mut path = rel.to_owned();
685        for _ in 0..HISTORY_WALK_LIMIT {
686            let Some(blob) = blob_at(&commit, &path)? else {
687                break;
688            };
689            let parent = commit.parent(0).ok();
690            let unchanged = match &parent {
691                Some(parent) => blob_at(parent, &path)? == Some(blob),
692                None => false,
693            };
694            if !unchanged {
695                versions.push(FileVersion {
696                    entry: log_entry(&commit)?,
697                    path: path.clone(),
698                });
699                if versions.len() >= limit {
700                    break;
701                }
702            }
703            let Some(parent) = parent else {
704                break;
705            };
706            if !unchanged {
707                match self.path_in_parent(&commit, &parent, &path)? {
708                    Some(older) => path = older,
709                    None => break,
710                }
711            }
712            commit = parent;
713        }
714        Ok(versions)
715    }
716
717    fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError> {
718        let commit = self.revparse(rev)?.peel_to_commit()?;
719        Ok(blob_at(&commit, path)?.and_then(|oid| blob_text(&self.repo, oid)))
720    }
721
722    fn read_blob(&self, oid: &str) -> Result<Option<Vec<u8>>, VcsError> {
723        let oid = git2::Oid::from_str(oid)?;
724        match self.repo.find_blob(oid) {
725            Ok(blob) => Ok(Some(blob.content().to_vec())),
726            Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
727            Err(err) => Err(err.into()),
728        }
729    }
730
731    fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError> {
732        if !self.indexed {
733            return self.tree_files();
734        }
735        let index = self.repo.index()?;
736        let mut out: Vec<PathBuf> = index
737            .iter()
738            .filter_map(|entry| {
739                std::str::from_utf8(&entry.path)
740                    .ok()
741                    .map(|path| PathBuf::from(path.to_owned()))
742            })
743            .collect();
744        out.sort_unstable();
745        out.dedup();
746        Ok(out)
747    }
748
749    fn attr(&self, rel: &Path, name: &str) -> bool {
750        let Ok(value) = self.repo.get_attr(rel, name, git2::AttrCheckFlags::empty()) else {
751            return false;
752        };
753        // a bare `linguist-generated` is git's boolean, `=true` is a string
754        matches!(
755            git2::AttrValue::from_string(value),
756            git2::AttrValue::True | git2::AttrValue::String("true")
757        )
758    }
759
760    fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
761        let mut out = Vec::new();
762        for entry in self.repo.branches(Some(git2::BranchType::Local))? {
763            let (branch, _) = entry?;
764            let Some(name) = branch.name()?.map(str::to_owned) else {
765                continue;
766            };
767            let tip_unix = branch
768                .get()
769                .peel_to_commit()
770                .ok()
771                .map_or(0, |commit| commit.time().seconds());
772            out.push(BranchInfo {
773                name,
774                is_head: branch.is_head(),
775                tip_unix,
776                divergence: None,
777            });
778        }
779        Ok(out)
780    }
781
782    fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError> {
783        let branch = self.repo.find_branch(branch, git2::BranchType::Local)?;
784        let Some(tip) = branch.get().peel_to_commit().ok() else {
785            return Ok(None);
786        };
787        let Some(target) = branch.upstream().ok().and_then(|up| up.get().target()) else {
788            return Ok(None);
789        };
790        Ok(Some(self.repo.graph_ahead_behind(tip.id(), target)?))
791    }
792
793    fn all_branches(&self) -> Result<Vec<String>, VcsError> {
794        let mut out = Vec::new();
795        for entry in self.repo.branches(None)? {
796            let (branch, _) = entry?;
797            let Some(name) = branch.name()?.map(str::to_owned) else {
798                continue;
799            };
800            // refs/remotes/<remote>/HEAD is a symbolic alias
801            if name.ends_with("/HEAD") {
802                continue;
803            }
804            out.push(name);
805        }
806        Ok(out)
807    }
808
809    fn stage(&self, rel: &Path) -> Result<(), VcsError> {
810        let root = self.workdir_path()?;
811        let mut index = self.repo.index()?;
812        if root.join(rel).exists() {
813            index.add_path(rel)?;
814        } else {
815            index.remove_path(rel)?;
816        }
817        index.write()?;
818        Ok(())
819    }
820
821    fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
822        let diff = self
823            .repo
824            .diff_index_to_workdir(None, Some(&mut self.workdir_diff_options()))?;
825        let patch = self.synthesize_patch(&diff, rel, hunk, false)?;
826        let diff = git2::Diff::from_buffer(&patch)?;
827        self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
828        Ok(())
829    }
830
831    fn stage_everything(&self) -> Result<(), VcsError> {
832        let sparse = self.sparse_paths();
833        let mut index = self.repo.index()?;
834        if sparse.is_empty() {
835            // update_all catches deletions, add_all catches untracked files
836            index.update_all(["*"], None)?;
837            index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
838        } else {
839            // both read the blob of every path outside the sparse cone, which a
840            // partial clone never downloaded, so we stage the worktree diff's
841            // paths ourselves; listing the deltas reads no blob
842            let workdir = self.workdir_path()?.to_path_buf();
843            let diff = self
844                .repo
845                .diff_index_to_workdir(Some(&index), Some(&mut self.workdir_diff_options()))?;
846            let changed: Vec<PathBuf> = diff
847                .deltas()
848                .filter_map(|delta| {
849                    let file = if delta.status() == git2::Delta::Deleted {
850                        delta.old_file()
851                    } else {
852                        delta.new_file()
853                    };
854                    let path = file.path_bytes()?;
855                    (!sparse.contains(path))
856                        .then(|| PathBuf::from(String::from_utf8_lossy(path).into_owned()))
857                })
858                .collect();
859            for path in changed {
860                if workdir.join(&path).exists() {
861                    index.add_path(&path)?;
862                } else {
863                    index.remove_path(&path)?;
864                }
865            }
866        }
867        index.write()?;
868        Ok(())
869    }
870
871    fn unstage_everything(&self) -> Result<(), VcsError> {
872        match self.repo.head() {
873            Ok(head) => {
874                let target = head.peel(git2::ObjectType::Commit)?;
875                self.repo.reset_default(Some(&target), ["*"])?;
876            }
877            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
878                let mut index = self.repo.index()?;
879                index.clear()?;
880                index.write()?;
881            }
882            Err(err) => return Err(err.into()),
883        }
884        Ok(())
885    }
886
887    fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
888        match self.repo.head() {
889            Ok(head) => {
890                let target = head.peel(git2::ObjectType::Commit)?;
891                self.repo.reset_default(Some(&target), [rel])?;
892            }
893            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
894                let mut index = self.repo.index()?;
895                index.remove_path(rel)?;
896                index.write()?;
897            }
898            Err(err) => return Err(err.into()),
899        }
900        Ok(())
901    }
902
903    fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
904        let head_tree = self.head_tree()?;
905        let diff = self.repo.diff_tree_to_index(
906            head_tree.as_ref(),
907            None,
908            Some(&mut self.plain_diff_options()),
909        )?;
910        let patch = self.synthesize_patch(&diff, rel, hunk, true)?;
911        let diff = git2::Diff::from_buffer(&patch)?;
912        self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
913        Ok(())
914    }
915
916    fn discard(&self, rel: &Path) -> Result<(), VcsError> {
917        let head_tree = self.head_tree()?;
918        let mut opts = git2::DiffOptions::new();
919        opts.pathspec(rel).disable_pathspec_match(true);
920        let staged = self
921            .repo
922            .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
923        if staged.deltas().len() > 0 {
924            return Err(VcsError::Rejected(
925                "file has staged changes; unstage first".into(),
926            ));
927        }
928        let status = self.repo.status_file(rel)?;
929        if status.contains(git2::Status::WT_NEW) {
930            fs::remove_file(self.workdir_path()?.join(rel))?;
931            return Ok(());
932        }
933        // we update the index stat cache too: under autocrlf the checkout
934        // grows the file, and a stale cached size makes git report a phantom
935        // modification. Nothing is staged here, so only metadata changes.
936        let mut checkout = git2::build::CheckoutBuilder::new();
937        checkout.path(rel).force().update_index(true);
938        self.repo.checkout_head(Some(&mut checkout))?;
939        Ok(())
940    }
941
942    fn commit(&self, message: &str) -> Result<String, VcsError> {
943        if message.trim().is_empty() {
944            return Err(VcsError::Rejected("empty commit message".into()));
945        }
946        let mut index = self.repo.index()?;
947        let tree_id = index.write_tree()?;
948        let tree = self.repo.find_tree(tree_id)?;
949        let signature = self.repo.signature()?;
950        let parent = match self.repo.head() {
951            Ok(head) => Some(head.peel_to_commit()?),
952            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
953            Err(err) => return Err(err.into()),
954        };
955        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
956        let oid = self.repo.commit(
957            Some("HEAD"),
958            &signature,
959            &signature,
960            message,
961            &tree,
962            &parents,
963        )?;
964        Ok(oid.to_string())
965    }
966
967    fn head_message(&self) -> Result<String, VcsError> {
968        let commit = self.head_commit_or_unborn()?;
969        Ok(commit.message().unwrap_or_default().to_owned())
970    }
971
972    fn commit_message(&self, oid: &str) -> Result<String, VcsError> {
973        let commit = self.repo.find_commit(git2::Oid::from_str(oid)?)?;
974        Ok(commit.message().unwrap_or_default().to_owned())
975    }
976
977    fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
978        if let Some(message) = message
979            && message.trim().is_empty()
980        {
981            return Err(VcsError::Rejected("empty commit message".into()));
982        }
983        let head = self.repo.head()?.peel_to_commit()?;
984        let tree = if use_index {
985            let mut index = self.repo.index()?;
986            let tree_id = index.write_tree()?;
987            self.repo.find_tree(tree_id)?
988        } else {
989            head.tree()?
990        };
991        let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
992        Ok(oid.to_string())
993    }
994
995    fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
996        let head = self.repo.head()?.peel_to_commit()?;
997        self.repo.branch(name, &head, false)?;
998        if checkout {
999            self.checkout(name)?;
1000        }
1001        Ok(())
1002    }
1003
1004    fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
1005        let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
1006        if branch.is_head() {
1007            return Err(VcsError::Rejected(
1008                "cannot delete the checked-out branch".into(),
1009            ));
1010        }
1011        branch.delete()?;
1012        Ok(())
1013    }
1014
1015    fn checkout(&self, name: &str) -> Result<(), VcsError> {
1016        let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
1017        let target = branch.get().peel(git2::ObjectType::Commit)?;
1018        // a non-force checkout refuses to clobber local modifications
1019        self.repo.checkout_tree(&target, None)?;
1020        self.repo.set_head(&format!("refs/heads/{name}"))?;
1021        Ok(())
1022    }
1023
1024    fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError> {
1025        if !self.has_tracked_changes()? {
1026            return Err(VcsError::Rejected("nothing to stash".into()));
1027        }
1028        // git2's stash needs `&mut Repository` and the trait is `&self`, so we
1029        // open a fresh handle
1030        let mut repo = git2::Repository::open(self.workdir_path()?)?;
1031        let signature = repo.signature()?;
1032        repo.stash_save2(&signature, message, None)?;
1033        Ok(())
1034    }
1035
1036    fn stash_pop(&self) -> Result<(), VcsError> {
1037        let mut repo = git2::Repository::open(self.workdir_path()?)?;
1038        match repo.stash_pop(0, None) {
1039            Ok(()) => Ok(()),
1040            Err(err) if err.code() == git2::ErrorCode::NotFound => {
1041                Err(VcsError::Rejected("no stash to pop".into()))
1042            }
1043            // a conflicting pop leaves the merge in the worktree and keeps the
1044            // stash entry
1045            Err(err) if err.code() == git2::ErrorCode::Conflict => Err(VcsError::Rejected(
1046                "stash applied with conflicts; resolve them (the stash was kept)".into(),
1047            )),
1048            Err(err) => Err(err.into()),
1049        }
1050    }
1051
1052    fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError> {
1053        let args: Vec<String> = match op {
1054            NetworkOp::Fetch => vec!["fetch".into()],
1055            NetworkOp::FetchAll => vec!["fetch".into(), "--all".into()],
1056            NetworkOp::Push => vec!["push".into()],
1057            NetworkOp::PushSetUpstream { remote } => {
1058                vec!["push".into(), "-u".into(), remote, "HEAD".into()]
1059            }
1060            NetworkOp::Pull => vec!["pull".into()],
1061            NetworkOp::PullFrom { remote, branch } => vec!["pull".into(), remote, branch],
1062            NetworkOp::PullRebase => vec!["pull".into(), "--rebase".into()],
1063            NetworkOp::PullMerge => vec!["pull".into(), "--no-rebase".into()],
1064        };
1065        Ok(std::iter::once("git".to_owned()).chain(args).collect())
1066    }
1067
1068    fn worktrees(&self) -> Result<Vec<Worktree>, VcsError> {
1069        let here = self
1070            .repo
1071            .workdir()
1072            .and_then(|dir| fs::canonicalize(dir).ok());
1073        let opened;
1074        let main = if self.repo.is_worktree() {
1075            opened = git2::Repository::open(self.repo.commondir())?;
1076            &opened
1077        } else {
1078            &self.repo
1079        };
1080        let entry = |name: String, path: &Path, repo: Option<&git2::Repository>, main: bool| {
1081            let (label, subject, time_unix) =
1082                repo.map_or_else(|| (name.clone(), String::new(), 0), worktree_head);
1083            let branch = repo
1084                .and_then(|repo| repo.head().ok())
1085                .filter(git2::Reference::is_branch)
1086                .and_then(|head| head.shorthand().ok().map(str::to_owned));
1087            Worktree {
1088                name,
1089                current: here.is_some() && fs::canonicalize(path).ok() == here,
1090                path: path.to_path_buf(),
1091                label,
1092                branch,
1093                subject,
1094                time_unix,
1095                main,
1096                missing: repo.is_none(),
1097            }
1098        };
1099        let mut found = Vec::new();
1100        // a bare clone has no main worktree, only linked ones
1101        if let Some(dir) = main.workdir() {
1102            found.push(entry(String::new(), dir, Some(main), true));
1103        }
1104        let mut names: Vec<String> = main
1105            .worktrees()?
1106            .iter()
1107            .flatten()
1108            .flatten()
1109            .map(str::to_owned)
1110            .collect();
1111        names.sort();
1112        for name in names {
1113            let linked = main.find_worktree(&name)?;
1114            let repo = linked
1115                .validate()
1116                .ok()
1117                .and_then(|()| git2::Repository::open_from_worktree(&linked).ok());
1118            found.push(entry(name, linked.path(), repo.as_ref(), false));
1119        }
1120        Ok(found)
1121    }
1122
1123    fn worktree_argv(&self, op: &WorktreeOp) -> Result<Vec<String>, VcsError> {
1124        let mut argv: Vec<String> = vec!["git".into(), "worktree".into()];
1125        match op {
1126            WorktreeOp::Add {
1127                path,
1128                branch,
1129                new_branch: true,
1130            } => argv.extend([
1131                "add".into(),
1132                "-b".into(),
1133                branch.clone(),
1134                path.display().to_string(),
1135            ]),
1136            WorktreeOp::Add { path, branch, .. } => {
1137                argv.extend(["add".into(), path.display().to_string(), branch.clone()]);
1138            }
1139            WorktreeOp::Remove { worktree, .. } if worktree.main => {
1140                return Err(VcsError::Rejected(
1141                    "the main worktree stays with the repository; remove a linked one".into(),
1142                ));
1143            }
1144            WorktreeOp::Remove { worktree, force } => {
1145                argv.push("remove".into());
1146                if *force {
1147                    argv.push("--force".into());
1148                }
1149                argv.push(worktree.path.display().to_string());
1150            }
1151        }
1152        Ok(argv)
1153    }
1154
1155    fn workdir(&self) -> Result<PathBuf, VcsError> {
1156        Ok(self.workdir_path()?.to_path_buf())
1157    }
1158
1159    fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
1160        match self.repo.find_remote(name) {
1161            // url() errs only on a non-UTF-8 URL, which we treat as none
1162            Ok(remote) => Ok(remote.url().ok().map(str::to_owned)),
1163            Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
1164            Err(err) => Err(err.into()),
1165        }
1166    }
1167
1168    fn remotes(&self) -> Result<Vec<String>, VcsError> {
1169        let array = self.repo.remotes()?;
1170        let mut names = Vec::new();
1171        for i in 0..array.len() {
1172            if let Ok(Some(name)) = array.get(i) {
1173                names.push(name.to_owned());
1174            }
1175        }
1176        Ok(names)
1177    }
1178
1179    fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
1180        self.algorithm.set(algorithm);
1181        self.indent_heuristic.set(indent_heuristic);
1182    }
1183}
1184
1185/// Histogram and structural have no git2 flag; [`GitVcs::imara_hunks`]
1186/// computes those.
1187fn apply_git_algorithm(
1188    opts: &mut git2::DiffOptions,
1189    algorithm: DiffAlgorithm,
1190    indent_heuristic: bool,
1191) {
1192    match algorithm {
1193        DiffAlgorithm::Minimal => {
1194            opts.minimal(true);
1195        }
1196        DiffAlgorithm::Patience => {
1197            opts.patience(true);
1198        }
1199        DiffAlgorithm::Myers | DiffAlgorithm::Histogram | DiffAlgorithm::Structural => {}
1200    }
1201    opts.indent_heuristic(indent_heuristic);
1202}
1203
1204impl GitVcs {
1205    /// Render one hunk of `rel` as a unified patch libgit2 can apply to the
1206    /// index. `reverse` flips the patch so applying it undoes a staged hunk.
1207    fn synthesize_patch(
1208        &self,
1209        diff: &git2::Diff<'_>,
1210        rel: &Path,
1211        target: &HunkId,
1212        reverse: bool,
1213    ) -> Result<Vec<u8>, VcsError> {
1214        let rel = rel.to_string_lossy();
1215        for idx in 0..diff.deltas().len() {
1216            let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
1217                continue;
1218            };
1219            // we take `delta` from the patch, since loading the patch fills in
1220            // the worktree side's id that `imara_hunks` checks
1221            let delta = patch.delta();
1222            if delta.flags().is_binary() || delta_new_path(&delta) != rel {
1223                continue;
1224            }
1225            if self.algorithm.get().is_imara() {
1226                let old_text = blob_text(&self.repo, delta.old_file().id());
1227                let new_text = new_side_text(&self.repo, &delta, &rel);
1228                let (old, new) = (old_text.as_deref(), new_text.as_deref());
1229                if let Some(hunks) = self.imara_hunks(&delta, old, new, &rel)
1230                    && let (Some(old), Some(new)) = (old, new)
1231                {
1232                    if let Some(hunk) = hunks.iter().find(|h| h.id == *target) {
1233                        return Ok(render_hunk_patch_from_model(hunk, &rel, reverse, old, new));
1234                    }
1235                    continue;
1236                }
1237            }
1238            let mut seen = HashMap::new();
1239            for h in 0..patch.num_hunks() {
1240                let lines = hunk_model_lines(&patch, h)?;
1241                if disambiguated_hunk_id(&rel, &lines, &mut seen) == *target {
1242                    return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
1243                }
1244            }
1245        }
1246        Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
1247    }
1248
1249    fn build_file(
1250        &self,
1251        diff: &mut git2::Diff<'_>,
1252        idx: usize,
1253    ) -> Result<Option<FileDiff>, VcsError> {
1254        let repo = &self.repo;
1255        let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
1256            return Ok(build_binary_file(diff, idx));
1257        };
1258        let delta = patch.delta();
1259        if delta.flags().is_binary() {
1260            return Ok(build_binary_file(diff, idx));
1261        }
1262        let file_path = delta_new_path(&delta);
1263        let status = map_status(delta.status());
1264        let old_path = if status == FileStatus::Renamed {
1265            delta
1266                .old_file()
1267                .path()
1268                .map(|p| p.to_string_lossy().into_owned())
1269        } else {
1270            None
1271        };
1272
1273        let old_text = blob_text(repo, delta.old_file().id());
1274        let new_text = new_side_text(repo, &delta, &file_path);
1275
1276        let imara = self.imara_hunks(&delta, old_text.as_deref(), new_text.as_deref(), &file_path);
1277        let hunks = match imara {
1278            Some(hunks) => hunks,
1279            None => patch_hunks(&patch, &file_path)?,
1280        };
1281
1282        Ok(Some(FileDiff {
1283            path: file_path,
1284            old_path,
1285            status,
1286            binary: false,
1287            old_text,
1288            new_text,
1289            hunks,
1290            hashes: crate::model::HashCache::default(),
1291            blobs: crate::model::BlobIds::default(),
1292        }))
1293    }
1294}
1295
1296fn render_hunk_patch(
1297    patch: &git2::Patch<'_>,
1298    h: usize,
1299    rel: &str,
1300    status: git2::Delta,
1301    reverse: bool,
1302) -> Result<Vec<u8>, VcsError> {
1303    let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
1304    let deleted = status == git2::Delta::Deleted;
1305    let mut out = Vec::new();
1306    out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
1307    // whole-file adds and deletes keep that identity (sides swapped under
1308    // reverse) so applying creates or drops the index entry; otherwise git
1309    // leaves an empty blob behind
1310    if (added && !reverse) || (deleted && reverse) {
1311        out.extend_from_slice(
1312            format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
1313        );
1314    } else if (deleted && !reverse) || (added && reverse) {
1315        out.extend_from_slice(
1316            format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
1317        );
1318    } else {
1319        out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
1320    }
1321    let (hunk, line_count) = patch.hunk(h)?;
1322    let mut lines: Vec<(LineKind, Vec<u8>)> = Vec::with_capacity(line_count);
1323    for l in 0..line_count {
1324        let line = patch.line_in_hunk(h, l)?;
1325        let kind = match line.origin() {
1326            ' ' => LineKind::Context,
1327            '+' => LineKind::Added,
1328            '-' => LineKind::Deleted,
1329            // an EOF-newline marker carries the "\ No newline" text and the
1330            // newline ending the previous line, so we append it to that line
1331            '=' | '>' | '<' => {
1332                if let Some((_, text)) = lines.last_mut() {
1333                    text.extend_from_slice(line.content());
1334                }
1335                continue;
1336            }
1337            _ => continue,
1338        };
1339        lines.push((kind, line.content().to_vec()));
1340    }
1341    write_hunk(
1342        &mut out,
1343        (hunk.old_start(), hunk.old_lines()),
1344        (hunk.new_start(), hunk.new_lines()),
1345        &lines,
1346        reverse,
1347    );
1348    Ok(out)
1349}
1350
1351/// One hunk's `@@` header and lines. `old`/`new` are `(start, count)`; each
1352/// line's bytes are already terminated.
1353fn write_hunk(
1354    out: &mut Vec<u8>,
1355    old: (u32, u32),
1356    new: (u32, u32),
1357    lines: &[(LineKind, Vec<u8>)],
1358    reverse: bool,
1359) {
1360    let ((minus_start, minus_lines), (_, plus_lines)) =
1361        if reverse { (new, old) } else { (old, new) };
1362    // libgit2 applies a hunk at its `+` start, and we send this hunk without
1363    // the ones before it, so we point that start at the minus side's
1364    // position; git's header gives an empty side the line before the change
1365    let plus_start = if minus_lines == 0 {
1366        minus_start + 1
1367    } else {
1368        minus_start
1369    };
1370    out.extend_from_slice(
1371        format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
1372    );
1373    // minus lines go first in a change run even under `reverse`: libgit2
1374    // rejects a `+` line marked "No newline at end of file" before a `-` one
1375    let (minus_kind, plus_kind) = if reverse {
1376        (LineKind::Added, LineKind::Deleted)
1377    } else {
1378        (LineKind::Deleted, LineKind::Added)
1379    };
1380    let is_context = |kind: LineKind| kind == LineKind::Context;
1381    for run in lines.chunk_by(|a, b| is_context(a.0) == is_context(b.0)) {
1382        for (origin, kind) in [
1383            (b' ', LineKind::Context),
1384            (b'-', minus_kind),
1385            (b'+', plus_kind),
1386        ] {
1387            for (_, text) in run.iter().filter(|(k, _)| *k == kind) {
1388                out.push(origin);
1389                out.extend_from_slice(text);
1390            }
1391        }
1392    }
1393}
1394
1395/// [`render_hunk_patch`] for an imara-diff hunk. We copy each line's raw
1396/// bytes from `old_text`/`new_text`, since the model's text has CR and LF
1397/// stripped.
1398fn render_hunk_patch_from_model(
1399    hunk: &Hunk,
1400    rel: &str,
1401    reverse: bool,
1402    old_text: &str,
1403    new_text: &str,
1404) -> Vec<u8> {
1405    let old_raw: Vec<&str> = old_text.split_inclusive('\n').collect();
1406    let new_raw: Vec<&str> = new_text.split_inclusive('\n').collect();
1407    let raw = |line: &DiffLine| -> Vec<u8> {
1408        let (side, number) = match line.kind {
1409            LineKind::Added => (&new_raw, line.new_no),
1410            LineKind::Deleted | LineKind::Context => (&old_raw, line.old_no),
1411        };
1412        let text = number
1413            .and_then(|n| n.checked_sub(1))
1414            .and_then(|i| side.get(i as usize))
1415            .copied()
1416            .unwrap_or_default();
1417        let mut bytes = text.as_bytes().to_vec();
1418        if !text.ends_with('\n') {
1419            bytes.extend_from_slice(b"\n\\ No newline at end of file\n");
1420        }
1421        bytes
1422    };
1423    let lines: Vec<(LineKind, Vec<u8>)> = hunk.lines.iter().map(|l| (l.kind, raw(l))).collect();
1424
1425    let mut out = format!("diff --git a/{rel} b/{rel}\n--- a/{rel}\n+++ b/{rel}\n").into_bytes();
1426    write_hunk(
1427        &mut out,
1428        (hunk.old_start, hunk.old_lines),
1429        (hunk.new_start, hunk.new_lines),
1430        &lines,
1431        reverse,
1432    );
1433    out
1434}
1435
1436/// `(label, subject, time)` of a worktree's HEAD: the branch, or the commit a
1437/// detached HEAD sits on.
1438fn worktree_head(repo: &git2::Repository) -> (String, String, i64) {
1439    let Ok(head) = repo.head() else {
1440        return ("(no commits)".to_owned(), String::new(), 0);
1441    };
1442    let Ok(commit) = head.peel_to_commit() else {
1443        return ("(no commits)".to_owned(), String::new(), 0);
1444    };
1445    let label = if head.is_branch() {
1446        head.shorthand().unwrap_or_default().to_owned()
1447    } else {
1448        format!("detached {}", short7(&commit.id().to_string()))
1449    };
1450    (
1451        label,
1452        commit
1453            .summary()
1454            .ok()
1455            .flatten()
1456            .unwrap_or_default()
1457            .to_owned(),
1458        commit.time().seconds(),
1459    )
1460}
1461
1462pub fn short7(oid: &str) -> String {
1463    oid.get(..7).unwrap_or(oid).to_owned()
1464}
1465
1466/// Re-diff a file's own old/new text at `context` lines (`u32::MAX` for the
1467/// whole file). `None` for binary files or a missing side.
1468pub fn rehunk_file(
1469    file: &FileDiff,
1470    context: u32,
1471    algorithm: DiffAlgorithm,
1472    indent_heuristic: bool,
1473) -> Option<Vec<Hunk>> {
1474    if file.binary {
1475        return None;
1476    }
1477    let (old, new) = (file.old_text.as_deref()?, file.new_text.as_deref()?);
1478    if algorithm.is_imara() {
1479        return Some(histogram_hunks(
1480            old,
1481            new,
1482            &file.path,
1483            context,
1484            indent_heuristic,
1485        ));
1486    }
1487    let as_path = Path::new(&file.path);
1488    // libgit2's context math overflows on u32::MAX and yields zero context on
1489    // some platforms, so we cap it at the line count
1490    let cap = u32::try_from(old.lines().count().max(new.lines().count())).unwrap_or(u32::MAX);
1491    let mut opts = git2::DiffOptions::new();
1492    opts.context_lines(context.min(cap));
1493    apply_git_algorithm(&mut opts, algorithm, indent_heuristic);
1494    let patch = git2::Patch::from_buffers(
1495        old.as_bytes(),
1496        Some(as_path),
1497        new.as_bytes(),
1498        Some(as_path),
1499        Some(&mut opts),
1500    )
1501    .ok()?;
1502    patch_hunks(&patch, &file.path).ok()
1503}
1504
1505/// Shared by the initial diff and the context re-diff so their ids agree.
1506fn patch_hunks(patch: &git2::Patch<'_>, file_path: &str) -> Result<Vec<Hunk>, VcsError> {
1507    let mut hunks = Vec::with_capacity(patch.num_hunks());
1508    let mut seen = HashMap::new();
1509    for h in 0..patch.num_hunks() {
1510        let (hunk, _) = patch.hunk(h)?;
1511        let lines = hunk_model_lines(patch, h)?;
1512        let id = disambiguated_hunk_id(file_path, &lines, &mut seen);
1513        hunks.push(Hunk {
1514            id,
1515            old_start: hunk.old_start(),
1516            old_lines: hunk.old_lines(),
1517            new_start: hunk.new_start(),
1518            new_lines: hunk.new_lines(),
1519            context: hunk_context(&hunk),
1520            lines,
1521        });
1522    }
1523    Ok(hunks)
1524}
1525
1526/// The text after the header's second `@@`, empty when git emits none.
1527fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
1528    let header = String::from_utf8_lossy(hunk.header());
1529    match header.split_once(" @@") {
1530        Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
1531        None => String::new(),
1532    }
1533}
1534
1535/// Shared by model building and hunk lookup so their hunk ids agree.
1536fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
1537    let (_, line_count) = patch.hunk(h)?;
1538    let mut lines = Vec::with_capacity(line_count);
1539    for l in 0..line_count {
1540        let line = patch.line_in_hunk(h, l)?;
1541        let kind = match line.origin() {
1542            '-' => LineKind::Deleted,
1543            '+' => LineKind::Added,
1544            ' ' => LineKind::Context,
1545            _ => continue,
1546        };
1547        let text = String::from_utf8_lossy(line.content())
1548            .trim_end_matches(['\n', '\r'])
1549            .to_owned();
1550        lines.push(DiffLine::new(
1551            kind,
1552            line.old_lineno(),
1553            line.new_lineno(),
1554            text,
1555        ));
1556    }
1557    Ok(lines)
1558}
1559
1560fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
1561    let delta = diff.get_delta(idx)?;
1562    let blob = |file: git2::DiffFile<'_>| (!file.id().is_zero()).then(|| file.id().to_string());
1563    let blobs = crate::model::BlobIds {
1564        old: blob(delta.old_file()),
1565        new: blob(delta.new_file()),
1566    };
1567    Some(FileDiff {
1568        path: delta_new_path(&delta),
1569        old_path: None,
1570        status: map_status(delta.status()),
1571        binary: true,
1572        old_text: None,
1573        new_text: None,
1574        hunks: Vec::new(),
1575        hashes: crate::model::HashCache::default(),
1576        blobs,
1577    })
1578}
1579
1580fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
1581    delta
1582        .new_file()
1583        .path()
1584        .or_else(|| delta.old_file().path())
1585        .map(|p| p.to_string_lossy().into_owned())
1586        .unwrap_or_default()
1587}
1588
1589fn map_status(status: git2::Delta) -> FileStatus {
1590    match status {
1591        git2::Delta::Added => FileStatus::Added,
1592        git2::Delta::Deleted => FileStatus::Deleted,
1593        git2::Delta::Renamed => FileStatus::Renamed,
1594        git2::Delta::Untracked => FileStatus::Untracked,
1595        _ => FileStatus::Modified,
1596    }
1597}
1598
1599fn log_entry(commit: &git2::Commit<'_>) -> Result<LogEntry, VcsError> {
1600    let full = commit.id().to_string();
1601    Ok(LogEntry {
1602        oid7: short7(&full),
1603        oid: full,
1604        refs: Vec::new(),
1605        parents: commit.parent_ids().map(|id| id.to_string()).collect(),
1606        subject: commit.summary()?.unwrap_or_default().to_owned(),
1607        author: commit.author().name().unwrap_or_default().to_owned(),
1608        time_unix: commit.time().seconds(),
1609        change_id: None,
1610    })
1611}
1612
1613/// The blob `path` names in `commit`'s tree, `None` when the tree lacks it.
1614fn blob_at(commit: &git2::Commit<'_>, path: &str) -> Result<Option<git2::Oid>, VcsError> {
1615    match commit.tree()?.get_path(Path::new(path)) {
1616        Ok(entry) => Ok(Some(entry.id())),
1617        Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
1618        Err(err) => Err(err.into()),
1619    }
1620}
1621
1622fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
1623    if oid.is_zero() {
1624        return None;
1625    }
1626    let blob = repo.find_blob(oid).ok()?;
1627    if blob.is_binary() {
1628        return None;
1629    }
1630    String::from_utf8(blob.content().to_vec()).ok()
1631}
1632
1633/// The recorded blob when the diff target is a tree or the index, else the
1634/// worktree file.
1635fn new_side_text(
1636    repo: &git2::Repository,
1637    delta: &git2::DiffDelta<'_>,
1638    rel: &str,
1639) -> Option<String> {
1640    if delta.status() == git2::Delta::Deleted {
1641        return None;
1642    }
1643    if let Some(text) = blob_text(repo, delta.new_file().id()) {
1644        return Some(text);
1645    }
1646    let root = repo.workdir()?;
1647    fs::read_to_string(root.join(rel)).ok()
1648}