1use anyhow::Context;
2use sha2::{Digest, Sha256};
3use std::path::{Path, PathBuf};
4
5fn project_root(start: &Path) -> PathBuf {
22 let mut cur = start;
23 loop {
24 if cur.join(".task-journal").is_dir() || cur.join(".git").exists() {
25 return cur.to_path_buf();
26 }
27 match cur.parent() {
28 Some(p) => cur = p,
29 None => return start.to_path_buf(),
30 }
31 }
32}
33
34pub fn from_path(p: impl AsRef<Path>) -> anyhow::Result<String> {
35 let canonical = dunce::canonicalize(p.as_ref())
36 .with_context(|| format!("canonicalize {:?}", p.as_ref()))?;
37 let root = project_root(&canonical);
38 let bytes = root.as_os_str().as_encoded_bytes();
39 let mut h = Sha256::new();
40 h.update(bytes);
41 let digest = h.finalize();
42 let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
43 debug_assert_eq!(hex.len(), 16);
44 Ok(hex)
45}
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50 use tempfile::TempDir;
51
52 #[test]
53 fn same_path_yields_same_hash() {
54 let d = TempDir::new().unwrap();
55 let a = from_path(d.path()).unwrap();
56 let b = from_path(d.path()).unwrap();
57 assert_eq!(a, b);
58 assert_eq!(a.len(), 16, "16 hex chars expected, got: {a}");
59 }
60
61 #[test]
62 fn different_paths_yield_different_hashes() {
63 let d1 = TempDir::new().unwrap();
64 let d2 = TempDir::new().unwrap();
65 let a = from_path(d1.path()).unwrap();
66 let b = from_path(d2.path()).unwrap();
67 assert_ne!(a, b);
68 }
69
70 #[test]
71 fn subdir_under_git_root_hashes_to_root() {
72 let repo = TempDir::new().unwrap();
74 std::fs::create_dir(repo.path().join(".git")).unwrap();
75 let sub = repo.path().join("src").join("foo");
76 std::fs::create_dir_all(&sub).unwrap();
77
78 let root_hash = from_path(repo.path()).unwrap();
79 let sub_hash = from_path(&sub).unwrap();
80 assert_eq!(
81 root_hash, sub_hash,
82 "subdir of a git repo must hash to the repo root, not the subdir"
83 );
84 }
85
86 #[test]
87 fn dot_task_journal_marker_overrides_git_boundary() {
88 let repo = TempDir::new().unwrap();
91 std::fs::create_dir(repo.path().join(".git")).unwrap();
92 let sub = repo.path().join("sub");
93 std::fs::create_dir(&sub).unwrap();
94 std::fs::create_dir(sub.join(".task-journal")).unwrap();
95
96 let root_hash = from_path(repo.path()).unwrap();
97 let sub_hash = from_path(&sub).unwrap();
98 assert_ne!(
99 root_hash, sub_hash,
100 "subdir with .task-journal/ marker must NOT inherit parent's project hash"
101 );
102 }
103
104 #[test]
105 fn dot_git_file_in_worktree_root_is_a_boundary() {
106 let wt = TempDir::new().unwrap();
109 std::fs::write(wt.path().join(".git"), "gitdir: /elsewhere\n").unwrap();
110 let sub = wt.path().join("inner");
111 std::fs::create_dir(&sub).unwrap();
112
113 let wt_hash = from_path(wt.path()).unwrap();
114 let sub_hash = from_path(&sub).unwrap();
115 assert_eq!(
116 wt_hash, sub_hash,
117 "worktree subdir must normalise to worktree root via .git file"
118 );
119 }
120}