Skip to main content

majordomus_cli/worktree/
fingerprint.rs

1//! The proof that a move lost nothing.
2//!
3//! A migration takes one of these before a worktree moves and one after, and refuses to
4//! call the move a success unless they are equal. It is strong enough to notice a lost
5//! untracked file, a dropped staged change, a modified tracked file that reverted, a branch
6//! that switched or a HEAD that moved; it is deliberately not taken on any hot path, because
7//! it hashes the content of every modified and untracked file. Migration is exceptional and
8//! data safety is worth more there than a few milliseconds.
9//!
10//! Ignored content (build output, the checkout's own `.ai/local/` state) is attested by
11//! presence and size rather than by content: it moves with the directory as everything else
12//! does, and hashing a `target/` tree to prove a rename would cost minutes for nothing.
13
14use std::path::Path;
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20use super::error::{Result, WorktreeError};
21use super::git;
22
23/// What a work tree held at one moment, reduced to digests.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25pub struct WorktreeFingerprint {
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    /// The branch, or none when detached.
28    pub branch: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    /// The commit.
31    pub head: Option<String>,
32    /// A digest of the index: every tracked path with its mode, blob and stage.
33    pub index_digest: String,
34    /// A digest of the staged diff against HEAD.
35    pub staged_diff_digest: String,
36    /// A digest of the unstaged diff against the index.
37    pub unstaged_diff_digest: String,
38    /// A digest of every untracked file: path, kind, size and content.
39    pub untracked_manifest_digest: String,
40    /// How many untracked files the manifest holds.
41    pub untracked_files: usize,
42    /// A digest of every ignored entry: path, kind and size, never content.
43    pub ignored_manifest_digest: String,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    /// The operation in progress, if any.
46    pub in_progress: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    /// A digest of every entry of the directory tree, ignored included: path, kind, size
49    /// and link target. Taken only for a move made by copying, where the rename guarantee
50    /// does not hold.
51    pub tree_manifest_digest: Option<String>,
52}
53
54fn hex(bytes: &[u8]) -> String {
55    let mut h = Sha256::new();
56    h.update(bytes);
57    format!("{:x}", h.finalize())
58}
59
60fn hash_file(path: &Path) -> Result<String> {
61    let bytes = std::fs::read(path).map_err(|e| WorktreeError::io(path, &e))?;
62    Ok(hex(&bytes))
63}
64
65/// Take a fingerprint of `worktree`. `whole_tree` also walks the directory for the tree
66/// manifest, which a copy-based move needs and a rename does not.
67pub fn capture(worktree: &Path, whole_tree: bool) -> Result<WorktreeFingerprint> {
68    let branch = git::branch_of(worktree)?;
69    let head = git::head_of(worktree)?;
70    let index = git::run(worktree, &["ls-files", "--stage", "-z"])?.stdout;
71    let staged = git::run(
72        worktree,
73        &[
74            "diff",
75            "--cached",
76            "--binary",
77            "--no-ext-diff",
78            "--no-color",
79        ],
80    )?
81    .stdout;
82    let unstaged = git::run(
83        worktree,
84        &["diff", "--binary", "--no-ext-diff", "--no-color"],
85    )?
86    .stdout;
87
88    let untracked_list = git::run(
89        worktree,
90        &["ls-files", "--others", "--exclude-standard", "-z"],
91    )?
92    .stdout;
93    let mut untracked_manifest = String::new();
94    let mut untracked_files = 0usize;
95    for raw in untracked_list.split(|b| *b == 0).filter(|s| !s.is_empty()) {
96        let rel = String::from_utf8_lossy(raw).to_string();
97        let path = worktree.join(&rel);
98        let meta = std::fs::symlink_metadata(&path).map_err(|e| WorktreeError::io(&path, &e))?;
99        let line = if meta.file_type().is_symlink() {
100            let target = std::fs::read_link(&path).map_err(|e| WorktreeError::io(&path, &e))?;
101            format!("{rel}\0link\0{}\n", target.display())
102        } else if meta.is_file() {
103            format!("{rel}\0file\0{}\0{}\n", meta.len(), hash_file(&path)?)
104        } else {
105            format!("{rel}\0other\n")
106        };
107        untracked_manifest.push_str(&line);
108        untracked_files += 1;
109    }
110
111    let ignored_list = git::run(
112        worktree,
113        &[
114            "ls-files",
115            "--others",
116            "--ignored",
117            "--exclude-standard",
118            "--directory",
119            "-z",
120        ],
121    )?
122    .stdout;
123    let mut ignored_manifest = String::new();
124    for raw in ignored_list.split(|b| *b == 0).filter(|s| !s.is_empty()) {
125        let rel = String::from_utf8_lossy(raw).to_string();
126        let path = worktree.join(&rel);
127        match std::fs::symlink_metadata(&path) {
128            Ok(meta) if meta.is_file() => {
129                ignored_manifest.push_str(&format!("{rel}\0file\0{}\n", meta.len()))
130            }
131            Ok(meta) if meta.file_type().is_symlink() => {
132                ignored_manifest.push_str(&format!("{rel}\0link\n"))
133            }
134            Ok(_) => ignored_manifest.push_str(&format!("{rel}\0dir\n")),
135            Err(_) => ignored_manifest.push_str(&format!("{rel}\0gone\n")),
136        }
137    }
138
139    let tree_manifest_digest = if whole_tree {
140        Some(hex(tree_manifest(worktree)?.as_bytes()))
141    } else {
142        None
143    };
144
145    Ok(WorktreeFingerprint {
146        branch,
147        head,
148        index_digest: hex(&index),
149        staged_diff_digest: hex(&staged),
150        unstaged_diff_digest: hex(&unstaged),
151        untracked_manifest_digest: hex(untracked_manifest.as_bytes()),
152        untracked_files,
153        ignored_manifest_digest: hex(ignored_manifest.as_bytes()),
154        in_progress: super::state::operation_in_progress(worktree),
155        tree_manifest_digest,
156    })
157}
158
159/// Every entry below `root`, relative, sorted, with kind, size and link target. The `.git`
160/// file of a linked worktree is excluded: it is rewritten by the move on purpose.
161pub fn tree_manifest(root: &Path) -> Result<String> {
162    let mut entries: Vec<String> = Vec::new();
163    let mut stack = vec![root.to_path_buf()];
164    while let Some(dir) = stack.pop() {
165        let read = std::fs::read_dir(&dir).map_err(|e| WorktreeError::io(&dir, &e))?;
166        for entry in read {
167            let entry = entry.map_err(|e| WorktreeError::io(&dir, &e))?;
168            let path = entry.path();
169            let rel = path
170                .strip_prefix(root)
171                .unwrap_or(&path)
172                .to_string_lossy()
173                .to_string();
174            if rel == ".git" {
175                continue;
176            }
177            let meta =
178                std::fs::symlink_metadata(&path).map_err(|e| WorktreeError::io(&path, &e))?;
179            if meta.file_type().is_symlink() {
180                let target = std::fs::read_link(&path).map_err(|e| WorktreeError::io(&path, &e))?;
181                entries.push(format!("{rel}\0link\0{}", target.display()));
182            } else if meta.is_dir() {
183                entries.push(format!("{rel}\0dir"));
184                stack.push(path);
185            } else {
186                entries.push(format!("{rel}\0file\0{}", meta.len()));
187            }
188        }
189    }
190    entries.sort();
191    Ok(entries.join("\n"))
192}
193
194/// Everything that differs between two fingerprints, one line each. Empty means equal.
195///
196/// ```
197/// use majordomus_cli::worktree::fingerprint::{differences, WorktreeFingerprint};
198/// let a = WorktreeFingerprint {
199///     branch: Some("x".into()), head: Some("abc".into()), index_digest: "i".into(),
200///     staged_diff_digest: "s".into(), unstaged_diff_digest: "u".into(),
201///     untracked_manifest_digest: "m".into(), untracked_files: 2, ignored_manifest_digest: "g".into(),
202///     in_progress: None, tree_manifest_digest: None,
203/// };
204/// let mut b = a.clone();
205/// assert!(differences(&a, &b).is_empty());
206/// b.untracked_files = 1; b.untracked_manifest_digest = "n".into();
207/// let d = differences(&a, &b);
208/// assert_eq!(d.len(), 1);
209/// assert!(d[0].contains("untracked"));
210/// ```
211pub fn differences(before: &WorktreeFingerprint, after: &WorktreeFingerprint) -> Vec<String> {
212    let mut out = Vec::new();
213    if before.branch != after.branch {
214        out.push(format!(
215            "branch changed from {} to {}",
216            before.branch.as_deref().unwrap_or("(detached)"),
217            after.branch.as_deref().unwrap_or("(detached)")
218        ));
219    }
220    if before.head != after.head {
221        out.push(format!(
222            "HEAD changed from {} to {}",
223            before.head.as_deref().unwrap_or("(unborn)"),
224            after.head.as_deref().unwrap_or("(unborn)")
225        ));
226    }
227    if before.index_digest != after.index_digest {
228        out.push("the index differs".into());
229    }
230    if before.staged_diff_digest != after.staged_diff_digest {
231        out.push("the staged changes differ".into());
232    }
233    if before.unstaged_diff_digest != after.unstaged_diff_digest {
234        out.push("the unstaged changes differ".into());
235    }
236    if before.untracked_manifest_digest != after.untracked_manifest_digest {
237        out.push(format!(
238            "the untracked files differ ({} before, {} after)",
239            before.untracked_files, after.untracked_files
240        ));
241    }
242    if before.ignored_manifest_digest != after.ignored_manifest_digest {
243        out.push("the ignored entries differ".into());
244    }
245    if before.in_progress != after.in_progress {
246        out.push("the operation in progress differs".into());
247    }
248    if let (Some(a), Some(b)) = (&before.tree_manifest_digest, &after.tree_manifest_digest) {
249        if a != b {
250            out.push("the directory tree differs".into());
251        }
252    }
253    out
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    /// A fingerprint whose every field is set, so that a test can change exactly one and
261    /// know that the line it reads back is about that one.
262    fn full() -> WorktreeFingerprint {
263        WorktreeFingerprint {
264            branch: Some("feature/x".into()),
265            head: Some("abc123".into()),
266            index_digest: "i".into(),
267            staged_diff_digest: "s".into(),
268            unstaged_diff_digest: "u".into(),
269            untracked_manifest_digest: "n".into(),
270            untracked_files: 2,
271            ignored_manifest_digest: "g".into(),
272            in_progress: None,
273            tree_manifest_digest: Some("t".into()),
274        }
275    }
276
277    /// Write a small tree: a nested directory, a file of known size, a symbolic link, and
278    /// the `.git` file a linked worktree carries, which the manifest must not attest.
279    fn tree(root: &std::path::Path, order_reversed: bool) {
280        std::fs::create_dir_all(root.join("src/deep")).unwrap();
281        let files: Vec<&str> = if order_reversed {
282            vec!["src/deep/z.rs", "src/deep/a.rs"]
283        } else {
284            vec!["src/deep/a.rs", "src/deep/z.rs"]
285        };
286        for f in files {
287            std::fs::write(root.join(f), "fn main() {}").unwrap();
288        }
289        std::fs::write(root.join(".git"), "gitdir: /elsewhere").unwrap();
290        #[cfg(unix)]
291        std::os::unix::fs::symlink("../elsewhere", root.join("link")).unwrap();
292    }
293
294    #[test]
295    fn the_manifest_attests_every_entry_and_never_the_worktrees_own_git_file() {
296        let dir = tempfile::tempdir().unwrap();
297        tree(dir.path(), false);
298        let manifest = tree_manifest(dir.path()).unwrap();
299
300        assert!(
301            manifest.contains("src\u{0}dir"),
302            "a directory is attested as one"
303        );
304        assert!(
305            manifest.contains("src/deep/a.rs\u{0}file\u{0}12"),
306            "a file is attested with its size: {manifest}"
307        );
308        assert!(
309            !manifest.contains(".git\u{0}"),
310            "the .git file is rewritten by the move on purpose and must not be compared: {manifest}"
311        );
312        #[cfg(unix)]
313        assert!(
314            manifest.contains("link\u{0}link\u{0}../elsewhere"),
315            "a symbolic link is attested as a link and its target, never followed: {manifest}"
316        );
317    }
318
319    #[test]
320    fn the_manifest_does_not_depend_on_the_order_the_directory_was_written_in() {
321        let one = tempfile::tempdir().unwrap();
322        let other = tempfile::tempdir().unwrap();
323        tree(one.path(), false);
324        tree(other.path(), true);
325        assert_eq!(
326            tree_manifest(one.path()).unwrap(),
327            tree_manifest(other.path()).unwrap(),
328            "read_dir order reached the manifest, so two equal trees would compare unequal"
329        );
330    }
331
332    #[test]
333    fn a_manifest_notices_a_file_that_changed_size_and_a_link_that_changed_target() {
334        let dir = tempfile::tempdir().unwrap();
335        tree(dir.path(), false);
336        let before = tree_manifest(dir.path()).unwrap();
337        std::fs::write(dir.path().join("src/deep/a.rs"), "fn main() { todo!() }").unwrap();
338        assert_ne!(
339            before,
340            tree_manifest(dir.path()).unwrap(),
341            "a longer file is a difference"
342        );
343    }
344
345    #[test]
346    fn two_equal_fingerprints_differ_in_nothing() {
347        assert!(differences(&full(), &full()).is_empty());
348    }
349
350    #[test]
351    fn every_field_of_the_fingerprint_is_compared_and_named() {
352        // The whole point of this type: it decides whether a migration may delete the
353        // original directory. A field it stopped comparing would be a field a move could
354        // lose silently, so each is changed alone and read back by name.
355        /// One field changed, and the line the reader must get back for it.
356        type Change = (&'static str, fn(&mut WorktreeFingerprint));
357        let cases: Vec<Change> = vec![
358            ("branch changed", |f| f.branch = Some("other".into())),
359            ("HEAD changed", |f| f.head = Some("def456".into())),
360            ("the index differs", |f| f.index_digest = "other".into()),
361            ("the staged changes differ", |f| {
362                f.staged_diff_digest = "other".into()
363            }),
364            ("the unstaged changes differ", |f| {
365                f.unstaged_diff_digest = "other".into()
366            }),
367            ("the untracked files differ", |f| {
368                f.untracked_manifest_digest = "other".into()
369            }),
370            ("the ignored entries differ", |f| {
371                f.ignored_manifest_digest = "other".into()
372            }),
373            ("the operation in progress differs", |f| {
374                f.in_progress = Some("rebase".into())
375            }),
376            ("the directory tree differs", |f| {
377                f.tree_manifest_digest = Some("other".into())
378            }),
379        ];
380        for (expected, change) in cases {
381            let mut after = full();
382            change(&mut after);
383            let found = differences(&full(), &after);
384            assert_eq!(found.len(), 1, "changing one field reported {found:?}");
385            assert!(
386                found[0].starts_with(expected),
387                "expected a line beginning {expected:?}, got {:?}",
388                found[0]
389            );
390        }
391    }
392
393    #[test]
394    fn a_tree_manifest_absent_on_either_side_is_not_a_difference() {
395        // `capture` takes the manifest only when asked. Comparing a fingerprint that has one
396        // with a fingerprint that does not must not read as a lost directory.
397        let (mut before, mut after) = (full(), full());
398        after.tree_manifest_digest = None;
399        assert!(
400            differences(&before, &after).is_empty(),
401            "absent is unknown, not different"
402        );
403        before.tree_manifest_digest = None;
404        after.tree_manifest_digest = Some("t".into());
405        assert!(differences(&before, &after).is_empty());
406    }
407
408    #[test]
409    fn an_unborn_head_and_a_detached_one_are_named_rather_than_left_blank() {
410        let (mut before, mut after) = (full(), full());
411        before.head = None;
412        after.branch = None;
413        let found = differences(&before, &after);
414        assert!(found.iter().any(|l| l.contains("(unborn)")), "{found:?}");
415        assert!(found.iter().any(|l| l.contains("(detached)")), "{found:?}");
416    }
417
418    /// A directory tree, for the manifest: the half of a fingerprint that needs no git.
419    fn tree_of(files: &[(&str, &str)]) -> tempfile::TempDir {
420        let dir = tempfile::tempdir().unwrap();
421        for (path, content) in files {
422            let p = dir.path().join(path);
423            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
424            std::fs::write(p, content).unwrap();
425        }
426        dir
427    }
428
429    #[test]
430    fn the_manifest_notices_a_file_that_changed_size_and_one_that_went_missing() {
431        // the property a migration depends on: a tree that lost something cannot produce
432        // the manifest of the tree that had it
433        let dir = tree_of(&[("a.txt", "one"), ("deep/b.txt", "two")]);
434        let before = tree_manifest(dir.path()).unwrap();
435
436        std::fs::write(dir.path().join("a.txt"), "one and more").unwrap();
437        assert_ne!(
438            tree_manifest(dir.path()).unwrap(),
439            before,
440            "size is attested"
441        );
442
443        std::fs::write(dir.path().join("a.txt"), "one").unwrap();
444        assert_eq!(tree_manifest(dir.path()).unwrap(), before, "and only that");
445
446        std::fs::remove_file(dir.path().join("deep/b.txt")).unwrap();
447        assert_ne!(tree_manifest(dir.path()).unwrap(), before);
448    }
449
450    #[test]
451    fn the_manifest_is_the_same_whatever_order_the_filesystem_hands_entries_back_in() {
452        // two trees with the same content built in opposite orders: a manifest that
453        // depended on readdir order would call a faithful copy a difference
454        let one = tree_of(&[("a", "1"), ("b", "2"), ("c/d", "3")]);
455        let two = tree_of(&[("c/d", "3"), ("b", "2"), ("a", "1")]);
456        assert_eq!(
457            tree_manifest(one.path()).unwrap(),
458            tree_manifest(two.path()).unwrap()
459        );
460    }
461
462    #[test]
463    fn the_git_file_of_a_linked_worktree_is_left_out_because_the_move_rewrites_it() {
464        let dir = tree_of(&[("a", "1")]);
465        let before = tree_manifest(dir.path()).unwrap();
466        std::fs::write(dir.path().join(".git"), "gitdir: /somewhere/else").unwrap();
467        assert_eq!(
468            tree_manifest(dir.path()).unwrap(),
469            before,
470            "a move rewrites .git on purpose, so attesting it would fail every migration"
471        );
472    }
473
474    #[test]
475    fn two_equal_fingerprints_differ_in_nothing_and_each_change_is_named() {
476        let base = WorktreeFingerprint {
477            branch: Some("feature/x".into()),
478            head: Some("abc123".into()),
479            index_digest: "i".into(),
480            staged_diff_digest: "s".into(),
481            unstaged_diff_digest: "u".into(),
482            untracked_manifest_digest: "n".into(),
483            untracked_files: 2,
484            ignored_manifest_digest: "g".into(),
485            in_progress: None,
486            tree_manifest_digest: None,
487        };
488        assert!(differences(&base, &base).is_empty(), "equal is equal");
489
490        let mut moved = base.clone();
491        moved.branch = Some("feature/y".into());
492        let found = differences(&base, &moved);
493        assert_eq!(found.len(), 1, "{found:?}");
494        assert!(found[0].contains("feature/x") && found[0].contains("feature/y"));
495
496        // the one a migration exists to catch: work that was there and is not
497        let mut lost = base.clone();
498        lost.untracked_manifest_digest = "different".into();
499        lost.untracked_files = 1;
500        assert!(
501            !differences(&base, &lost).is_empty(),
502            "a lost untracked file is a difference, or the guarantee is worthless"
503        );
504    }
505}