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