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