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