1use std::path::{Path, PathBuf};
5
6use crate::diff::{DiffLine, FileDiff, Hunk, HunkKind, LineOrigin};
7
8pub struct Repo {
9 inner: git2::Repository,
10 pub(crate) workdir: PathBuf,
11}
12
13impl Repo {
14 pub fn discover(from: &Path) -> Option<Self> {
16 let inner = git2::Repository::discover(from).ok()?;
17 let workdir = inner.workdir()?.to_path_buf();
18 Some(Self { inner, workdir })
19 }
20
21 pub fn workdir(&self) -> &Path {
22 &self.workdir
23 }
24
25 pub fn remotes(&self) -> Vec<(String, String)> {
27 let Ok(remotes) = self.inner.remotes() else {
28 return vec![];
29 };
30 remotes
31 .iter()
32 .flatten()
33 .filter_map(|name| {
34 self.inner
35 .find_remote(name)
36 .ok()
37 .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
38 })
39 .collect()
40 }
41
42 pub fn head_sha(&self) -> Option<String> {
44 Some(
45 self.inner
46 .head()
47 .ok()?
48 .peel_to_commit()
49 .ok()?
50 .id()
51 .to_string(),
52 )
53 }
54
55 pub fn head_branch(&self) -> Option<String> {
57 self.inner
58 .head()
59 .ok()
60 .and_then(|h| h.shorthand().map(String::from))
61 }
62
63 fn rel_path(&self, path: &Path) -> Option<PathBuf> {
65 let abs = if path.is_absolute() {
66 path.to_path_buf()
67 } else {
68 self.workdir.join(path)
69 };
70 abs.strip_prefix(&self.workdir)
71 .ok()
72 .map(|p| p.to_path_buf())
73 }
74
75 pub fn head_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
78 let commit = self.inner.head().ok()?.peel_to_commit().ok()?;
79 let tree = commit.tree().ok()?;
80 let entry = tree.get_path(rel).ok()?;
81 let blob = self.inner.find_blob(entry.id()).ok()?;
82 Some(blob.content().to_vec())
83 }
84
85 pub fn commit_bytes(&self, sha: &str, rel: &Path) -> Option<Vec<u8>> {
87 let oid = self.inner.revparse_single(sha).ok()?.id();
88 let commit = self.inner.find_commit(oid).ok()?;
89 let tree = commit.tree().ok()?;
90 let entry = tree.get_path(rel).ok()?;
91 let blob = self.inner.find_blob(entry.id()).ok()?;
92 Some(blob.content().to_vec())
93 }
94
95 pub fn index_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
98 let mut index = self.inner.index().ok()?;
99 index.read(true).ok()?;
100 let entry = index.get_path(rel, 0)?;
101 let blob = self.inner.find_blob(entry.id).ok()?;
102 Some(blob.content().to_vec())
103 }
104
105 pub fn merge_base(&self, a: &str, b: &str) -> Option<String> {
107 let a = self.inner.revparse_single(a).ok()?.id();
108 let b = self.inner.revparse_single(b).ok()?.id();
109 let base = self.inner.merge_base(a, b).ok()?;
110 Some(base.to_string())
111 }
112
113 pub fn head_content(&self, path: &Path) -> Option<String> {
114 let rel = self.rel_path(path)?;
115 let head = self.inner.head().ok()?.peel_to_tree().ok()?;
116 let entry = head.get_path(&rel).ok()?;
117 let blob = self.inner.find_blob(entry.id()).ok()?;
118 String::from_utf8(blob.content().to_vec()).ok()
119 }
120
121 pub fn index_content(&self, path: &Path) -> Option<String> {
123 let rel = self.rel_path(path)?;
124 let mut index = self.inner.index().ok()?;
127 index.read(true).ok()?;
128 let entry = index.get_path(&rel, 0)?;
129 let blob = self.inner.find_blob(entry.id).ok()?;
130 String::from_utf8(blob.content().to_vec()).ok()
131 }
132
133 pub fn staged_hunks(&self, path: &Path) -> Vec<Hunk> {
137 let Some(rel) = self.rel_path(path) else {
138 return vec![];
139 };
140 let (Some(head), Some(index)) = (self.head_content(path), self.index_content(path)) else {
141 return vec![];
142 };
143 self.diff_strings(&head, &index, &rel)
144 }
145
146 pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
150 let Some(rel) = self.rel_path(path) else {
151 return vec![];
152 };
153 let base = self.index_content(path).or_else(|| self.head_content(path));
154 match base {
155 None => self.hunks(path, content), Some(base) => self.diff_strings(&base, content, &rel),
157 }
158 }
159
160 pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
163 let old_side: Vec<&DiffLine> = hunk
164 .lines
165 .iter()
166 .filter(|l| l.origin != LineOrigin::Addition)
167 .collect();
168 self.index_region_edit(rel, hunk.new_start, hunk.new_count, &old_side)
169 }
170
171 pub fn hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
174 let Some(rel) = self.rel_path(path) else {
175 return vec![];
176 };
177 let old = self.head_content(path);
178 match old {
179 None => {
180 let count = content.lines().count();
181 if count == 0 {
182 return vec![];
183 }
184 vec![Hunk {
185 kind: HunkKind::Add,
186 new_start: 1,
187 new_count: count,
188 old_start: 0,
189 old_count: 0,
190 lines: split_lines_bytes(content.as_bytes())
191 .into_iter()
192 .enumerate()
193 .map(|(i, (text, has_newline))| DiffLine {
194 origin: LineOrigin::Addition,
195 old_lineno: None,
196 new_lineno: Some(i + 1),
197 text,
198 has_newline,
199 })
200 .collect(),
201 }]
202 }
203 Some(old) => self.diff_strings(&old, content, &rel),
204 }
205 }
206
207 fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
208 let mut opts = git2::DiffOptions::new();
209 opts.context_lines(3);
210 let Ok(patch) = git2::Patch::from_buffers(
211 old.as_bytes(),
212 Some(rel),
213 new.as_bytes(),
214 Some(rel),
215 Some(&mut opts),
216 ) else {
217 return vec![];
218 };
219 hunks_from_patch(&patch)
220 }
221
222 pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
226 let commit = self
227 .inner
228 .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
229 .map_err(|e| e.to_string())?;
230 let new_tree = commit.tree().map_err(|e| e.to_string())?;
231 let old_tree = match commit.parent(0) {
232 Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
233 Err(_) => None,
235 };
236 let mut opts = git2::DiffOptions::new();
237 opts.context_lines(3)
238 .pathspec(path)
239 .include_unmodified(false);
240 let diff = self
241 .inner
242 .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
243 .map_err(|e| e.to_string())?;
244 let mut file = None;
245 for (d, _delta) in diff.deltas().enumerate() {
246 let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
247 continue; };
249 let hunks = hunks_from_patch(&patch);
250 let added = hunks
251 .iter()
252 .flat_map(|h| &h.lines)
253 .filter(|l| l.origin == LineOrigin::Addition)
254 .count();
255 let deleted = hunks
256 .iter()
257 .flat_map(|h| &h.lines)
258 .filter(|l| l.origin == LineOrigin::Deletion)
259 .count();
260 file = Some(FileDiff {
261 path: path.to_path_buf(),
262 hunks,
263 added,
264 deleted,
265 });
266 }
267 file.ok_or_else(|| "no diff for path".to_string())
268 }
269
270 pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
276 let new_side: Vec<&DiffLine> = hunk
277 .lines
278 .iter()
279 .filter(|l| l.origin != LineOrigin::Deletion)
280 .collect();
281 self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
282 }
283
284 fn index_region_edit(
288 &self,
289 rel: &Path,
290 start: usize,
291 count: usize,
292 new_lines: &[&DiffLine],
293 ) -> Result<(), String> {
294 let mut index = self.inner.index().map_err(|e| e.to_string())?;
295 index.read(true).map_err(|e| e.to_string())?; let entry = index.get_path(rel, 0);
297 let (old_bytes, mode) = match entry {
298 Some(e) => {
299 let blob = self
300 .inner
301 .find_blob(e.id)
302 .map_err(|e| format!("index blob: {e}"))?;
303 (blob.content().to_vec(), e.mode)
304 }
305 None => (Vec::new(), 0o100644), };
307 let lines = split_lines_bytes(&old_bytes);
308 let lo = start.saturating_sub(1).min(lines.len());
309 let hi = (lo + count).min(lines.len());
310 let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
311 for (text, nl) in &lines[..lo] {
312 out.extend_from_slice(text);
313 if *nl {
314 out.push(b'\n');
315 }
316 }
317 for l in new_lines {
318 out.extend_from_slice(&l.bytes_with_terminator());
319 }
320 for (text, nl) in &lines[hi..] {
321 out.extend_from_slice(text);
322 if *nl {
323 out.push(b'\n');
324 }
325 }
326 let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
327 index
328 .add(&git2::IndexEntry {
329 ctime: git2::IndexTime::new(0, 0),
330 mtime: git2::IndexTime::new(0, 0),
331 dev: 0,
332 ino: 0,
333 mode,
334 uid: 0,
335 gid: 0,
336 file_size: 0,
337 id: oid,
338 flags: 0,
339 flags_extended: 0,
340 path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
341 })
342 .map_err(|e| e.to_string())?;
343 index.write().map_err(|e| e.to_string())?;
344 Ok(())
345 }
346}
347
348fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
352 let mut out = Vec::new();
353 let mut start = 0;
354 for (i, b) in bytes.iter().enumerate() {
355 if *b == b'\n' {
356 out.push((bytes[start..i].to_vec(), true));
357 start = i + 1;
358 }
359 }
360 if start < bytes.len() {
361 out.push((bytes[start..].to_vec(), false));
362 }
363 out
364}
365
366fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
369 let mut hunks = Vec::new();
370 for h in 0..patch.num_hunks() {
371 let Ok((header, line_count)) = patch.hunk(h) else {
372 continue;
373 };
374 let mut lines = Vec::with_capacity(line_count);
375 for l in 0..line_count {
376 let Ok(line) = patch.line_in_hunk(h, l) else {
377 continue;
378 };
379 let raw = line.content();
383 if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
384 continue;
385 }
386 let origin = match line.origin() {
387 '+' => LineOrigin::Addition,
388 '-' => LineOrigin::Deletion,
389 _ => LineOrigin::Context,
390 };
391 let old_lineno = line.old_lineno().map(|n| n as usize);
393 let new_lineno = line.new_lineno().map(|n| n as usize);
394 let content = line.content();
395 let (text, has_newline) = match content.last() {
396 Some(b'\n') => (&content[..content.len() - 1], true),
397 _ => (content, false),
398 };
399 lines.push(DiffLine {
400 origin,
401 old_lineno,
402 new_lineno,
403 text: text.to_vec(),
404 has_newline,
405 });
406 }
407 hunks.push(Hunk::build(
408 header.old_start() as usize,
409 header.old_lines() as usize,
410 header.new_start() as usize,
411 header.new_lines() as usize,
412 lines,
413 ));
414 }
415 hunks
416}
417
418#[cfg(test)]
419mod head_tests {
420 use super::*;
421 use crate::tests::fixture;
422 use std::process::Command;
423
424 #[test]
425 fn head_content_probe() {
426 let dir = tempfile::tempdir().unwrap();
427 let root = dir.path();
428 let git = |args: &[&str]| {
429 Command::new("git")
430 .args(args)
431 .current_dir(root)
432 .output()
433 .unwrap();
434 };
435 git(&["init", "-q"]);
436 git(&["config", "user.email", "t@t.t"]);
437 git(&["config", "user.name", "t"]);
438 std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
439 git(&["add", "."]);
440 git(&["commit", "-qm", "init"]);
441 let repo = Repo::discover(root).unwrap();
442 eprintln!("workdir: {:?}", repo.workdir());
443 let abs = root.join("f.rs");
444 eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
445 eprintln!("head: {:?}", repo.head_content(&abs));
446 assert!(repo.head_content(&abs).is_some());
447 }
448
449 #[test]
451 fn four_state_edges() {
452 let (_d, repo, path) = fixture();
453 std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
455 let staged = repo.unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap());
456 assert_eq!(staged.len(), 1);
457 let hunk = staged.into_iter().next().unwrap();
458 repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
459 let idx = repo.index_content(&path).unwrap();
461 assert!(idx.contains("STAGED"));
462 let head = repo.head_content(&path).unwrap();
463 assert!(!head.contains("STAGED"));
464 assert_eq!(repo.staged_hunks(&path).len(), 1);
466 let wt = std::fs::read_to_string(&path).unwrap();
467 assert!(repo.unstaged_hunks(&path, &wt).is_empty());
468 let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
470 let unstaged = repo.unstaged_hunks(&path, live);
471 assert_eq!(unstaged.len(), 1);
472 assert!(unstaged[0]
473 .lines
474 .iter()
475 .any(|l| l.text.starts_with(b"fn live")));
476 assert_eq!(repo.staged_hunks(&path).len(), 1, "staged untouched");
477 let staged = repo.staged_hunks(&path);
479 repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
480 assert!(repo.staged_hunks(&path).is_empty());
481 assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
482 }
483}