1use std::collections::BTreeSet;
2use std::ffi::OsStr;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use sha2::{Digest, Sha256};
9
10use crate::DiffkitResult;
11
12static SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
13const SNAPSHOT_FINGERPRINT_FILE: &str = ".diffkit-snapshot-key";
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub enum GitEndpoint {
17 Revision(String),
18 Worktree,
19}
20
21impl GitEndpoint {
22 pub fn label(&self) -> &str {
23 match self {
24 Self::Revision(revision) => revision,
25 Self::Worktree => "worktree",
26 }
27 }
28}
29
30#[derive(Debug)]
31pub struct GitComparison {
32 pub root: PathBuf,
33 pub before: GitEndpoint,
34 pub after: GitEndpoint,
35 pub changed_paths: Vec<PathBuf>,
36 restricted: bool,
37}
38
39impl GitComparison {
40 pub fn discover(
41 directory: &Path,
42 revisions: &[String],
43 pathspecs: &[PathBuf],
44 ) -> DiffkitResult<Self> {
45 if revisions.len() > 2 {
46 return Err(std::io::Error::other("git accepts zero, one, or two revisions").into());
47 }
48 let root = git_root(directory)?;
49 let (before, after) = match revisions {
50 [] => (
51 GitEndpoint::Revision("HEAD".to_owned()),
52 GitEndpoint::Worktree,
53 ),
54 [revision] => (
55 GitEndpoint::Revision(format!("{revision}^")),
56 GitEndpoint::Revision(revision.clone()),
57 ),
58 [before, after] => (
59 GitEndpoint::Revision(before.clone()),
60 GitEndpoint::Revision(after.clone()),
61 ),
62 _ => unreachable!("revision arity was checked before Git discovery"),
63 };
64 validate_endpoint(&root, &before)?;
65 validate_endpoint(&root, &after)?;
66 let changed_paths = changed_paths(&root, &before, &after, pathspecs)?;
67 Ok(Self {
68 root,
69 before,
70 after,
71 changed_paths,
72 restricted: !pathspecs.is_empty(),
73 })
74 }
75
76 pub fn materialize(&self) -> DiffkitResult<(GitSnapshot, GitSnapshot)> {
77 let before = GitSnapshot::create(&self.root, &self.before)?;
78 let mut after = GitSnapshot::create(&self.root, &self.after)?;
79 if self.restricted {
80 restrict_snapshot(before.path(), after.path(), &self.changed_paths)?;
81 after.fingerprint = restricted_snapshot_fingerprint(
82 &before.fingerprint,
83 after.path(),
84 &self.changed_paths,
85 )?;
86 after.write_fingerprint()?;
87 }
88 Ok((before, after))
89 }
90}
91
92fn restrict_snapshot(before: &Path, after: &Path, selected_paths: &[PathBuf]) -> DiffkitResult<()> {
93 let selected = selected_paths
94 .iter()
95 .map(|relative| {
96 let source = after.join(relative);
97 let contents = source.is_file().then(|| fs::read(source)).transpose()?;
98 Ok((relative.clone(), contents))
99 })
100 .collect::<std::io::Result<Vec<_>>>()?;
101
102 fs::remove_dir_all(after)?;
106 fs::create_dir(after)?;
107 copy_worktree(before, after)?;
108 for (relative, contents) in selected {
109 let destination = after.join(relative);
110 match contents {
111 Some(contents) => {
112 if let Some(parent) = destination.parent() {
113 fs::create_dir_all(parent)?;
114 }
115 fs::write(destination, contents)?;
116 }
117 None if destination.is_file() => fs::remove_file(destination)?,
118 None if destination.is_dir() => fs::remove_dir_all(destination)?,
119 None => {}
120 }
121 }
122 Ok(())
123}
124
125#[derive(Debug)]
126pub struct GitSnapshot {
127 directory: PathBuf,
128 label: String,
129 fingerprint: String,
130}
131
132impl GitSnapshot {
133 fn create(root: &Path, endpoint: &GitEndpoint) -> DiffkitResult<Self> {
134 let sequence = SNAPSHOT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
135 let directory = std::env::temp_dir().join(format!(
136 "diffkit-git-snapshot-{}-{sequence}",
137 std::process::id()
138 ));
139 fs::create_dir(&directory)?;
140 let snapshot = Self {
141 directory,
142 label: endpoint.label().to_owned(),
143 fingerprint: endpoint_fingerprint(root, endpoint)?,
144 };
145 match endpoint {
146 GitEndpoint::Revision(revision) => snapshot.copy_revision(root, revision)?,
147 GitEndpoint::Worktree => copy_worktree(root, snapshot.path())?,
148 }
149 snapshot.write_fingerprint()?;
150 Ok(snapshot)
151 }
152
153 pub fn path(&self) -> &Path {
154 &self.directory
155 }
156
157 pub fn label(&self) -> &str {
158 &self.label
159 }
160
161 fn copy_revision(&self, root: &Path, revision: &str) -> DiffkitResult<()> {
162 let mut archive = Command::new("git")
163 .args(["archive", "--format=tar", revision])
164 .current_dir(root)
165 .stdout(Stdio::piped())
166 .spawn()?;
167 let archive_output = archive
168 .stdout
169 .take()
170 .ok_or_else(|| std::io::Error::other("git archive stdout was not captured"))?;
171 let extract = Command::new("tar")
172 .args(["-xf", "-", "-C"])
173 .arg(&self.directory)
174 .stdin(Stdio::from(archive_output))
175 .output()?;
176 let archive_status = archive.wait()?;
177 if !archive_status.success() {
178 return Err(
179 std::io::Error::other(format!("git archive exited with {archive_status}")).into(),
180 );
181 }
182 if !extract.status.success() {
183 let diagnostic = String::from_utf8_lossy(&extract.stderr).trim().to_owned();
184 return Err(std::io::Error::other(if diagnostic.is_empty() {
185 format!("tar extraction exited with {}", extract.status)
186 } else {
187 diagnostic
188 })
189 .into());
190 }
191 Ok(())
192 }
193
194 fn write_fingerprint(&self) -> std::io::Result<()> {
195 fs::write(
196 self.directory.join(SNAPSHOT_FINGERPRINT_FILE),
197 &self.fingerprint,
198 )
199 }
200}
201
202fn endpoint_fingerprint(root: &Path, endpoint: &GitEndpoint) -> DiffkitResult<String> {
203 match endpoint {
204 GitEndpoint::Revision(revision) => {
205 let tree = git_output(root, ["rev-parse", &format!("{revision}^{{tree}}")])?;
206 Ok(format!("tree:{}", String::from_utf8_lossy(&tree).trim()))
207 }
208 GitEndpoint::Worktree => {
209 let mut hasher = Sha256::new();
210 let head = git_output(root, ["rev-parse", "HEAD^{tree}"])?;
211 hasher.update(&head);
212 let changed = git_output_bytes(
213 root,
214 [
215 OsStr::new("diff"),
216 OsStr::new("--name-only"),
217 OsStr::new("-z"),
218 OsStr::new("HEAD"),
219 ],
220 )?;
221 let untracked = git_output_bytes(
222 root,
223 [
224 OsStr::new("ls-files"),
225 OsStr::new("--others"),
226 OsStr::new("--exclude-standard"),
227 OsStr::new("-z"),
228 ],
229 )?;
230 let mut paths = changed
231 .split(|byte| *byte == 0)
232 .chain(untracked.split(|byte| *byte == 0))
233 .filter(|path| !path.is_empty())
234 .map(bytes_to_path)
235 .collect::<Result<BTreeSet<_>, _>>()?;
236 for path in std::mem::take(&mut paths) {
237 if should_skip(&path) {
238 continue;
239 }
240 hash_path_state(&mut hasher, root, &path)?;
241 }
242 Ok(format!("worktree:{:x}", hasher.finalize()))
243 }
244 }
245}
246
247fn restricted_snapshot_fingerprint(
248 before_fingerprint: &str,
249 after_root: &Path,
250 selected_paths: &[PathBuf],
251) -> std::io::Result<String> {
252 let mut hasher = Sha256::new();
253 hasher.update(b"restricted\0");
254 hasher.update(before_fingerprint.as_bytes());
255 for path in selected_paths.iter().collect::<BTreeSet<_>>() {
256 hash_path_state(&mut hasher, after_root, path)?;
257 }
258 Ok(format!("restricted:{:x}", hasher.finalize()))
259}
260
261fn hash_path_state(hasher: &mut Sha256, root: &Path, relative: &Path) -> std::io::Result<()> {
262 let raw_path = relative.as_os_str().as_encoded_bytes();
263 hasher.update((raw_path.len() as u64).to_le_bytes());
264 hasher.update(raw_path);
265
266 let absolute = root.join(relative);
267 let metadata = match fs::symlink_metadata(&absolute) {
268 Ok(metadata) => metadata,
269 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
270 hasher.update(b"missing");
271 return Ok(());
272 }
273 Err(error) => return Err(error),
274 };
275 hash_file_mode(hasher, &metadata);
276 if metadata.file_type().is_symlink() {
277 hasher.update(b"symlink");
278 let target = fs::read_link(absolute)?;
279 let raw_target = target.as_os_str().as_encoded_bytes();
280 hasher.update((raw_target.len() as u64).to_le_bytes());
281 hasher.update(raw_target);
282 } else if metadata.is_file() {
283 hasher.update(b"file");
284 let contents = fs::read(absolute)?;
285 hasher.update((contents.len() as u64).to_le_bytes());
286 hasher.update(contents);
287 } else if metadata.is_dir() {
288 hasher.update(b"directory");
289 } else {
290 hasher.update(b"other");
291 }
292 Ok(())
293}
294
295#[cfg(unix)]
296fn hash_file_mode(hasher: &mut Sha256, metadata: &fs::Metadata) {
297 use std::os::unix::fs::PermissionsExt;
298 hasher.update(metadata.permissions().mode().to_le_bytes());
299}
300
301#[cfg(not(unix))]
302fn hash_file_mode(hasher: &mut Sha256, metadata: &fs::Metadata) {
303 hasher.update([u8::from(metadata.permissions().readonly())]);
304}
305
306impl Drop for GitSnapshot {
307 fn drop(&mut self) {
308 let _ = fs::remove_dir_all(&self.directory);
309 }
310}
311
312fn git_root(directory: &Path) -> DiffkitResult<PathBuf> {
313 let output = git_output(directory, ["rev-parse", "--show-toplevel"])?;
314 let root = String::from_utf8(output)?;
315 Ok(PathBuf::from(root.trim()))
316}
317
318fn validate_endpoint(root: &Path, endpoint: &GitEndpoint) -> DiffkitResult<()> {
319 if let GitEndpoint::Revision(revision) = endpoint {
320 git_output(
321 root,
322 ["rev-parse", "--verify", &format!("{revision}^{{commit}}")],
323 )?;
324 }
325 Ok(())
326}
327
328fn changed_paths(
329 root: &Path,
330 before: &GitEndpoint,
331 after: &GitEndpoint,
332 pathspecs: &[PathBuf],
333) -> DiffkitResult<Vec<PathBuf>> {
334 let mut arguments = vec![
335 OsStr::new("diff"),
336 OsStr::new("--no-renames"),
337 OsStr::new("--name-only"),
338 OsStr::new("-z"),
339 ];
340 let before_label;
341 let after_label;
342 match (before, after) {
343 (GitEndpoint::Revision(before), GitEndpoint::Revision(after)) => {
344 before_label = before.as_str();
345 after_label = after.as_str();
346 arguments.push(OsStr::new(before_label));
347 arguments.push(OsStr::new(after_label));
348 }
349 (GitEndpoint::Revision(before), GitEndpoint::Worktree) => {
350 before_label = before.as_str();
351 arguments.push(OsStr::new(before_label));
352 }
353 (GitEndpoint::Worktree, _) => {
354 return Err(std::io::Error::other("worktree can only be the after endpoint").into());
355 }
356 }
357 if !pathspecs.is_empty() {
358 arguments.push(OsStr::new("--"));
359 arguments.extend(pathspecs.iter().map(|path| path.as_os_str()));
360 }
361 let output = git_output_bytes(root, arguments)?;
362 let mut paths = output
363 .split(|byte| *byte == 0)
364 .filter(|path| !path.is_empty())
365 .map(bytes_to_path)
366 .collect::<Result<BTreeSet<_>, _>>()?;
367
368 if matches!(after, GitEndpoint::Worktree) {
369 let mut untracked_args = vec![
370 OsStr::new("ls-files"),
371 OsStr::new("--others"),
372 OsStr::new("--exclude-standard"),
373 OsStr::new("-z"),
374 ];
375 if !pathspecs.is_empty() {
376 untracked_args.push(OsStr::new("--"));
377 untracked_args.extend(pathspecs.iter().map(|path| path.as_os_str()));
378 }
379 let untracked = git_output_bytes(root, untracked_args)?;
380 for path in untracked
381 .split(|byte| *byte == 0)
382 .filter(|path| !path.is_empty())
383 {
384 paths.insert(bytes_to_path(path)?);
385 }
386 }
387 Ok(paths.into_iter().collect())
388}
389
390fn copy_worktree(source: &Path, destination: &Path) -> DiffkitResult<()> {
391 if source.join(".git").exists() {
392 let listing = git_output_bytes(
393 source,
394 [
395 OsStr::new("ls-files"),
396 OsStr::new("--cached"),
397 OsStr::new("--others"),
398 OsStr::new("--exclude-standard"),
399 OsStr::new("-z"),
400 ],
401 )?;
402 for raw_path in listing
403 .split(|byte| *byte == 0)
404 .filter(|path| !path.is_empty())
405 {
406 let relative = bytes_to_path(raw_path)?;
407 if should_skip(&relative) {
408 continue;
409 }
410 let path = source.join(&relative);
411 if !path.exists() {
412 continue;
413 }
414 let target = destination.join(&relative);
415 if let Some(parent) = target.parent() {
416 fs::create_dir_all(parent)?;
417 }
418 let metadata = fs::symlink_metadata(&path)?;
419 if metadata.file_type().is_symlink() {
420 let resolved = fs::canonicalize(&path)?;
421 if resolved.starts_with(source) && resolved.is_file() {
422 fs::copy(resolved, target)?;
423 }
424 } else if metadata.is_file() {
425 fs::copy(path, target)?;
426 }
427 }
428 return Ok(());
429 }
430 copy_directory(source, destination, source)
431}
432
433fn copy_directory(source: &Path, destination: &Path, root: &Path) -> DiffkitResult<()> {
434 let mut entries = fs::read_dir(source)?.collect::<Result<Vec<_>, _>>()?;
435 entries.sort_by_key(std::fs::DirEntry::path);
436 for entry in entries {
437 let file_type = entry.file_type()?;
438 let path = entry.path();
439 let relative = path.strip_prefix(root).unwrap_or(&path);
440 if should_skip(relative) {
441 continue;
442 }
443 let target = destination.join(relative);
444 if file_type.is_dir() {
445 fs::create_dir_all(&target)?;
446 copy_directory(&path, destination, root)?;
447 } else if file_type.is_file() {
448 if let Some(parent) = target.parent() {
449 fs::create_dir_all(parent)?;
450 }
451 fs::copy(path, target)?;
452 } else if file_type.is_symlink() {
453 let resolved = fs::canonicalize(&path)?;
454 if resolved.starts_with(root) && resolved.is_file() {
455 if let Some(parent) = target.parent() {
456 fs::create_dir_all(parent)?;
457 }
458 fs::copy(resolved, target)?;
459 }
460 }
461 }
462 Ok(())
463}
464
465fn should_skip(relative: &Path) -> bool {
466 relative.components().any(|component| {
467 matches!(
468 component.as_os_str().to_str(),
469 Some(".git" | "target" | "_build" | "node_modules" | ".zig-cache" | "zig-out")
470 )
471 })
472}
473
474fn git_output<'a>(
475 directory: &Path,
476 arguments: impl IntoIterator<Item = &'a str>,
477) -> DiffkitResult<Vec<u8>> {
478 git_output_bytes(directory, arguments.into_iter().map(OsStr::new))
479}
480
481fn git_output_bytes<I, S>(directory: &Path, arguments: I) -> DiffkitResult<Vec<u8>>
482where
483 I: IntoIterator<Item = S>,
484 S: AsRef<OsStr>,
485{
486 let output = Command::new("git")
487 .args(arguments)
488 .current_dir(directory)
489 .output()?;
490 if output.status.success() {
491 Ok(output.stdout)
492 } else {
493 let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
494 Err(std::io::Error::other(if message.is_empty() {
495 format!("git exited with {}", output.status)
496 } else {
497 message
498 })
499 .into())
500 }
501}
502
503#[cfg(unix)]
504fn bytes_to_path(bytes: &[u8]) -> std::io::Result<PathBuf> {
505 use std::os::unix::ffi::OsStrExt;
506 Ok(PathBuf::from(OsStr::from_bytes(bytes)))
507}
508
509#[cfg(not(unix))]
510fn bytes_to_path(bytes: &[u8]) -> std::io::Result<PathBuf> {
511 String::from_utf8(bytes.to_vec())
512 .map(PathBuf::from)
513 .map_err(std::io::Error::other)
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn validates_revision_arity_before_running_git() {
522 let error =
523 GitComparison::discover(Path::new("."), &["a".into(), "b".into(), "c".into()], &[])
524 .unwrap_err();
525 assert!(error.to_string().contains("zero, one, or two"));
526 }
527
528 #[test]
529 fn skips_generated_and_dependency_directories() {
530 assert!(should_skip(Path::new("target/debug/app")));
531 assert!(should_skip(Path::new("web/node_modules/pkg")));
532 assert!(!should_skip(Path::new("src/main.rs")));
533 }
534
535 #[test]
536 fn restricted_fingerprints_include_the_selected_path_and_state() {
537 let sequence = SNAPSHOT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
538 let root = std::env::temp_dir().join(format!(
539 "diffkit-fingerprint-test-{}-{sequence}",
540 std::process::id()
541 ));
542 fs::create_dir(&root).unwrap();
543 fs::write(root.join("a.rs"), "fn same() {}\n").unwrap();
544 fs::write(root.join("b.rs"), "fn same() {}\n").unwrap();
545
546 let a = restricted_snapshot_fingerprint("tree:before", &root, &[PathBuf::from("a.rs")])
547 .unwrap();
548 let b = restricted_snapshot_fingerprint("tree:before", &root, &[PathBuf::from("b.rs")])
549 .unwrap();
550 assert_ne!(a, b);
551
552 fs::write(root.join("a.rs"), "fn changed() {}\n").unwrap();
553 let changed =
554 restricted_snapshot_fingerprint("tree:before", &root, &[PathBuf::from("a.rs")])
555 .unwrap();
556 assert_ne!(a, changed);
557
558 fs::remove_dir_all(root).unwrap();
559 }
560}