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