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
50impl Vcs for GitVcs {
51 fn git_dir(&self) -> Result<PathBuf, VcsError> {
52 Ok(self.repo.path().to_path_buf())
55 }
56
57 fn head(&self) -> Result<HeadInfo, VcsError> {
58 match self.repo.head() {
59 Ok(head) => {
60 let branch = if head.is_branch() {
61 Some(head.shorthand()?.to_owned())
62 } else {
63 None
64 };
65 let commit = head.peel_to_commit()?;
66 let upstream = branch.as_deref().and_then(|name| {
67 let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
68 let upstream = local.upstream().ok()?;
69 upstream.name().ok().flatten().map(str::to_owned)
70 });
71 Ok(HeadInfo {
72 branch,
73 oid7: short7(&commit.id().to_string()),
74 subject: commit.summary()?.unwrap_or_default().to_owned(),
75 upstream,
76 })
77 }
78 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
79 let branch = self
80 .repo
81 .find_reference("HEAD")
82 .ok()
83 .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
84 .and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
85 Ok(HeadInfo {
86 branch,
87 oid7: String::new(),
88 subject: String::new(),
89 upstream: None,
90 })
91 }
92 Err(err) => Err(err.into()),
93 }
94 }
95
96 fn status(&self) -> Result<StatusModel, VcsError> {
97 let mut workdir = self
100 .repo
101 .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
102 let workdir_model = diff_to_model(&self.repo, &mut workdir)?;
103 let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
104 .files
105 .into_iter()
106 .partition(|f| f.status == FileStatus::Untracked);
107
108 let head_tree = self.head_tree()?;
109 let mut opts = git2::DiffOptions::new();
110 opts.context_lines(self.context_lines);
111 let mut staged = self
112 .repo
113 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
114 let staged = diff_to_model(&self.repo, &mut staged)?;
115
116 Ok(StatusModel {
117 untracked: DiffModel { files: untracked },
118 unstaged: DiffModel { files: unstaged },
119 staged,
120 })
121 }
122
123 fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
124 let head_tree = self.head_tree()?;
125 let mut diff = self.repo.diff_tree_to_workdir_with_index(
126 head_tree.as_ref(),
127 Some(&mut workdir_diff_options(self.context_lines)),
128 )?;
129 let mut find = git2::DiffFindOptions::new();
130 find.renames(true);
131 diff.find_similar(Some(&mut find))?;
132 diff_to_model(&self.repo, &mut diff)
133 }
134
135 fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
136 let oid = git2::Oid::from_str(oid)?;
137 let commit = self.repo.find_commit(oid)?;
138 let tree = commit.tree()?;
139 let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
141 let mut opts = git2::DiffOptions::new();
142 opts.context_lines(self.context_lines);
143 let mut diff =
144 self.repo
145 .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?;
146 diff_to_model(&self.repo, &mut diff)
147 }
148
149 fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
150 let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
151 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
152 let newest_tree = newest.tree()?;
153 let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
157 let mut opts = git2::DiffOptions::new();
158 opts.context_lines(self.context_lines);
159 let mut diff =
160 self.repo
161 .diff_tree_to_tree(base_tree.as_ref(), Some(&newest_tree), Some(&mut opts))?;
162 diff_to_model(&self.repo, &mut diff)
163 }
164
165 fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError> {
166 if self.head_tree()?.is_none() {
167 return Ok(Vec::new());
168 }
169 let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
170 for reference in self.repo.references()?.flatten() {
171 let Ok(name) = reference.shorthand().map(str::to_owned) else {
172 continue;
173 };
174 let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
176 continue;
177 };
178 refs_by_oid.entry(target).or_default().push(name);
179 }
180
181 let mut walk = self.repo.revwalk()?;
182 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
183 walk.push_head()?;
184 let mut entries = Vec::new();
185 for oid in walk.take(limit) {
186 let oid = oid?;
187 let commit = self.repo.find_commit(oid)?;
188 let full = oid.to_string();
189 entries.push(LogEntry {
190 oid7: short7(&full),
191 oid: full,
192 refs: refs_by_oid.get(&oid).cloned().unwrap_or_default(),
193 subject: commit.summary()?.unwrap_or_default().to_owned(),
194 author: commit.author().name().unwrap_or_default().to_owned(),
195 time_unix: commit.time().seconds(),
196 });
197 }
198 Ok(entries)
199 }
200
201 fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
202 let mut out = Vec::new();
203 for entry in self.repo.branches(Some(git2::BranchType::Local))? {
204 let (branch, _) = entry?;
205 let Some(name) = branch.name()?.map(str::to_owned) else {
206 continue;
207 };
208 out.push(BranchInfo {
209 name,
210 is_head: branch.is_head(),
211 });
212 }
213 Ok(out)
214 }
215
216 fn stage(&self, rel: &Path) -> Result<(), VcsError> {
217 let root = self.workdir_path()?;
218 let mut index = self.repo.index()?;
219 if root.join(rel).exists() {
220 index.add_path(rel)?;
221 } else {
222 index.remove_path(rel)?;
223 }
224 index.write()?;
225 Ok(())
226 }
227
228 fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
229 let diff = self
230 .repo
231 .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
232 let patch = synthesize_patch(&diff, rel, hunk, false)?;
233 let diff = git2::Diff::from_buffer(&patch)?;
234 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
235 Ok(())
236 }
237
238 fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
239 match self.repo.head() {
240 Ok(head) => {
241 let target = head.peel(git2::ObjectType::Commit)?;
242 self.repo.reset_default(Some(&target), [rel])?;
243 }
244 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
246 let mut index = self.repo.index()?;
247 index.remove_path(rel)?;
248 index.write()?;
249 }
250 Err(err) => return Err(err.into()),
251 }
252 Ok(())
253 }
254
255 fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
256 let head_tree = self.head_tree()?;
257 let mut opts = git2::DiffOptions::new();
258 opts.context_lines(self.context_lines);
259 let diff = self
260 .repo
261 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
262 let patch = synthesize_patch(&diff, rel, hunk, true)?;
263 let diff = git2::Diff::from_buffer(&patch)?;
264 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
265 Ok(())
266 }
267
268 fn discard(&self, rel: &Path) -> Result<(), VcsError> {
269 let head_tree = self.head_tree()?;
270 let mut opts = git2::DiffOptions::new();
271 opts.pathspec(rel).disable_pathspec_match(true);
272 let staged = self
273 .repo
274 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
275 if staged.deltas().len() > 0 {
276 return Err(VcsError::Rejected(
277 "file has staged changes; unstage first".into(),
278 ));
279 }
280 let status = self.repo.status_file(rel)?;
281 if status.contains(git2::Status::WT_NEW) {
282 fs::remove_file(self.workdir_path()?.join(rel))?;
283 return Ok(());
284 }
285 let mut checkout = git2::build::CheckoutBuilder::new();
292 checkout.path(rel).force().update_index(true);
293 self.repo.checkout_head(Some(&mut checkout))?;
294 Ok(())
295 }
296
297 fn commit(&self, message: &str) -> Result<String, VcsError> {
298 if message.trim().is_empty() {
299 return Err(VcsError::Rejected("empty commit message".into()));
300 }
301 let mut index = self.repo.index()?;
302 let tree_id = index.write_tree()?;
303 let tree = self.repo.find_tree(tree_id)?;
304 let signature = self.repo.signature()?;
305 let parent = match self.repo.head() {
306 Ok(head) => Some(head.peel_to_commit()?),
307 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
308 Err(err) => return Err(err.into()),
309 };
310 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
311 let oid = self.repo.commit(
312 Some("HEAD"),
313 &signature,
314 &signature,
315 message,
316 &tree,
317 &parents,
318 )?;
319 Ok(oid.to_string())
320 }
321
322 fn head_message(&self) -> Result<String, VcsError> {
323 let commit = self.repo.head()?.peel_to_commit()?;
324 Ok(commit.message().unwrap_or_default().to_owned())
325 }
326
327 fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
328 if let Some(message) = message
329 && message.trim().is_empty()
330 {
331 return Err(VcsError::Rejected("empty commit message".into()));
332 }
333 let head = self.repo.head()?.peel_to_commit()?;
334 let tree = if use_index {
337 let mut index = self.repo.index()?;
338 let tree_id = index.write_tree()?;
339 self.repo.find_tree(tree_id)?
340 } else {
341 head.tree()?
342 };
343 let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
344 Ok(oid.to_string())
345 }
346
347 fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
348 let head = self.repo.head()?.peel_to_commit()?;
349 self.repo.branch(name, &head, false)?;
350 if checkout {
351 self.checkout(name)?;
352 }
353 Ok(())
354 }
355
356 fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
357 let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
358 if branch.is_head() {
359 return Err(VcsError::Rejected(
360 "cannot delete the checked-out branch".into(),
361 ));
362 }
363 branch.delete()?;
364 Ok(())
365 }
366
367 fn checkout(&self, name: &str) -> Result<(), VcsError> {
368 let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
369 let target = branch.get().peel(git2::ObjectType::Commit)?;
370 self.repo.checkout_tree(&target, None)?;
372 self.repo.set_head(&format!("refs/heads/{name}"))?;
373 Ok(())
374 }
375
376 fn network_argv(&self, op: NetworkOp) -> Vec<String> {
377 let args: &[&str] = match op {
381 NetworkOp::Push => &["push"],
382 NetworkOp::PushSetUpstream => &["push", "-u", "origin", "HEAD"],
383 NetworkOp::Pull => &["pull"],
384 NetworkOp::Fetch => &["fetch"],
385 NetworkOp::FetchAll => &["fetch", "--all"],
386 };
387 std::iter::once("git")
388 .chain(args.iter().copied())
389 .map(str::to_owned)
390 .collect()
391 }
392
393 fn workdir(&self) -> Result<PathBuf, VcsError> {
394 Ok(self.workdir_path()?.to_path_buf())
395 }
396}
397
398fn synthesize_patch(
403 diff: &git2::Diff<'_>,
404 rel: &Path,
405 target: &HunkId,
406 reverse: bool,
407) -> Result<Vec<u8>, VcsError> {
408 let rel = rel.to_string_lossy();
409 for idx in 0..diff.deltas().len() {
410 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
411 continue;
412 };
413 let delta = patch.delta();
414 if delta.flags().is_binary() || delta_new_path(&delta) != rel {
415 continue;
416 }
417 for h in 0..patch.num_hunks() {
418 if hunk_id(&rel, &hunk_model_lines(&patch, h)?)? == *target {
419 return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
420 }
421 }
422 }
423 Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
424}
425
426fn render_hunk_patch(
427 patch: &git2::Patch<'_>,
428 h: usize,
429 rel: &str,
430 status: git2::Delta,
431 reverse: bool,
432) -> Result<Vec<u8>, VcsError> {
433 let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
434 let deleted = status == git2::Delta::Deleted;
435 let mut out = Vec::new();
436 out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
437 if (added && !reverse) || (deleted && reverse) {
441 out.extend_from_slice(
442 format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
443 );
444 } else if (deleted && !reverse) || (added && reverse) {
445 out.extend_from_slice(
446 format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
447 );
448 } else {
449 out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
450 }
451 let (hunk, line_count) = patch.hunk(h)?;
452 let (old, new) = (
453 (hunk.old_start(), hunk.old_lines()),
454 (hunk.new_start(), hunk.new_lines()),
455 );
456 let ((minus_start, minus_lines), (plus_start, plus_lines)) =
457 if reverse { (new, old) } else { (old, new) };
458 out.extend_from_slice(
459 format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
460 );
461 for l in 0..line_count {
462 let line = patch.line_in_hunk(h, l)?;
463 let origin = match (line.origin(), reverse) {
464 (' ', _) => Some(b' '),
465 ('+', false) | ('-', true) => Some(b'+'),
466 ('-', false) | ('+', true) => Some(b'-'),
467 ('=' | '>' | '<', _) => None,
471 _ => continue,
472 };
473 if let Some(origin) = origin {
474 out.push(origin);
475 }
476 out.extend_from_slice(line.content());
477 }
478 Ok(out)
479}
480
481fn workdir_diff_options(context_lines: u32) -> git2::DiffOptions {
482 let mut opts = git2::DiffOptions::new();
483 opts.include_untracked(true)
484 .recurse_untracked_dirs(true)
485 .show_untracked_content(true)
486 .context_lines(context_lines);
487 opts
488}
489
490fn short7(oid: &str) -> String {
491 oid.get(..7).unwrap_or(oid).to_owned()
492}
493
494fn diff_to_model(
495 repo: &git2::Repository,
496 diff: &mut git2::Diff<'_>,
497) -> Result<DiffModel, VcsError> {
498 let mut files = Vec::new();
499 for idx in 0..diff.deltas().len() {
500 if let Some(file) = build_file(repo, diff, idx)? {
501 files.push(file);
502 }
503 }
504 Ok(DiffModel { files })
508}
509
510fn build_file(
511 repo: &git2::Repository,
512 diff: &mut git2::Diff<'_>,
513 idx: usize,
514) -> Result<Option<FileDiff>, VcsError> {
515 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
516 return Ok(build_binary_file(diff, idx));
518 };
519 let delta = patch.delta();
520 if delta.flags().is_binary() {
521 return Ok(build_binary_file(diff, idx));
522 }
523 let file_path = delta_new_path(&delta);
524 let status = map_status(delta.status());
525 let old_path = if status == FileStatus::Renamed {
526 delta
527 .old_file()
528 .path()
529 .map(|p| p.to_string_lossy().into_owned())
530 } else {
531 None
532 };
533
534 let old_text = blob_text(repo, delta.old_file().id());
535 let new_text = new_side_text(repo, &delta, &file_path);
536
537 let mut hunks = Vec::new();
538 for h in 0..patch.num_hunks() {
539 let (hunk, _) = patch.hunk(h)?;
540 let lines = hunk_model_lines(&patch, h)?;
541 let id = hunk_id(&file_path, &lines)?;
542 hunks.push(Hunk {
543 id,
544 old_start: hunk.old_start(),
545 old_lines: hunk.old_lines(),
546 new_start: hunk.new_start(),
547 new_lines: hunk.new_lines(),
548 context: hunk_context(&hunk),
549 lines,
550 });
551 }
552
553 Ok(Some(FileDiff {
554 path: file_path,
555 old_path,
556 status,
557 binary: false,
558 old_text,
559 new_text,
560 hunks,
561 }))
562}
563
564fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
568 let header = String::from_utf8_lossy(hunk.header());
569 match header.split_once(" @@") {
570 Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
571 None => String::new(),
572 }
573}
574
575fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
579 let (_, line_count) = patch.hunk(h)?;
580 let mut lines = Vec::with_capacity(line_count);
581 for l in 0..line_count {
582 let line = patch.line_in_hunk(h, l)?;
583 let kind = match line.origin() {
584 '-' => LineKind::Deleted,
585 '+' => LineKind::Added,
586 ' ' => LineKind::Context,
587 _ => continue,
589 };
590 let text = String::from_utf8_lossy(line.content())
591 .trim_end_matches(['\n', '\r'])
592 .to_owned();
593 lines.push(DiffLine::new(
594 kind,
595 line.old_lineno(),
596 line.new_lineno(),
597 text,
598 ));
599 }
600 Ok(lines)
601}
602
603fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
604 let delta = diff.get_delta(idx)?;
605 Some(FileDiff {
606 path: delta_new_path(&delta),
607 old_path: None,
608 status: map_status(delta.status()),
609 binary: true,
610 old_text: None,
611 new_text: None,
612 hunks: Vec::new(),
613 })
614}
615
616fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
617 delta
618 .new_file()
619 .path()
620 .or_else(|| delta.old_file().path())
621 .map(|p| p.to_string_lossy().into_owned())
622 .unwrap_or_default()
623}
624
625fn map_status(status: git2::Delta) -> FileStatus {
626 match status {
627 git2::Delta::Added => FileStatus::Added,
628 git2::Delta::Deleted => FileStatus::Deleted,
629 git2::Delta::Renamed => FileStatus::Renamed,
630 git2::Delta::Untracked => FileStatus::Untracked,
631 _ => FileStatus::Modified,
632 }
633}
634
635fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
636 if oid.is_zero() {
637 return None;
638 }
639 let blob = repo.find_blob(oid).ok()?;
640 if blob.is_binary() {
641 return None;
642 }
643 String::from_utf8(blob.content().to_vec()).ok()
644}
645
646fn new_side_text(
649 repo: &git2::Repository,
650 delta: &git2::DiffDelta<'_>,
651 rel: &str,
652) -> Option<String> {
653 if delta.status() == git2::Delta::Deleted {
654 return None;
655 }
656 if let Some(text) = blob_text(repo, delta.new_file().id()) {
657 return Some(text);
658 }
659 let root = repo.workdir()?;
660 fs::read_to_string(root.join(rel)).ok()
661}