Skip to main content

diffler_core/
git.rs

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