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 read_blob(&self, oid: &str) -> Result<Option<Vec<u8>>, VcsError> {
481 let oid = git2::Oid::from_str(oid)?;
482 match self.repo.find_blob(oid) {
483 Ok(blob) => Ok(Some(blob.content().to_vec())),
484 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
485 Err(err) => Err(err.into()),
486 }
487 }
488
489 fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError> {
490 let index = self.repo.index()?;
491 let mut out: Vec<PathBuf> = index
492 .iter()
493 .filter_map(|entry| {
494 std::str::from_utf8(&entry.path)
495 .ok()
496 .map(|path| PathBuf::from(path.to_owned()))
497 })
498 .collect();
499 out.sort_unstable();
500 out.dedup();
501 Ok(out)
502 }
503
504 fn attr(&self, rel: &Path, name: &str) -> bool {
505 let Ok(value) = self.repo.get_attr(rel, name, git2::AttrCheckFlags::empty()) else {
506 return false;
507 };
508 matches!(
511 git2::AttrValue::from_string(value),
512 git2::AttrValue::True | git2::AttrValue::String("true")
513 )
514 }
515
516 fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
517 let mut out = Vec::new();
518 for entry in self.repo.branches(Some(git2::BranchType::Local))? {
519 let (branch, _) = entry?;
520 let Some(name) = branch.name()?.map(str::to_owned) else {
521 continue;
522 };
523 let tip_unix = branch
524 .get()
525 .peel_to_commit()
526 .ok()
527 .map_or(0, |commit| commit.time().seconds());
528 out.push(BranchInfo {
529 name,
530 is_head: branch.is_head(),
531 tip_unix,
532 divergence: None,
533 });
534 }
535 Ok(out)
536 }
537
538 fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError> {
539 let branch = self.repo.find_branch(branch, git2::BranchType::Local)?;
540 let Some(tip) = branch.get().peel_to_commit().ok() else {
541 return Ok(None);
542 };
543 let Some(target) = branch.upstream().ok().and_then(|up| up.get().target()) else {
544 return Ok(None);
545 };
546 Ok(Some(self.repo.graph_ahead_behind(tip.id(), target)?))
547 }
548
549 fn all_branches(&self) -> Result<Vec<String>, VcsError> {
550 let mut out = Vec::new();
551 for entry in self.repo.branches(None)? {
552 let (branch, _) = entry?;
553 let Some(name) = branch.name()?.map(str::to_owned) else {
554 continue;
555 };
556 if name.ends_with("/HEAD") {
558 continue;
559 }
560 out.push(name);
561 }
562 Ok(out)
563 }
564
565 fn stage(&self, rel: &Path) -> Result<(), VcsError> {
566 let root = self.workdir_path()?;
567 let mut index = self.repo.index()?;
568 if root.join(rel).exists() {
569 index.add_path(rel)?;
570 } else {
571 index.remove_path(rel)?;
572 }
573 index.write()?;
574 Ok(())
575 }
576
577 fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
578 let diff = self
579 .repo
580 .diff_index_to_workdir(None, Some(&mut self.workdir_diff_options()))?;
581 let patch = self.synthesize_patch(&diff, rel, hunk, false)?;
582 let diff = git2::Diff::from_buffer(&patch)?;
583 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
584 Ok(())
585 }
586
587 fn stage_everything(&self) -> Result<(), VcsError> {
588 let mut index = self.repo.index()?;
589 index.update_all(["*"], None)?;
592 index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
593 index.write()?;
594 Ok(())
595 }
596
597 fn unstage_everything(&self) -> Result<(), VcsError> {
598 match self.repo.head() {
599 Ok(head) => {
600 let target = head.peel(git2::ObjectType::Commit)?;
601 self.repo.reset_default(Some(&target), ["*"])?;
602 }
603 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
605 let mut index = self.repo.index()?;
606 index.clear()?;
607 index.write()?;
608 }
609 Err(err) => return Err(err.into()),
610 }
611 Ok(())
612 }
613
614 fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
615 match self.repo.head() {
616 Ok(head) => {
617 let target = head.peel(git2::ObjectType::Commit)?;
618 self.repo.reset_default(Some(&target), [rel])?;
619 }
620 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
622 let mut index = self.repo.index()?;
623 index.remove_path(rel)?;
624 index.write()?;
625 }
626 Err(err) => return Err(err.into()),
627 }
628 Ok(())
629 }
630
631 fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
632 let head_tree = self.head_tree()?;
633 let diff = self.repo.diff_tree_to_index(
634 head_tree.as_ref(),
635 None,
636 Some(&mut self.plain_diff_options()),
637 )?;
638 let patch = self.synthesize_patch(&diff, rel, hunk, true)?;
639 let diff = git2::Diff::from_buffer(&patch)?;
640 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
641 Ok(())
642 }
643
644 fn discard(&self, rel: &Path) -> Result<(), VcsError> {
645 let head_tree = self.head_tree()?;
646 let mut opts = git2::DiffOptions::new();
647 opts.pathspec(rel).disable_pathspec_match(true);
648 let staged = self
649 .repo
650 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
651 if staged.deltas().len() > 0 {
652 return Err(VcsError::Rejected(
653 "file has staged changes; unstage first".into(),
654 ));
655 }
656 let status = self.repo.status_file(rel)?;
657 if status.contains(git2::Status::WT_NEW) {
658 fs::remove_file(self.workdir_path()?.join(rel))?;
659 return Ok(());
660 }
661 let mut checkout = git2::build::CheckoutBuilder::new();
668 checkout.path(rel).force().update_index(true);
669 self.repo.checkout_head(Some(&mut checkout))?;
670 Ok(())
671 }
672
673 fn commit(&self, message: &str) -> Result<String, VcsError> {
674 if message.trim().is_empty() {
675 return Err(VcsError::Rejected("empty commit message".into()));
676 }
677 let mut index = self.repo.index()?;
678 let tree_id = index.write_tree()?;
679 let tree = self.repo.find_tree(tree_id)?;
680 let signature = self.repo.signature()?;
681 let parent = match self.repo.head() {
682 Ok(head) => Some(head.peel_to_commit()?),
683 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
684 Err(err) => return Err(err.into()),
685 };
686 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
687 let oid = self.repo.commit(
688 Some("HEAD"),
689 &signature,
690 &signature,
691 message,
692 &tree,
693 &parents,
694 )?;
695 Ok(oid.to_string())
696 }
697
698 fn head_message(&self) -> Result<String, VcsError> {
699 let commit = self.repo.head()?.peel_to_commit()?;
700 Ok(commit.message().unwrap_or_default().to_owned())
701 }
702
703 fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
704 if let Some(message) = message
705 && message.trim().is_empty()
706 {
707 return Err(VcsError::Rejected("empty commit message".into()));
708 }
709 let head = self.repo.head()?.peel_to_commit()?;
710 let tree = if use_index {
713 let mut index = self.repo.index()?;
714 let tree_id = index.write_tree()?;
715 self.repo.find_tree(tree_id)?
716 } else {
717 head.tree()?
718 };
719 let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
720 Ok(oid.to_string())
721 }
722
723 fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
724 let head = self.repo.head()?.peel_to_commit()?;
725 self.repo.branch(name, &head, false)?;
726 if checkout {
727 self.checkout(name)?;
728 }
729 Ok(())
730 }
731
732 fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
733 let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
734 if branch.is_head() {
735 return Err(VcsError::Rejected(
736 "cannot delete the checked-out branch".into(),
737 ));
738 }
739 branch.delete()?;
740 Ok(())
741 }
742
743 fn checkout(&self, name: &str) -> Result<(), VcsError> {
744 let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
745 let target = branch.get().peel(git2::ObjectType::Commit)?;
746 self.repo.checkout_tree(&target, None)?;
748 self.repo.set_head(&format!("refs/heads/{name}"))?;
749 Ok(())
750 }
751
752 fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError> {
753 if !self.has_tracked_changes()? {
754 return Err(VcsError::Rejected("nothing to stash".into()));
755 }
756 let mut repo = git2::Repository::open(self.workdir_path()?)?;
759 let signature = repo.signature()?;
760 repo.stash_save2(&signature, message, None)?;
761 Ok(())
762 }
763
764 fn stash_pop(&self) -> Result<(), VcsError> {
765 let mut repo = git2::Repository::open(self.workdir_path()?)?;
766 match repo.stash_pop(0, None) {
767 Ok(()) => Ok(()),
768 Err(err) if err.code() == git2::ErrorCode::NotFound => {
769 Err(VcsError::Rejected("no stash to pop".into()))
770 }
771 Err(err) if err.code() == git2::ErrorCode::Conflict => Err(VcsError::Rejected(
774 "stash applied with conflicts; resolve them (the stash was kept)".into(),
775 )),
776 Err(err) => Err(err.into()),
777 }
778 }
779
780 fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError> {
781 let args: Vec<String> = match op {
784 NetworkOp::Fetch => vec!["fetch".into()],
785 NetworkOp::FetchAll => vec!["fetch".into(), "--all".into()],
786 NetworkOp::Push => vec!["push".into()],
787 NetworkOp::PushSetUpstream { remote } => {
788 vec!["push".into(), "-u".into(), remote, "HEAD".into()]
789 }
790 NetworkOp::Pull => vec!["pull".into()],
791 NetworkOp::PullFrom { remote, branch } => vec!["pull".into(), remote, branch],
792 NetworkOp::PullRebase => vec!["pull".into(), "--rebase".into()],
793 NetworkOp::PullMerge => vec!["pull".into(), "--no-rebase".into()],
794 };
795 Ok(std::iter::once("git".to_owned()).chain(args).collect())
796 }
797
798 fn workdir(&self) -> Result<PathBuf, VcsError> {
799 Ok(self.workdir_path()?.to_path_buf())
800 }
801
802 fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
803 match self.repo.find_remote(name) {
804 Ok(remote) => Ok(remote.url().ok().map(str::to_owned)),
806 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
807 Err(err) => Err(err.into()),
808 }
809 }
810
811 fn remotes(&self) -> Result<Vec<String>, VcsError> {
812 let array = self.repo.remotes()?;
813 let mut names = Vec::new();
814 for i in 0..array.len() {
815 if let Ok(Some(name)) = array.get(i) {
816 names.push(name.to_owned());
817 }
818 }
819 Ok(names)
820 }
821
822 fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
823 self.algorithm.set(algorithm);
824 self.indent_heuristic.set(indent_heuristic);
825 }
826}
827
828fn apply_git_algorithm(
832 opts: &mut git2::DiffOptions,
833 algorithm: DiffAlgorithm,
834 indent_heuristic: bool,
835) {
836 match algorithm {
837 DiffAlgorithm::Minimal => {
838 opts.minimal(true);
839 }
840 DiffAlgorithm::Patience => {
841 opts.patience(true);
842 }
843 DiffAlgorithm::Myers | DiffAlgorithm::Histogram | DiffAlgorithm::Structural => {}
844 }
845 opts.indent_heuristic(indent_heuristic);
846}
847
848impl GitVcs {
849 fn synthesize_patch(
852 &self,
853 diff: &git2::Diff<'_>,
854 rel: &Path,
855 target: &HunkId,
856 reverse: bool,
857 ) -> Result<Vec<u8>, VcsError> {
858 let rel = rel.to_string_lossy();
859 for idx in 0..diff.deltas().len() {
860 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
861 continue;
862 };
863 let delta = patch.delta();
867 if delta.flags().is_binary() || delta_new_path(&delta) != rel {
868 continue;
869 }
870 if self.algorithm.get().is_imara() {
871 let old_text = blob_text(&self.repo, delta.old_file().id());
872 let new_text = new_side_text(&self.repo, &delta, &rel);
873 let (old, new) = (old_text.as_deref(), new_text.as_deref());
874 if let Some(hunks) = self.imara_hunks(&delta, old, new, &rel)
875 && let (Some(old), Some(new)) = (old, new)
876 {
877 if let Some(hunk) = hunks.iter().find(|h| h.id == *target) {
878 return Ok(render_hunk_patch_from_model(hunk, &rel, reverse, old, new));
879 }
880 continue;
881 }
882 }
883 let mut seen = HashMap::new();
884 for h in 0..patch.num_hunks() {
885 let lines = hunk_model_lines(&patch, h)?;
886 if disambiguated_hunk_id(&rel, &lines, &mut seen) == *target {
887 return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
888 }
889 }
890 }
891 Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
892 }
893
894 fn build_file(
895 &self,
896 diff: &mut git2::Diff<'_>,
897 idx: usize,
898 ) -> Result<Option<FileDiff>, VcsError> {
899 let repo = &self.repo;
900 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
901 return Ok(build_binary_file(diff, idx));
903 };
904 let delta = patch.delta();
905 if delta.flags().is_binary() {
906 return Ok(build_binary_file(diff, idx));
907 }
908 let file_path = delta_new_path(&delta);
909 let status = map_status(delta.status());
910 let old_path = if status == FileStatus::Renamed {
911 delta
912 .old_file()
913 .path()
914 .map(|p| p.to_string_lossy().into_owned())
915 } else {
916 None
917 };
918
919 let old_text = blob_text(repo, delta.old_file().id());
920 let new_text = new_side_text(repo, &delta, &file_path);
921
922 let imara = self.imara_hunks(&delta, old_text.as_deref(), new_text.as_deref(), &file_path);
923 let hunks = match imara {
924 Some(hunks) => hunks,
925 None => patch_hunks(&patch, &file_path)?,
926 };
927
928 Ok(Some(FileDiff {
929 path: file_path,
930 old_path,
931 status,
932 binary: false,
933 old_text,
934 new_text,
935 hunks,
936 hashes: crate::model::HashCache::default(),
937 blobs: crate::model::BlobIds::default(),
938 }))
939 }
940}
941
942fn render_hunk_patch(
943 patch: &git2::Patch<'_>,
944 h: usize,
945 rel: &str,
946 status: git2::Delta,
947 reverse: bool,
948) -> Result<Vec<u8>, VcsError> {
949 let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
950 let deleted = status == git2::Delta::Deleted;
951 let mut out = Vec::new();
952 out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
953 if (added && !reverse) || (deleted && reverse) {
957 out.extend_from_slice(
958 format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
959 );
960 } else if (deleted && !reverse) || (added && reverse) {
961 out.extend_from_slice(
962 format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
963 );
964 } else {
965 out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
966 }
967 let (hunk, line_count) = patch.hunk(h)?;
968 let mut lines: Vec<(LineKind, Vec<u8>)> = Vec::with_capacity(line_count);
969 for l in 0..line_count {
970 let line = patch.line_in_hunk(h, l)?;
971 let kind = match line.origin() {
972 ' ' => LineKind::Context,
973 '+' => LineKind::Added,
974 '-' => LineKind::Deleted,
975 '=' | '>' | '<' => {
979 if let Some((_, text)) = lines.last_mut() {
980 text.extend_from_slice(line.content());
981 }
982 continue;
983 }
984 _ => continue,
985 };
986 lines.push((kind, line.content().to_vec()));
987 }
988 write_hunk(
989 &mut out,
990 (hunk.old_start(), hunk.old_lines()),
991 (hunk.new_start(), hunk.new_lines()),
992 &lines,
993 reverse,
994 );
995 Ok(out)
996}
997
998fn write_hunk(
1002 out: &mut Vec<u8>,
1003 old: (u32, u32),
1004 new: (u32, u32),
1005 lines: &[(LineKind, Vec<u8>)],
1006 reverse: bool,
1007) {
1008 let ((minus_start, minus_lines), (_, plus_lines)) =
1009 if reverse { (new, old) } else { (old, new) };
1010 let plus_start = if minus_lines == 0 {
1014 minus_start + 1
1015 } else {
1016 minus_start
1017 };
1018 out.extend_from_slice(
1019 format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
1020 );
1021 let (minus_kind, plus_kind) = if reverse {
1024 (LineKind::Added, LineKind::Deleted)
1025 } else {
1026 (LineKind::Deleted, LineKind::Added)
1027 };
1028 let is_context = |kind: LineKind| kind == LineKind::Context;
1029 for run in lines.chunk_by(|a, b| is_context(a.0) == is_context(b.0)) {
1030 for (origin, kind) in [
1031 (b' ', LineKind::Context),
1032 (b'-', minus_kind),
1033 (b'+', plus_kind),
1034 ] {
1035 for (_, text) in run.iter().filter(|(k, _)| *k == kind) {
1036 out.push(origin);
1037 out.extend_from_slice(text);
1038 }
1039 }
1040 }
1041}
1042
1043fn render_hunk_patch_from_model(
1048 hunk: &Hunk,
1049 rel: &str,
1050 reverse: bool,
1051 old_text: &str,
1052 new_text: &str,
1053) -> Vec<u8> {
1054 let old_raw: Vec<&str> = old_text.split_inclusive('\n').collect();
1055 let new_raw: Vec<&str> = new_text.split_inclusive('\n').collect();
1056 let raw = |line: &DiffLine| -> Vec<u8> {
1057 let (side, number) = match line.kind {
1058 LineKind::Added => (&new_raw, line.new_no),
1059 LineKind::Deleted | LineKind::Context => (&old_raw, line.old_no),
1060 };
1061 let text = number
1062 .and_then(|n| n.checked_sub(1))
1063 .and_then(|i| side.get(i as usize))
1064 .copied()
1065 .unwrap_or_default();
1066 let mut bytes = text.as_bytes().to_vec();
1067 if !text.ends_with('\n') {
1068 bytes.extend_from_slice(b"\n\\ No newline at end of file\n");
1069 }
1070 bytes
1071 };
1072 let lines: Vec<(LineKind, Vec<u8>)> = hunk.lines.iter().map(|l| (l.kind, raw(l))).collect();
1073
1074 let mut out = format!("diff --git a/{rel} b/{rel}\n--- a/{rel}\n+++ b/{rel}\n").into_bytes();
1075 write_hunk(
1076 &mut out,
1077 (hunk.old_start, hunk.old_lines),
1078 (hunk.new_start, hunk.new_lines),
1079 &lines,
1080 reverse,
1081 );
1082 out
1083}
1084
1085fn short7(oid: &str) -> String {
1086 oid.get(..7).unwrap_or(oid).to_owned()
1087}
1088
1089pub fn rehunk_file(
1094 file: &FileDiff,
1095 context: u32,
1096 algorithm: DiffAlgorithm,
1097 indent_heuristic: bool,
1098) -> Option<Vec<Hunk>> {
1099 if file.binary {
1100 return None;
1101 }
1102 let (old, new) = (file.old_text.as_deref()?, file.new_text.as_deref()?);
1103 if algorithm.is_imara() {
1104 return Some(histogram_hunks(
1105 old,
1106 new,
1107 &file.path,
1108 context,
1109 indent_heuristic,
1110 ));
1111 }
1112 let as_path = Path::new(&file.path);
1113 let cap = u32::try_from(old.lines().count().max(new.lines().count())).unwrap_or(u32::MAX);
1117 let mut opts = git2::DiffOptions::new();
1118 opts.context_lines(context.min(cap));
1119 apply_git_algorithm(&mut opts, algorithm, indent_heuristic);
1120 let patch = git2::Patch::from_buffers(
1121 old.as_bytes(),
1122 Some(as_path),
1123 new.as_bytes(),
1124 Some(as_path),
1125 Some(&mut opts),
1126 )
1127 .ok()?;
1128 patch_hunks(&patch, &file.path).ok()
1129}
1130
1131fn patch_hunks(patch: &git2::Patch<'_>, file_path: &str) -> Result<Vec<Hunk>, VcsError> {
1134 let mut hunks = Vec::with_capacity(patch.num_hunks());
1135 let mut seen = HashMap::new();
1136 for h in 0..patch.num_hunks() {
1137 let (hunk, _) = patch.hunk(h)?;
1138 let lines = hunk_model_lines(patch, h)?;
1139 let id = disambiguated_hunk_id(file_path, &lines, &mut seen);
1140 hunks.push(Hunk {
1141 id,
1142 old_start: hunk.old_start(),
1143 old_lines: hunk.old_lines(),
1144 new_start: hunk.new_start(),
1145 new_lines: hunk.new_lines(),
1146 context: hunk_context(&hunk),
1147 lines,
1148 });
1149 }
1150 Ok(hunks)
1151}
1152
1153fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
1157 let header = String::from_utf8_lossy(hunk.header());
1158 match header.split_once(" @@") {
1159 Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
1160 None => String::new(),
1161 }
1162}
1163
1164fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
1168 let (_, line_count) = patch.hunk(h)?;
1169 let mut lines = Vec::with_capacity(line_count);
1170 for l in 0..line_count {
1171 let line = patch.line_in_hunk(h, l)?;
1172 let kind = match line.origin() {
1173 '-' => LineKind::Deleted,
1174 '+' => LineKind::Added,
1175 ' ' => LineKind::Context,
1176 _ => continue,
1178 };
1179 let text = String::from_utf8_lossy(line.content())
1180 .trim_end_matches(['\n', '\r'])
1181 .to_owned();
1182 lines.push(DiffLine::new(
1183 kind,
1184 line.old_lineno(),
1185 line.new_lineno(),
1186 text,
1187 ));
1188 }
1189 Ok(lines)
1190}
1191
1192fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
1193 let delta = diff.get_delta(idx)?;
1194 let blob = |file: git2::DiffFile<'_>| (!file.id().is_zero()).then(|| file.id().to_string());
1195 let blobs = crate::model::BlobIds {
1196 old: blob(delta.old_file()),
1197 new: blob(delta.new_file()),
1198 };
1199 Some(FileDiff {
1200 path: delta_new_path(&delta),
1201 old_path: None,
1202 status: map_status(delta.status()),
1203 binary: true,
1204 old_text: None,
1205 new_text: None,
1206 hunks: Vec::new(),
1207 hashes: crate::model::HashCache::default(),
1208 blobs,
1209 })
1210}
1211
1212fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
1213 delta
1214 .new_file()
1215 .path()
1216 .or_else(|| delta.old_file().path())
1217 .map(|p| p.to_string_lossy().into_owned())
1218 .unwrap_or_default()
1219}
1220
1221fn map_status(status: git2::Delta) -> FileStatus {
1222 match status {
1223 git2::Delta::Added => FileStatus::Added,
1224 git2::Delta::Deleted => FileStatus::Deleted,
1225 git2::Delta::Renamed => FileStatus::Renamed,
1226 git2::Delta::Untracked => FileStatus::Untracked,
1227 _ => FileStatus::Modified,
1228 }
1229}
1230
1231fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
1232 if oid.is_zero() {
1233 return None;
1234 }
1235 let blob = repo.find_blob(oid).ok()?;
1236 if blob.is_binary() {
1237 return None;
1238 }
1239 String::from_utf8(blob.content().to_vec()).ok()
1240}
1241
1242fn new_side_text(
1245 repo: &git2::Repository,
1246 delta: &git2::DiffDelta<'_>,
1247 rel: &str,
1248) -> Option<String> {
1249 if delta.status() == git2::Delta::Deleted {
1250 return None;
1251 }
1252 if let Some(text) = blob_text(repo, delta.new_file().id()) {
1253 return Some(text);
1254 }
1255 let root = repo.workdir()?;
1256 fs::read_to_string(root.join(rel)).ok()
1257}