1use std::cell::Cell;
4use std::collections::{HashMap, HashSet};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::diffalgo::{DiffAlgorithm, DiffSettings, histogram_hunks};
9use crate::model::{
10 DiffLine, DiffModel, FileDiff, FileStatus, Hunk, HunkId, LineKind, disambiguated_hunk_id,
11};
12use crate::vcs::{
13 BlameSpan, BranchInfo, FileVersion, HeadInfo, LogEntry, NetworkOp, StatusModel, Vcs, VcsError,
14 Worktree, WorktreeOp,
15};
16
17pub const DEFAULT_CONTEXT_LINES: u32 = 3;
18
19const HISTORY_WALK_LIMIT: usize = 20_000;
22
23pub const DEFAULT_INDENT_HEURISTIC: bool = true;
24
25pub struct GitVcs {
26 repo: git2::Repository,
27 context_lines: u32,
28 algorithm: Cell<DiffAlgorithm>,
30 indent_heuristic: Cell<bool>,
31 indexed: bool,
33 base: Cell<Base>,
36}
37
38#[derive(Debug, Clone, Copy)]
39enum Base {
40 FollowHead,
41 Unborn,
42 At(git2::Oid),
43}
44
45impl GitVcs {
46 pub fn open(root: &Path) -> Result<Self, VcsError> {
47 Self::open_with_settings(root, &DiffSettings::default())
48 }
49
50 pub fn open_with_settings(root: &Path, settings: &DiffSettings) -> Result<Self, VcsError> {
51 let repo = git2::Repository::open(root)?;
52 if repo.workdir().is_none() {
53 return Err(VcsError::NoWorkdir);
54 }
55 Ok(Self {
56 repo,
57 context_lines: settings.context_lines,
58 algorithm: Cell::new(settings.algorithm),
59 indent_heuristic: Cell::new(settings.indent_heuristic),
60 indexed: true,
61 base: Cell::new(Base::FollowHead),
62 })
63 }
64
65 pub fn open_jj_store(
68 git_dir: &Path,
69 root: &Path,
70 settings: &DiffSettings,
71 ) -> Result<Self, VcsError> {
72 let repo = git2::Repository::open_bare(git_dir)?;
73 repo.set_workdir(root, false)?;
74 repo.add_ignore_rule(".jj/")?;
76 Ok(Self {
77 repo,
78 context_lines: settings.context_lines,
79 algorithm: Cell::new(settings.algorithm),
80 indent_heuristic: Cell::new(settings.indent_heuristic),
81 indexed: false,
82 base: Cell::new(Base::Unborn),
83 })
84 }
85
86 pub(crate) fn set_base(&self, base: Option<&str>) -> Result<(), VcsError> {
88 self.base.set(match base {
89 Some(oid) => Base::At(git2::Oid::from_str(oid)?),
90 None => Base::Unborn,
91 });
92 Ok(())
93 }
94
95 fn head_commit(&self) -> Result<Option<git2::Commit<'_>>, VcsError> {
97 match self.base.get() {
98 Base::At(oid) => Ok(Some(self.repo.find_commit(oid)?)),
99 Base::Unborn => Ok(None),
100 Base::FollowHead => match self.repo.head() {
101 Ok(head) => Ok(Some(head.peel_to_commit()?)),
102 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
103 Err(err) => Err(err.into()),
104 },
105 }
106 }
107
108 fn head_commit_or_unborn(&self) -> Result<git2::Commit<'_>, VcsError> {
109 self.head_commit()?.ok_or_else(|| {
110 git2::Error::new(
111 git2::ErrorCode::UnbornBranch,
112 git2::ErrorClass::Reference,
113 "HEAD has no commit yet",
114 )
115 .into()
116 })
117 }
118
119 fn revparse(&self, rev: &str) -> Result<git2::Object<'_>, VcsError> {
122 let head_relative = rev
123 .strip_prefix("HEAD")
124 .filter(|rest| rest.is_empty() || rest.starts_with(['~', '^']));
125 match (self.base.get(), head_relative) {
126 (Base::At(_) | Base::Unborn, Some(rest)) => {
127 let oid = self.head_commit_or_unborn()?.id();
128 Ok(self.repo.revparse_single(&format!("{oid}{rest}"))?)
129 }
130 _ => Ok(self.repo.revparse_single(rev)?),
131 }
132 }
133
134 fn tree_files(&self) -> Result<Vec<PathBuf>, VcsError> {
135 let Some(tree) = self.head_tree()? else {
136 return Ok(Vec::new());
137 };
138 let mut out = Vec::new();
139 tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
140 if entry.kind() == Some(git2::ObjectType::Blob)
141 && let Ok(name) = entry.name()
142 {
143 out.push(PathBuf::from(format!("{dir}{name}")));
144 }
145 git2::TreeWalkResult::Ok
146 })?;
147 out.sort_unstable();
148 Ok(out)
149 }
150
151 fn head_tree(&self) -> Result<Option<git2::Tree<'_>>, VcsError> {
153 Ok(self
154 .head_commit()?
155 .map(|commit| commit.tree())
156 .transpose()?)
157 }
158
159 fn workdir_path(&self) -> Result<&Path, VcsError> {
160 self.repo.workdir().ok_or(VcsError::NoWorkdir)
161 }
162
163 fn workdir_diff(&self, base: Option<&git2::Tree<'_>>) -> Result<DiffModel, VcsError> {
166 let mut options = self.workdir_diff_options();
167 let mut diff = if self.indexed {
168 self.repo
169 .diff_tree_to_workdir_with_index(base, Some(&mut options))?
170 } else {
171 self.repo.diff_tree_to_workdir(base, Some(&mut options))?
172 };
173 let sparse = self.sparse_paths();
174 if sparse.is_empty() {
177 let mut find = git2::DiffFindOptions::new();
178 find.renames(true);
179 diff.find_similar(Some(&mut find))?;
180 }
181 let mut model = self.diff_to_model_skipping(&mut diff, &sparse)?;
182 if !self.indexed {
183 for file in &mut model.files {
185 if file.status == FileStatus::Untracked {
186 file.status = FileStatus::Added;
187 }
188 }
189 }
190 Ok(model)
191 }
192
193 fn sparse_paths(&self) -> HashSet<Vec<u8>> {
197 let Ok(index) = self.repo.index() else {
198 return HashSet::new();
199 };
200 index
201 .iter()
202 .filter(|entry| {
203 git2::IndexEntryExtendedFlag::from_bits_truncate(entry.flags_extended)
204 .is_skip_worktree()
205 })
206 .map(|entry| entry.path)
207 .collect()
208 }
209
210 fn plain_diff_options(&self) -> git2::DiffOptions {
211 let mut opts = git2::DiffOptions::new();
212 opts.context_lines(self.context_lines);
213 apply_git_algorithm(&mut opts, self.algorithm.get(), self.indent_heuristic.get());
214 opts
215 }
216
217 fn workdir_diff_options(&self) -> git2::DiffOptions {
218 let mut opts = self.plain_diff_options();
219 opts.include_untracked(true)
220 .recurse_untracked_dirs(true)
221 .show_untracked_content(true);
222 opts
223 }
224
225 fn diff_to_model(&self, diff: &mut git2::Diff<'_>) -> Result<DiffModel, VcsError> {
227 self.diff_to_model_skipping(diff, &HashSet::new())
228 }
229
230 fn diff_to_model_skipping(
233 &self,
234 diff: &mut git2::Diff<'_>,
235 sparse: &HashSet<Vec<u8>>,
236 ) -> Result<DiffModel, VcsError> {
237 let mut files = Vec::new();
238 for idx in 0..diff.deltas().len() {
239 let outside_cone = diff.get_delta(idx).is_some_and(|delta| {
240 delta.status() == git2::Delta::Deleted
241 && sparse.contains(delta.old_file().path_bytes().unwrap_or_default())
242 });
243 if outside_cone {
244 continue;
245 }
246 if let Some(file) = self.build_file(diff, idx)? {
247 files.push(file);
248 }
249 }
250 Ok(DiffModel { files })
251 }
252
253 fn imara_hunks(
257 &self,
258 delta: &git2::DiffDelta<'_>,
259 old: Option<&str>,
260 new: Option<&str>,
261 path: &str,
262 ) -> Option<Vec<Hunk>> {
263 if !self.algorithm.get().is_imara() {
264 return None;
265 }
266 let (old, new) = (old?, new?);
267 let hashed = git2::Oid::hash_object(git2::ObjectType::Blob, new.as_bytes()).ok()?;
270 if hashed != delta.new_file().id() {
271 return None;
272 }
273 Some(histogram_hunks(
274 old,
275 new,
276 path,
277 self.context_lines,
278 self.indent_heuristic.get(),
279 ))
280 }
281
282 fn walk_entries(
283 &self,
284 walk: git2::Revwalk<'_>,
285 limit: usize,
286 ) -> Result<Vec<LogEntry>, VcsError> {
287 let mut entries = Vec::new();
288 for oid in walk.take(limit) {
289 entries.push(log_entry(&self.repo.find_commit(oid?)?)?);
290 }
291 Ok(entries)
292 }
293
294 fn path_in_parent(
297 &self,
298 commit: &git2::Commit<'_>,
299 parent: &git2::Commit<'_>,
300 path: &str,
301 ) -> Result<Option<String>, VcsError> {
302 if blob_at(parent, path)?.is_some() {
303 return Ok(Some(path.to_owned()));
304 }
305 let mut diff = self.repo.diff_tree_to_tree(
306 Some(&parent.tree()?),
307 Some(&commit.tree()?),
308 Some(&mut git2::DiffOptions::new()),
309 )?;
310 diff.find_similar(Some(git2::DiffFindOptions::new().renames(true)))?;
311 Ok(diff.deltas().find_map(|delta| {
312 let renamed = delta.status() == git2::Delta::Renamed
313 && delta.new_file().path() == Some(Path::new(path));
314 renamed
315 .then(|| {
316 delta
317 .old_file()
318 .path()
319 .map(|old| old.to_string_lossy().into_owned())
320 })
321 .flatten()
322 }))
323 }
324
325 fn blame_spans(&self, blame: &git2::Blame<'_>) -> Vec<BlameSpan> {
326 let mut out = Vec::new();
327 for hunk in blame.iter() {
328 let oid = hunk.final_commit_id();
329 let commit = self.repo.find_commit(oid).ok();
330 let full = oid.to_string();
331 out.push(BlameSpan {
332 start_line: u32::try_from(hunk.final_start_line()).unwrap_or(u32::MAX),
333 line_count: u32::try_from(hunk.lines_in_hunk()).unwrap_or(u32::MAX),
334 oid7: short7(&full),
335 oid: full,
336 author: commit
337 .as_ref()
338 .map(|c| c.author().name().unwrap_or_default().to_owned())
339 .unwrap_or_default(),
340 time_unix: commit.as_ref().map_or(0, |c| c.time().seconds()),
341 summary: commit
342 .as_ref()
343 .and_then(|c| c.summary().ok().flatten().map(str::to_owned))
344 .unwrap_or_default(),
345 committed: !oid.is_zero(),
346 });
347 }
348 out.sort_by_key(|span| span.start_line);
349 out
350 }
351
352 pub(crate) fn log_from(
355 &self,
356 limit: usize,
357 all_branches: bool,
358 tips: &[&str],
359 ) -> Result<Vec<LogEntry>, VcsError> {
360 let Some(head) = self.head_commit()? else {
361 return Ok(Vec::new());
362 };
363 let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
364 for reference in self.repo.references()?.flatten() {
365 let Ok(name) = reference.shorthand().map(str::to_owned) else {
366 continue;
367 };
368 let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
369 continue;
370 };
371 refs_by_oid.entry(target).or_default().push(name);
372 }
373
374 let mut walk = self.repo.revwalk()?;
375 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
376 walk.push(head.id())?;
377 if all_branches {
378 for glob in ["refs/heads", "refs/remotes", "refs/tags"] {
379 walk.push_glob(glob)?;
380 }
381 for tip in tips {
382 walk.push(git2::Oid::from_str(tip)?)?;
383 }
384 }
385 let mut entries = self.walk_entries(walk, limit)?;
386 for entry in &mut entries {
387 if let Some(refs) = git2::Oid::from_str(&entry.oid)
388 .ok()
389 .and_then(|oid| refs_by_oid.remove(&oid))
390 {
391 entry.refs = refs;
392 }
393 }
394 Ok(entries)
395 }
396
397 pub(crate) fn ahead_behind(
399 &self,
400 local: &str,
401 upstream: &str,
402 ) -> Result<(usize, usize), VcsError> {
403 Ok(self
404 .repo
405 .graph_ahead_behind(git2::Oid::from_str(local)?, git2::Oid::from_str(upstream)?)?)
406 }
407
408 fn has_tracked_changes(&self) -> Result<bool, VcsError> {
410 let mut opts = git2::StatusOptions::new();
411 opts.include_untracked(false).include_ignored(false);
412 let statuses = self.repo.statuses(Some(&mut opts))?;
413 let sparse = self.sparse_paths();
414 Ok(statuses.iter().any(|entry| {
415 !(entry.status() == git2::Status::WT_DELETED && sparse.contains(entry.path_bytes()))
416 }))
417 }
418}
419
420impl Vcs for GitVcs {
421 fn has_index(&self) -> bool {
422 true
423 }
424
425 fn native_git_checkout(&self) -> bool {
426 true
427 }
428
429 fn git_dir(&self) -> Result<PathBuf, VcsError> {
430 Ok(self.repo.path().to_path_buf())
431 }
432
433 fn head(&self) -> Result<HeadInfo, VcsError> {
434 if !matches!(self.base.get(), Base::FollowHead) {
435 let commit = self.head_commit()?;
436 return Ok(HeadInfo {
437 branch: None,
438 oid7: commit
439 .as_ref()
440 .map(|c| short7(&c.id().to_string()))
441 .unwrap_or_default(),
442 subject: commit
443 .as_ref()
444 .and_then(|c| c.summary().ok().flatten().map(str::to_owned))
445 .unwrap_or_default(),
446 upstream: None,
447 ahead: 0,
448 behind: 0,
449 });
450 }
451 match self.repo.head() {
452 Ok(head) => {
453 let branch = if head.is_branch() {
454 Some(head.shorthand()?.to_owned())
455 } else {
456 None
457 };
458 let commit = head.peel_to_commit()?;
459 let tracking = branch.as_deref().and_then(|name| {
460 let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
461 let upstream = local.upstream().ok()?;
462 let name = upstream.name().ok().flatten()?.to_owned();
463 Some((name, upstream.get().target()))
464 });
465 let (upstream, ahead, behind) = match tracking {
466 Some((name, Some(target))) => {
467 let (ahead, behind) = self
468 .repo
469 .graph_ahead_behind(commit.id(), target)
470 .unwrap_or((0, 0));
471 (Some(name), ahead, behind)
472 }
473 Some((name, None)) => (Some(name), 0, 0),
474 None => (None, 0, 0),
475 };
476 Ok(HeadInfo {
477 branch,
478 oid7: short7(&commit.id().to_string()),
479 subject: commit.summary()?.unwrap_or_default().to_owned(),
480 upstream,
481 ahead,
482 behind,
483 })
484 }
485 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
486 let branch = self
487 .repo
488 .find_reference("HEAD")
489 .ok()
490 .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
491 .and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
492 Ok(HeadInfo {
493 branch,
494 oid7: String::new(),
495 subject: String::new(),
496 upstream: None,
497 ahead: 0,
498 behind: 0,
499 })
500 }
501 Err(err) => Err(err.into()),
502 }
503 }
504
505 fn status(&self) -> Result<StatusModel, VcsError> {
506 let mut workdir = self
508 .repo
509 .diff_index_to_workdir(None, Some(&mut self.workdir_diff_options()))?;
510 let workdir_model = self.diff_to_model_skipping(&mut workdir, &self.sparse_paths())?;
511 let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
512 .files
513 .into_iter()
514 .partition(|f| f.status == FileStatus::Untracked);
515
516 let head_tree = self.head_tree()?;
517 let mut staged = self.repo.diff_tree_to_index(
518 head_tree.as_ref(),
519 None,
520 Some(&mut self.plain_diff_options()),
521 )?;
522 let staged = self.diff_to_model(&mut staged)?;
523
524 Ok(StatusModel {
525 untracked: DiffModel { files: untracked },
526 unstaged: DiffModel { files: unstaged },
527 staged,
528 })
529 }
530
531 fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
532 self.workdir_diff(self.head_tree()?.as_ref())
533 }
534
535 fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
536 let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
537 self.workdir_diff(Some(&base.tree()?))
538 }
539
540 fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
541 let oid = git2::Oid::from_str(oid)?;
542 let commit = self.repo.find_commit(oid)?;
543 let tree = commit.tree()?;
544 let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
545 let mut diff = self.repo.diff_tree_to_tree(
546 parent_tree.as_ref(),
547 Some(&tree),
548 Some(&mut self.plain_diff_options()),
549 )?;
550 self.diff_to_model(&mut diff)
551 }
552
553 fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
554 let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
555 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
556 let mut diff = self.repo.diff_tree_to_tree(
557 Some(&base.tree()?),
558 Some(&newest.tree()?),
559 Some(&mut self.plain_diff_options()),
560 )?;
561 self.diff_to_model(&mut diff)
562 }
563
564 fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
565 let base = self
566 .repo
567 .merge_base(git2::Oid::from_str(a)?, git2::Oid::from_str(b)?)?;
568 Ok(base.to_string())
569 }
570
571 fn resolve(&self, revision: &str) -> Result<String, VcsError> {
572 let object = self.revparse(revision)?;
573 let commit = object.peel_to_commit()?;
574 Ok(commit.id().to_string())
575 }
576
577 fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
578 let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
579 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
580 let newest_tree = newest.tree()?;
581 let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
582 let mut diff = self.repo.diff_tree_to_tree(
583 base_tree.as_ref(),
584 Some(&newest_tree),
585 Some(&mut self.plain_diff_options()),
586 )?;
587 self.diff_to_model(&mut diff)
588 }
589
590 fn log(&self, limit: usize, all_branches: bool) -> Result<Vec<LogEntry>, VcsError> {
591 self.log_from(limit, all_branches, &[])
592 }
593
594 fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
595 let head_ref = format!("refs/remotes/{remote}/HEAD");
596 let prefix = format!("refs/remotes/{remote}/");
597 if let Ok(reference) = self.repo.find_reference(&head_ref)
598 && let Ok(Some(target)) = reference.symbolic_target()
599 && let Some(name) = target.strip_prefix(prefix.as_str())
601 {
602 return Ok(Some(name.to_owned()));
603 }
604 for name in ["main", "master"] {
605 if self
606 .repo
607 .find_branch(name, git2::BranchType::Local)
608 .or_else(|_| {
609 self.repo
610 .find_branch(&format!("{remote}/{name}"), git2::BranchType::Remote)
611 })
612 .is_ok()
613 {
614 return Ok(Some(name.to_owned()));
615 }
616 }
617 Ok(None)
618 }
619
620 fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
621 let (base, head) = (self.revparse(base)?, self.revparse(head)?);
622 let mut walk = self.repo.revwalk()?;
623 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
624 walk.push(head.id())?;
625 walk.hide(base.id())?;
626 self.walk_entries(walk, usize::MAX)
627 }
628
629 fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError> {
630 let Some(head) = self.head_commit().ok().flatten() else {
631 return Ok(None);
632 };
633 let mut walk = self.repo.revwalk()?;
634 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
635 walk.push(head.id())?;
636 let mut remotes = 0;
637 for reference in self.repo.references()? {
638 let reference = reference?;
639 if let (true, Some(oid)) = (reference.is_remote(), reference.target()) {
642 remotes += 1;
643 walk.hide(oid)?;
644 }
645 }
646 if remotes == 0 {
647 return Ok(None);
648 }
649 self.walk_entries(walk, limit).map(Some)
650 }
651
652 fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
653 let mut options = git2::BlameOptions::new();
654 options.track_copies_same_file(true);
655 if !matches!(self.base.get(), Base::FollowHead) {
656 options.newest_commit(self.head_commit_or_unborn()?.id());
657 }
658 let blame = self.repo.blame_file(rel, Some(&mut options))?;
659 let workdir = self.workdir()?.join(rel);
662 let blame = match fs::read(&workdir) {
663 Ok(bytes) => blame.blame_buffer(&bytes)?,
664 Err(_) => blame,
665 };
666 Ok(self.blame_spans(&blame))
667 }
668
669 fn blame_at(&self, rev: &str, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
670 let commit = self.revparse(rev)?.peel_to_commit()?;
671 let mut options = git2::BlameOptions::new();
672 options
673 .track_copies_same_file(true)
674 .newest_commit(commit.id());
675 let blame = self.repo.blame_file(rel, Some(&mut options))?;
676 Ok(self.blame_spans(&blame))
677 }
678
679 fn file_history(&self, rel: &str, limit: usize) -> Result<Vec<FileVersion>, VcsError> {
680 let mut versions = Vec::new();
681 let Some(mut commit) = self.head_commit()? else {
682 return Ok(versions);
683 };
684 let mut path = rel.to_owned();
685 for _ in 0..HISTORY_WALK_LIMIT {
686 let Some(blob) = blob_at(&commit, &path)? else {
687 break;
688 };
689 let parent = commit.parent(0).ok();
690 let unchanged = match &parent {
691 Some(parent) => blob_at(parent, &path)? == Some(blob),
692 None => false,
693 };
694 if !unchanged {
695 versions.push(FileVersion {
696 entry: log_entry(&commit)?,
697 path: path.clone(),
698 });
699 if versions.len() >= limit {
700 break;
701 }
702 }
703 let Some(parent) = parent else {
704 break;
705 };
706 if !unchanged {
707 match self.path_in_parent(&commit, &parent, &path)? {
708 Some(older) => path = older,
709 None => break,
710 }
711 }
712 commit = parent;
713 }
714 Ok(versions)
715 }
716
717 fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError> {
718 let commit = self.revparse(rev)?.peel_to_commit()?;
719 Ok(blob_at(&commit, path)?.and_then(|oid| blob_text(&self.repo, oid)))
720 }
721
722 fn read_blob(&self, oid: &str) -> Result<Option<Vec<u8>>, VcsError> {
723 let oid = git2::Oid::from_str(oid)?;
724 match self.repo.find_blob(oid) {
725 Ok(blob) => Ok(Some(blob.content().to_vec())),
726 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
727 Err(err) => Err(err.into()),
728 }
729 }
730
731 fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError> {
732 if !self.indexed {
733 return self.tree_files();
734 }
735 let index = self.repo.index()?;
736 let mut out: Vec<PathBuf> = index
737 .iter()
738 .filter_map(|entry| {
739 std::str::from_utf8(&entry.path)
740 .ok()
741 .map(|path| PathBuf::from(path.to_owned()))
742 })
743 .collect();
744 out.sort_unstable();
745 out.dedup();
746 Ok(out)
747 }
748
749 fn attr(&self, rel: &Path, name: &str) -> bool {
750 let Ok(value) = self.repo.get_attr(rel, name, git2::AttrCheckFlags::empty()) else {
751 return false;
752 };
753 matches!(
755 git2::AttrValue::from_string(value),
756 git2::AttrValue::True | git2::AttrValue::String("true")
757 )
758 }
759
760 fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
761 let mut out = Vec::new();
762 for entry in self.repo.branches(Some(git2::BranchType::Local))? {
763 let (branch, _) = entry?;
764 let Some(name) = branch.name()?.map(str::to_owned) else {
765 continue;
766 };
767 let tip_unix = branch
768 .get()
769 .peel_to_commit()
770 .ok()
771 .map_or(0, |commit| commit.time().seconds());
772 out.push(BranchInfo {
773 name,
774 is_head: branch.is_head(),
775 tip_unix,
776 divergence: None,
777 });
778 }
779 Ok(out)
780 }
781
782 fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError> {
783 let branch = self.repo.find_branch(branch, git2::BranchType::Local)?;
784 let Some(tip) = branch.get().peel_to_commit().ok() else {
785 return Ok(None);
786 };
787 let Some(target) = branch.upstream().ok().and_then(|up| up.get().target()) else {
788 return Ok(None);
789 };
790 Ok(Some(self.repo.graph_ahead_behind(tip.id(), target)?))
791 }
792
793 fn all_branches(&self) -> Result<Vec<String>, VcsError> {
794 let mut out = Vec::new();
795 for entry in self.repo.branches(None)? {
796 let (branch, _) = entry?;
797 let Some(name) = branch.name()?.map(str::to_owned) else {
798 continue;
799 };
800 if name.ends_with("/HEAD") {
802 continue;
803 }
804 out.push(name);
805 }
806 Ok(out)
807 }
808
809 fn stage(&self, rel: &Path) -> Result<(), VcsError> {
810 let root = self.workdir_path()?;
811 let mut index = self.repo.index()?;
812 if root.join(rel).exists() {
813 index.add_path(rel)?;
814 } else {
815 index.remove_path(rel)?;
816 }
817 index.write()?;
818 Ok(())
819 }
820
821 fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
822 let diff = self
823 .repo
824 .diff_index_to_workdir(None, Some(&mut self.workdir_diff_options()))?;
825 let patch = self.synthesize_patch(&diff, rel, hunk, false)?;
826 let diff = git2::Diff::from_buffer(&patch)?;
827 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
828 Ok(())
829 }
830
831 fn stage_everything(&self) -> Result<(), VcsError> {
832 let sparse = self.sparse_paths();
833 let mut index = self.repo.index()?;
834 if sparse.is_empty() {
835 index.update_all(["*"], None)?;
837 index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
838 } else {
839 let workdir = self.workdir_path()?.to_path_buf();
843 let diff = self
844 .repo
845 .diff_index_to_workdir(Some(&index), Some(&mut self.workdir_diff_options()))?;
846 let changed: Vec<PathBuf> = diff
847 .deltas()
848 .filter_map(|delta| {
849 let file = if delta.status() == git2::Delta::Deleted {
850 delta.old_file()
851 } else {
852 delta.new_file()
853 };
854 let path = file.path_bytes()?;
855 (!sparse.contains(path))
856 .then(|| PathBuf::from(String::from_utf8_lossy(path).into_owned()))
857 })
858 .collect();
859 for path in changed {
860 if workdir.join(&path).exists() {
861 index.add_path(&path)?;
862 } else {
863 index.remove_path(&path)?;
864 }
865 }
866 }
867 index.write()?;
868 Ok(())
869 }
870
871 fn unstage_everything(&self) -> Result<(), VcsError> {
872 match self.repo.head() {
873 Ok(head) => {
874 let target = head.peel(git2::ObjectType::Commit)?;
875 self.repo.reset_default(Some(&target), ["*"])?;
876 }
877 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
878 let mut index = self.repo.index()?;
879 index.clear()?;
880 index.write()?;
881 }
882 Err(err) => return Err(err.into()),
883 }
884 Ok(())
885 }
886
887 fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
888 match self.repo.head() {
889 Ok(head) => {
890 let target = head.peel(git2::ObjectType::Commit)?;
891 self.repo.reset_default(Some(&target), [rel])?;
892 }
893 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
894 let mut index = self.repo.index()?;
895 index.remove_path(rel)?;
896 index.write()?;
897 }
898 Err(err) => return Err(err.into()),
899 }
900 Ok(())
901 }
902
903 fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
904 let head_tree = self.head_tree()?;
905 let diff = self.repo.diff_tree_to_index(
906 head_tree.as_ref(),
907 None,
908 Some(&mut self.plain_diff_options()),
909 )?;
910 let patch = self.synthesize_patch(&diff, rel, hunk, true)?;
911 let diff = git2::Diff::from_buffer(&patch)?;
912 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
913 Ok(())
914 }
915
916 fn discard(&self, rel: &Path) -> Result<(), VcsError> {
917 let head_tree = self.head_tree()?;
918 let mut opts = git2::DiffOptions::new();
919 opts.pathspec(rel).disable_pathspec_match(true);
920 let staged = self
921 .repo
922 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
923 if staged.deltas().len() > 0 {
924 return Err(VcsError::Rejected(
925 "file has staged changes; unstage first".into(),
926 ));
927 }
928 let status = self.repo.status_file(rel)?;
929 if status.contains(git2::Status::WT_NEW) {
930 fs::remove_file(self.workdir_path()?.join(rel))?;
931 return Ok(());
932 }
933 let mut checkout = git2::build::CheckoutBuilder::new();
937 checkout.path(rel).force().update_index(true);
938 self.repo.checkout_head(Some(&mut checkout))?;
939 Ok(())
940 }
941
942 fn commit(&self, message: &str) -> Result<String, VcsError> {
943 if message.trim().is_empty() {
944 return Err(VcsError::Rejected("empty commit message".into()));
945 }
946 let mut index = self.repo.index()?;
947 let tree_id = index.write_tree()?;
948 let tree = self.repo.find_tree(tree_id)?;
949 let signature = self.repo.signature()?;
950 let parent = match self.repo.head() {
951 Ok(head) => Some(head.peel_to_commit()?),
952 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
953 Err(err) => return Err(err.into()),
954 };
955 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
956 let oid = self.repo.commit(
957 Some("HEAD"),
958 &signature,
959 &signature,
960 message,
961 &tree,
962 &parents,
963 )?;
964 Ok(oid.to_string())
965 }
966
967 fn head_message(&self) -> Result<String, VcsError> {
968 let commit = self.head_commit_or_unborn()?;
969 Ok(commit.message().unwrap_or_default().to_owned())
970 }
971
972 fn commit_message(&self, oid: &str) -> Result<String, VcsError> {
973 let commit = self.repo.find_commit(git2::Oid::from_str(oid)?)?;
974 Ok(commit.message().unwrap_or_default().to_owned())
975 }
976
977 fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
978 if let Some(message) = message
979 && message.trim().is_empty()
980 {
981 return Err(VcsError::Rejected("empty commit message".into()));
982 }
983 let head = self.repo.head()?.peel_to_commit()?;
984 let tree = if use_index {
985 let mut index = self.repo.index()?;
986 let tree_id = index.write_tree()?;
987 self.repo.find_tree(tree_id)?
988 } else {
989 head.tree()?
990 };
991 let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
992 Ok(oid.to_string())
993 }
994
995 fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
996 let head = self.repo.head()?.peel_to_commit()?;
997 self.repo.branch(name, &head, false)?;
998 if checkout {
999 self.checkout(name)?;
1000 }
1001 Ok(())
1002 }
1003
1004 fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
1005 let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
1006 if branch.is_head() {
1007 return Err(VcsError::Rejected(
1008 "cannot delete the checked-out branch".into(),
1009 ));
1010 }
1011 branch.delete()?;
1012 Ok(())
1013 }
1014
1015 fn checkout(&self, name: &str) -> Result<(), VcsError> {
1016 let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
1017 let target = branch.get().peel(git2::ObjectType::Commit)?;
1018 self.repo.checkout_tree(&target, None)?;
1020 self.repo.set_head(&format!("refs/heads/{name}"))?;
1021 Ok(())
1022 }
1023
1024 fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError> {
1025 if !self.has_tracked_changes()? {
1026 return Err(VcsError::Rejected("nothing to stash".into()));
1027 }
1028 let mut repo = git2::Repository::open(self.workdir_path()?)?;
1031 let signature = repo.signature()?;
1032 repo.stash_save2(&signature, message, None)?;
1033 Ok(())
1034 }
1035
1036 fn stash_pop(&self) -> Result<(), VcsError> {
1037 let mut repo = git2::Repository::open(self.workdir_path()?)?;
1038 match repo.stash_pop(0, None) {
1039 Ok(()) => Ok(()),
1040 Err(err) if err.code() == git2::ErrorCode::NotFound => {
1041 Err(VcsError::Rejected("no stash to pop".into()))
1042 }
1043 Err(err) if err.code() == git2::ErrorCode::Conflict => Err(VcsError::Rejected(
1046 "stash applied with conflicts; resolve them (the stash was kept)".into(),
1047 )),
1048 Err(err) => Err(err.into()),
1049 }
1050 }
1051
1052 fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError> {
1053 let args: Vec<String> = match op {
1054 NetworkOp::Fetch => vec!["fetch".into()],
1055 NetworkOp::FetchAll => vec!["fetch".into(), "--all".into()],
1056 NetworkOp::Push => vec!["push".into()],
1057 NetworkOp::PushSetUpstream { remote } => {
1058 vec!["push".into(), "-u".into(), remote, "HEAD".into()]
1059 }
1060 NetworkOp::Pull => vec!["pull".into()],
1061 NetworkOp::PullFrom { remote, branch } => vec!["pull".into(), remote, branch],
1062 NetworkOp::PullRebase => vec!["pull".into(), "--rebase".into()],
1063 NetworkOp::PullMerge => vec!["pull".into(), "--no-rebase".into()],
1064 };
1065 Ok(std::iter::once("git".to_owned()).chain(args).collect())
1066 }
1067
1068 fn worktrees(&self) -> Result<Vec<Worktree>, VcsError> {
1069 let here = self
1070 .repo
1071 .workdir()
1072 .and_then(|dir| fs::canonicalize(dir).ok());
1073 let opened;
1074 let main = if self.repo.is_worktree() {
1075 opened = git2::Repository::open(self.repo.commondir())?;
1076 &opened
1077 } else {
1078 &self.repo
1079 };
1080 let entry = |name: String, path: &Path, repo: Option<&git2::Repository>, main: bool| {
1081 let (label, subject, time_unix) =
1082 repo.map_or_else(|| (name.clone(), String::new(), 0), worktree_head);
1083 let branch = repo
1084 .and_then(|repo| repo.head().ok())
1085 .filter(git2::Reference::is_branch)
1086 .and_then(|head| head.shorthand().ok().map(str::to_owned));
1087 Worktree {
1088 name,
1089 current: here.is_some() && fs::canonicalize(path).ok() == here,
1090 path: path.to_path_buf(),
1091 label,
1092 branch,
1093 subject,
1094 time_unix,
1095 main,
1096 missing: repo.is_none(),
1097 }
1098 };
1099 let mut found = Vec::new();
1100 if let Some(dir) = main.workdir() {
1102 found.push(entry(String::new(), dir, Some(main), true));
1103 }
1104 let mut names: Vec<String> = main
1105 .worktrees()?
1106 .iter()
1107 .flatten()
1108 .flatten()
1109 .map(str::to_owned)
1110 .collect();
1111 names.sort();
1112 for name in names {
1113 let linked = main.find_worktree(&name)?;
1114 let repo = linked
1115 .validate()
1116 .ok()
1117 .and_then(|()| git2::Repository::open_from_worktree(&linked).ok());
1118 found.push(entry(name, linked.path(), repo.as_ref(), false));
1119 }
1120 Ok(found)
1121 }
1122
1123 fn worktree_argv(&self, op: &WorktreeOp) -> Result<Vec<String>, VcsError> {
1124 let mut argv: Vec<String> = vec!["git".into(), "worktree".into()];
1125 match op {
1126 WorktreeOp::Add {
1127 path,
1128 branch,
1129 new_branch: true,
1130 } => argv.extend([
1131 "add".into(),
1132 "-b".into(),
1133 branch.clone(),
1134 path.display().to_string(),
1135 ]),
1136 WorktreeOp::Add { path, branch, .. } => {
1137 argv.extend(["add".into(), path.display().to_string(), branch.clone()]);
1138 }
1139 WorktreeOp::Remove { worktree, .. } if worktree.main => {
1140 return Err(VcsError::Rejected(
1141 "the main worktree stays with the repository; remove a linked one".into(),
1142 ));
1143 }
1144 WorktreeOp::Remove { worktree, force } => {
1145 argv.push("remove".into());
1146 if *force {
1147 argv.push("--force".into());
1148 }
1149 argv.push(worktree.path.display().to_string());
1150 }
1151 }
1152 Ok(argv)
1153 }
1154
1155 fn workdir(&self) -> Result<PathBuf, VcsError> {
1156 Ok(self.workdir_path()?.to_path_buf())
1157 }
1158
1159 fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
1160 match self.repo.find_remote(name) {
1161 Ok(remote) => Ok(remote.url().ok().map(str::to_owned)),
1163 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
1164 Err(err) => Err(err.into()),
1165 }
1166 }
1167
1168 fn remotes(&self) -> Result<Vec<String>, VcsError> {
1169 let array = self.repo.remotes()?;
1170 let mut names = Vec::new();
1171 for i in 0..array.len() {
1172 if let Ok(Some(name)) = array.get(i) {
1173 names.push(name.to_owned());
1174 }
1175 }
1176 Ok(names)
1177 }
1178
1179 fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
1180 self.algorithm.set(algorithm);
1181 self.indent_heuristic.set(indent_heuristic);
1182 }
1183}
1184
1185fn apply_git_algorithm(
1188 opts: &mut git2::DiffOptions,
1189 algorithm: DiffAlgorithm,
1190 indent_heuristic: bool,
1191) {
1192 match algorithm {
1193 DiffAlgorithm::Minimal => {
1194 opts.minimal(true);
1195 }
1196 DiffAlgorithm::Patience => {
1197 opts.patience(true);
1198 }
1199 DiffAlgorithm::Myers | DiffAlgorithm::Histogram | DiffAlgorithm::Structural => {}
1200 }
1201 opts.indent_heuristic(indent_heuristic);
1202}
1203
1204impl GitVcs {
1205 fn synthesize_patch(
1208 &self,
1209 diff: &git2::Diff<'_>,
1210 rel: &Path,
1211 target: &HunkId,
1212 reverse: bool,
1213 ) -> Result<Vec<u8>, VcsError> {
1214 let rel = rel.to_string_lossy();
1215 for idx in 0..diff.deltas().len() {
1216 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
1217 continue;
1218 };
1219 let delta = patch.delta();
1222 if delta.flags().is_binary() || delta_new_path(&delta) != rel {
1223 continue;
1224 }
1225 if self.algorithm.get().is_imara() {
1226 let old_text = blob_text(&self.repo, delta.old_file().id());
1227 let new_text = new_side_text(&self.repo, &delta, &rel);
1228 let (old, new) = (old_text.as_deref(), new_text.as_deref());
1229 if let Some(hunks) = self.imara_hunks(&delta, old, new, &rel)
1230 && let (Some(old), Some(new)) = (old, new)
1231 {
1232 if let Some(hunk) = hunks.iter().find(|h| h.id == *target) {
1233 return Ok(render_hunk_patch_from_model(hunk, &rel, reverse, old, new));
1234 }
1235 continue;
1236 }
1237 }
1238 let mut seen = HashMap::new();
1239 for h in 0..patch.num_hunks() {
1240 let lines = hunk_model_lines(&patch, h)?;
1241 if disambiguated_hunk_id(&rel, &lines, &mut seen) == *target {
1242 return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
1243 }
1244 }
1245 }
1246 Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
1247 }
1248
1249 fn build_file(
1250 &self,
1251 diff: &mut git2::Diff<'_>,
1252 idx: usize,
1253 ) -> Result<Option<FileDiff>, VcsError> {
1254 let repo = &self.repo;
1255 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
1256 return Ok(build_binary_file(diff, idx));
1257 };
1258 let delta = patch.delta();
1259 if delta.flags().is_binary() {
1260 return Ok(build_binary_file(diff, idx));
1261 }
1262 let file_path = delta_new_path(&delta);
1263 let status = map_status(delta.status());
1264 let old_path = if status == FileStatus::Renamed {
1265 delta
1266 .old_file()
1267 .path()
1268 .map(|p| p.to_string_lossy().into_owned())
1269 } else {
1270 None
1271 };
1272
1273 let old_text = blob_text(repo, delta.old_file().id());
1274 let new_text = new_side_text(repo, &delta, &file_path);
1275
1276 let imara = self.imara_hunks(&delta, old_text.as_deref(), new_text.as_deref(), &file_path);
1277 let hunks = match imara {
1278 Some(hunks) => hunks,
1279 None => patch_hunks(&patch, &file_path)?,
1280 };
1281
1282 Ok(Some(FileDiff {
1283 path: file_path,
1284 old_path,
1285 status,
1286 binary: false,
1287 old_text,
1288 new_text,
1289 hunks,
1290 hashes: crate::model::HashCache::default(),
1291 blobs: crate::model::BlobIds::default(),
1292 }))
1293 }
1294}
1295
1296fn render_hunk_patch(
1297 patch: &git2::Patch<'_>,
1298 h: usize,
1299 rel: &str,
1300 status: git2::Delta,
1301 reverse: bool,
1302) -> Result<Vec<u8>, VcsError> {
1303 let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
1304 let deleted = status == git2::Delta::Deleted;
1305 let mut out = Vec::new();
1306 out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
1307 if (added && !reverse) || (deleted && reverse) {
1311 out.extend_from_slice(
1312 format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
1313 );
1314 } else if (deleted && !reverse) || (added && reverse) {
1315 out.extend_from_slice(
1316 format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
1317 );
1318 } else {
1319 out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
1320 }
1321 let (hunk, line_count) = patch.hunk(h)?;
1322 let mut lines: Vec<(LineKind, Vec<u8>)> = Vec::with_capacity(line_count);
1323 for l in 0..line_count {
1324 let line = patch.line_in_hunk(h, l)?;
1325 let kind = match line.origin() {
1326 ' ' => LineKind::Context,
1327 '+' => LineKind::Added,
1328 '-' => LineKind::Deleted,
1329 '=' | '>' | '<' => {
1332 if let Some((_, text)) = lines.last_mut() {
1333 text.extend_from_slice(line.content());
1334 }
1335 continue;
1336 }
1337 _ => continue,
1338 };
1339 lines.push((kind, line.content().to_vec()));
1340 }
1341 write_hunk(
1342 &mut out,
1343 (hunk.old_start(), hunk.old_lines()),
1344 (hunk.new_start(), hunk.new_lines()),
1345 &lines,
1346 reverse,
1347 );
1348 Ok(out)
1349}
1350
1351fn write_hunk(
1354 out: &mut Vec<u8>,
1355 old: (u32, u32),
1356 new: (u32, u32),
1357 lines: &[(LineKind, Vec<u8>)],
1358 reverse: bool,
1359) {
1360 let ((minus_start, minus_lines), (_, plus_lines)) =
1361 if reverse { (new, old) } else { (old, new) };
1362 let plus_start = if minus_lines == 0 {
1366 minus_start + 1
1367 } else {
1368 minus_start
1369 };
1370 out.extend_from_slice(
1371 format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
1372 );
1373 let (minus_kind, plus_kind) = if reverse {
1376 (LineKind::Added, LineKind::Deleted)
1377 } else {
1378 (LineKind::Deleted, LineKind::Added)
1379 };
1380 let is_context = |kind: LineKind| kind == LineKind::Context;
1381 for run in lines.chunk_by(|a, b| is_context(a.0) == is_context(b.0)) {
1382 for (origin, kind) in [
1383 (b' ', LineKind::Context),
1384 (b'-', minus_kind),
1385 (b'+', plus_kind),
1386 ] {
1387 for (_, text) in run.iter().filter(|(k, _)| *k == kind) {
1388 out.push(origin);
1389 out.extend_from_slice(text);
1390 }
1391 }
1392 }
1393}
1394
1395fn render_hunk_patch_from_model(
1399 hunk: &Hunk,
1400 rel: &str,
1401 reverse: bool,
1402 old_text: &str,
1403 new_text: &str,
1404) -> Vec<u8> {
1405 let old_raw: Vec<&str> = old_text.split_inclusive('\n').collect();
1406 let new_raw: Vec<&str> = new_text.split_inclusive('\n').collect();
1407 let raw = |line: &DiffLine| -> Vec<u8> {
1408 let (side, number) = match line.kind {
1409 LineKind::Added => (&new_raw, line.new_no),
1410 LineKind::Deleted | LineKind::Context => (&old_raw, line.old_no),
1411 };
1412 let text = number
1413 .and_then(|n| n.checked_sub(1))
1414 .and_then(|i| side.get(i as usize))
1415 .copied()
1416 .unwrap_or_default();
1417 let mut bytes = text.as_bytes().to_vec();
1418 if !text.ends_with('\n') {
1419 bytes.extend_from_slice(b"\n\\ No newline at end of file\n");
1420 }
1421 bytes
1422 };
1423 let lines: Vec<(LineKind, Vec<u8>)> = hunk.lines.iter().map(|l| (l.kind, raw(l))).collect();
1424
1425 let mut out = format!("diff --git a/{rel} b/{rel}\n--- a/{rel}\n+++ b/{rel}\n").into_bytes();
1426 write_hunk(
1427 &mut out,
1428 (hunk.old_start, hunk.old_lines),
1429 (hunk.new_start, hunk.new_lines),
1430 &lines,
1431 reverse,
1432 );
1433 out
1434}
1435
1436fn worktree_head(repo: &git2::Repository) -> (String, String, i64) {
1439 let Ok(head) = repo.head() else {
1440 return ("(no commits)".to_owned(), String::new(), 0);
1441 };
1442 let Ok(commit) = head.peel_to_commit() else {
1443 return ("(no commits)".to_owned(), String::new(), 0);
1444 };
1445 let label = if head.is_branch() {
1446 head.shorthand().unwrap_or_default().to_owned()
1447 } else {
1448 format!("detached {}", short7(&commit.id().to_string()))
1449 };
1450 (
1451 label,
1452 commit
1453 .summary()
1454 .ok()
1455 .flatten()
1456 .unwrap_or_default()
1457 .to_owned(),
1458 commit.time().seconds(),
1459 )
1460}
1461
1462pub fn short7(oid: &str) -> String {
1463 oid.get(..7).unwrap_or(oid).to_owned()
1464}
1465
1466pub fn rehunk_file(
1469 file: &FileDiff,
1470 context: u32,
1471 algorithm: DiffAlgorithm,
1472 indent_heuristic: bool,
1473) -> Option<Vec<Hunk>> {
1474 if file.binary {
1475 return None;
1476 }
1477 let (old, new) = (file.old_text.as_deref()?, file.new_text.as_deref()?);
1478 if algorithm.is_imara() {
1479 return Some(histogram_hunks(
1480 old,
1481 new,
1482 &file.path,
1483 context,
1484 indent_heuristic,
1485 ));
1486 }
1487 let as_path = Path::new(&file.path);
1488 let cap = u32::try_from(old.lines().count().max(new.lines().count())).unwrap_or(u32::MAX);
1491 let mut opts = git2::DiffOptions::new();
1492 opts.context_lines(context.min(cap));
1493 apply_git_algorithm(&mut opts, algorithm, indent_heuristic);
1494 let patch = git2::Patch::from_buffers(
1495 old.as_bytes(),
1496 Some(as_path),
1497 new.as_bytes(),
1498 Some(as_path),
1499 Some(&mut opts),
1500 )
1501 .ok()?;
1502 patch_hunks(&patch, &file.path).ok()
1503}
1504
1505fn patch_hunks(patch: &git2::Patch<'_>, file_path: &str) -> Result<Vec<Hunk>, VcsError> {
1507 let mut hunks = Vec::with_capacity(patch.num_hunks());
1508 let mut seen = HashMap::new();
1509 for h in 0..patch.num_hunks() {
1510 let (hunk, _) = patch.hunk(h)?;
1511 let lines = hunk_model_lines(patch, h)?;
1512 let id = disambiguated_hunk_id(file_path, &lines, &mut seen);
1513 hunks.push(Hunk {
1514 id,
1515 old_start: hunk.old_start(),
1516 old_lines: hunk.old_lines(),
1517 new_start: hunk.new_start(),
1518 new_lines: hunk.new_lines(),
1519 context: hunk_context(&hunk),
1520 lines,
1521 });
1522 }
1523 Ok(hunks)
1524}
1525
1526fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
1528 let header = String::from_utf8_lossy(hunk.header());
1529 match header.split_once(" @@") {
1530 Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
1531 None => String::new(),
1532 }
1533}
1534
1535fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
1537 let (_, line_count) = patch.hunk(h)?;
1538 let mut lines = Vec::with_capacity(line_count);
1539 for l in 0..line_count {
1540 let line = patch.line_in_hunk(h, l)?;
1541 let kind = match line.origin() {
1542 '-' => LineKind::Deleted,
1543 '+' => LineKind::Added,
1544 ' ' => LineKind::Context,
1545 _ => continue,
1546 };
1547 let text = String::from_utf8_lossy(line.content())
1548 .trim_end_matches(['\n', '\r'])
1549 .to_owned();
1550 lines.push(DiffLine::new(
1551 kind,
1552 line.old_lineno(),
1553 line.new_lineno(),
1554 text,
1555 ));
1556 }
1557 Ok(lines)
1558}
1559
1560fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
1561 let delta = diff.get_delta(idx)?;
1562 let blob = |file: git2::DiffFile<'_>| (!file.id().is_zero()).then(|| file.id().to_string());
1563 let blobs = crate::model::BlobIds {
1564 old: blob(delta.old_file()),
1565 new: blob(delta.new_file()),
1566 };
1567 Some(FileDiff {
1568 path: delta_new_path(&delta),
1569 old_path: None,
1570 status: map_status(delta.status()),
1571 binary: true,
1572 old_text: None,
1573 new_text: None,
1574 hunks: Vec::new(),
1575 hashes: crate::model::HashCache::default(),
1576 blobs,
1577 })
1578}
1579
1580fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
1581 delta
1582 .new_file()
1583 .path()
1584 .or_else(|| delta.old_file().path())
1585 .map(|p| p.to_string_lossy().into_owned())
1586 .unwrap_or_default()
1587}
1588
1589fn map_status(status: git2::Delta) -> FileStatus {
1590 match status {
1591 git2::Delta::Added => FileStatus::Added,
1592 git2::Delta::Deleted => FileStatus::Deleted,
1593 git2::Delta::Renamed => FileStatus::Renamed,
1594 git2::Delta::Untracked => FileStatus::Untracked,
1595 _ => FileStatus::Modified,
1596 }
1597}
1598
1599fn log_entry(commit: &git2::Commit<'_>) -> Result<LogEntry, VcsError> {
1600 let full = commit.id().to_string();
1601 Ok(LogEntry {
1602 oid7: short7(&full),
1603 oid: full,
1604 refs: Vec::new(),
1605 parents: commit.parent_ids().map(|id| id.to_string()).collect(),
1606 subject: commit.summary()?.unwrap_or_default().to_owned(),
1607 author: commit.author().name().unwrap_or_default().to_owned(),
1608 time_unix: commit.time().seconds(),
1609 change_id: None,
1610 })
1611}
1612
1613fn blob_at(commit: &git2::Commit<'_>, path: &str) -> Result<Option<git2::Oid>, VcsError> {
1615 match commit.tree()?.get_path(Path::new(path)) {
1616 Ok(entry) => Ok(Some(entry.id())),
1617 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
1618 Err(err) => Err(err.into()),
1619 }
1620}
1621
1622fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
1623 if oid.is_zero() {
1624 return None;
1625 }
1626 let blob = repo.find_blob(oid).ok()?;
1627 if blob.is_binary() {
1628 return None;
1629 }
1630 String::from_utf8(blob.content().to_vec()).ok()
1631}
1632
1633fn new_side_text(
1636 repo: &git2::Repository,
1637 delta: &git2::DiffDelta<'_>,
1638 rel: &str,
1639) -> Option<String> {
1640 if delta.status() == git2::Delta::Deleted {
1641 return None;
1642 }
1643 if let Some(text) = blob_text(repo, delta.new_file().id()) {
1644 return Some(text);
1645 }
1646 let root = repo.workdir()?;
1647 fs::read_to_string(root.join(rel)).ok()
1648}