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