|
1 | 1 | use anyhow::Context; |
2 | 2 | use sha2::{Digest, Sha256}; |
3 | | -use std::path::Path; |
| 3 | +use 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. |
| 21 | +fn 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 | +} |
4 | 33 |
|
5 | 34 | pub fn from_path(p: impl AsRef<Path>) -> anyhow::Result<String> { |
6 | 35 | let canonical = dunce::canonicalize(p.as_ref()) |
7 | 36 | .with_context(|| format!("canonicalize {:?}", p.as_ref()))?; |
8 | | - let bytes = canonical.as_os_str().as_encoded_bytes(); |
| 37 | + let root = project_root(&canonical); |
| 38 | + let bytes = root.as_os_str().as_encoded_bytes(); |
9 | 39 | let mut h = Sha256::new(); |
10 | 40 | h.update(bytes); |
11 | 41 | let digest = h.finalize(); |
@@ -36,4 +66,55 @@ mod tests { |
36 | 66 | let b = from_path(d2.path()).unwrap(); |
37 | 67 | assert_ne!(a, b); |
38 | 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 | + } |
39 | 120 | } |
0 commit comments