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