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 .include_unmodified(false);
316 let diff = self
317 .inner
318 .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
319 .map_err(|e| e.to_string())?;
320 let mut file = None;
321 for (d, _delta) in diff.deltas().enumerate() {
322 let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
323 continue; };
325 let hunks = hunks_from_patch(&patch);
326 let added = hunks
327 .iter()
328 .flat_map(|h| &h.lines)
329 .filter(|l| l.origin == LineOrigin::Addition)
330 .count();
331 let deleted = hunks
332 .iter()
333 .flat_map(|h| &h.lines)
334 .filter(|l| l.origin == LineOrigin::Deletion)
335 .count();
336 file = Some(FileDiff {
337 path: path.to_path_buf(),
338 hunks,
339 added,
340 deleted,
341 });
342 }
343 file.ok_or_else(|| "no diff for path".to_string())
344 }
345
346 pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
352 let new_side: Vec<&DiffLine> = hunk
353 .lines
354 .iter()
355 .filter(|l| l.origin != LineOrigin::Deletion)
356 .collect();
357 self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
358 }
359
360 fn index_region_edit(
364 &self,
365 rel: &Path,
366 start: usize,
367 count: usize,
368 new_lines: &[&DiffLine],
369 ) -> Result<(), String> {
370 let mut index = self.inner.index().map_err(|e| e.to_string())?;
371 index.read(true).map_err(|e| e.to_string())?; let entry = index.get_path(rel, 0);
373 let (old_bytes, mode) = match entry {
374 Some(e) => {
375 let blob = self
376 .inner
377 .find_blob(e.id)
378 .map_err(|e| format!("index blob: {e}"))?;
379 (blob.content().to_vec(), e.mode)
380 }
381 None => (Vec::new(), 0o100644), };
383 let lines = split_lines_bytes(&old_bytes);
384 let lo = start.saturating_sub(1).min(lines.len());
385 let hi = (lo + count).min(lines.len());
386 let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
387 for (text, nl) in &lines[..lo] {
388 out.extend_from_slice(text);
389 if *nl {
390 out.push(b'\n');
391 }
392 }
393 for l in new_lines {
394 out.extend_from_slice(&l.bytes_with_terminator());
395 }
396 for (text, nl) in &lines[hi..] {
397 out.extend_from_slice(text);
398 if *nl {
399 out.push(b'\n');
400 }
401 }
402 let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
403 index
404 .add(&git2::IndexEntry {
405 ctime: git2::IndexTime::new(0, 0),
406 mtime: git2::IndexTime::new(0, 0),
407 dev: 0,
408 ino: 0,
409 mode,
410 uid: 0,
411 gid: 0,
412 file_size: 0,
413 id: oid,
414 flags: 0,
415 flags_extended: 0,
416 path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
417 })
418 .map_err(|e| e.to_string())?;
419 index.write().map_err(|e| e.to_string())?;
420 Ok(())
421 }
422}
423
424fn all_add_hunk(content: &str) -> Vec<Hunk> {
427 let count = content.lines().count();
428 if count == 0 {
429 return vec![];
430 }
431 vec![Hunk {
432 kind: HunkKind::Add,
433 new_start: 1,
434 new_count: count,
435 old_start: 0,
436 old_count: 0,
437 lines: split_lines_bytes(content.as_bytes())
438 .into_iter()
439 .enumerate()
440 .map(|(i, (text, has_newline))| DiffLine {
441 origin: LineOrigin::Addition,
442 old_lineno: None,
443 new_lineno: Some(i + 1),
444 text,
445 has_newline,
446 })
447 .collect(),
448 }]
449}
450
451fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
455 let mut out = Vec::new();
456 let mut start = 0;
457 for (i, b) in bytes.iter().enumerate() {
458 if *b == b'\n' {
459 out.push((bytes[start..i].to_vec(), true));
460 start = i + 1;
461 }
462 }
463 if start < bytes.len() {
464 out.push((bytes[start..].to_vec(), false));
465 }
466 out
467}
468
469fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
472 let mut hunks = Vec::new();
473 for h in 0..patch.num_hunks() {
474 let Ok((header, line_count)) = patch.hunk(h) else {
475 continue;
476 };
477 let mut lines = Vec::with_capacity(line_count);
478 for l in 0..line_count {
479 let Ok(line) = patch.line_in_hunk(h, l) else {
480 continue;
481 };
482 let raw = line.content();
486 if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
487 continue;
488 }
489 let origin = match line.origin() {
490 '+' => LineOrigin::Addition,
491 '-' => LineOrigin::Deletion,
492 _ => LineOrigin::Context,
493 };
494 let old_lineno = line.old_lineno().map(|n| n as usize);
496 let new_lineno = line.new_lineno().map(|n| n as usize);
497 let content = line.content();
498 let (text, has_newline) = match content.last() {
499 Some(b'\n') => (&content[..content.len() - 1], true),
500 _ => (content, false),
501 };
502 lines.push(DiffLine {
503 origin,
504 old_lineno,
505 new_lineno,
506 text: text.to_vec(),
507 has_newline,
508 });
509 }
510 hunks.push(Hunk::build(
511 header.old_start() as usize,
512 header.old_lines() as usize,
513 header.new_start() as usize,
514 header.new_lines() as usize,
515 lines,
516 ));
517 }
518 hunks
519}
520
521#[cfg(test)]
522mod head_tests {
523 use super::*;
524 use crate::tests::fixture;
525 use std::process::Command;
526
527 #[test]
528 fn head_content_probe() {
529 let dir = tempfile::tempdir().unwrap();
530 let root = dir.path();
531 let git = |args: &[&str]| {
532 Command::new("git")
533 .args(args)
534 .current_dir(root)
535 .output()
536 .unwrap();
537 };
538 git(&["init", "-q"]);
539 git(&["config", "user.email", "t@t.t"]);
540 git(&["config", "user.name", "t"]);
541 std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
542 git(&["add", "."]);
543 git(&["commit", "-qm", "init"]);
544 let repo = Repo::discover(root).unwrap();
545 eprintln!("workdir: {:?}", repo.workdir());
546 let abs = root.join("f.rs");
547 eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
548 eprintln!("head: {:?}", repo.head_content(&abs));
549 assert!(repo.head_content(&abs).is_some());
550 }
551
552 #[test]
554 fn four_state_edges() {
555 let (_d, repo, path) = fixture();
556 std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
558 let staged = repo
559 .unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap())
560 .unwrap();
561 assert_eq!(staged.len(), 1);
562 let hunk = staged.into_iter().next().unwrap();
563 repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
564 let idx = repo.index_content(&path).unwrap();
566 assert!(idx.contains("STAGED"));
567 let head = repo.head_content(&path).unwrap();
568 assert!(!head.contains("STAGED"));
569 assert_eq!(repo.staged_hunks(&path).unwrap().len(), 1);
571 let wt = std::fs::read_to_string(&path).unwrap();
572 assert!(repo.unstaged_hunks(&path, &wt).unwrap().is_empty());
573 let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
575 let unstaged = repo.unstaged_hunks(&path, live).unwrap();
576 assert_eq!(unstaged.len(), 1);
577 assert!(unstaged[0]
578 .lines
579 .iter()
580 .any(|l| l.text.starts_with(b"fn live")));
581 assert_eq!(
582 repo.staged_hunks(&path).unwrap().len(),
583 1,
584 "staged untouched"
585 );
586 let staged = repo.staged_hunks(&path).unwrap();
588 repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
589 assert!(repo.staged_hunks(&path).unwrap().is_empty());
590 assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
591 }
592}