返回 CodeWhale
paths.rs
根目录 / crates / tui / src / snapshot / paths.rs
1 //! Path resolution for the per-workspace snapshot side-repos.
2 //!
3 //! Snapshots live under the shared resolved state directory (including an
4 //! explicit `CODEWHALE_HOME`, or the existing primary/legacy snapshot store) with
5 //! a two-level hash split so we can snapshot multiple worktrees of the
6 //! same project independently — `git worktree list` users won't get
7 //! cross-talk between feature branches.
8
9 use std::io;
10 use std::path::{Path, PathBuf};
11
12 /// Compute the snapshot directory for a given workspace path.
13 ///
14 /// Returns `<snapshot state dir>/<project_hash>/<worktree_hash>/`, using
15 /// `codewhale_config::resolve_state_dir("snapshots")` for the shared base.
16 /// This resolves paths without creating directories or migrating state.
17 ///
18 /// The `project_hash` is derived from the canonicalized workspace path
19 /// after stripping any `.worktrees/<name>` suffix — multiple worktrees
20 /// of the same repo share the same `project_hash` so users can browse
21 /// snapshots cross-worktree if they want, but the `worktree_hash` keeps
22 /// commits isolated by default.
23 pub fn snapshot_dir_for(workspace: &Path) -> io::Result<PathBuf> {
24 // An explicit profile must never read or create ambient snapshots. The
25 // shared resolver also preserves legacy stores and rejects invalid
26 // overrides; do not silently fall back to the OS home or working directory.
27 let base = snapshot_state_base()?;
28 Ok(snapshot_dir_with_base(workspace, &base))
29 }
30
31 /// The shared snapshot store. An unsealed test gets a private one: a snapshot
32 /// repo opened by a fixture must not land in the developer's real store.
33 #[cfg(test)]
34 fn snapshot_state_base() -> io::Result<PathBuf> {
35 match crate::test_support::unsealed_state_dir("snapshots") {
36 Some(base) => Ok(base),
37 None => codewhale_config::resolve_state_dir("snapshots").map_err(io::Error::other),
38 }
39 }
40
41 #[cfg(not(test))]
42 fn snapshot_state_base() -> io::Result<PathBuf> {
43 codewhale_config::resolve_state_dir("snapshots").map_err(io::Error::other)
44 }
45
46 fn snapshot_dir_with_base(workspace: &Path, base: &Path) -> PathBuf {
47 let canonical = workspace
48 .canonicalize()
49 .unwrap_or_else(|_| workspace.to_path_buf());
50 let project_root = strip_worktree_suffix(&canonical);
51 let project_hash = stable_hex(&project_root);
52 let worktree_hash = stable_hex(&canonical);
53 base.join(project_hash).join(worktree_hash)
54 }
55
56 /// Resolve the `.git` directory inside the snapshot dir.
57 pub fn snapshot_git_dir(workspace: &Path) -> io::Result<PathBuf> {
58 Ok(snapshot_dir_for(workspace)?.join(".git"))
59 }
60
61 /// Ensure the snapshot dir exists on disk and return its path.
62 pub fn ensure_snapshot_dir(workspace: &Path) -> io::Result<PathBuf> {
63 let dir = snapshot_dir_for(workspace)?;
64 std::fs::create_dir_all(&dir)?;
65 Ok(dir)
66 }
67
68 /// Strip a trailing `.worktrees/<name>` segment so all worktrees of the
69 /// same checkout share a `project_hash`. If the path doesn't look like a
70 /// worktree it's returned unchanged.
71 fn strip_worktree_suffix(path: &Path) -> PathBuf {
72 let mut components: Vec<_> = path.components().collect();
73 if components.len() >= 2
74 && let Some(parent) = components.get(components.len() - 2)
75 && parent.as_os_str() == ".worktrees"
76 {
77 components.truncate(components.len() - 2);
78 let mut p = PathBuf::new();
79 for c in components {
80 p.push(c.as_os_str());
81 }
82 return p;
83 }
84 path.to_path_buf()
85 }
86
87 /// Hex-encoded deterministic FNV-1a digest. This is only a directory tag, not
88 /// a security boundary, but it must remain stable across process launches.
89 fn stable_hex(path: &Path) -> String {
90 let mut hash = 0xcbf2_9ce4_8422_2325u64;
91 for byte in path.to_string_lossy().as_bytes() {
92 hash ^= u64::from(*byte);
93 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
94 }
95 format!("{hash:016x}")
96 }
97
98 #[cfg(test)]
99 mod tests {
100 use super::*;
101 use tempfile::tempdir;
102
103 #[test]
104 fn snapshot_dir_layout_keeps_two_hash_levels_under_selected_base() {
105 let tmp = tempdir().expect("tempdir");
106 let base = tmp.path().join("snapshots");
107 let dir = snapshot_dir_with_base(tmp.path(), &base);
108 let mut iter = dir.strip_prefix(&base).unwrap().components();
109 assert!(iter.next().is_some()); // project_hash
110 assert!(iter.next().is_some()); // worktree_hash
111 assert!(iter.next().is_none());
112 }
113
114 #[test]
115 fn worktree_suffix_stripped_for_project_hash() {
116 let tmp = tempdir().expect("tempdir");
117 let main_path = tmp.path().join("repo");
118 let wt_path = tmp.path().join("repo").join(".worktrees").join("featX");
119 std::fs::create_dir_all(&main_path).unwrap();
120 std::fs::create_dir_all(&wt_path).unwrap();
121
122 let base = tmp.path().join("snapshots");
123 let main_dir = snapshot_dir_with_base(&main_path, &base);
124 let wt_dir = snapshot_dir_with_base(&wt_path, &base);
125
126 // Same project_hash (parent component before the worktree-specific tail).
127 let main_components: Vec<_> = main_dir.components().collect();
128 let wt_components: Vec<_> = wt_dir.components().collect();
129 assert_eq!(
130 main_components[main_components.len() - 2],
131 wt_components[wt_components.len() - 2],
132 "worktrees should share project_hash",
133 );
134 // But different worktree_hash (the tail).
135 assert_ne!(main_components.last(), wt_components.last());
136 }
137
138 #[test]
139 fn explicit_profile_owns_snapshot_creation_and_lookup() {
140 let _lock = crate::test_support::lock_test_env();
141 let tmp = tempdir().expect("tempdir");
142 let profile = tmp.path().join("selected-profile");
143 let _profile = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &profile);
144 let workspace = tmp.path().join("workspace");
145 std::fs::create_dir_all(&workspace).expect("workspace");
146 let expected = snapshot_dir_with_base(&workspace, &profile.join("snapshots"));
147 let dir = ensure_snapshot_dir(&workspace).expect("create selected snapshot directory");
148 assert_eq!(
149 dir, expected,
150 "snapshot writes must use the selected profile"
151 );
152 assert!(dir.exists());
153 assert_eq!(
154 snapshot_git_dir(&workspace).expect("lookup"),
155 dir.join(".git"),
156 "snapshot reads must use the same selected profile as writes"
157 );
158 }
159
160 #[test]
161 fn invalid_profile_is_an_error_without_ambient_fallback() {
162 let _lock = crate::test_support::lock_test_env();
163 let tmp = tempdir().expect("tempdir");
164 let _profile = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", "relative-profile");
165 assert!(snapshot_dir_for(tmp.path()).is_err());
166 assert!(snapshot_git_dir(tmp.path()).is_err());
167 assert!(ensure_snapshot_dir(tmp.path()).is_err());
168 assert!(
169 tmp.path()
170 .read_dir()
171 .expect("unchanged workspace")
172 .next()
173 .is_none()
174 );
175 }
176 }
177
177 lines RUST