1use std::path::{Path, PathBuf};
5
6use strop_workspace::RemoteEndpoint;
7
8use crate::diff::{DiffLine, FileDiff, Hunk, HunkKind, LineOrigin};
9use crate::target::RepoTarget;
10
11#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14pub enum GitError {
15 Native(String),
17 OutsideWorkdir,
20}
21
22impl std::fmt::Display for GitError {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 match self {
25 Self::Native(message) => write!(f, "{message}"),
26 Self::OutsideWorkdir => write!(f, "path is outside the repository workdir"),
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41pub struct GitContext {
42 pub repo: RepoTarget,
43 pub head_sha: Option<String>,
44 pub head_branch: Option<String>,
45 pub remotes: Vec<(String, String)>,
47}
48
49impl GitContext {
50 pub fn workdir(&self) -> &Path {
53 self.repo.workdir()
54 }
55
56 pub fn endpoint(&self) -> Option<&RemoteEndpoint> {
58 self.repo.endpoint()
59 }
60
61 pub fn is_remote(&self) -> bool {
65 self.repo.is_remote()
66 }
67}
68
69pub struct Repo {
70 inner: git2::Repository,
71 pub(crate) workdir: PathBuf,
72}
73
74impl Repo {
75 pub fn discover(from: &Path) -> Option<Self> {
77 let inner = git2::Repository::discover(from).ok()?;
78 let workdir = inner.workdir()?.to_path_buf();
79 Some(Self { inner, workdir })
80 }
81
82 pub fn workdir(&self) -> &Path {
83 &self.workdir
84 }
85
86 pub fn remotes(&self) -> Vec<(String, String)> {
88 let Ok(remotes) = self.inner.remotes() else {
89 return vec![];
90 };
91 remotes
92 .iter()
93 .flatten()
94 .filter_map(|name| {
95 self.inner
96 .find_remote(name)
97 .ok()
98 .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
99 })
100 .collect()
101 }
102
103 pub fn head_sha(&self) -> Option<String> {
105 Some(
106 self.inner
107 .head()
108 .ok()?
109 .peel_to_commit()
110 .ok()?
111 .id()
112 .to_string(),
113 )
114 }
115
116 pub fn head_branch(&self) -> Option<String> {
118 self.inner
119 .head()
120 .ok()
121 .and_then(|h| h.shorthand().map(String::from))
122 }
123
124 pub fn context(&self) -> GitContext {
128 GitContext {
129 repo: RepoTarget::Local {
130 workdir: self.workdir.clone(),
131 },
132 head_sha: self.head_sha(),
133 head_branch: self.head_branch(),
134 remotes: self.remotes(),
135 }
136 }
137
138 fn rel_path(&self, path: &Path) -> Option<PathBuf> {
140 let abs = if path.is_absolute() {
141 path.to_path_buf()
142 } else {
143 self.workdir.join(path)
144 };
145 abs.strip_prefix(&self.workdir)
146 .ok()
147 .map(|p| p.to_path_buf())
148 }
149
150 pub fn head_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
153 let commit = self.inner.head().ok()?.peel_to_commit().ok()?;
154 let tree = commit.tree().ok()?;
155 let entry = tree.get_path(rel).ok()?;
156 let blob = self.inner.find_blob(entry.id()).ok()?;
157 Some(blob.content().to_vec())
158 }
159
160 pub fn commit_bytes(&self, sha: &str, rel: &Path) -> Option<Vec<u8>> {
162 let oid = self.inner.revparse_single(sha).ok()?.id();
163 let commit = self.inner.find_commit(oid).ok()?;
164 let tree = commit.tree().ok()?;
165 let entry = tree.get_path(rel).ok()?;
166 let blob = self.inner.find_blob(entry.id()).ok()?;
167 Some(blob.content().to_vec())
168 }
169
170 pub fn index_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
173 let mut index = self.inner.index().ok()?;
174 index.read(true).ok()?;
175 let entry = index.get_path(rel, 0)?;
176 let blob = self.inner.find_blob(entry.id).ok()?;
177 Some(blob.content().to_vec())
178 }
179
180 pub fn merge_base(&self, a: &str, b: &str) -> Option<String> {
182 let a = self.inner.revparse_single(a).ok()?.id();
183 let b = self.inner.revparse_single(b).ok()?.id();
184 let base = self.inner.merge_base(a, b).ok()?;
185 Some(base.to_string())
186 }
187
188 pub fn head_content(&self, path: &Path) -> Option<String> {
189 self.head_content_res(path).ok().flatten()
190 }
191
192 fn head_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
195 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
196 let head = match self.inner.head() {
197 Ok(reference) => reference
198 .peel_to_tree()
199 .map_err(|e| GitError::Native(format!("read HEAD: {e}")))?,
200 Err(error) if error.code() == git2::ErrorCode::UnbornBranch => {
203 return Ok(None);
204 }
205 Err(error) => return Err(GitError::Native(format!("read HEAD: {error}"))),
206 };
207 match head.get_path(&rel) {
208 Err(_) => Ok(None),
210 Ok(entry) => self.blob_utf8(entry.id(), "HEAD").map(Some),
211 }
212 }
213
214 pub fn index_content(&self, path: &Path) -> Option<String> {
216 self.index_content_res(path).ok().flatten()
217 }
218
219 fn index_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
222 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
223 let mut index = self
224 .inner
225 .index()
226 .map_err(|e| GitError::Native(format!("open index: {e}")))?;
227 index
228 .read(true)
229 .map_err(|e| GitError::Native(format!("reload index: {e}")))?;
230 match index.get_path(&rel, 0) {
231 Some(entry) => self.blob_utf8(entry.id, "index").map(Some),
232 None => Ok(None),
233 }
234 }
235
236 fn blob_utf8(&self, id: git2::Oid, edge: &str) -> Result<String, GitError> {
239 let blob = self
240 .inner
241 .find_blob(id)
242 .map_err(|e| GitError::Native(format!("{edge} blob: {e}")))?;
243 String::from_utf8(blob.content().to_vec())
244 .map_err(|_| GitError::Native(format!("{edge} blob is not UTF-8")))
245 }
246
247 pub fn is_untracked(&self, path: &Path) -> Result<bool, GitError> {
250 Ok(self.index_content_res(path)?.is_none() && self.head_content_res(path)?.is_none())
251 }
252
253 pub fn staged_hunks(&self, path: &Path) -> Result<Vec<Hunk>, GitError> {
258 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
259 let Some(index) = self.index_content_res(path)? else {
260 return Ok(vec![]);
261 };
262 let head = self.head_content_res(path)?.unwrap_or_default();
263 self.diff_strings(&head, &index, &rel)
264 }
265
266 pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
271 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
272 let base = match self.index_content_res(path)? {
273 Some(index) => Some(index),
274 None => self.head_content_res(path)?,
275 };
276 match base {
277 Some(base) => self.diff_strings(&base, content, &rel),
278 None => self.hunks(path, content),
279 }
280 }
281
282 pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
285 let old_side: Vec<&DiffLine> = hunk
286 .lines
287 .iter()
288 .filter(|l| l.origin != LineOrigin::Addition)
289 .collect();
290 self.index_region_edit(rel, hunk.new_start, hunk.new_count, &old_side)
291 }
292
293 pub fn hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
297 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
298 match self.head_content_res(path)? {
299 Some(old) => self.diff_strings(&old, content, &rel),
300 None => Ok(all_add_hunk(content)),
301 }
302 }
303
304 fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Result<Vec<Hunk>, GitError> {
305 let mut opts = git2::DiffOptions::new();
306 opts.context_lines(3);
307 let patch = git2::Patch::from_buffers(
308 old.as_bytes(),
309 Some(rel),
310 new.as_bytes(),
311 Some(rel),
312 Some(&mut opts),
313 )
314 .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
315 Ok(hunks_from_patch(&patch))
316 }
317
318 pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
322 let commit = self
323 .inner
324 .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
325 .map_err(|e| e.to_string())?;
326 let new_tree = commit.tree().map_err(|e| e.to_string())?;
327 let old_tree = match commit.parent(0) {
328 Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
329 Err(_) => None,
331 };
332 let mut opts = git2::DiffOptions::new();
333 opts.context_lines(3)
334 .pathspec(path)
335 .disable_pathspec_match(true)
336 .include_unmodified(false);
337 let diff = self
338 .inner
339 .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
340 .map_err(|e| e.to_string())?;
341 let mut file = None;
342 for (d, _delta) in diff.deltas().enumerate() {
343 let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
344 continue; };
346 file = Some(FileDiff::from_hunks(
347 path.to_path_buf(),
348 hunks_from_patch(&patch),
349 ));
350 }
351 file.ok_or_else(|| "no diff for path".to_string())
352 }
353
354 pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
360 let new_side: Vec<&DiffLine> = hunk
361 .lines
362 .iter()
363 .filter(|l| l.origin != LineOrigin::Deletion)
364 .collect();
365 self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
366 }
367
368 fn index_region_edit(
372 &self,
373 rel: &Path,
374 start: usize,
375 count: usize,
376 new_lines: &[&DiffLine],
377 ) -> Result<(), String> {
378 let mut index = self.inner.index().map_err(|e| e.to_string())?;
379 index.read(true).map_err(|e| e.to_string())?; let entry = index.get_path(rel, 0);
381 let (old_bytes, mode) = match entry {
382 Some(e) => {
383 let blob = self
384 .inner
385 .find_blob(e.id)
386 .map_err(|e| format!("index blob: {e}"))?;
387 (blob.content().to_vec(), e.mode)
388 }
389 None => (Vec::new(), 0o100644), };
391 let lines = split_lines_bytes(&old_bytes);
392 let lo = start.saturating_sub(1).min(lines.len());
393 let hi = (lo + count).min(lines.len());
394 let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
395 for (text, nl) in &lines[..lo] {
396 out.extend_from_slice(text);
397 if *nl {
398 out.push(b'\n');
399 }
400 }
401 for l in new_lines {
402 out.extend_from_slice(&l.bytes_with_terminator());
403 }
404 for (text, nl) in &lines[hi..] {
405 out.extend_from_slice(text);
406 if *nl {
407 out.push(b'\n');
408 }
409 }
410 let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
411 index
412 .add(&git2::IndexEntry {
413 ctime: git2::IndexTime::new(0, 0),
414 mtime: git2::IndexTime::new(0, 0),
415 dev: 0,
416 ino: 0,
417 mode,
418 uid: 0,
419 gid: 0,
420 file_size: 0,
421 id: oid,
422 flags: 0,
423 flags_extended: 0,
424 path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
425 })
426 .map_err(|e| e.to_string())?;
427 index.write().map_err(|e| e.to_string())?;
428 Ok(())
429 }
430}
431
432fn all_add_hunk(content: &str) -> Vec<Hunk> {
435 let count = content.lines().count();
436 if count == 0 {
437 return vec![];
438 }
439 vec![Hunk {
440 kind: HunkKind::Add,
441 new_start: 1,
442 new_count: count,
443 old_start: 0,
444 old_count: 0,
445 lines: split_lines_bytes(content.as_bytes())
446 .into_iter()
447 .enumerate()
448 .map(|(i, (text, has_newline))| DiffLine {
449 origin: LineOrigin::Addition,
450 old_lineno: None,
451 new_lineno: Some(i + 1),
452 text,
453 has_newline,
454 })
455 .collect(),
456 }]
457}
458
459pub(crate) fn gutter_from_contents(
467 head: Option<&str>,
468 index: Option<&str>,
469 text: &str,
470 rel: &Path,
471) -> Result<(Vec<Hunk>, Vec<Hunk>, bool), GitError> {
472 let staged = match index {
473 Some(index) => hunks_from_strings(head.unwrap_or_default(), index, rel)?,
474 None => Vec::new(),
475 };
476 let unstaged = match index.or(head) {
477 Some(base) => hunks_from_strings(base, text, rel)?,
478 None => all_add_hunk(text),
479 };
480 let untracked = index.is_none() && head.is_none();
481 Ok((unstaged, staged, untracked))
482}
483
484fn hunks_from_strings(old: &str, new: &str, rel: &Path) -> Result<Vec<Hunk>, GitError> {
487 let mut opts = git2::DiffOptions::new();
488 opts.context_lines(3);
489 let patch = git2::Patch::from_buffers(
490 old.as_bytes(),
491 Some(rel),
492 new.as_bytes(),
493 Some(rel),
494 Some(&mut opts),
495 )
496 .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
497 Ok(hunks_from_patch(&patch))
498}
499
500pub(crate) fn hunks_from_buffers(
506 old: Option<&[u8]>,
507 new: &[u8],
508 rel: &Path,
509) -> Result<Vec<Hunk>, GitError> {
510 let mut opts = git2::DiffOptions::new();
511 opts.context_lines(3);
512 let patch = git2::Patch::from_buffers(
513 old.unwrap_or(&[]),
514 Some(rel),
515 new,
516 Some(rel),
517 Some(&mut opts),
518 )
519 .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
520 Ok(hunks_from_patch(&patch))
521}
522
523fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
527 let mut out = Vec::new();
528 let mut start = 0;
529 for (i, b) in bytes.iter().enumerate() {
530 if *b == b'\n' {
531 out.push((bytes[start..i].to_vec(), true));
532 start = i + 1;
533 }
534 }
535 if start < bytes.len() {
536 out.push((bytes[start..].to_vec(), false));
537 }
538 out
539}
540
541fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
544 let mut hunks = Vec::new();
545 for h in 0..patch.num_hunks() {
546 let Ok((header, line_count)) = patch.hunk(h) else {
547 continue;
548 };
549 let mut lines = Vec::with_capacity(line_count);
550 for l in 0..line_count {
551 let Ok(line) = patch.line_in_hunk(h, l) else {
552 continue;
553 };
554 let raw = line.content();
558 if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
559 continue;
560 }
561 let origin = match line.origin() {
562 '+' => LineOrigin::Addition,
563 '-' => LineOrigin::Deletion,
564 _ => LineOrigin::Context,
565 };
566 let old_lineno = line.old_lineno().map(|n| n as usize);
568 let new_lineno = line.new_lineno().map(|n| n as usize);
569 let content = line.content();
570 let (text, has_newline) = match content.last() {
571 Some(b'\n') => (&content[..content.len() - 1], true),
572 _ => (content, false),
573 };
574 lines.push(DiffLine {
575 origin,
576 old_lineno,
577 new_lineno,
578 text: text.to_vec(),
579 has_newline,
580 });
581 }
582 hunks.push(Hunk::build(
583 header.old_start() as usize,
584 header.old_lines() as usize,
585 header.new_start() as usize,
586 header.new_lines() as usize,
587 lines,
588 ));
589 }
590 hunks
591}
592
593#[cfg(test)]
594mod head_tests {
595 use super::*;
596 use crate::tests::fixture;
597 use std::process::Command;
598
599 #[test]
600 fn head_content_probe() {
601 let dir = tempfile::tempdir().unwrap();
602 let root = dir.path();
603 let git = |args: &[&str]| {
604 Command::new("git")
605 .args(args)
606 .current_dir(root)
607 .output()
608 .unwrap();
609 };
610 git(&["init", "-q"]);
611 git(&["config", "user.email", "t@t.t"]);
612 git(&["config", "user.name", "t"]);
613 std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
614 git(&["add", "."]);
615 git(&["commit", "-qm", "init"]);
616 let repo = Repo::discover(root).unwrap();
617 eprintln!("workdir: {:?}", repo.workdir());
618 let abs = root.join("f.rs");
619 eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
620 eprintln!("head: {:?}", repo.head_content(&abs));
621 assert!(repo.head_content(&abs).is_some());
622 }
623
624 #[test]
626 fn four_state_edges() {
627 let (_d, repo, path) = fixture();
628 std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
630 let staged = repo
631 .unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap())
632 .unwrap();
633 assert_eq!(staged.len(), 1);
634 let hunk = staged.into_iter().next().unwrap();
635 repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
636 let idx = repo.index_content(&path).unwrap();
638 assert!(idx.contains("STAGED"));
639 let head = repo.head_content(&path).unwrap();
640 assert!(!head.contains("STAGED"));
641 assert_eq!(repo.staged_hunks(&path).unwrap().len(), 1);
643 let wt = std::fs::read_to_string(&path).unwrap();
644 assert!(repo.unstaged_hunks(&path, &wt).unwrap().is_empty());
645 let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
647 let unstaged = repo.unstaged_hunks(&path, live).unwrap();
648 assert_eq!(unstaged.len(), 1);
649 assert!(unstaged[0]
650 .lines
651 .iter()
652 .any(|l| l.text.starts_with(b"fn live")));
653 assert_eq!(
654 repo.staged_hunks(&path).unwrap().len(),
655 1,
656 "staged untouched"
657 );
658 let staged = repo.staged_hunks(&path).unwrap();
660 repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
661 assert!(repo.staged_hunks(&path).unwrap().is_empty());
662 assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
663 }
664}