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