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