1use std::cell::Cell;
4use std::collections::HashMap;
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, HeadInfo, LogEntry, NetworkOp, StatusModel, Vcs, VcsError,
14};
15
16pub const DEFAULT_CONTEXT_LINES: u32 = 3;
17
18pub const DEFAULT_INDENT_HEURISTIC: bool = true;
19
20pub struct GitVcs {
21 repo: git2::Repository,
22 context_lines: u32,
23 algorithm: Cell<DiffAlgorithm>,
25 indent_heuristic: Cell<bool>,
26 indexed: bool,
28 base: Cell<Base>,
31}
32
33#[derive(Debug, Clone, Copy)]
34enum Base {
35 FollowHead,
36 Unborn,
37 At(git2::Oid),
38}
39
40impl GitVcs {
41 pub fn open(root: &Path) -> Result<Self, VcsError> {
42 Self::open_with_settings(root, &DiffSettings::default())
43 }
44
45 pub fn open_with_settings(root: &Path, settings: &DiffSettings) -> Result<Self, VcsError> {
46 let repo = git2::Repository::open(root)?;
47 if repo.workdir().is_none() {
48 return Err(VcsError::NoWorkdir);
49 }
50 Ok(Self {
51 repo,
52 context_lines: settings.context_lines,
53 algorithm: Cell::new(settings.algorithm),
54 indent_heuristic: Cell::new(settings.indent_heuristic),
55 indexed: true,
56 base: Cell::new(Base::FollowHead),
57 })
58 }
59
60 pub fn open_jj_store(
63 git_dir: &Path,
64 root: &Path,
65 settings: &DiffSettings,
66 ) -> Result<Self, VcsError> {
67 let repo = git2::Repository::open_bare(git_dir)?;
68 repo.set_workdir(root, false)?;
69 repo.add_ignore_rule(".jj/")?;
71 Ok(Self {
72 repo,
73 context_lines: settings.context_lines,
74 algorithm: Cell::new(settings.algorithm),
75 indent_heuristic: Cell::new(settings.indent_heuristic),
76 indexed: false,
77 base: Cell::new(Base::Unborn),
78 })
79 }
80
81 pub(crate) fn set_base(&self, base: Option<&str>) -> Result<(), VcsError> {
83 self.base.set(match base {
84 Some(oid) => Base::At(git2::Oid::from_str(oid)?),
85 None => Base::Unborn,
86 });
87 Ok(())
88 }
89
90 fn head_commit(&self) -> Result<Option<git2::Commit<'_>>, VcsError> {
92 match self.base.get() {
93 Base::At(oid) => Ok(Some(self.repo.find_commit(oid)?)),
94 Base::Unborn => Ok(None),
95 Base::FollowHead => match self.repo.head() {
96 Ok(head) => Ok(Some(head.peel_to_commit()?)),
97 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
98 Err(err) => Err(err.into()),
99 },
100 }
101 }
102
103 fn head_commit_or_unborn(&self) -> Result<git2::Commit<'_>, VcsError> {
104 self.head_commit()?.ok_or_else(|| {
105 git2::Error::new(
106 git2::ErrorCode::UnbornBranch,
107 git2::ErrorClass::Reference,
108 "HEAD has no commit yet",
109 )
110 .into()
111 })
112 }
113
114 fn revparse(&self, rev: &str) -> Result<git2::Object<'_>, VcsError> {
117 let head_relative = rev
118 .strip_prefix("HEAD")
119 .filter(|rest| rest.is_empty() || rest.starts_with(['~', '^']));
120 match (self.base.get(), head_relative) {
121 (Base::At(_) | Base::Unborn, Some(rest)) => {
122 let oid = self.head_commit_or_unborn()?.id();
123 Ok(self.repo.revparse_single(&format!("{oid}{rest}"))?)
124 }
125 _ => Ok(self.repo.revparse_single(rev)?),
126 }
127 }
128
129 fn tree_files(&self) -> Result<Vec<PathBuf>, VcsError> {
130 let Some(tree) = self.head_tree()? else {
131 return Ok(Vec::new());
132 };
133 let mut out = Vec::new();
134 tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
135 if entry.kind() == Some(git2::ObjectType::Blob)
136 && let Ok(name) = entry.name()
137 {
138 out.push(PathBuf::from(format!("{dir}{name}")));
139 }
140 git2::TreeWalkResult::Ok
141 })?;
142 out.sort_unstable();
143 Ok(out)
144 }
145
146 fn head_tree(&self) -> Result<Option<git2::Tree<'_>>, VcsError> {
148 Ok(self
149 .head_commit()?
150 .map(|commit| commit.tree())
151 .transpose()?)
152 }
153
154 fn workdir_path(&self) -> Result<&Path, VcsError> {
155 self.repo.workdir().ok_or(VcsError::NoWorkdir)
156 }
157
158 fn workdir_diff(&self, base: Option<&git2::Tree<'_>>) -> Result<DiffModel, VcsError> {
161 let mut options = self.workdir_diff_options();
162 let mut diff = if self.indexed {
163 self.repo
164 .diff_tree_to_workdir_with_index(base, Some(&mut options))?
165 } else {
166 self.repo.diff_tree_to_workdir(base, Some(&mut options))?
167 };
168 let mut find = git2::DiffFindOptions::new();
169 find.renames(true);
170 diff.find_similar(Some(&mut find))?;
171 let mut model = self.diff_to_model(&mut diff)?;
172 if !self.indexed {
173 for file in &mut model.files {
175 if file.status == FileStatus::Untracked {
176 file.status = FileStatus::Added;
177 }
178 }
179 }
180 Ok(model)
181 }
182
183 fn plain_diff_options(&self) -> git2::DiffOptions {
184 let mut opts = git2::DiffOptions::new();
185 opts.context_lines(self.context_lines);
186 apply_git_algorithm(&mut opts, self.algorithm.get(), self.indent_heuristic.get());
187 opts
188 }
189
190 fn workdir_diff_options(&self) -> git2::DiffOptions {
191 let mut opts = self.plain_diff_options();
192 opts.include_untracked(true)
193 .recurse_untracked_dirs(true)
194 .show_untracked_content(true);
195 opts
196 }
197
198 fn diff_to_model(&self, diff: &mut git2::Diff<'_>) -> Result<DiffModel, VcsError> {
200 let mut files = Vec::new();
201 for idx in 0..diff.deltas().len() {
202 if let Some(file) = self.build_file(diff, idx)? {
203 files.push(file);
204 }
205 }
206 Ok(DiffModel { files })
207 }
208
209 fn imara_hunks(
213 &self,
214 delta: &git2::DiffDelta<'_>,
215 old: Option<&str>,
216 new: Option<&str>,
217 path: &str,
218 ) -> Option<Vec<Hunk>> {
219 if !self.algorithm.get().is_imara() {
220 return None;
221 }
222 let (old, new) = (old?, new?);
223 let hashed = git2::Oid::hash_object(git2::ObjectType::Blob, new.as_bytes()).ok()?;
226 if hashed != delta.new_file().id() {
227 return None;
228 }
229 Some(histogram_hunks(
230 old,
231 new,
232 path,
233 self.context_lines,
234 self.indent_heuristic.get(),
235 ))
236 }
237
238 fn walk_entries(
239 &self,
240 walk: git2::Revwalk<'_>,
241 limit: usize,
242 ) -> Result<Vec<LogEntry>, VcsError> {
243 let mut entries = Vec::new();
244 for oid in walk.take(limit) {
245 let oid = oid?;
246 let commit = self.repo.find_commit(oid)?;
247 let full = oid.to_string();
248 entries.push(LogEntry {
249 oid7: short7(&full),
250 oid: full,
251 refs: Vec::new(),
252 parents: commit.parent_ids().map(|id| id.to_string()).collect(),
253 subject: commit.summary()?.unwrap_or_default().to_owned(),
254 author: commit.author().name().unwrap_or_default().to_owned(),
255 time_unix: commit.time().seconds(),
256 change_id: None,
257 });
258 }
259 Ok(entries)
260 }
261
262 pub(crate) fn log_from(
265 &self,
266 limit: usize,
267 all_branches: bool,
268 tips: &[&str],
269 ) -> Result<Vec<LogEntry>, VcsError> {
270 let Some(head) = self.head_commit()? else {
271 return Ok(Vec::new());
272 };
273 let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
274 for reference in self.repo.references()?.flatten() {
275 let Ok(name) = reference.shorthand().map(str::to_owned) else {
276 continue;
277 };
278 let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
279 continue;
280 };
281 refs_by_oid.entry(target).or_default().push(name);
282 }
283
284 let mut walk = self.repo.revwalk()?;
285 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
286 walk.push(head.id())?;
287 if all_branches {
288 for glob in ["refs/heads", "refs/remotes", "refs/tags"] {
289 walk.push_glob(glob)?;
290 }
291 for tip in tips {
292 walk.push(git2::Oid::from_str(tip)?)?;
293 }
294 }
295 let mut entries = self.walk_entries(walk, limit)?;
296 for entry in &mut entries {
297 if let Some(refs) = git2::Oid::from_str(&entry.oid)
298 .ok()
299 .and_then(|oid| refs_by_oid.remove(&oid))
300 {
301 entry.refs = refs;
302 }
303 }
304 Ok(entries)
305 }
306
307 pub(crate) fn ahead_behind(
309 &self,
310 local: &str,
311 upstream: &str,
312 ) -> Result<(usize, usize), VcsError> {
313 Ok(self
314 .repo
315 .graph_ahead_behind(git2::Oid::from_str(local)?, git2::Oid::from_str(upstream)?)?)
316 }
317
318 fn has_tracked_changes(&self) -> Result<bool, VcsError> {
320 let mut opts = git2::StatusOptions::new();
321 opts.include_untracked(false).include_ignored(false);
322 let statuses = self.repo.statuses(Some(&mut opts))?;
323 Ok(statuses.iter().next().is_some())
324 }
325}
326
327impl Vcs for GitVcs {
328 fn has_index(&self) -> bool {
329 true
330 }
331
332 fn native_git_checkout(&self) -> bool {
333 true
334 }
335
336 fn git_dir(&self) -> Result<PathBuf, VcsError> {
337 Ok(self.repo.path().to_path_buf())
338 }
339
340 fn head(&self) -> Result<HeadInfo, VcsError> {
341 if !matches!(self.base.get(), Base::FollowHead) {
342 let commit = self.head_commit()?;
343 return Ok(HeadInfo {
344 branch: None,
345 oid7: commit
346 .as_ref()
347 .map(|c| short7(&c.id().to_string()))
348 .unwrap_or_default(),
349 subject: commit
350 .as_ref()
351 .and_then(|c| c.summary().ok().flatten().map(str::to_owned))
352 .unwrap_or_default(),
353 upstream: None,
354 ahead: 0,
355 behind: 0,
356 });
357 }
358 match self.repo.head() {
359 Ok(head) => {
360 let branch = if head.is_branch() {
361 Some(head.shorthand()?.to_owned())
362 } else {
363 None
364 };
365 let commit = head.peel_to_commit()?;
366 let tracking = branch.as_deref().and_then(|name| {
367 let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
368 let upstream = local.upstream().ok()?;
369 let name = upstream.name().ok().flatten()?.to_owned();
370 Some((name, upstream.get().target()))
371 });
372 let (upstream, ahead, behind) = match tracking {
373 Some((name, Some(target))) => {
374 let (ahead, behind) = self
375 .repo
376 .graph_ahead_behind(commit.id(), target)
377 .unwrap_or((0, 0));
378 (Some(name), ahead, behind)
379 }
380 Some((name, None)) => (Some(name), 0, 0),
381 None => (None, 0, 0),
382 };
383 Ok(HeadInfo {
384 branch,
385 oid7: short7(&commit.id().to_string()),
386 subject: commit.summary()?.unwrap_or_default().to_owned(),
387 upstream,
388 ahead,
389 behind,
390 })
391 }
392 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
393 let branch = self
394 .repo
395 .find_reference("HEAD")
396 .ok()
397 .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
398 .and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
399 Ok(HeadInfo {
400 branch,
401 oid7: String::new(),
402 subject: String::new(),
403 upstream: None,
404 ahead: 0,
405 behind: 0,
406 })
407 }
408 Err(err) => Err(err.into()),
409 }
410 }
411
412 fn status(&self) -> Result<StatusModel, VcsError> {
413 let mut workdir = self
415 .repo
416 .diff_index_to_workdir(None, Some(&mut self.workdir_diff_options()))?;
417 let workdir_model = self.diff_to_model(&mut workdir)?;
418 let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
419 .files
420 .into_iter()
421 .partition(|f| f.status == FileStatus::Untracked);
422
423 let head_tree = self.head_tree()?;
424 let mut staged = self.repo.diff_tree_to_index(
425 head_tree.as_ref(),
426 None,
427 Some(&mut self.plain_diff_options()),
428 )?;
429 let staged = self.diff_to_model(&mut staged)?;
430
431 Ok(StatusModel {
432 untracked: DiffModel { files: untracked },
433 unstaged: DiffModel { files: unstaged },
434 staged,
435 })
436 }
437
438 fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
439 self.workdir_diff(self.head_tree()?.as_ref())
440 }
441
442 fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
443 let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
444 self.workdir_diff(Some(&base.tree()?))
445 }
446
447 fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
448 let oid = git2::Oid::from_str(oid)?;
449 let commit = self.repo.find_commit(oid)?;
450 let tree = commit.tree()?;
451 let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
452 let mut diff = self.repo.diff_tree_to_tree(
453 parent_tree.as_ref(),
454 Some(&tree),
455 Some(&mut self.plain_diff_options()),
456 )?;
457 self.diff_to_model(&mut diff)
458 }
459
460 fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
461 let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
462 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
463 let mut diff = self.repo.diff_tree_to_tree(
464 Some(&base.tree()?),
465 Some(&newest.tree()?),
466 Some(&mut self.plain_diff_options()),
467 )?;
468 self.diff_to_model(&mut diff)
469 }
470
471 fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
472 let base = self
473 .repo
474 .merge_base(git2::Oid::from_str(a)?, git2::Oid::from_str(b)?)?;
475 Ok(base.to_string())
476 }
477
478 fn resolve(&self, revision: &str) -> Result<String, VcsError> {
479 let object = self.revparse(revision)?;
480 let commit = object.peel_to_commit()?;
481 Ok(commit.id().to_string())
482 }
483
484 fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
485 let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
486 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
487 let newest_tree = newest.tree()?;
488 let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
489 let mut diff = self.repo.diff_tree_to_tree(
490 base_tree.as_ref(),
491 Some(&newest_tree),
492 Some(&mut self.plain_diff_options()),
493 )?;
494 self.diff_to_model(&mut diff)
495 }
496
497 fn log(&self, limit: usize, all_branches: bool) -> Result<Vec<LogEntry>, VcsError> {
498 self.log_from(limit, all_branches, &[])
499 }
500
501 fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
502 let head_ref = format!("refs/remotes/{remote}/HEAD");
503 let prefix = format!("refs/remotes/{remote}/");
504 if let Ok(reference) = self.repo.find_reference(&head_ref)
505 && let Ok(Some(target)) = reference.symbolic_target()
506 && let Some(name) = target.strip_prefix(prefix.as_str())
508 {
509 return Ok(Some(name.to_owned()));
510 }
511 for name in ["main", "master"] {
512 if self
513 .repo
514 .find_branch(name, git2::BranchType::Local)
515 .or_else(|_| {
516 self.repo
517 .find_branch(&format!("{remote}/{name}"), git2::BranchType::Remote)
518 })
519 .is_ok()
520 {
521 return Ok(Some(name.to_owned()));
522 }
523 }
524 Ok(None)
525 }
526
527 fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
528 let (base, head) = (self.revparse(base)?, self.revparse(head)?);
529 let mut walk = self.repo.revwalk()?;
530 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
531 walk.push(head.id())?;
532 walk.hide(base.id())?;
533 self.walk_entries(walk, usize::MAX)
534 }
535
536 fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError> {
537 let Some(head) = self.head_commit().ok().flatten() else {
538 return Ok(None);
539 };
540 let mut walk = self.repo.revwalk()?;
541 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
542 walk.push(head.id())?;
543 let mut remotes = 0;
544 for reference in self.repo.references()? {
545 let reference = reference?;
546 if let (true, Some(oid)) = (reference.is_remote(), reference.target()) {
549 remotes += 1;
550 walk.hide(oid)?;
551 }
552 }
553 if remotes == 0 {
554 return Ok(None);
555 }
556 self.walk_entries(walk, limit).map(Some)
557 }
558
559 fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
560 let mut options = git2::BlameOptions::new();
561 options.track_copies_same_file(true);
562 if !matches!(self.base.get(), Base::FollowHead) {
563 options.newest_commit(self.head_commit_or_unborn()?.id());
564 }
565 let blame = self.repo.blame_file(rel, Some(&mut options))?;
566 let workdir = self.workdir()?.join(rel);
569 let blame = match fs::read(&workdir) {
570 Ok(bytes) => blame.blame_buffer(&bytes)?,
571 Err(_) => blame,
572 };
573
574 let mut out = Vec::new();
575 for hunk in blame.iter() {
576 let oid = hunk.final_commit_id();
577 let commit = self.repo.find_commit(oid).ok();
578 let full = oid.to_string();
579 out.push(BlameSpan {
580 start_line: u32::try_from(hunk.final_start_line()).unwrap_or(u32::MAX),
581 line_count: u32::try_from(hunk.lines_in_hunk()).unwrap_or(u32::MAX),
582 oid7: short7(&full),
583 oid: full,
584 author: commit
585 .as_ref()
586 .map(|c| c.author().name().unwrap_or_default().to_owned())
587 .unwrap_or_default(),
588 time_unix: commit.as_ref().map_or(0, |c| c.time().seconds()),
589 summary: commit
590 .as_ref()
591 .and_then(|c| c.summary().ok().flatten().map(str::to_owned))
592 .unwrap_or_default(),
593 committed: !oid.is_zero(),
594 });
595 }
596 out.sort_by_key(|span| span.start_line);
597 Ok(out)
598 }
599
600 fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError> {
601 let commit = self.revparse(rev)?.peel_to_commit()?;
602 match commit.tree()?.get_path(Path::new(path)) {
603 Ok(entry) => Ok(blob_text(&self.repo, entry.id())),
604 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
605 Err(err) => Err(err.into()),
606 }
607 }
608
609 fn read_blob(&self, oid: &str) -> Result<Option<Vec<u8>>, VcsError> {
610 let oid = git2::Oid::from_str(oid)?;
611 match self.repo.find_blob(oid) {
612 Ok(blob) => Ok(Some(blob.content().to_vec())),
613 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
614 Err(err) => Err(err.into()),
615 }
616 }
617
618 fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError> {
619 if !self.indexed {
620 return self.tree_files();
621 }
622 let index = self.repo.index()?;
623 let mut out: Vec<PathBuf> = index
624 .iter()
625 .filter_map(|entry| {
626 std::str::from_utf8(&entry.path)
627 .ok()
628 .map(|path| PathBuf::from(path.to_owned()))
629 })
630 .collect();
631 out.sort_unstable();
632 out.dedup();
633 Ok(out)
634 }
635
636 fn attr(&self, rel: &Path, name: &str) -> bool {
637 let Ok(value) = self.repo.get_attr(rel, name, git2::AttrCheckFlags::empty()) else {
638 return false;
639 };
640 matches!(
642 git2::AttrValue::from_string(value),
643 git2::AttrValue::True | git2::AttrValue::String("true")
644 )
645 }
646
647 fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
648 let mut out = Vec::new();
649 for entry in self.repo.branches(Some(git2::BranchType::Local))? {
650 let (branch, _) = entry?;
651 let Some(name) = branch.name()?.map(str::to_owned) else {
652 continue;
653 };
654 let tip_unix = branch
655 .get()
656 .peel_to_commit()
657 .ok()
658 .map_or(0, |commit| commit.time().seconds());
659 out.push(BranchInfo {
660 name,
661 is_head: branch.is_head(),
662 tip_unix,
663 divergence: None,
664 });
665 }
666 Ok(out)
667 }
668
669 fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError> {
670 let branch = self.repo.find_branch(branch, git2::BranchType::Local)?;
671 let Some(tip) = branch.get().peel_to_commit().ok() else {
672 return Ok(None);
673 };
674 let Some(target) = branch.upstream().ok().and_then(|up| up.get().target()) else {
675 return Ok(None);
676 };
677 Ok(Some(self.repo.graph_ahead_behind(tip.id(), target)?))
678 }
679
680 fn all_branches(&self) -> Result<Vec<String>, VcsError> {
681 let mut out = Vec::new();
682 for entry in self.repo.branches(None)? {
683 let (branch, _) = entry?;
684 let Some(name) = branch.name()?.map(str::to_owned) else {
685 continue;
686 };
687 if name.ends_with("/HEAD") {
689 continue;
690 }
691 out.push(name);
692 }
693 Ok(out)
694 }
695
696 fn stage(&self, rel: &Path) -> Result<(), VcsError> {
697 let root = self.workdir_path()?;
698 let mut index = self.repo.index()?;
699 if root.join(rel).exists() {
700 index.add_path(rel)?;
701 } else {
702 index.remove_path(rel)?;
703 }
704 index.write()?;
705 Ok(())
706 }
707
708 fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
709 let diff = self
710 .repo
711 .diff_index_to_workdir(None, Some(&mut self.workdir_diff_options()))?;
712 let patch = self.synthesize_patch(&diff, rel, hunk, false)?;
713 let diff = git2::Diff::from_buffer(&patch)?;
714 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
715 Ok(())
716 }
717
718 fn stage_everything(&self) -> Result<(), VcsError> {
719 let mut index = self.repo.index()?;
720 index.update_all(["*"], None)?;
722 index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
723 index.write()?;
724 Ok(())
725 }
726
727 fn unstage_everything(&self) -> Result<(), VcsError> {
728 match self.repo.head() {
729 Ok(head) => {
730 let target = head.peel(git2::ObjectType::Commit)?;
731 self.repo.reset_default(Some(&target), ["*"])?;
732 }
733 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
734 let mut index = self.repo.index()?;
735 index.clear()?;
736 index.write()?;
737 }
738 Err(err) => return Err(err.into()),
739 }
740 Ok(())
741 }
742
743 fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
744 match self.repo.head() {
745 Ok(head) => {
746 let target = head.peel(git2::ObjectType::Commit)?;
747 self.repo.reset_default(Some(&target), [rel])?;
748 }
749 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
750 let mut index = self.repo.index()?;
751 index.remove_path(rel)?;
752 index.write()?;
753 }
754 Err(err) => return Err(err.into()),
755 }
756 Ok(())
757 }
758
759 fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
760 let head_tree = self.head_tree()?;
761 let diff = self.repo.diff_tree_to_index(
762 head_tree.as_ref(),
763 None,
764 Some(&mut self.plain_diff_options()),
765 )?;
766 let patch = self.synthesize_patch(&diff, rel, hunk, true)?;
767 let diff = git2::Diff::from_buffer(&patch)?;
768 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
769 Ok(())
770 }
771
772 fn discard(&self, rel: &Path) -> Result<(), VcsError> {
773 let head_tree = self.head_tree()?;
774 let mut opts = git2::DiffOptions::new();
775 opts.pathspec(rel).disable_pathspec_match(true);
776 let staged = self
777 .repo
778 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
779 if staged.deltas().len() > 0 {
780 return Err(VcsError::Rejected(
781 "file has staged changes; unstage first".into(),
782 ));
783 }
784 let status = self.repo.status_file(rel)?;
785 if status.contains(git2::Status::WT_NEW) {
786 fs::remove_file(self.workdir_path()?.join(rel))?;
787 return Ok(());
788 }
789 let mut checkout = git2::build::CheckoutBuilder::new();
793 checkout.path(rel).force().update_index(true);
794 self.repo.checkout_head(Some(&mut checkout))?;
795 Ok(())
796 }
797
798 fn commit(&self, message: &str) -> Result<String, VcsError> {
799 if message.trim().is_empty() {
800 return Err(VcsError::Rejected("empty commit message".into()));
801 }
802 let mut index = self.repo.index()?;
803 let tree_id = index.write_tree()?;
804 let tree = self.repo.find_tree(tree_id)?;
805 let signature = self.repo.signature()?;
806 let parent = match self.repo.head() {
807 Ok(head) => Some(head.peel_to_commit()?),
808 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
809 Err(err) => return Err(err.into()),
810 };
811 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
812 let oid = self.repo.commit(
813 Some("HEAD"),
814 &signature,
815 &signature,
816 message,
817 &tree,
818 &parents,
819 )?;
820 Ok(oid.to_string())
821 }
822
823 fn head_message(&self) -> Result<String, VcsError> {
824 let commit = self.head_commit_or_unborn()?;
825 Ok(commit.message().unwrap_or_default().to_owned())
826 }
827
828 fn commit_message(&self, oid: &str) -> Result<String, VcsError> {
829 let commit = self.repo.find_commit(git2::Oid::from_str(oid)?)?;
830 Ok(commit.message().unwrap_or_default().to_owned())
831 }
832
833 fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
834 if let Some(message) = message
835 && message.trim().is_empty()
836 {
837 return Err(VcsError::Rejected("empty commit message".into()));
838 }
839 let head = self.repo.head()?.peel_to_commit()?;
840 let tree = if use_index {
841 let mut index = self.repo.index()?;
842 let tree_id = index.write_tree()?;
843 self.repo.find_tree(tree_id)?
844 } else {
845 head.tree()?
846 };
847 let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
848 Ok(oid.to_string())
849 }
850
851 fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
852 let head = self.repo.head()?.peel_to_commit()?;
853 self.repo.branch(name, &head, false)?;
854 if checkout {
855 self.checkout(name)?;
856 }
857 Ok(())
858 }
859
860 fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
861 let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
862 if branch.is_head() {
863 return Err(VcsError::Rejected(
864 "cannot delete the checked-out branch".into(),
865 ));
866 }
867 branch.delete()?;
868 Ok(())
869 }
870
871 fn checkout(&self, name: &str) -> Result<(), VcsError> {
872 let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
873 let target = branch.get().peel(git2::ObjectType::Commit)?;
874 self.repo.checkout_tree(&target, None)?;
876 self.repo.set_head(&format!("refs/heads/{name}"))?;
877 Ok(())
878 }
879
880 fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError> {
881 if !self.has_tracked_changes()? {
882 return Err(VcsError::Rejected("nothing to stash".into()));
883 }
884 let mut repo = git2::Repository::open(self.workdir_path()?)?;
887 let signature = repo.signature()?;
888 repo.stash_save2(&signature, message, None)?;
889 Ok(())
890 }
891
892 fn stash_pop(&self) -> Result<(), VcsError> {
893 let mut repo = git2::Repository::open(self.workdir_path()?)?;
894 match repo.stash_pop(0, None) {
895 Ok(()) => Ok(()),
896 Err(err) if err.code() == git2::ErrorCode::NotFound => {
897 Err(VcsError::Rejected("no stash to pop".into()))
898 }
899 Err(err) if err.code() == git2::ErrorCode::Conflict => Err(VcsError::Rejected(
902 "stash applied with conflicts; resolve them (the stash was kept)".into(),
903 )),
904 Err(err) => Err(err.into()),
905 }
906 }
907
908 fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError> {
909 let args: Vec<String> = match op {
910 NetworkOp::Fetch => vec!["fetch".into()],
911 NetworkOp::FetchAll => vec!["fetch".into(), "--all".into()],
912 NetworkOp::Push => vec!["push".into()],
913 NetworkOp::PushSetUpstream { remote } => {
914 vec!["push".into(), "-u".into(), remote, "HEAD".into()]
915 }
916 NetworkOp::Pull => vec!["pull".into()],
917 NetworkOp::PullFrom { remote, branch } => vec!["pull".into(), remote, branch],
918 NetworkOp::PullRebase => vec!["pull".into(), "--rebase".into()],
919 NetworkOp::PullMerge => vec!["pull".into(), "--no-rebase".into()],
920 };
921 Ok(std::iter::once("git".to_owned()).chain(args).collect())
922 }
923
924 fn workdir(&self) -> Result<PathBuf, VcsError> {
925 Ok(self.workdir_path()?.to_path_buf())
926 }
927
928 fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
929 match self.repo.find_remote(name) {
930 Ok(remote) => Ok(remote.url().ok().map(str::to_owned)),
932 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
933 Err(err) => Err(err.into()),
934 }
935 }
936
937 fn remotes(&self) -> Result<Vec<String>, VcsError> {
938 let array = self.repo.remotes()?;
939 let mut names = Vec::new();
940 for i in 0..array.len() {
941 if let Ok(Some(name)) = array.get(i) {
942 names.push(name.to_owned());
943 }
944 }
945 Ok(names)
946 }
947
948 fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
949 self.algorithm.set(algorithm);
950 self.indent_heuristic.set(indent_heuristic);
951 }
952}
953
954fn apply_git_algorithm(
957 opts: &mut git2::DiffOptions,
958 algorithm: DiffAlgorithm,
959 indent_heuristic: bool,
960) {
961 match algorithm {
962 DiffAlgorithm::Minimal => {
963 opts.minimal(true);
964 }
965 DiffAlgorithm::Patience => {
966 opts.patience(true);
967 }
968 DiffAlgorithm::Myers | DiffAlgorithm::Histogram | DiffAlgorithm::Structural => {}
969 }
970 opts.indent_heuristic(indent_heuristic);
971}
972
973impl GitVcs {
974 fn synthesize_patch(
977 &self,
978 diff: &git2::Diff<'_>,
979 rel: &Path,
980 target: &HunkId,
981 reverse: bool,
982 ) -> Result<Vec<u8>, VcsError> {
983 let rel = rel.to_string_lossy();
984 for idx in 0..diff.deltas().len() {
985 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
986 continue;
987 };
988 let delta = patch.delta();
991 if delta.flags().is_binary() || delta_new_path(&delta) != rel {
992 continue;
993 }
994 if self.algorithm.get().is_imara() {
995 let old_text = blob_text(&self.repo, delta.old_file().id());
996 let new_text = new_side_text(&self.repo, &delta, &rel);
997 let (old, new) = (old_text.as_deref(), new_text.as_deref());
998 if let Some(hunks) = self.imara_hunks(&delta, old, new, &rel)
999 && let (Some(old), Some(new)) = (old, new)
1000 {
1001 if let Some(hunk) = hunks.iter().find(|h| h.id == *target) {
1002 return Ok(render_hunk_patch_from_model(hunk, &rel, reverse, old, new));
1003 }
1004 continue;
1005 }
1006 }
1007 let mut seen = HashMap::new();
1008 for h in 0..patch.num_hunks() {
1009 let lines = hunk_model_lines(&patch, h)?;
1010 if disambiguated_hunk_id(&rel, &lines, &mut seen) == *target {
1011 return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
1012 }
1013 }
1014 }
1015 Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
1016 }
1017
1018 fn build_file(
1019 &self,
1020 diff: &mut git2::Diff<'_>,
1021 idx: usize,
1022 ) -> Result<Option<FileDiff>, VcsError> {
1023 let repo = &self.repo;
1024 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
1025 return Ok(build_binary_file(diff, idx));
1026 };
1027 let delta = patch.delta();
1028 if delta.flags().is_binary() {
1029 return Ok(build_binary_file(diff, idx));
1030 }
1031 let file_path = delta_new_path(&delta);
1032 let status = map_status(delta.status());
1033 let old_path = if status == FileStatus::Renamed {
1034 delta
1035 .old_file()
1036 .path()
1037 .map(|p| p.to_string_lossy().into_owned())
1038 } else {
1039 None
1040 };
1041
1042 let old_text = blob_text(repo, delta.old_file().id());
1043 let new_text = new_side_text(repo, &delta, &file_path);
1044
1045 let imara = self.imara_hunks(&delta, old_text.as_deref(), new_text.as_deref(), &file_path);
1046 let hunks = match imara {
1047 Some(hunks) => hunks,
1048 None => patch_hunks(&patch, &file_path)?,
1049 };
1050
1051 Ok(Some(FileDiff {
1052 path: file_path,
1053 old_path,
1054 status,
1055 binary: false,
1056 old_text,
1057 new_text,
1058 hunks,
1059 hashes: crate::model::HashCache::default(),
1060 blobs: crate::model::BlobIds::default(),
1061 }))
1062 }
1063}
1064
1065fn render_hunk_patch(
1066 patch: &git2::Patch<'_>,
1067 h: usize,
1068 rel: &str,
1069 status: git2::Delta,
1070 reverse: bool,
1071) -> Result<Vec<u8>, VcsError> {
1072 let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
1073 let deleted = status == git2::Delta::Deleted;
1074 let mut out = Vec::new();
1075 out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
1076 if (added && !reverse) || (deleted && reverse) {
1080 out.extend_from_slice(
1081 format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
1082 );
1083 } else if (deleted && !reverse) || (added && reverse) {
1084 out.extend_from_slice(
1085 format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
1086 );
1087 } else {
1088 out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
1089 }
1090 let (hunk, line_count) = patch.hunk(h)?;
1091 let mut lines: Vec<(LineKind, Vec<u8>)> = Vec::with_capacity(line_count);
1092 for l in 0..line_count {
1093 let line = patch.line_in_hunk(h, l)?;
1094 let kind = match line.origin() {
1095 ' ' => LineKind::Context,
1096 '+' => LineKind::Added,
1097 '-' => LineKind::Deleted,
1098 '=' | '>' | '<' => {
1101 if let Some((_, text)) = lines.last_mut() {
1102 text.extend_from_slice(line.content());
1103 }
1104 continue;
1105 }
1106 _ => continue,
1107 };
1108 lines.push((kind, line.content().to_vec()));
1109 }
1110 write_hunk(
1111 &mut out,
1112 (hunk.old_start(), hunk.old_lines()),
1113 (hunk.new_start(), hunk.new_lines()),
1114 &lines,
1115 reverse,
1116 );
1117 Ok(out)
1118}
1119
1120fn write_hunk(
1123 out: &mut Vec<u8>,
1124 old: (u32, u32),
1125 new: (u32, u32),
1126 lines: &[(LineKind, Vec<u8>)],
1127 reverse: bool,
1128) {
1129 let ((minus_start, minus_lines), (_, plus_lines)) =
1130 if reverse { (new, old) } else { (old, new) };
1131 let plus_start = if minus_lines == 0 {
1135 minus_start + 1
1136 } else {
1137 minus_start
1138 };
1139 out.extend_from_slice(
1140 format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
1141 );
1142 let (minus_kind, plus_kind) = if reverse {
1145 (LineKind::Added, LineKind::Deleted)
1146 } else {
1147 (LineKind::Deleted, LineKind::Added)
1148 };
1149 let is_context = |kind: LineKind| kind == LineKind::Context;
1150 for run in lines.chunk_by(|a, b| is_context(a.0) == is_context(b.0)) {
1151 for (origin, kind) in [
1152 (b' ', LineKind::Context),
1153 (b'-', minus_kind),
1154 (b'+', plus_kind),
1155 ] {
1156 for (_, text) in run.iter().filter(|(k, _)| *k == kind) {
1157 out.push(origin);
1158 out.extend_from_slice(text);
1159 }
1160 }
1161 }
1162}
1163
1164fn render_hunk_patch_from_model(
1168 hunk: &Hunk,
1169 rel: &str,
1170 reverse: bool,
1171 old_text: &str,
1172 new_text: &str,
1173) -> Vec<u8> {
1174 let old_raw: Vec<&str> = old_text.split_inclusive('\n').collect();
1175 let new_raw: Vec<&str> = new_text.split_inclusive('\n').collect();
1176 let raw = |line: &DiffLine| -> Vec<u8> {
1177 let (side, number) = match line.kind {
1178 LineKind::Added => (&new_raw, line.new_no),
1179 LineKind::Deleted | LineKind::Context => (&old_raw, line.old_no),
1180 };
1181 let text = number
1182 .and_then(|n| n.checked_sub(1))
1183 .and_then(|i| side.get(i as usize))
1184 .copied()
1185 .unwrap_or_default();
1186 let mut bytes = text.as_bytes().to_vec();
1187 if !text.ends_with('\n') {
1188 bytes.extend_from_slice(b"\n\\ No newline at end of file\n");
1189 }
1190 bytes
1191 };
1192 let lines: Vec<(LineKind, Vec<u8>)> = hunk.lines.iter().map(|l| (l.kind, raw(l))).collect();
1193
1194 let mut out = format!("diff --git a/{rel} b/{rel}\n--- a/{rel}\n+++ b/{rel}\n").into_bytes();
1195 write_hunk(
1196 &mut out,
1197 (hunk.old_start, hunk.old_lines),
1198 (hunk.new_start, hunk.new_lines),
1199 &lines,
1200 reverse,
1201 );
1202 out
1203}
1204
1205fn short7(oid: &str) -> String {
1206 oid.get(..7).unwrap_or(oid).to_owned()
1207}
1208
1209pub fn rehunk_file(
1212 file: &FileDiff,
1213 context: u32,
1214 algorithm: DiffAlgorithm,
1215 indent_heuristic: bool,
1216) -> Option<Vec<Hunk>> {
1217 if file.binary {
1218 return None;
1219 }
1220 let (old, new) = (file.old_text.as_deref()?, file.new_text.as_deref()?);
1221 if algorithm.is_imara() {
1222 return Some(histogram_hunks(
1223 old,
1224 new,
1225 &file.path,
1226 context,
1227 indent_heuristic,
1228 ));
1229 }
1230 let as_path = Path::new(&file.path);
1231 let cap = u32::try_from(old.lines().count().max(new.lines().count())).unwrap_or(u32::MAX);
1234 let mut opts = git2::DiffOptions::new();
1235 opts.context_lines(context.min(cap));
1236 apply_git_algorithm(&mut opts, algorithm, indent_heuristic);
1237 let patch = git2::Patch::from_buffers(
1238 old.as_bytes(),
1239 Some(as_path),
1240 new.as_bytes(),
1241 Some(as_path),
1242 Some(&mut opts),
1243 )
1244 .ok()?;
1245 patch_hunks(&patch, &file.path).ok()
1246}
1247
1248fn patch_hunks(patch: &git2::Patch<'_>, file_path: &str) -> Result<Vec<Hunk>, VcsError> {
1250 let mut hunks = Vec::with_capacity(patch.num_hunks());
1251 let mut seen = HashMap::new();
1252 for h in 0..patch.num_hunks() {
1253 let (hunk, _) = patch.hunk(h)?;
1254 let lines = hunk_model_lines(patch, h)?;
1255 let id = disambiguated_hunk_id(file_path, &lines, &mut seen);
1256 hunks.push(Hunk {
1257 id,
1258 old_start: hunk.old_start(),
1259 old_lines: hunk.old_lines(),
1260 new_start: hunk.new_start(),
1261 new_lines: hunk.new_lines(),
1262 context: hunk_context(&hunk),
1263 lines,
1264 });
1265 }
1266 Ok(hunks)
1267}
1268
1269fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
1271 let header = String::from_utf8_lossy(hunk.header());
1272 match header.split_once(" @@") {
1273 Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
1274 None => String::new(),
1275 }
1276}
1277
1278fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
1280 let (_, line_count) = patch.hunk(h)?;
1281 let mut lines = Vec::with_capacity(line_count);
1282 for l in 0..line_count {
1283 let line = patch.line_in_hunk(h, l)?;
1284 let kind = match line.origin() {
1285 '-' => LineKind::Deleted,
1286 '+' => LineKind::Added,
1287 ' ' => LineKind::Context,
1288 _ => continue,
1289 };
1290 let text = String::from_utf8_lossy(line.content())
1291 .trim_end_matches(['\n', '\r'])
1292 .to_owned();
1293 lines.push(DiffLine::new(
1294 kind,
1295 line.old_lineno(),
1296 line.new_lineno(),
1297 text,
1298 ));
1299 }
1300 Ok(lines)
1301}
1302
1303fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
1304 let delta = diff.get_delta(idx)?;
1305 let blob = |file: git2::DiffFile<'_>| (!file.id().is_zero()).then(|| file.id().to_string());
1306 let blobs = crate::model::BlobIds {
1307 old: blob(delta.old_file()),
1308 new: blob(delta.new_file()),
1309 };
1310 Some(FileDiff {
1311 path: delta_new_path(&delta),
1312 old_path: None,
1313 status: map_status(delta.status()),
1314 binary: true,
1315 old_text: None,
1316 new_text: None,
1317 hunks: Vec::new(),
1318 hashes: crate::model::HashCache::default(),
1319 blobs,
1320 })
1321}
1322
1323fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
1324 delta
1325 .new_file()
1326 .path()
1327 .or_else(|| delta.old_file().path())
1328 .map(|p| p.to_string_lossy().into_owned())
1329 .unwrap_or_default()
1330}
1331
1332fn map_status(status: git2::Delta) -> FileStatus {
1333 match status {
1334 git2::Delta::Added => FileStatus::Added,
1335 git2::Delta::Deleted => FileStatus::Deleted,
1336 git2::Delta::Renamed => FileStatus::Renamed,
1337 git2::Delta::Untracked => FileStatus::Untracked,
1338 _ => FileStatus::Modified,
1339 }
1340}
1341
1342fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
1343 if oid.is_zero() {
1344 return None;
1345 }
1346 let blob = repo.find_blob(oid).ok()?;
1347 if blob.is_binary() {
1348 return None;
1349 }
1350 String::from_utf8(blob.content().to_vec()).ok()
1351}
1352
1353fn new_side_text(
1356 repo: &git2::Repository,
1357 delta: &git2::DiffDelta<'_>,
1358 rel: &str,
1359) -> Option<String> {
1360 if delta.status() == git2::Delta::Deleted {
1361 return None;
1362 }
1363 if let Some(text) = blob_text(repo, delta.new_file().id()) {
1364 return Some(text);
1365 }
1366 let root = repo.workdir()?;
1367 fs::read_to_string(root.join(rel)).ok()
1368}