1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25pub struct WorktreeFingerprint {
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub branch: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub head: Option<String>,
32 pub index_digest: String,
34 pub staged_diff_digest: String,
36 pub unstaged_diff_digest: String,
38 pub untracked_manifest_digest: String,
40 pub untracked_files: usize,
42 pub ignored_manifest_digest: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub in_progress: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 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
65pub 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
159pub 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
194pub 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 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 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 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 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 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 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 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 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}