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