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