1pub mod memory;
6
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum HunkKind {
11 Add,
12 Change,
13 Delete,
14}
15
16#[derive(Debug, Clone)]
18pub struct Hunk {
19 pub kind: HunkKind,
20 pub new_start: usize,
23 pub new_count: usize,
24 pub old_start: usize,
25 pub old_count: usize,
26 pub lines: Vec<String>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Sign {
34 AddOrChange,
36 DeleteAfter,
39}
40
41impl Hunk {
42 pub fn signs(&self) -> Vec<(usize, Sign)> {
44 let mut out = Vec::new();
45 let mut nl = self.new_start;
46 for line in &self.lines {
47 match line.as_bytes().first() {
48 Some(b'+') => {
49 out.push((nl, Sign::AddOrChange));
50 nl += 1;
51 }
52 Some(b'-') => out.push((nl, Sign::DeleteAfter)),
53 _ => nl += 1,
54 }
55 }
56 out
57 }
58
59 pub fn changed_region(&self) -> (usize, usize, usize, usize) {
64 let mut nl = self.new_start;
65 let mut ol = self.old_start;
66 let mut new_lines = Vec::new();
67 let mut old_lines = Vec::new();
68 for line in &self.lines {
69 match line.as_bytes().first() {
70 Some(b'+') => {
71 new_lines.push(nl);
72 nl += 1;
73 }
74 Some(b'-') => {
75 old_lines.push(ol);
76 ol += 1;
77 }
78 _ => {
79 nl += 1;
80 ol += 1;
81 }
82 }
83 }
84 let new_first = new_lines.first().copied().unwrap_or(nl);
85 let old_first = old_lines.first().copied().unwrap_or(ol);
86 (new_first, new_lines.len(), old_first, old_lines.len())
87 }
88
89 pub fn covers(&self, line_1based: usize, total_lines: usize) -> bool {
92 self.signs().iter().any(|&(l, kind)| match kind {
93 Sign::AddOrChange => l == line_1based,
94 Sign::DeleteAfter => l.min(total_lines) == line_1based,
95 })
96 }
97}
98
99pub struct Repo {
100 inner: git2::Repository,
101 workdir: PathBuf,
102}
103
104impl Repo {
105 pub fn discover(from: &Path) -> Option<Self> {
107 let inner = git2::Repository::discover(from).ok()?;
108 let workdir = inner.workdir()?.to_path_buf();
109 Some(Self { inner, workdir })
110 }
111
112 pub fn workdir(&self) -> &Path {
113 &self.workdir
114 }
115
116 pub fn remotes(&self) -> Vec<(String, String)> {
118 let Ok(remotes) = self.inner.remotes() else {
119 return vec![];
120 };
121 remotes
122 .iter()
123 .flatten()
124 .filter_map(|name| {
125 self.inner
126 .find_remote(name)
127 .ok()
128 .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
129 })
130 .collect()
131 }
132
133 pub fn head_sha(&self) -> Option<String> {
135 Some(
136 self.inner
137 .head()
138 .ok()?
139 .peel_to_commit()
140 .ok()?
141 .id()
142 .to_string(),
143 )
144 }
145
146 fn rel_path(&self, path: &Path) -> Option<PathBuf> {
148 let abs = if path.is_absolute() {
149 path.to_path_buf()
150 } else {
151 self.workdir.join(path)
152 };
153 abs.strip_prefix(&self.workdir)
154 .ok()
155 .map(|p| p.to_path_buf())
156 }
157
158 pub fn head_content(&self, path: &Path) -> Option<String> {
160 let rel = self.rel_path(path)?;
161 let head = self.inner.head().ok()?.peel_to_tree().ok()?;
162 let entry = head.get_path(&rel).ok()?;
163 let blob = self.inner.find_blob(entry.id()).ok()?;
164 String::from_utf8(blob.content().to_vec()).ok()
165 }
166
167 pub fn hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
170 let Some(rel) = self.rel_path(path) else {
171 return vec![];
172 };
173 let old = self.head_content(path);
174 match old {
175 None => {
176 let count = content.lines().count();
177 if count == 0 {
178 return vec![];
179 }
180 vec![Hunk {
181 kind: HunkKind::Add,
182 new_start: 1,
183 new_count: count,
184 old_start: 0,
185 old_count: 0,
186 lines: content.lines().map(|l| format!("+{l}")).collect(),
187 }]
188 }
189 Some(old) => self.diff_strings(&old, content, &rel),
190 }
191 }
192
193 fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
194 let mut opts = git2::DiffOptions::new();
195 opts.context_lines(3);
196 let Ok(patch) = git2::Patch::from_buffers(
197 old.as_bytes(),
198 Some(rel),
199 new.as_bytes(),
200 Some(rel),
201 Some(&mut opts),
202 ) else {
203 return vec![];
204 };
205 let mut hunks = Vec::new();
206 for h in 0..patch.num_hunks() {
207 let Ok((header, line_count)) = patch.hunk(h) else {
208 continue;
209 };
210 let mut lines = Vec::with_capacity(line_count);
211 for l in 0..line_count {
212 if let Ok(line) = patch.line_in_hunk(h, l) {
213 let prefix = match line.origin() {
214 '+' | '-' => line.origin(),
215 _ => ' ',
216 };
217 let text = String::from_utf8_lossy(line.content())
218 .trim_end_matches('\n')
219 .to_string();
220 lines.push(format!("{prefix}{text}"));
221 }
222 }
223 let has_plus = lines.iter().any(|l| l.starts_with('+'));
226 let has_minus = lines.iter().any(|l| l.starts_with('-'));
227 let kind = match (has_plus, has_minus) {
228 (true, false) => HunkKind::Add,
229 (false, true) => HunkKind::Delete,
230 _ => HunkKind::Change,
231 };
232 hunks.push(Hunk {
233 kind,
234 new_start: header.new_start() as usize,
235 new_count: header.new_lines() as usize,
236 old_start: header.old_start() as usize,
237 old_count: header.old_lines() as usize,
238 lines,
239 });
240 }
241 hunks
242 }
243
244 pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
248 let mut patch = format!("--- a/{}\n+++ b/{}\n", rel.display(), rel.display());
249 patch.push_str(&format!(
250 "@@ -{},{} +{},{} @@\n",
251 hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
252 ));
253 for line in &hunk.lines {
254 patch.push_str(line);
255 patch.push('\n');
256 }
257 let mut child = std::process::Command::new("git")
258 .args([
259 "-C",
260 &self.workdir.display().to_string(),
261 "apply",
262 "--cached",
263 "--unidiff-zero",
264 ])
265 .stdin(std::process::Stdio::piped())
266 .stdout(std::process::Stdio::null())
267 .stderr(std::process::Stdio::piped())
268 .spawn()
269 .map_err(|e| format!("spawn git: {e}"))?;
270 use std::io::Write;
271 child
272 .stdin
273 .as_mut()
274 .expect("piped")
275 .write_all(patch.as_bytes())
276 .map_err(|e| e.to_string())?;
277 let out = child.wait_with_output().map_err(|e| e.to_string())?;
278 if out.status.success() {
279 Ok(())
280 } else {
281 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use std::process::Command;
290
291 fn git(root: &std::path::Path, args: &[&str]) {
292 Command::new("git")
293 .args(args)
294 .current_dir(root)
295 .output()
296 .unwrap();
297 }
298
299 fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
300 let dir = tempfile::tempdir().unwrap();
301 let root = dir.path();
302 git(root, &["init", "-q"]);
303 git(root, &["config", "user.email", "t@t.t"]);
304 git(root, &["config", "user.name", "t"]);
305 std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
306 git(root, &["add", "."]);
307 git(root, &["commit", "-qm", "init"]);
308 let repo = Repo::discover(root).unwrap();
309 let file = root.join("f.rs");
310 (dir, repo, file)
311 }
312
313 #[test]
314 fn clean_buffer_has_no_hunks() {
315 let (_d, repo, path) = fixture();
316 let content = repo.head_content(&path).unwrap();
317 assert!(repo.hunks(&path, &content).is_empty());
318 }
319
320 #[test]
321 fn change_and_add_and_delete() {
322 let (_d, repo, path) = fixture();
323 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
324 let hunks = repo.hunks(&path, edited);
325 assert_eq!(hunks.len(), 1);
326 assert_eq!(hunks[0].kind, HunkKind::Change);
327 assert!(hunks[0].covers(2, 4));
328 assert!(hunks[0].covers(4, 4));
329 assert!(!hunks[0].covers(1, 4));
330 assert!(hunks[0]
331 .lines
332 .iter()
333 .any(|l| l.starts_with("+fn d2") || l.starts_with("+fn d()")));
334 }
335
336 #[test]
337 fn pure_delete_marks_following_line() {
338 let (_d, repo, path) = fixture();
339 let edited = "fn a() {}\nfn c() {}\n";
340 let hunks = repo.hunks(&path, edited);
341 assert_eq!(hunks.len(), 1);
342 assert_eq!(hunks[0].kind, HunkKind::Delete);
343 assert!(hunks[0].covers(2, 4)); }
345
346 #[test]
347 fn stage_hunk_applies_to_index() {
348 let (_d, repo, path) = fixture();
349 let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
350 let hunks = repo.hunks(&path, edited);
351 assert_eq!(hunks.len(), 1);
352 assert_eq!(hunks[0].kind, HunkKind::Add);
353 let root = repo.workdir.clone();
354 repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
355 let out = Command::new("git")
356 .args([
357 "-C",
358 &root.display().to_string(),
359 "diff",
360 "--cached",
361 "--stat",
362 ])
363 .output()
364 .unwrap();
365 let stat = String::from_utf8_lossy(&out.stdout);
366 assert!(stat.contains("f.rs"), "{stat}");
367 }
368}
369
370#[cfg(test)]
371mod head_tests {
372 use super::*;
373 use std::process::Command;
374
375 #[test]
376 fn head_content_probe() {
377 let dir = tempfile::tempdir().unwrap();
378 let root = dir.path();
379 let git = |args: &[&str]| {
380 Command::new("git")
381 .args(args)
382 .current_dir(root)
383 .output()
384 .unwrap();
385 };
386 git(&["init", "-q"]);
387 git(&["config", "user.email", "t@t.t"]);
388 git(&["config", "user.name", "t"]);
389 std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
390 git(&["add", "."]);
391 git(&["commit", "-qm", "init"]);
392 let repo = Repo::discover(root).unwrap();
393 eprintln!("workdir: {:?}", repo.workdir());
394 let abs = root.join("f.rs");
395 eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
396 eprintln!("head: {:?}", repo.head_content(&abs));
397 assert!(repo.head_content(&abs).is_some());
398 }
399}