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::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::model::{DiffLine, DiffModel, FileDiff, FileStatus, Hunk, HunkId, LineKind, hunk_id};
9use crate::vcs::{BranchInfo, HeadInfo, LogEntry, NetworkOp, StatusModel, Vcs, VcsError};
10
11/// git's own default amount of context around hunks.
12pub const DEFAULT_CONTEXT_LINES: u32 = 3;
13
14pub struct GitVcs {
15    repo: git2::Repository,
16    context_lines: u32,
17}
18
19impl GitVcs {
20    pub fn open(root: &Path) -> Result<Self, VcsError> {
21        Self::open_with_context(root, DEFAULT_CONTEXT_LINES)
22    }
23
24    /// Open with a custom number of context lines around diff hunks.
25    pub fn open_with_context(root: &Path, context_lines: u32) -> Result<Self, VcsError> {
26        let repo = git2::Repository::open(root)?;
27        if repo.workdir().is_none() {
28            return Err(VcsError::NoWorkdir);
29        }
30        Ok(Self {
31            repo,
32            context_lines,
33        })
34    }
35
36    /// HEAD tree, or `None` on an unborn branch (fresh repo).
37    fn head_tree(&self) -> Result<Option<git2::Tree<'_>>, VcsError> {
38        match self.repo.head() {
39            Ok(head) => Ok(Some(head.peel_to_tree()?)),
40            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
41            Err(err) => Err(err.into()),
42        }
43    }
44
45    fn workdir_path(&self) -> Result<&Path, VcsError> {
46        self.repo.workdir().ok_or(VcsError::NoWorkdir)
47    }
48}
49
50impl Vcs for GitVcs {
51    fn git_dir(&self) -> Result<PathBuf, VcsError> {
52        // libgit2 resolves gitlink files, so linked worktrees come back as
53        // their external gitdir under the main repo's .git/worktrees/
54        Ok(self.repo.path().to_path_buf())
55    }
56
57    fn head(&self) -> Result<HeadInfo, VcsError> {
58        match self.repo.head() {
59            Ok(head) => {
60                let branch = if head.is_branch() {
61                    Some(head.shorthand()?.to_owned())
62                } else {
63                    None
64                };
65                let commit = head.peel_to_commit()?;
66                let upstream = branch.as_deref().and_then(|name| {
67                    let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
68                    let upstream = local.upstream().ok()?;
69                    upstream.name().ok().flatten().map(str::to_owned)
70                });
71                Ok(HeadInfo {
72                    branch,
73                    oid7: short7(&commit.id().to_string()),
74                    subject: commit.summary()?.unwrap_or_default().to_owned(),
75                    upstream,
76                })
77            }
78            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
79                let branch = self
80                    .repo
81                    .find_reference("HEAD")
82                    .ok()
83                    .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
84                    .and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
85                Ok(HeadInfo {
86                    branch,
87                    oid7: String::new(),
88                    subject: String::new(),
89                    upstream: None,
90                })
91            }
92            Err(err) => Err(err.into()),
93        }
94    }
95
96    fn status(&self) -> Result<StatusModel, VcsError> {
97        // index vs workdir classifies "untracked" against the index, so a
98        // staged new file lands in staged only, not here
99        let mut workdir = self
100            .repo
101            .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
102        let workdir_model = diff_to_model(&self.repo, &mut workdir)?;
103        let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
104            .files
105            .into_iter()
106            .partition(|f| f.status == FileStatus::Untracked);
107
108        let head_tree = self.head_tree()?;
109        let mut opts = git2::DiffOptions::new();
110        opts.context_lines(self.context_lines);
111        let mut staged = self
112            .repo
113            .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
114        let staged = diff_to_model(&self.repo, &mut staged)?;
115
116        Ok(StatusModel {
117            untracked: DiffModel { files: untracked },
118            unstaged: DiffModel { files: unstaged },
119            staged,
120        })
121    }
122
123    fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
124        let head_tree = self.head_tree()?;
125        let mut diff = self.repo.diff_tree_to_workdir_with_index(
126            head_tree.as_ref(),
127            Some(&mut workdir_diff_options(self.context_lines)),
128        )?;
129        let mut find = git2::DiffFindOptions::new();
130        find.renames(true);
131        diff.find_similar(Some(&mut find))?;
132        diff_to_model(&self.repo, &mut diff)
133    }
134
135    fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
136        let oid = git2::Oid::from_str(oid)?;
137        let commit = self.repo.find_commit(oid)?;
138        let tree = commit.tree()?;
139        // root commit: first-parent tree is the empty tree
140        let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
141        let mut opts = git2::DiffOptions::new();
142        opts.context_lines(self.context_lines);
143        let mut diff =
144            self.repo
145                .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?;
146        diff_to_model(&self.repo, &mut diff)
147    }
148
149    fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
150        let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
151        let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
152        let newest_tree = newest.tree()?;
153        // the range starts before the oldest commit, so its base is that
154        // commit's first parent; a root commit has none and diffs against the
155        // empty tree, matching commit_diff
156        let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
157        let mut opts = git2::DiffOptions::new();
158        opts.context_lines(self.context_lines);
159        let mut diff =
160            self.repo
161                .diff_tree_to_tree(base_tree.as_ref(), Some(&newest_tree), Some(&mut opts))?;
162        diff_to_model(&self.repo, &mut diff)
163    }
164
165    fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError> {
166        if self.head_tree()?.is_none() {
167            return Ok(Vec::new());
168        }
169        let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
170        for reference in self.repo.references()?.flatten() {
171            let Ok(name) = reference.shorthand().map(str::to_owned) else {
172                continue;
173            };
174            // peel through symbolic refs and annotated tags to the commit
175            let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
176                continue;
177            };
178            refs_by_oid.entry(target).or_default().push(name);
179        }
180
181        let mut walk = self.repo.revwalk()?;
182        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
183        walk.push_head()?;
184        let mut entries = Vec::new();
185        for oid in walk.take(limit) {
186            let oid = oid?;
187            let commit = self.repo.find_commit(oid)?;
188            let full = oid.to_string();
189            entries.push(LogEntry {
190                oid7: short7(&full),
191                oid: full,
192                refs: refs_by_oid.get(&oid).cloned().unwrap_or_default(),
193                subject: commit.summary()?.unwrap_or_default().to_owned(),
194                author: commit.author().name().unwrap_or_default().to_owned(),
195                time_unix: commit.time().seconds(),
196            });
197        }
198        Ok(entries)
199    }
200
201    fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
202        let mut out = Vec::new();
203        for entry in self.repo.branches(Some(git2::BranchType::Local))? {
204            let (branch, _) = entry?;
205            let Some(name) = branch.name()?.map(str::to_owned) else {
206                continue;
207            };
208            out.push(BranchInfo {
209                name,
210                is_head: branch.is_head(),
211            });
212        }
213        Ok(out)
214    }
215
216    fn stage(&self, rel: &Path) -> Result<(), VcsError> {
217        let root = self.workdir_path()?;
218        let mut index = self.repo.index()?;
219        if root.join(rel).exists() {
220            index.add_path(rel)?;
221        } else {
222            index.remove_path(rel)?;
223        }
224        index.write()?;
225        Ok(())
226    }
227
228    fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
229        let diff = self
230            .repo
231            .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
232        let patch = synthesize_patch(&diff, rel, hunk, false)?;
233        let diff = git2::Diff::from_buffer(&patch)?;
234        self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
235        Ok(())
236    }
237
238    fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
239        match self.repo.head() {
240            Ok(head) => {
241                let target = head.peel(git2::ObjectType::Commit)?;
242                self.repo.reset_default(Some(&target), [rel])?;
243            }
244            // unborn branch: there is no HEAD entry to restore, drop from index
245            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
246                let mut index = self.repo.index()?;
247                index.remove_path(rel)?;
248                index.write()?;
249            }
250            Err(err) => return Err(err.into()),
251        }
252        Ok(())
253    }
254
255    fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
256        let head_tree = self.head_tree()?;
257        let mut opts = git2::DiffOptions::new();
258        opts.context_lines(self.context_lines);
259        let diff = self
260            .repo
261            .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
262        let patch = synthesize_patch(&diff, rel, hunk, true)?;
263        let diff = git2::Diff::from_buffer(&patch)?;
264        self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
265        Ok(())
266    }
267
268    fn discard(&self, rel: &Path) -> Result<(), VcsError> {
269        let head_tree = self.head_tree()?;
270        let mut opts = git2::DiffOptions::new();
271        opts.pathspec(rel).disable_pathspec_match(true);
272        let staged = self
273            .repo
274            .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
275        if staged.deltas().len() > 0 {
276            return Err(VcsError::Rejected(
277                "file has staged changes; unstage first".into(),
278            ));
279        }
280        let status = self.repo.status_file(rel)?;
281        if status.contains(git2::Status::WT_NEW) {
282            fs::remove_file(self.workdir_path()?.join(rel))?;
283            return Ok(());
284        }
285        // refresh the index stat cache to match the file we just wrote. with
286        // autocrlf the checkout smudges LF->CRLF, growing the file; leaving the
287        // cached stat stale makes git report a phantom modification (size
288        // mismatch defeats the racy-clean check) even though the content is
289        // identical to HEAD. the file has no staged changes here, so the index
290        // blob already equals HEAD and updating it only corrects the metadata.
291        let mut checkout = git2::build::CheckoutBuilder::new();
292        checkout.path(rel).force().update_index(true);
293        self.repo.checkout_head(Some(&mut checkout))?;
294        Ok(())
295    }
296
297    fn commit(&self, message: &str) -> Result<String, VcsError> {
298        if message.trim().is_empty() {
299            return Err(VcsError::Rejected("empty commit message".into()));
300        }
301        let mut index = self.repo.index()?;
302        let tree_id = index.write_tree()?;
303        let tree = self.repo.find_tree(tree_id)?;
304        let signature = self.repo.signature()?;
305        let parent = match self.repo.head() {
306            Ok(head) => Some(head.peel_to_commit()?),
307            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
308            Err(err) => return Err(err.into()),
309        };
310        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
311        let oid = self.repo.commit(
312            Some("HEAD"),
313            &signature,
314            &signature,
315            message,
316            &tree,
317            &parents,
318        )?;
319        Ok(oid.to_string())
320    }
321
322    fn head_message(&self) -> Result<String, VcsError> {
323        let commit = self.repo.head()?.peel_to_commit()?;
324        Ok(commit.message().unwrap_or_default().to_owned())
325    }
326
327    fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
328        if let Some(message) = message
329            && message.trim().is_empty()
330        {
331            return Err(VcsError::Rejected("empty commit message".into()));
332        }
333        let head = self.repo.head()?.peel_to_commit()?;
334        // extend/amend fold the staged index into the new tree; a pure reword
335        // keeps HEAD's tree so only the message changes
336        let tree = if use_index {
337            let mut index = self.repo.index()?;
338            let tree_id = index.write_tree()?;
339            self.repo.find_tree(tree_id)?
340        } else {
341            head.tree()?
342        };
343        let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
344        Ok(oid.to_string())
345    }
346
347    fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
348        let head = self.repo.head()?.peel_to_commit()?;
349        self.repo.branch(name, &head, false)?;
350        if checkout {
351            self.checkout(name)?;
352        }
353        Ok(())
354    }
355
356    fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
357        let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
358        if branch.is_head() {
359            return Err(VcsError::Rejected(
360                "cannot delete the checked-out branch".into(),
361            ));
362        }
363        branch.delete()?;
364        Ok(())
365    }
366
367    fn checkout(&self, name: &str) -> Result<(), VcsError> {
368        let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
369        let target = branch.get().peel(git2::ObjectType::Commit)?;
370        // safe (non-force) checkout: refuses to clobber local modifications
371        self.repo.checkout_tree(&target, None)?;
372        self.repo.set_head(&format!("refs/heads/{name}"))?;
373        Ok(())
374    }
375
376    fn network_argv(&self, op: NetworkOp) -> Vec<String> {
377        // shelling to `git` (not git2) so the user's credential helper, SSH
378        // agent, and config drive auth; PushSetUpstream targets HEAD so the
379        // current branch publishes to its same-named remote ref
380        let args: &[&str] = match op {
381            NetworkOp::Push => &["push"],
382            NetworkOp::PushSetUpstream => &["push", "-u", "origin", "HEAD"],
383            NetworkOp::Pull => &["pull"],
384            NetworkOp::Fetch => &["fetch"],
385            NetworkOp::FetchAll => &["fetch", "--all"],
386        };
387        std::iter::once("git")
388            .chain(args.iter().copied())
389            .map(str::to_owned)
390            .collect()
391    }
392
393    fn workdir(&self) -> Result<PathBuf, VcsError> {
394        Ok(self.workdir_path()?.to_path_buf())
395    }
396}
397
398/// Render one hunk of `rel` as a unified patch libgit2 can apply to the
399/// index. `reverse` flips the patch so applying it undoes a staged hunk.
400/// Built from the raw git2 patch lines (not the display model) so original
401/// line endings and missing-trailing-newline markers survive intact.
402fn synthesize_patch(
403    diff: &git2::Diff<'_>,
404    rel: &Path,
405    target: &HunkId,
406    reverse: bool,
407) -> Result<Vec<u8>, VcsError> {
408    let rel = rel.to_string_lossy();
409    for idx in 0..diff.deltas().len() {
410        let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
411            continue;
412        };
413        let delta = patch.delta();
414        if delta.flags().is_binary() || delta_new_path(&delta) != rel {
415            continue;
416        }
417        for h in 0..patch.num_hunks() {
418            if hunk_id(&rel, &hunk_model_lines(&patch, h)?)? == *target {
419                return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
420            }
421        }
422    }
423    Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
424}
425
426fn render_hunk_patch(
427    patch: &git2::Patch<'_>,
428    h: usize,
429    rel: &str,
430    status: git2::Delta,
431    reverse: bool,
432) -> Result<Vec<u8>, VcsError> {
433    let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
434    let deleted = status == git2::Delta::Deleted;
435    let mut out = Vec::new();
436    out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
437    // whole-file adds and deletes must keep that identity (with the sides
438    // swapped under reverse) so applying creates or drops the index entry
439    // instead of leaving an empty blob behind
440    if (added && !reverse) || (deleted && reverse) {
441        out.extend_from_slice(
442            format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
443        );
444    } else if (deleted && !reverse) || (added && reverse) {
445        out.extend_from_slice(
446            format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
447        );
448    } else {
449        out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
450    }
451    let (hunk, line_count) = patch.hunk(h)?;
452    let (old, new) = (
453        (hunk.old_start(), hunk.old_lines()),
454        (hunk.new_start(), hunk.new_lines()),
455    );
456    let ((minus_start, minus_lines), (plus_start, plus_lines)) =
457        if reverse { (new, old) } else { (old, new) };
458    out.extend_from_slice(
459        format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
460    );
461    for l in 0..line_count {
462        let line = patch.line_in_hunk(h, l)?;
463        let origin = match (line.origin(), reverse) {
464            (' ', _) => Some(b' '),
465            ('+', false) | ('-', true) => Some(b'+'),
466            ('-', false) | ('+', true) => Some(b'-'),
467            // EOF-newline markers already carry the full "\ No newline at
468            // end of file" text, including the newline that terminates the
469            // preceding unterminated line, so they pass through unprefixed
470            ('=' | '>' | '<', _) => None,
471            _ => continue,
472        };
473        if let Some(origin) = origin {
474            out.push(origin);
475        }
476        out.extend_from_slice(line.content());
477    }
478    Ok(out)
479}
480
481fn workdir_diff_options(context_lines: u32) -> git2::DiffOptions {
482    let mut opts = git2::DiffOptions::new();
483    opts.include_untracked(true)
484        .recurse_untracked_dirs(true)
485        .show_untracked_content(true)
486        .context_lines(context_lines);
487    opts
488}
489
490fn short7(oid: &str) -> String {
491    oid.get(..7).unwrap_or(oid).to_owned()
492}
493
494fn diff_to_model(
495    repo: &git2::Repository,
496    diff: &mut git2::Diff<'_>,
497) -> Result<DiffModel, VcsError> {
498    let mut files = Vec::new();
499    for idx in 0..diff.deltas().len() {
500        if let Some(file) = build_file(repo, diff, idx)? {
501            files.push(file);
502        }
503    }
504    // intra-line emphasis is a render-time concern: the TUI enriches the
505    // file it is about to draw (see crate::pairing::enrich_file), so the
506    // backend leaves `.emphasis` empty.
507    Ok(DiffModel { files })
508}
509
510fn build_file(
511    repo: &git2::Repository,
512    diff: &mut git2::Diff<'_>,
513    idx: usize,
514) -> Result<Option<FileDiff>, VcsError> {
515    let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
516        // binary or unreadable: fall back to delta metadata only
517        return Ok(build_binary_file(diff, idx));
518    };
519    let delta = patch.delta();
520    if delta.flags().is_binary() {
521        return Ok(build_binary_file(diff, idx));
522    }
523    let file_path = delta_new_path(&delta);
524    let status = map_status(delta.status());
525    let old_path = if status == FileStatus::Renamed {
526        delta
527            .old_file()
528            .path()
529            .map(|p| p.to_string_lossy().into_owned())
530    } else {
531        None
532    };
533
534    let old_text = blob_text(repo, delta.old_file().id());
535    let new_text = new_side_text(repo, &delta, &file_path);
536
537    let mut hunks = Vec::new();
538    for h in 0..patch.num_hunks() {
539        let (hunk, _) = patch.hunk(h)?;
540        let lines = hunk_model_lines(&patch, h)?;
541        let id = hunk_id(&file_path, &lines)?;
542        hunks.push(Hunk {
543            id,
544            old_start: hunk.old_start(),
545            old_lines: hunk.old_lines(),
546            new_start: hunk.new_start(),
547            new_lines: hunk.new_lines(),
548            context: hunk_context(&hunk),
549            lines,
550        });
551    }
552
553    Ok(Some(FileDiff {
554        path: file_path,
555        old_path,
556        status,
557        binary: false,
558        old_text,
559        new_text,
560        hunks,
561    }))
562}
563
564/// git's section heading for a hunk: the text git appends after the second
565/// `@@` of the header (`@@ -a,b +c,d @@ <context>`), typically the enclosing
566/// function or section. Empty when git emits none (e.g. a top-of-file hunk).
567fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
568    let header = String::from_utf8_lossy(hunk.header());
569    match header.split_once(" @@") {
570        Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
571        None => String::new(),
572    }
573}
574
575/// Content lines of one hunk as model lines (headers and EOF-newline markers
576/// excluded). Shared by model building and hunk lookup so the hunk ids
577/// computed in both places agree.
578fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
579    let (_, line_count) = patch.hunk(h)?;
580    let mut lines = Vec::with_capacity(line_count);
581    for l in 0..line_count {
582        let line = patch.line_in_hunk(h, l)?;
583        let kind = match line.origin() {
584            '-' => LineKind::Deleted,
585            '+' => LineKind::Added,
586            ' ' => LineKind::Context,
587            // headers, EOF-newline markers etc. are not content lines
588            _ => continue,
589        };
590        let text = String::from_utf8_lossy(line.content())
591            .trim_end_matches(['\n', '\r'])
592            .to_owned();
593        lines.push(DiffLine::new(
594            kind,
595            line.old_lineno(),
596            line.new_lineno(),
597            text,
598        ));
599    }
600    Ok(lines)
601}
602
603fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
604    let delta = diff.get_delta(idx)?;
605    Some(FileDiff {
606        path: delta_new_path(&delta),
607        old_path: None,
608        status: map_status(delta.status()),
609        binary: true,
610        old_text: None,
611        new_text: None,
612        hunks: Vec::new(),
613    })
614}
615
616fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
617    delta
618        .new_file()
619        .path()
620        .or_else(|| delta.old_file().path())
621        .map(|p| p.to_string_lossy().into_owned())
622        .unwrap_or_default()
623}
624
625fn map_status(status: git2::Delta) -> FileStatus {
626    match status {
627        git2::Delta::Added => FileStatus::Added,
628        git2::Delta::Deleted => FileStatus::Deleted,
629        git2::Delta::Renamed => FileStatus::Renamed,
630        git2::Delta::Untracked => FileStatus::Untracked,
631        _ => FileStatus::Modified,
632    }
633}
634
635fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
636    if oid.is_zero() {
637        return None;
638    }
639    let blob = repo.find_blob(oid).ok()?;
640    if blob.is_binary() {
641        return None;
642    }
643    String::from_utf8(blob.content().to_vec()).ok()
644}
645
646/// New-side content: the recorded blob when the diff target is a tree or the
647/// index (where the workdir may differ), the workdir file otherwise.
648fn new_side_text(
649    repo: &git2::Repository,
650    delta: &git2::DiffDelta<'_>,
651    rel: &str,
652) -> Option<String> {
653    if delta.status() == git2::Delta::Deleted {
654        return None;
655    }
656    if let Some(text) = blob_text(repo, delta.new_file().id()) {
657        return Some(text);
658    }
659    let root = repo.workdir()?;
660    fs::read_to_string(root.join(rel)).ok()
661}