1use std::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::model::{
9 DiffLine, DiffModel, FileDiff, FileStatus, Hunk, HunkId, LineKind, disambiguated_hunk_id,
10};
11use crate::vcs::{
12 BlameSpan, BranchInfo, HeadInfo, LogEntry, NetworkOp, StatusModel, Vcs, VcsError,
13};
14
15pub const DEFAULT_CONTEXT_LINES: u32 = 3;
17
18pub struct GitVcs {
19 repo: git2::Repository,
20 context_lines: u32,
21}
22
23impl GitVcs {
24 pub fn open(root: &Path) -> Result<Self, VcsError> {
25 Self::open_with_context(root, DEFAULT_CONTEXT_LINES)
26 }
27
28 pub fn open_with_context(root: &Path, context_lines: u32) -> Result<Self, VcsError> {
30 let repo = git2::Repository::open(root)?;
31 if repo.workdir().is_none() {
32 return Err(VcsError::NoWorkdir);
33 }
34 Ok(Self {
35 repo,
36 context_lines,
37 })
38 }
39
40 fn head_tree(&self) -> Result<Option<git2::Tree<'_>>, VcsError> {
42 match self.repo.head() {
43 Ok(head) => Ok(Some(head.peel_to_tree()?)),
44 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
45 Err(err) => Err(err.into()),
46 }
47 }
48
49 fn workdir_path(&self) -> Result<&Path, VcsError> {
50 self.repo.workdir().ok_or(VcsError::NoWorkdir)
51 }
52
53 fn workdir_diff(&self, base: Option<&git2::Tree<'_>>) -> Result<DiffModel, VcsError> {
56 let mut diff = self.repo.diff_tree_to_workdir_with_index(
57 base,
58 Some(&mut workdir_diff_options(self.context_lines)),
59 )?;
60 let mut find = git2::DiffFindOptions::new();
61 find.renames(true);
62 diff.find_similar(Some(&mut find))?;
63 diff_to_model(&self.repo, &mut diff)
64 }
65
66 fn walk_entries(
67 &self,
68 walk: git2::Revwalk<'_>,
69 limit: usize,
70 ) -> Result<Vec<LogEntry>, VcsError> {
71 let mut entries = Vec::new();
72 for oid in walk.take(limit) {
73 let oid = oid?;
74 let commit = self.repo.find_commit(oid)?;
75 let full = oid.to_string();
76 entries.push(LogEntry {
77 oid7: short7(&full),
78 oid: full,
79 refs: Vec::new(),
80 subject: commit.summary()?.unwrap_or_default().to_owned(),
81 author: commit.author().name().unwrap_or_default().to_owned(),
82 time_unix: commit.time().seconds(),
83 });
84 }
85 Ok(entries)
86 }
87
88 fn has_tracked_changes(&self) -> Result<bool, VcsError> {
92 let mut opts = git2::StatusOptions::new();
93 opts.include_untracked(false).include_ignored(false);
94 let statuses = self.repo.statuses(Some(&mut opts))?;
95 Ok(statuses.iter().next().is_some())
96 }
97}
98
99impl Vcs for GitVcs {
100 fn git_dir(&self) -> Result<PathBuf, VcsError> {
101 Ok(self.repo.path().to_path_buf())
104 }
105
106 fn head(&self) -> Result<HeadInfo, VcsError> {
107 match self.repo.head() {
108 Ok(head) => {
109 let branch = if head.is_branch() {
110 Some(head.shorthand()?.to_owned())
111 } else {
112 None
113 };
114 let commit = head.peel_to_commit()?;
115 let tracking = branch.as_deref().and_then(|name| {
116 let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
117 let upstream = local.upstream().ok()?;
118 let name = upstream.name().ok().flatten()?.to_owned();
119 Some((name, upstream.get().target()))
120 });
121 let (upstream, ahead, behind) = match tracking {
122 Some((name, Some(target))) => {
123 let (ahead, behind) = self
124 .repo
125 .graph_ahead_behind(commit.id(), target)
126 .unwrap_or((0, 0));
127 (Some(name), ahead, behind)
128 }
129 Some((name, None)) => (Some(name), 0, 0),
130 None => (None, 0, 0),
131 };
132 Ok(HeadInfo {
133 branch,
134 oid7: short7(&commit.id().to_string()),
135 subject: commit.summary()?.unwrap_or_default().to_owned(),
136 upstream,
137 ahead,
138 behind,
139 })
140 }
141 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
142 let branch = self
143 .repo
144 .find_reference("HEAD")
145 .ok()
146 .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
147 .and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
148 Ok(HeadInfo {
149 branch,
150 oid7: String::new(),
151 subject: String::new(),
152 upstream: None,
153 ahead: 0,
154 behind: 0,
155 })
156 }
157 Err(err) => Err(err.into()),
158 }
159 }
160
161 fn status(&self) -> Result<StatusModel, VcsError> {
162 let mut workdir = self
165 .repo
166 .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
167 let workdir_model = diff_to_model(&self.repo, &mut workdir)?;
168 let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
169 .files
170 .into_iter()
171 .partition(|f| f.status == FileStatus::Untracked);
172
173 let head_tree = self.head_tree()?;
174 let mut opts = git2::DiffOptions::new();
175 opts.context_lines(self.context_lines);
176 let mut staged = self
177 .repo
178 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
179 let staged = diff_to_model(&self.repo, &mut staged)?;
180
181 Ok(StatusModel {
182 untracked: DiffModel { files: untracked },
183 unstaged: DiffModel { files: unstaged },
184 staged,
185 })
186 }
187
188 fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
189 self.workdir_diff(self.head_tree()?.as_ref())
190 }
191
192 fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
193 let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
194 self.workdir_diff(Some(&base.tree()?))
195 }
196
197 fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
198 let oid = git2::Oid::from_str(oid)?;
199 let commit = self.repo.find_commit(oid)?;
200 let tree = commit.tree()?;
201 let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
203 let mut opts = git2::DiffOptions::new();
204 opts.context_lines(self.context_lines);
205 let mut diff =
206 self.repo
207 .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?;
208 diff_to_model(&self.repo, &mut diff)
209 }
210
211 fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
212 let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
213 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
214 let mut opts = git2::DiffOptions::new();
215 opts.context_lines(self.context_lines);
216 let mut diff = self.repo.diff_tree_to_tree(
217 Some(&base.tree()?),
218 Some(&newest.tree()?),
219 Some(&mut opts),
220 )?;
221 diff_to_model(&self.repo, &mut diff)
222 }
223
224 fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
225 let base = self
226 .repo
227 .merge_base(git2::Oid::from_str(a)?, git2::Oid::from_str(b)?)?;
228 Ok(base.to_string())
229 }
230
231 fn resolve(&self, revision: &str) -> Result<String, VcsError> {
232 let object = self.repo.revparse_single(revision)?;
233 let commit = object.peel_to_commit()?;
234 Ok(commit.id().to_string())
235 }
236
237 fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
238 let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
239 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
240 let newest_tree = newest.tree()?;
241 let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
245 let mut opts = git2::DiffOptions::new();
246 opts.context_lines(self.context_lines);
247 let mut diff =
248 self.repo
249 .diff_tree_to_tree(base_tree.as_ref(), Some(&newest_tree), Some(&mut opts))?;
250 diff_to_model(&self.repo, &mut diff)
251 }
252
253 fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError> {
254 if self.head_tree()?.is_none() {
255 return Ok(Vec::new());
256 }
257 let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
258 for reference in self.repo.references()?.flatten() {
259 let Ok(name) = reference.shorthand().map(str::to_owned) else {
260 continue;
261 };
262 let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
264 continue;
265 };
266 refs_by_oid.entry(target).or_default().push(name);
267 }
268
269 let mut walk = self.repo.revwalk()?;
270 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
271 walk.push_head()?;
272 let mut entries = Vec::new();
273 for oid in walk.take(limit) {
274 let oid = oid?;
275 let commit = self.repo.find_commit(oid)?;
276 let full = oid.to_string();
277 entries.push(LogEntry {
278 oid7: short7(&full),
279 oid: full,
280 refs: refs_by_oid.get(&oid).cloned().unwrap_or_default(),
281 subject: commit.summary()?.unwrap_or_default().to_owned(),
282 author: commit.author().name().unwrap_or_default().to_owned(),
283 time_unix: commit.time().seconds(),
284 });
285 }
286 Ok(entries)
287 }
288
289 fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
290 let head_ref = format!("refs/remotes/{remote}/HEAD");
293 let prefix = format!("refs/remotes/{remote}/");
294 if let Ok(reference) = self.repo.find_reference(&head_ref)
295 && let Ok(Some(target)) = reference.symbolic_target()
296 && let Some(name) = target.strip_prefix(prefix.as_str())
298 {
299 return Ok(Some(name.to_owned()));
300 }
301 for name in ["main", "master"] {
302 if self
303 .repo
304 .find_branch(name, git2::BranchType::Local)
305 .or_else(|_| {
306 self.repo
307 .find_branch(&format!("{remote}/{name}"), git2::BranchType::Remote)
308 })
309 .is_ok()
310 {
311 return Ok(Some(name.to_owned()));
312 }
313 }
314 Ok(None)
315 }
316
317 fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
318 let (base, head) = (
319 self.repo.revparse_single(base)?,
320 self.repo.revparse_single(head)?,
321 );
322 let mut walk = self.repo.revwalk()?;
323 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
324 walk.push(head.id())?;
325 walk.hide(base.id())?;
326 self.walk_entries(walk, usize::MAX)
327 }
328
329 fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError> {
330 let Ok(head) = self.repo.head() else {
331 return Ok(None);
332 };
333 let mut walk = self.repo.revwalk()?;
334 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
335 walk.push(head.peel_to_commit()?.id())?;
336 let mut remotes = 0;
337 for reference in self.repo.references()? {
338 let reference = reference?;
339 if let (true, Some(oid)) = (reference.is_remote(), reference.target()) {
342 remotes += 1;
343 walk.hide(oid)?;
344 }
345 }
346 if remotes == 0 {
347 return Ok(None);
348 }
349 self.walk_entries(walk, limit).map(Some)
350 }
351
352 fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
353 let mut options = git2::BlameOptions::new();
354 options.track_copies_same_file(true);
355 let blame = self.repo.blame_file(rel, Some(&mut options))?;
356 let workdir = self.workdir()?.join(rel);
360 let blame = match fs::read(&workdir) {
361 Ok(bytes) => blame.blame_buffer(&bytes)?,
362 Err(_) => blame,
363 };
364
365 let mut out = Vec::new();
366 for hunk in blame.iter() {
367 let oid = hunk.final_commit_id();
368 let commit = self.repo.find_commit(oid).ok();
369 let full = oid.to_string();
370 out.push(BlameSpan {
371 start_line: u32::try_from(hunk.final_start_line()).unwrap_or(u32::MAX),
372 line_count: u32::try_from(hunk.lines_in_hunk()).unwrap_or(u32::MAX),
373 oid7: short7(&full),
374 oid: full,
375 author: commit
376 .as_ref()
377 .map(|c| c.author().name().unwrap_or_default().to_owned())
378 .unwrap_or_default(),
379 time_unix: commit.as_ref().map_or(0, |c| c.time().seconds()),
380 summary: commit
381 .as_ref()
382 .and_then(|c| c.summary().ok().flatten().map(str::to_owned))
383 .unwrap_or_default(),
384 committed: !oid.is_zero(),
385 });
386 }
387 out.sort_by_key(|span| span.start_line);
388 Ok(out)
389 }
390
391 fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError> {
392 let commit = self.repo.revparse_single(rev)?.peel_to_commit()?;
393 match commit.tree()?.get_path(Path::new(path)) {
394 Ok(entry) => Ok(blob_text(&self.repo, entry.id())),
395 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
396 Err(err) => Err(err.into()),
397 }
398 }
399
400 fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError> {
401 let index = self.repo.index()?;
402 let mut out: Vec<PathBuf> = index
403 .iter()
404 .filter_map(|entry| {
405 std::str::from_utf8(&entry.path)
406 .ok()
407 .map(|path| PathBuf::from(path.to_owned()))
408 })
409 .collect();
410 out.sort_unstable();
411 out.dedup();
412 Ok(out)
413 }
414
415 fn attr(&self, rel: &Path, name: &str) -> bool {
416 let Ok(value) = self.repo.get_attr(rel, name, git2::AttrCheckFlags::empty()) else {
417 return false;
418 };
419 matches!(
422 git2::AttrValue::from_string(value),
423 git2::AttrValue::True | git2::AttrValue::String("true")
424 )
425 }
426
427 fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
428 let mut out = Vec::new();
429 for entry in self.repo.branches(Some(git2::BranchType::Local))? {
430 let (branch, _) = entry?;
431 let Some(name) = branch.name()?.map(str::to_owned) else {
432 continue;
433 };
434 let tip_unix = branch
435 .get()
436 .peel_to_commit()
437 .ok()
438 .map_or(0, |commit| commit.time().seconds());
439 out.push(BranchInfo {
440 name,
441 is_head: branch.is_head(),
442 tip_unix,
443 divergence: None,
444 });
445 }
446 Ok(out)
447 }
448
449 fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError> {
450 let branch = self.repo.find_branch(branch, git2::BranchType::Local)?;
451 let Some(tip) = branch.get().peel_to_commit().ok() else {
452 return Ok(None);
453 };
454 let Some(target) = branch.upstream().ok().and_then(|up| up.get().target()) else {
455 return Ok(None);
456 };
457 Ok(Some(self.repo.graph_ahead_behind(tip.id(), target)?))
458 }
459
460 fn all_branches(&self) -> Result<Vec<String>, VcsError> {
461 let mut out = Vec::new();
462 for entry in self.repo.branches(None)? {
463 let (branch, _) = entry?;
464 let Some(name) = branch.name()?.map(str::to_owned) else {
465 continue;
466 };
467 if name.ends_with("/HEAD") {
469 continue;
470 }
471 out.push(name);
472 }
473 Ok(out)
474 }
475
476 fn stage(&self, rel: &Path) -> Result<(), VcsError> {
477 let root = self.workdir_path()?;
478 let mut index = self.repo.index()?;
479 if root.join(rel).exists() {
480 index.add_path(rel)?;
481 } else {
482 index.remove_path(rel)?;
483 }
484 index.write()?;
485 Ok(())
486 }
487
488 fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
489 let diff = self
490 .repo
491 .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
492 let patch = synthesize_patch(&diff, rel, hunk, false)?;
493 let diff = git2::Diff::from_buffer(&patch)?;
494 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
495 Ok(())
496 }
497
498 fn stage_everything(&self) -> Result<(), VcsError> {
499 let mut index = self.repo.index()?;
500 index.update_all(["*"], None)?;
503 index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
504 index.write()?;
505 Ok(())
506 }
507
508 fn unstage_everything(&self) -> Result<(), VcsError> {
509 match self.repo.head() {
510 Ok(head) => {
511 let target = head.peel(git2::ObjectType::Commit)?;
512 self.repo.reset_default(Some(&target), ["*"])?;
513 }
514 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
516 let mut index = self.repo.index()?;
517 index.clear()?;
518 index.write()?;
519 }
520 Err(err) => return Err(err.into()),
521 }
522 Ok(())
523 }
524
525 fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
526 match self.repo.head() {
527 Ok(head) => {
528 let target = head.peel(git2::ObjectType::Commit)?;
529 self.repo.reset_default(Some(&target), [rel])?;
530 }
531 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
533 let mut index = self.repo.index()?;
534 index.remove_path(rel)?;
535 index.write()?;
536 }
537 Err(err) => return Err(err.into()),
538 }
539 Ok(())
540 }
541
542 fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
543 let head_tree = self.head_tree()?;
544 let mut opts = git2::DiffOptions::new();
545 opts.context_lines(self.context_lines);
546 let diff = self
547 .repo
548 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
549 let patch = synthesize_patch(&diff, rel, hunk, true)?;
550 let diff = git2::Diff::from_buffer(&patch)?;
551 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
552 Ok(())
553 }
554
555 fn discard(&self, rel: &Path) -> Result<(), VcsError> {
556 let head_tree = self.head_tree()?;
557 let mut opts = git2::DiffOptions::new();
558 opts.pathspec(rel).disable_pathspec_match(true);
559 let staged = self
560 .repo
561 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
562 if staged.deltas().len() > 0 {
563 return Err(VcsError::Rejected(
564 "file has staged changes; unstage first".into(),
565 ));
566 }
567 let status = self.repo.status_file(rel)?;
568 if status.contains(git2::Status::WT_NEW) {
569 fs::remove_file(self.workdir_path()?.join(rel))?;
570 return Ok(());
571 }
572 let mut checkout = git2::build::CheckoutBuilder::new();
579 checkout.path(rel).force().update_index(true);
580 self.repo.checkout_head(Some(&mut checkout))?;
581 Ok(())
582 }
583
584 fn commit(&self, message: &str) -> Result<String, VcsError> {
585 if message.trim().is_empty() {
586 return Err(VcsError::Rejected("empty commit message".into()));
587 }
588 let mut index = self.repo.index()?;
589 let tree_id = index.write_tree()?;
590 let tree = self.repo.find_tree(tree_id)?;
591 let signature = self.repo.signature()?;
592 let parent = match self.repo.head() {
593 Ok(head) => Some(head.peel_to_commit()?),
594 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
595 Err(err) => return Err(err.into()),
596 };
597 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
598 let oid = self.repo.commit(
599 Some("HEAD"),
600 &signature,
601 &signature,
602 message,
603 &tree,
604 &parents,
605 )?;
606 Ok(oid.to_string())
607 }
608
609 fn head_message(&self) -> Result<String, VcsError> {
610 let commit = self.repo.head()?.peel_to_commit()?;
611 Ok(commit.message().unwrap_or_default().to_owned())
612 }
613
614 fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
615 if let Some(message) = message
616 && message.trim().is_empty()
617 {
618 return Err(VcsError::Rejected("empty commit message".into()));
619 }
620 let head = self.repo.head()?.peel_to_commit()?;
621 let tree = if use_index {
624 let mut index = self.repo.index()?;
625 let tree_id = index.write_tree()?;
626 self.repo.find_tree(tree_id)?
627 } else {
628 head.tree()?
629 };
630 let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
631 Ok(oid.to_string())
632 }
633
634 fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
635 let head = self.repo.head()?.peel_to_commit()?;
636 self.repo.branch(name, &head, false)?;
637 if checkout {
638 self.checkout(name)?;
639 }
640 Ok(())
641 }
642
643 fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
644 let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
645 if branch.is_head() {
646 return Err(VcsError::Rejected(
647 "cannot delete the checked-out branch".into(),
648 ));
649 }
650 branch.delete()?;
651 Ok(())
652 }
653
654 fn checkout(&self, name: &str) -> Result<(), VcsError> {
655 let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
656 let target = branch.get().peel(git2::ObjectType::Commit)?;
657 self.repo.checkout_tree(&target, None)?;
659 self.repo.set_head(&format!("refs/heads/{name}"))?;
660 Ok(())
661 }
662
663 fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError> {
664 if !self.has_tracked_changes()? {
665 return Err(VcsError::Rejected("nothing to stash".into()));
666 }
667 let mut repo = git2::Repository::open(self.workdir_path()?)?;
670 let signature = repo.signature()?;
671 repo.stash_save2(&signature, message, None)?;
672 Ok(())
673 }
674
675 fn stash_pop(&self) -> Result<(), VcsError> {
676 let mut repo = git2::Repository::open(self.workdir_path()?)?;
677 match repo.stash_pop(0, None) {
678 Ok(()) => Ok(()),
679 Err(err) if err.code() == git2::ErrorCode::NotFound => {
680 Err(VcsError::Rejected("no stash to pop".into()))
681 }
682 Err(err) if err.code() == git2::ErrorCode::Conflict => Err(VcsError::Rejected(
685 "stash applied with conflicts; resolve them (the stash was kept)".into(),
686 )),
687 Err(err) => Err(err.into()),
688 }
689 }
690
691 fn network_argv(&self, op: NetworkOp) -> Vec<String> {
692 let args: &[&str] = match op {
695 NetworkOp::Fetch => &["fetch"],
696 NetworkOp::FetchAll => &["fetch", "--all"],
697 };
698 std::iter::once("git")
699 .chain(args.iter().copied())
700 .map(str::to_owned)
701 .collect()
702 }
703
704 fn workdir(&self) -> Result<PathBuf, VcsError> {
705 Ok(self.workdir_path()?.to_path_buf())
706 }
707
708 fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
709 match self.repo.find_remote(name) {
710 Ok(remote) => Ok(remote.url().ok().map(str::to_owned)),
712 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
713 Err(err) => Err(err.into()),
714 }
715 }
716
717 fn remotes(&self) -> Result<Vec<String>, VcsError> {
718 let array = self.repo.remotes()?;
719 let mut names = Vec::new();
720 for i in 0..array.len() {
721 if let Ok(Some(name)) = array.get(i) {
722 names.push(name.to_owned());
723 }
724 }
725 Ok(names)
726 }
727}
728
729fn synthesize_patch(
734 diff: &git2::Diff<'_>,
735 rel: &Path,
736 target: &HunkId,
737 reverse: bool,
738) -> Result<Vec<u8>, VcsError> {
739 let rel = rel.to_string_lossy();
740 for idx in 0..diff.deltas().len() {
741 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
742 continue;
743 };
744 let delta = patch.delta();
745 if delta.flags().is_binary() || delta_new_path(&delta) != rel {
746 continue;
747 }
748 let mut seen = HashMap::new();
749 for h in 0..patch.num_hunks() {
750 let lines = hunk_model_lines(&patch, h)?;
751 if disambiguated_hunk_id(&rel, &lines, &mut seen) == *target {
752 return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
753 }
754 }
755 }
756 Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
757}
758
759fn render_hunk_patch(
760 patch: &git2::Patch<'_>,
761 h: usize,
762 rel: &str,
763 status: git2::Delta,
764 reverse: bool,
765) -> Result<Vec<u8>, VcsError> {
766 let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
767 let deleted = status == git2::Delta::Deleted;
768 let mut out = Vec::new();
769 out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
770 if (added && !reverse) || (deleted && reverse) {
774 out.extend_from_slice(
775 format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
776 );
777 } else if (deleted && !reverse) || (added && reverse) {
778 out.extend_from_slice(
779 format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
780 );
781 } else {
782 out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
783 }
784 let (hunk, line_count) = patch.hunk(h)?;
785 let (old, new) = (
786 (hunk.old_start(), hunk.old_lines()),
787 (hunk.new_start(), hunk.new_lines()),
788 );
789 let ((minus_start, minus_lines), (plus_start, plus_lines)) =
790 if reverse { (new, old) } else { (old, new) };
791 out.extend_from_slice(
792 format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
793 );
794 for l in 0..line_count {
795 let line = patch.line_in_hunk(h, l)?;
796 let origin = match (line.origin(), reverse) {
797 (' ', _) => Some(b' '),
798 ('+', false) | ('-', true) => Some(b'+'),
799 ('-', false) | ('+', true) => Some(b'-'),
800 ('=' | '>' | '<', _) => None,
804 _ => continue,
805 };
806 if let Some(origin) = origin {
807 out.push(origin);
808 }
809 out.extend_from_slice(line.content());
810 }
811 Ok(out)
812}
813
814fn workdir_diff_options(context_lines: u32) -> git2::DiffOptions {
815 let mut opts = git2::DiffOptions::new();
816 opts.include_untracked(true)
817 .recurse_untracked_dirs(true)
818 .show_untracked_content(true)
819 .context_lines(context_lines);
820 opts
821}
822
823fn short7(oid: &str) -> String {
824 oid.get(..7).unwrap_or(oid).to_owned()
825}
826
827fn diff_to_model(
828 repo: &git2::Repository,
829 diff: &mut git2::Diff<'_>,
830) -> Result<DiffModel, VcsError> {
831 let mut files = Vec::new();
832 for idx in 0..diff.deltas().len() {
833 if let Some(file) = build_file(repo, diff, idx)? {
834 files.push(file);
835 }
836 }
837 Ok(DiffModel { files })
841}
842
843fn build_file(
844 repo: &git2::Repository,
845 diff: &mut git2::Diff<'_>,
846 idx: usize,
847) -> Result<Option<FileDiff>, VcsError> {
848 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
849 return Ok(build_binary_file(diff, idx));
851 };
852 let delta = patch.delta();
853 if delta.flags().is_binary() {
854 return Ok(build_binary_file(diff, idx));
855 }
856 let file_path = delta_new_path(&delta);
857 let status = map_status(delta.status());
858 let old_path = if status == FileStatus::Renamed {
859 delta
860 .old_file()
861 .path()
862 .map(|p| p.to_string_lossy().into_owned())
863 } else {
864 None
865 };
866
867 let old_text = blob_text(repo, delta.old_file().id());
868 let new_text = new_side_text(repo, &delta, &file_path);
869
870 let hunks = patch_hunks(&patch, &file_path)?;
871
872 Ok(Some(FileDiff {
873 path: file_path,
874 old_path,
875 status,
876 binary: false,
877 old_text,
878 new_text,
879 hunks,
880 hashes: crate::model::HashCache::default(),
881 }))
882}
883
884pub fn rehunk_file(file: &FileDiff, context: u32) -> Option<Vec<Hunk>> {
889 if file.binary {
890 return None;
891 }
892 let (old, new) = (file.old_text.as_deref()?, file.new_text.as_deref()?);
893 let as_path = Path::new(&file.path);
894 let cap = u32::try_from(old.lines().count().max(new.lines().count())).unwrap_or(u32::MAX);
898 let mut opts = git2::DiffOptions::new();
899 opts.context_lines(context.min(cap));
900 let patch = git2::Patch::from_buffers(
901 old.as_bytes(),
902 Some(as_path),
903 new.as_bytes(),
904 Some(as_path),
905 Some(&mut opts),
906 )
907 .ok()?;
908 patch_hunks(&patch, &file.path).ok()
909}
910
911fn patch_hunks(patch: &git2::Patch<'_>, file_path: &str) -> Result<Vec<Hunk>, VcsError> {
914 let mut hunks = Vec::with_capacity(patch.num_hunks());
915 let mut seen = HashMap::new();
916 for h in 0..patch.num_hunks() {
917 let (hunk, _) = patch.hunk(h)?;
918 let lines = hunk_model_lines(patch, h)?;
919 let id = disambiguated_hunk_id(file_path, &lines, &mut seen);
920 hunks.push(Hunk {
921 id,
922 old_start: hunk.old_start(),
923 old_lines: hunk.old_lines(),
924 new_start: hunk.new_start(),
925 new_lines: hunk.new_lines(),
926 context: hunk_context(&hunk),
927 lines,
928 });
929 }
930 Ok(hunks)
931}
932
933fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
937 let header = String::from_utf8_lossy(hunk.header());
938 match header.split_once(" @@") {
939 Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
940 None => String::new(),
941 }
942}
943
944fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
948 let (_, line_count) = patch.hunk(h)?;
949 let mut lines = Vec::with_capacity(line_count);
950 for l in 0..line_count {
951 let line = patch.line_in_hunk(h, l)?;
952 let kind = match line.origin() {
953 '-' => LineKind::Deleted,
954 '+' => LineKind::Added,
955 ' ' => LineKind::Context,
956 _ => continue,
958 };
959 let text = String::from_utf8_lossy(line.content())
960 .trim_end_matches(['\n', '\r'])
961 .to_owned();
962 lines.push(DiffLine::new(
963 kind,
964 line.old_lineno(),
965 line.new_lineno(),
966 text,
967 ));
968 }
969 Ok(lines)
970}
971
972fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
973 let delta = diff.get_delta(idx)?;
974 Some(FileDiff {
975 path: delta_new_path(&delta),
976 old_path: None,
977 status: map_status(delta.status()),
978 binary: true,
979 old_text: None,
980 new_text: None,
981 hunks: Vec::new(),
982 hashes: crate::model::HashCache::default(),
983 })
984}
985
986fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
987 delta
988 .new_file()
989 .path()
990 .or_else(|| delta.old_file().path())
991 .map(|p| p.to_string_lossy().into_owned())
992 .unwrap_or_default()
993}
994
995fn map_status(status: git2::Delta) -> FileStatus {
996 match status {
997 git2::Delta::Added => FileStatus::Added,
998 git2::Delta::Deleted => FileStatus::Deleted,
999 git2::Delta::Renamed => FileStatus::Renamed,
1000 git2::Delta::Untracked => FileStatus::Untracked,
1001 _ => FileStatus::Modified,
1002 }
1003}
1004
1005fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
1006 if oid.is_zero() {
1007 return None;
1008 }
1009 let blob = repo.find_blob(oid).ok()?;
1010 if blob.is_binary() {
1011 return None;
1012 }
1013 String::from_utf8(blob.content().to_vec()).ok()
1014}
1015
1016fn new_side_text(
1019 repo: &git2::Repository,
1020 delta: &git2::DiffDelta<'_>,
1021 rel: &str,
1022) -> Option<String> {
1023 if delta.status() == git2::Delta::Deleted {
1024 return None;
1025 }
1026 if let Some(text) = blob_text(repo, delta.new_file().id()) {
1027 return Some(text);
1028 }
1029 let root = repo.workdir()?;
1030 fs::read_to_string(root.join(rel)).ok()
1031}