返回 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 resolved state directory
4 //! (`~/.codewhale/snapshots` or legacy `~/.deepseek/snapshots`) 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 `$STATE_DIR/snapshots/<project_hash>/<worktree_hash>/` where
15 /// `$STATE_DIR` is resolved via `codewhale_config::resolve_state_dir`.
16 /// The caller is responsible for creating it on disk; we purposefully
17 /// don't touch the filesystem here so this is cheap to call repeatedly.
18 ///
19 /// The `project_hash` is derived from the canonicalized workspace path
20 /// after stripping any `.worktrees/<name>` suffix — multiple worktrees
21 /// of the same repo share the same `project_hash` so users can browse
22 /// snapshots cross-worktree if they want, but the `worktree_hash` keeps
23 /// commits isolated by default.
24 pub fn snapshot_dir_for(workspace: &Path) -> PathBuf {
25 snapshot_dir_with_home(workspace, crate::config::effective_home_dir())
26 }
27
28 /// Same as [`snapshot_dir_for`] but with an injectable home directory.
29 /// Used by tests so they never touch the user's real state directory.
30 pub fn snapshot_dir_with_home(workspace: &Path, home: Option<PathBuf>) -> PathBuf {
31 let home = home.unwrap_or_else(|| PathBuf::from("."));
32 let canonical = workspace
33 .canonicalize()
34 .unwrap_or_else(|_| workspace.to_path_buf());
35 let project_root = strip_worktree_suffix(&canonical);
36 let project_hash = stable_hex(&project_root);
37 let worktree_hash = stable_hex(&canonical);
38 snapshot_base_with_home(Some(home))
39 .join(project_hash)
40 .join(worktree_hash)
41 }
42
43 fn snapshot_base_with_home(home: Option<PathBuf>) -> PathBuf {
44 let home = home.unwrap_or_else(|| PathBuf::from("."));
45 // Prefer .codewhale, fall back to .deepseek
46 let primary = home.join(".codewhale").join("snapshots");
47 if primary.exists() {
48 return primary;
49 }
50 home.join(".deepseek").join("snapshots")
51 }
52
53 /// Resolve the `.git` directory inside the snapshot dir.
54 pub fn snapshot_git_dir(workspace: &Path) -> PathBuf {
55 snapshot_dir_for(workspace).join(".git")
56 }
57
58 /// Ensure the snapshot dir exists on disk and return its path.
59 pub fn ensure_snapshot_dir(workspace: &Path) -> io::Result<PathBuf> {
60 let dir = snapshot_dir_for(workspace);
61 std::fs::create_dir_all(&dir)?;
62 Ok(dir)
63 }
64
65 /// Strip a trailing `.worktrees/<name>` segment so all worktrees of the
66 /// same checkout share a `project_hash`. If the path doesn't look like a
67 /// worktree it's returned unchanged.
68 fn strip_worktree_suffix(path: &Path) -> PathBuf {
69 let mut components: Vec<_> = path.components().collect();
70 if components.len() >= 2
71 && let Some(parent) = components.get(components.len() - 2)
72 && parent.as_os_str() == ".worktrees"
73 {
74 components.truncate(components.len() - 2);
75 let mut p = PathBuf::new();
76 for c in components {
77 p.push(c.as_os_str());
78 }
79 return p;
80 }
81 path.to_path_buf()
82 }
83
84 /// Hex-encoded deterministic FNV-1a digest. This is only a directory tag, not
85 /// a security boundary, but it must remain stable across process launches.
86 fn stable_hex(path: &Path) -> String {
87 let mut hash = 0xcbf2_9ce4_8422_2325u64;
88 for byte in path.to_string_lossy().as_bytes() {
89 hash ^= u64::from(*byte);
90 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
91 }
92 format!("{hash:016x}")
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use super::*;
98 use tempfile::tempdir;
99
100 #[test]
101 fn snapshot_dir_layout_two_levels_under_deepseek() {
102 let tmp = tempdir().expect("tempdir");
103 let dir = snapshot_dir_with_home(tmp.path(), Some(tmp.path().to_path_buf()));
104 let mut iter = dir.strip_prefix(tmp.path()).unwrap().components();
105 assert_eq!(iter.next().unwrap().as_os_str(), ".deepseek");
106 assert_eq!(iter.next().unwrap().as_os_str(), "snapshots");
107 assert!(iter.next().is_some()); // project_hash
108 assert!(iter.next().is_some()); // worktree_hash
109 assert!(iter.next().is_none());
110 }
111
112 #[test]
113 fn worktree_suffix_stripped_for_project_hash() {
114 let tmp = tempdir().expect("tempdir");
115 let main_path = tmp.path().join("repo");
116 let wt_path = tmp.path().join("repo").join(".worktrees").join("featX");
117 std::fs::create_dir_all(&main_path).unwrap();
118 std::fs::create_dir_all(&wt_path).unwrap();
119
120 let main_dir = snapshot_dir_with_home(&main_path, Some(tmp.path().to_path_buf()));
121 let wt_dir = snapshot_dir_with_home(&wt_path, Some(tmp.path().to_path_buf()));
122
123 // Same project_hash (parent component before the worktree-specific tail).
124 let main_components: Vec<_> = main_dir.components().collect();
125 let wt_components: Vec<_> = wt_dir.components().collect();
126 assert_eq!(
127 main_components[main_components.len() - 2],
128 wt_components[wt_components.len() - 2],
129 "worktrees should share project_hash",
130 );
131 // But different worktree_hash (the tail).
132 assert_ne!(main_components.last(), wt_components.last());
133 }
134
135 #[test]
136 fn ensure_snapshot_dir_creates_path() {
137 let tmp = tempdir().expect("tempdir");
138 // Use scoped HOME so we don't pollute the real one.
139 let dir = snapshot_dir_with_home(tmp.path(), Some(tmp.path().to_path_buf()));
140 std::fs::create_dir_all(&dir).unwrap();
141 assert!(dir.exists());
142 }
143
144 #[test]
145 fn snapshot_git_dir_appends_dot_git() {
146 let tmp = tempdir().expect("tempdir");
147 let git_dir = snapshot_git_dir(tmp.path());
148 assert_eq!(git_dir.file_name().unwrap(), ".git");
149 }
150 }
151
151 lines RUST