Skip to main content

diffkit/
git.rs

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    // Both directories are DiffKit-owned unique temporary snapshots. Rebase
104    // the analysis-side `after` tree on `before`, then overlay only selected
105    // Git changes.
106    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}