Skip to main content

tj_core/
project_hash.rs

1use anyhow::Context;
2use sha2::{Digest, Sha256};
3use std::path::{Path, PathBuf};
4
5/// Walk up from `start` to a project boundary so that running
6/// `task-journal` from `repo/`, `repo/src/`, and `repo/src/foo/bar/`
7/// all hash to the same project. Without this normalization, opening
8/// Claude Code in a subdir gave an empty journal — broke the
9/// "auto-memory" promise.
10///
11/// Boundary markers, priority order:
12/// 1. `.task-journal/` directory — explicit opt-in for sub-projects
13///    that intentionally want a separate journal from their parent.
14/// 2. `.git` (file or directory) — covers normal checkouts and
15///    worktrees alike (a worktree's root holds a `.git` *file*
16///    pointing at the real gitdir, but its presence still marks the
17///    boundary).
18///
19/// Falls back to `start` if no marker is found, preserving prior
20/// behaviour for non-git scratch directories.
21fn 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        // repo/ with .git inside; repo/src/foo/ should normalise to repo/.
73        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        // repo/.git + repo/sub/.task-journal/. Then sub is its own project
89        // (explicit opt-out of the parent's journal).
90        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        // Worktrees have a `.git` *file* (not a dir) at their root.
107        // We must still treat that as a boundary.
108        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}