1use std::path::{Path, PathBuf};
5
6use crate::diff::{DiffLine, FileDiff, Hunk, HunkKind, LineOrigin};
7
8#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11pub enum GitError {
12 Native(String),
14 OutsideWorkdir,
17}
18
19impl std::fmt::Display for GitError {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 Self::Native(message) => write!(f, "{message}"),
23 Self::OutsideWorkdir => write!(f, "path is outside the repository workdir"),
24 }
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
34pub struct GitContext {
35 #[serde(with = "strop_core::path_serde")]
36 pub workdir: PathBuf,
37 pub head_sha: Option<String>,
38 pub head_branch: Option<String>,
39 pub remotes: Vec<(String, String)>,
41}
42
43impl GitContext {
44 pub fn workdir(&self) -> &Path {
47 &self.workdir
48 }
49}
50
51pub struct Repo {
52 inner: git2::Repository,
53 pub(crate) workdir: PathBuf,
54}
55
56impl Repo {
57 pub fn discover(from: &Path) -> Option<Self> {
59 let inner = git2::Repository::discover(from).ok()?;
60 let workdir = inner.workdir()?.to_path_buf();
61 Some(Self { inner, workdir })
62 }
63
64 pub fn workdir(&self) -> &Path {
65 &self.workdir
66 }
67
68 pub fn remotes(&self) -> Vec<(String, String)> {
70 let Ok(remotes) = self.inner.remotes() else {
71 return vec![];
72 };
73 remotes
74 .iter()
75 .flatten()
76 .filter_map(|name| {
77 self.inner
78 .find_remote(name)
79 .ok()
80 .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
81 })
82 .collect()
83 }
84
85 pub fn head_sha(&self) -> Option<String> {
87 Some(
88 self.inner
89 .head()
90 .ok()?
91 .peel_to_commit()
92 .ok()?
93 .id()
94 .to_string(),
95 )
96 }
97
98 pub fn head_branch(&self) -> Option<String> {
100 self.inner
101 .head()
102 .ok()
103 .and_then(|h| h.shorthand().map(String::from))
104 }
105
106 pub fn context(&self) -> GitContext {
110 GitContext {
111 workdir: self.workdir.clone(),
112 head_sha: self.head_sha(),
113 head_branch: self.head_branch(),
114 remotes: self.remotes(),
115 }
116 }
117
118 fn rel_path(&self, path: &Path) -> Option<PathBuf> {
120 let abs = if path.is_absolute() {
121 path.to_path_buf()
122 } else {
123 self.workdir.join(path)
124 };
125 abs.strip_prefix(&self.workdir)
126 .ok()
127 .map(|p| p.to_path_buf())
128 }
129
130 pub fn head_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
133 let commit = self.inner.head().ok()?.peel_to_commit().ok()?;
134 let tree = commit.tree().ok()?;
135 let entry = tree.get_path(rel).ok()?;
136 let blob = self.inner.find_blob(entry.id()).ok()?;
137 Some(blob.content().to_vec())
138 }
139
140 pub fn commit_bytes(&self, sha: &str, rel: &Path) -> Option<Vec<u8>> {
142 let oid = self.inner.revparse_single(sha).ok()?.id();
143 let commit = self.inner.find_commit(oid).ok()?;
144 let tree = commit.tree().ok()?;
145 let entry = tree.get_path(rel).ok()?;
146 let blob = self.inner.find_blob(entry.id()).ok()?;
147 Some(blob.content().to_vec())
148 }
149
150 pub fn index_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
153 let mut index = self.inner.index().ok()?;
154 index.read(true).ok()?;
155 let entry = index.get_path(rel, 0)?;
156 let blob = self.inner.find_blob(entry.id).ok()?;
157 Some(blob.content().to_vec())
158 }
159
160 pub fn merge_base(&self, a: &str, b: &str) -> Option<String> {
162 let a = self.inner.revparse_single(a).ok()?.id();
163 let b = self.inner.revparse_single(b).ok()?.id();
164 let base = self.inner.merge_base(a, b).ok()?;
165 Some(base.to_string())
166 }
167
168 pub fn head_content(&self, path: &Path) -> Option<String> {
169 self.head_content_res(path).ok().flatten()
170 }
171
172 fn head_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
175 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
176 let head = match self.inner.head() {
177 Ok(reference) => reference
178 .peel_to_tree()
179 .map_err(|e| GitError::Native(format!("read HEAD: {e}")))?,
180 Err(error) if error.code() == git2::ErrorCode::UnbornBranch => {
183 return Ok(None);
184 }
185 Err(error) => return Err(GitError::Native(format!("read HEAD: {error}"))),
186 };
187 match head.get_path(&rel) {
188 Err(_) => Ok(None),
190 Ok(entry) => self.blob_utf8(entry.id(), "HEAD").map(Some),
191 }
192 }
193
194 pub fn index_content(&self, path: &Path) -> Option<String> {
196 self.index_content_res(path).ok().flatten()
197 }
198
199 fn index_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
202 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
203 let mut index = self
204 .inner
205 .index()
206 .map_err(|e| GitError::Native(format!("open index: {e}")))?;
207 index
208 .read(true)
209 .map_err(|e| GitError::Native(format!("reload index: {e}")))?;
210 match index.get_path(&rel, 0) {
211 Some(entry) => self.blob_utf8(entry.id, "index").map(Some),
212 None => Ok(None),
213 }
214 }
215
216 fn blob_utf8(&self, id: git2::Oid, edge: &str) -> Result<String, GitError> {
219 let blob = self
220 .inner
221 .find_blob(id)
222 .map_err(|e| GitError::Native(format!("{edge} blob: {e}")))?;
223 String::from_utf8(blob.content().to_vec())
224 .map_err(|_| GitError::Native(format!("{edge} blob is not UTF-8")))
225 }
226
227 pub fn is_untracked(&self, path: &Path) -> Result<bool, GitError> {
230 Ok(self.index_content_res(path)?.is_none() && self.head_content_res(path)?.is_none())
231 }
232
233 pub fn staged_hunks(&self, path: &Path) -> Result<Vec<Hunk>, GitError> {
238 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
239 let Some(index) = self.index_content_res(path)? else {
240 return Ok(vec![]);
241 };
242 let head = self.head_content_res(path)?.unwrap_or_default();
243 self.diff_strings(&head, &index, &rel)
244 }
245
246 pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
251 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
252 let base = match self.index_content_res(path)? {
253 Some(index) => Some(index),
254 None => self.head_content_res(path)?,
255 };
256 match base {
257 Some(base) => self.diff_strings(&base, content, &rel),
258 None => self.hunks(path, content),
259 }
260 }
261
262 pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
265 let old_side: Vec<&DiffLine> = hunk
266 .lines
267 .iter()
268 .filter(|l| l.origin != LineOrigin::Addition)
269 .collect();
270 self.index_region_edit(rel, hunk.new_start, hunk.new_count, &old_side)
271 }
272
273 pub fn hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
277 let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
278 match self.head_content_res(path)? {
279 Some(old) => self.diff_strings(&old, content, &rel),
280 None => Ok(all_add_hunk(content)),
281 }
282 }
283
284 fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Result<Vec<Hunk>, GitError> {
285 let mut opts = git2::DiffOptions::new();
286 opts.context_lines(3);
287 let patch = git2::Patch::from_buffers(
288 old.as_bytes(),
289 Some(rel),
290 new.as_bytes(),
291 Some(rel),
292 Some(&mut opts),
293 )
294 .map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
295 Ok(hunks_from_patch(&patch))
296 }
297
298 pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
302 let commit = self
303 .inner
304 .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
305 .map_err(|e| e.to_string())?;
306 let new_tree = commit.tree().map_err(|e| e.to_string())?;
307 let old_tree = match commit.parent(0) {
308 Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
309 Err(_) => None,
311 };
312 let mut opts = git2::DiffOptions::new();
313 opts.context_lines(3)
314 .pathspec(path)
315 .disable_pathspec_match(true)
316 .include_unmodified(false);
317 let diff = self
318 .inner
319 .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
320 .map_err(|e| e.to_string())?;
321 let mut file = None;
322 for (d, _delta) in diff.deltas().enumerate() {
323 let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
324 continue; };
326 let hunks = hunks_from_patch(&patch);
327 let added = hunks
328 .iter()
329 .flat_map(|h| &h.lines)
330 .filter(|l| l.origin == LineOrigin::Addition)
331 .count();
332 let deleted = hunks
333 .iter()
334 .flat_map(|h| &h.lines)
335 .filter(|l| l.origin == LineOrigin::Deletion)
336 .count();
337 file = Some(FileDiff {
338 path: path.to_path_buf(),
339 hunks,
340 added,
341 deleted,
342 });
343 }
344 file.ok_or_else(|| "no diff for path".to_string())
345 }
346
347 pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
353 let new_side: Vec<&DiffLine> = hunk
354 .lines
355 .iter()
356 .filter(|l| l.origin != LineOrigin::Deletion)
357 .collect();
358 self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
359 }
360
361 fn index_region_edit(
365 &self,
366 rel: &Path,
367 start: usize,
368 count: usize,
369 new_lines: &[&DiffLine],
370 ) -> Result<(), String> {
371 let mut index = self.inner.index().map_err(|e| e.to_string())?;
372 index.read(true).map_err(|e| e.to_string())?; let entry = index.get_path(rel, 0);
374 let (old_bytes, mode) = match entry {
375 Some(e) => {
376 let blob = self
377 .inner
378 .find_blob(e.id)
379 .map_err(|e| format!("index blob: {e}"))?;
380 (blob.content().to_vec(), e.mode)
381 }
382 None => (Vec::new(), 0o100644), };
384 let lines = split_lines_bytes(&old_bytes);
385 let lo = start.saturating_sub(1).min(lines.len());
386 let hi = (lo + count).min(lines.len());
387 let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
388 for (text, nl) in &lines[..lo] {
389 out.extend_from_slice(text);
390 if *nl {
391 out.push(b'\n');
392 }
393 }
394 for l in new_lines {
395 out.extend_from_slice(&l.bytes_with_terminator());
396 }
397 for (text, nl) in &lines[hi..] {
398 out.extend_from_slice(text);
399 if *nl {
400 out.push(b'\n');
401 }
402 }
403 let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
404 index
405 .add(&git2::IndexEntry {
406 ctime: git2::IndexTime::new(0, 0),
407 mtime: git2::IndexTime::new(0, 0),
408 dev: 0,
409 ino: 0,
410 mode,
411 uid: 0,
412 gid: 0,
413 file_size: 0,
414 id: oid,
415 flags: 0,
416 flags_extended: 0,
417 path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
418 })
419 .map_err(|e| e.to_string())?;
420 index.write().map_err(|e| e.to_string())?;
421 Ok(())
422 }
423}
424
425fn all_add_hunk(content: &str) -> Vec<Hunk> {
428 let count = content.lines().count();
429 if count == 0 {
430 return vec![];
431 }
432 vec![Hunk {
433 kind: HunkKind::Add,
434 new_start: 1,
435 new_count: count,
436 old_start: 0,
437 old_count: 0,
438 lines: split_lines_bytes(content.as_bytes())
439 .into_iter()
440 .enumerate()
441 .map(|(i, (text, has_newline))| DiffLine {
442 origin: LineOrigin::Addition,
443 old_lineno: None,
444 new_lineno: Some(i + 1),
445 text,
446 has_newline,
447 })
448 .collect(),
449 }]
450}
451
452fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
456 let mut out = Vec::new();
457 let mut start = 0;
458 for (i, b) in bytes.iter().enumerate() {
459 if *b == b'\n' {
460 out.push((bytes[start..i].to_vec(), true));
461 start = i + 1;
462 }
463 }
464 if start < bytes.len() {
465 out.push((bytes[start..].to_vec(), false));
466 }
467 out
468}
469
470fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
473 let mut hunks = Vec::new();
474 for h in 0..patch.num_hunks() {
475 let Ok((header, line_count)) = patch.hunk(h) else {
476 continue;
477 };
478 let mut lines = Vec::with_capacity(line_count);
479 for l in 0..line_count {
480 let Ok(line) = patch.line_in_hunk(h, l) else {
481 continue;
482 };
483 let raw = line.content();
487 if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
488 continue;
489 }
490 let origin = match line.origin() {
491 '+' => LineOrigin::Addition,
492 '-' => LineOrigin::Deletion,
493 _ => LineOrigin::Context,
494 };
495 let old_lineno = line.old_lineno().map(|n| n as usize);
497 let new_lineno = line.new_lineno().map(|n| n as usize);
498 let content = line.content();
499 let (text, has_newline) = match content.last() {
500 Some(b'\n') => (&content[..content.len() - 1], true),
501 _ => (content, false),
502 };
503 lines.push(DiffLine {
504 origin,
505 old_lineno,
506 new_lineno,
507 text: text.to_vec(),
508 has_newline,
509 });
510 }
511 hunks.push(Hunk::build(
512 header.old_start() as usize,
513 header.old_lines() as usize,
514 header.new_start() as usize,
515 header.new_lines() as usize,
516 lines,
517 ));
518 }
519 hunks
520}
521
522#[cfg(test)]
523mod head_tests {
524 use super::*;
525 use crate::tests::fixture;
526 use std::process::Command;
527
528 #[test]
529 fn head_content_probe() {
530 let dir = tempfile::tempdir().unwrap();
531 let root = dir.path();
532 let git = |args: &[&str]| {
533 Command::new("git")
534 .args(args)
535 .current_dir(root)
536 .output()
537 .unwrap();
538 };
539 git(&["init", "-q"]);
540 git(&["config", "user.email", "t@t.t"]);
541 git(&["config", "user.name", "t"]);
542 std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
543 git(&["add", "."]);
544 git(&["commit", "-qm", "init"]);
545 let repo = Repo::discover(root).unwrap();
546 eprintln!("workdir: {:?}", repo.workdir());
547 let abs = root.join("f.rs");
548 eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
549 eprintln!("head: {:?}", repo.head_content(&abs));
550 assert!(repo.head_content(&abs).is_some());
551 }
552
553 #[test]
555 fn four_state_edges() {
556 let (_d, repo, path) = fixture();
557 std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
559 let staged = repo
560 .unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap())
561 .unwrap();
562 assert_eq!(staged.len(), 1);
563 let hunk = staged.into_iter().next().unwrap();
564 repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
565 let idx = repo.index_content(&path).unwrap();
567 assert!(idx.contains("STAGED"));
568 let head = repo.head_content(&path).unwrap();
569 assert!(!head.contains("STAGED"));
570 assert_eq!(repo.staged_hunks(&path).unwrap().len(), 1);
572 let wt = std::fs::read_to_string(&path).unwrap();
573 assert!(repo.unstaged_hunks(&path, &wt).unwrap().is_empty());
574 let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
576 let unstaged = repo.unstaged_hunks(&path, live).unwrap();
577 assert_eq!(unstaged.len(), 1);
578 assert!(unstaged[0]
579 .lines
580 .iter()
581 .any(|l| l.text.starts_with(b"fn live")));
582 assert_eq!(
583 repo.staged_hunks(&path).unwrap().len(),
584 1,
585 "staged untouched"
586 );
587 let staged = repo.staged_hunks(&path).unwrap();
589 repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
590 assert!(repo.staged_hunks(&path).unwrap().is_empty());
591 assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
592 }
593}