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