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