gitcortex_store/
branch.rs1use std::{
2 fs::{self, File, OpenOptions},
3 hash::{DefaultHasher, Hash, Hasher},
4 io::{Read, Seek, Write},
5 path::{Path, PathBuf},
6};
7
8use directories::BaseDirs;
9use gitcortex_core::error::{GitCortexError, Result};
10
11pub fn sanitize(branch: &str) -> String {
26 let expanded = branch.replace('/', "__");
27 let mut s: String = expanded
28 .chars()
29 .map(|c| {
30 if c.is_alphanumeric() || c == '_' {
31 c
32 } else {
33 '_'
34 }
35 })
36 .collect();
37
38 if s.starts_with(|c: char| c.is_ascii_digit()) {
39 s.insert_str(0, "b_");
40 }
41 s
42}
43
44pub fn repo_id(repo_root: &Path) -> String {
51 let digest = blake3::hash(repo_root.to_string_lossy().as_bytes());
52 digest.to_hex()[..16].to_owned()
53}
54
55fn legacy_repo_id(repo_root: &Path) -> String {
56 let mut hasher = DefaultHasher::new();
57 repo_root.to_string_lossy().hash(&mut hasher);
58 format!("{:016x}", hasher.finish())
59}
60
61pub fn storage_repo_id(repo_root: &Path) -> String {
64 let stable = repo_id(repo_root);
65 if data_dir(&stable).exists() {
66 return stable;
67 }
68
69 let legacy = legacy_repo_id(repo_root);
70 if data_dir(&legacy).exists() {
71 legacy
72 } else {
73 stable
74 }
75}
76
77fn home_dir() -> PathBuf {
80 std::env::var_os("HOME")
81 .map(PathBuf::from)
82 .or_else(|| BaseDirs::new().map(|dirs| dirs.home_dir().to_owned()))
83 .unwrap_or_else(|| PathBuf::from("."))
84}
85
86pub fn data_root() -> PathBuf {
93 if let Some(path) = std::env::var_os("GCX_STORE_PATH") {
94 return PathBuf::from(path);
95 }
96 if let Some(path) = std::env::var_os("XDG_DATA_HOME") {
97 return PathBuf::from(path).join("gitcortex");
98 }
99
100 let native = BaseDirs::new()
101 .map(|dirs| dirs.data_local_dir().join("gitcortex"))
102 .unwrap_or_else(|| home_dir().join(".local/share/gitcortex"));
103 let legacy = home_dir().join(".local/share/gitcortex");
104 if cfg!(target_os = "macos") && legacy.exists() && !native.exists() {
105 legacy
106 } else {
107 native
108 }
109}
110
111pub fn data_dir(repo_id: &str) -> PathBuf {
113 data_root().join(repo_id)
114}
115
116pub struct RepositoryLock {
119 file: File,
120}
121
122impl RepositoryLock {
123 pub fn try_acquire(repo_root: &Path) -> Result<Option<Self>> {
125 let repo_id = storage_repo_id(repo_root);
126 let dir = data_dir(&repo_id);
127 fs::create_dir_all(&dir)?;
128 let file = OpenOptions::new()
129 .create(true)
130 .truncate(false)
131 .read(true)
132 .write(true)
133 .open(dir.join("serve.lock"))?;
134 match fs2::FileExt::try_lock_exclusive(&file) {
135 Ok(()) => Ok(Some(Self { file })),
136 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
137 Err(error) => Err(GitCortexError::Io(error)),
138 }
139 }
140
141 pub fn owner(&mut self) -> String {
143 let mut owner = String::new();
144 if self.file.rewind().is_ok() {
145 let _ = self.file.read_to_string(&mut owner);
146 }
147 owner.trim().to_owned()
148 }
149
150 pub fn set_owner(&mut self, owner: &str) -> Result<()> {
152 self.file.set_len(0)?;
153 self.file.rewind()?;
154 write!(self.file, "{owner}")?;
155 self.file.sync_data()?;
156 Ok(())
157 }
158}
159
160pub fn repository_lock_owner(repo_root: &Path) -> String {
161 let repo_id = storage_repo_id(repo_root);
162 fs::read_to_string(data_dir(&repo_id).join("serve.lock"))
163 .unwrap_or_default()
164 .trim()
165 .to_owned()
166}
167
168pub fn cache_root() -> PathBuf {
171 if let Some(path) = std::env::var_os("GCX_CACHE_PATH") {
172 return PathBuf::from(path);
173 }
174 if let Some(path) = std::env::var_os("XDG_CACHE_HOME") {
175 return PathBuf::from(path).join("gitcortex");
176 }
177 BaseDirs::new()
178 .map(|dirs| dirs.cache_dir().join("gitcortex"))
179 .unwrap_or_else(|| home_dir().join(".cache/gitcortex"))
180}
181
182pub fn models_dir() -> PathBuf {
185 let target = cache_root().join("models");
186 let legacy = data_root().join("models");
187 if !target.exists() && legacy.exists() {
188 if let Some(parent) = target.parent() {
189 let _ = fs::create_dir_all(parent);
190 }
191 if fs::rename(&legacy, &target).is_err() {
192 return legacy;
193 }
194 }
195 target
196}
197
198pub fn db_path(repo_id: &str) -> PathBuf {
200 data_dir(repo_id).join("graph.kuzu")
201}
202
203pub fn last_sha_path(repo_id: &str, branch: &str) -> PathBuf {
205 data_dir(repo_id).join(format!("{}.sha", sanitize(branch)))
206}
207
208pub fn schema_version_path(repo_id: &str) -> PathBuf {
210 data_dir(repo_id).join("schema_version")
211}
212
213pub fn read_schema_version(repo_id: &str) -> u32 {
215 let path = schema_version_path(repo_id);
216 std::fs::read_to_string(&path)
217 .ok()
218 .and_then(|s| s.trim().parse().ok())
219 .unwrap_or(0)
220}
221
222pub fn write_schema_version(repo_id: &str, version: u32) -> Result<()> {
224 let path = schema_version_path(repo_id);
225 if let Some(parent) = path.parent() {
226 std::fs::create_dir_all(parent)?;
227 }
228 std::fs::write(&path, version.to_string()).map_err(GitCortexError::Io)
229}
230
231pub fn has_repo_data(repo_id: &str) -> Result<bool> {
234 let entries = match fs::read_dir(data_dir(repo_id)) {
235 Ok(entries) => entries,
236 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
237 Err(error) => return Err(GitCortexError::Io(error)),
238 };
239 for entry in entries {
240 if entry?.file_name() != "serve.lock" {
241 return Ok(true);
242 }
243 }
244 Ok(false)
245}
246
247pub fn wipe_repo_data(repo_id: &str) -> Result<()> {
251 let dir = data_dir(repo_id);
252 let entries = match fs::read_dir(&dir) {
253 Ok(entries) => entries,
254 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
255 Err(error) => return Err(GitCortexError::Io(error)),
256 };
257 for entry in entries {
258 let entry = entry?;
259 if entry.file_name() == "serve.lock" {
260 continue;
261 }
262 let file_type = entry.file_type()?;
263 if file_type.is_dir() {
264 fs::remove_dir_all(entry.path())?;
265 } else {
266 fs::remove_file(entry.path())?;
267 }
268 }
269 Ok(())
270}
271
272pub fn read_last_sha(repo_id: &str, branch: &str) -> Result<Option<String>> {
275 let path = last_sha_path(repo_id, branch);
276 match fs::read_to_string(&path) {
277 Ok(s) => Ok(Some(s.trim().to_owned())),
278 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
279 Err(e) => Err(GitCortexError::Io(e)),
280 }
281}
282
283pub fn write_last_sha(repo_id: &str, branch: &str, sha: &str) -> Result<()> {
284 let path = last_sha_path(repo_id, branch);
285 if let Some(parent) = path.parent() {
286 fs::create_dir_all(parent)?;
287 }
288 fs::write(&path, sha).map_err(GitCortexError::Io)
289}
290
291#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn sanitize_plain() {
299 assert_eq!(sanitize("main"), "main");
300 }
301
302 #[test]
303 fn sanitize_slash_becomes_double_underscore() {
304 assert_eq!(sanitize("feat/auth"), "feat__auth");
305 }
306
307 #[test]
308 fn sanitize_dash_and_dot() {
309 assert_eq!(sanitize("release/v1.0-rc"), "release__v1_0_rc");
310 }
311
312 #[test]
313 fn sanitize_leading_digit() {
314 assert_eq!(sanitize("1-hotfix"), "b_1_hotfix");
315 }
316
317 #[test]
318 fn repo_id_is_stable() {
319 let path = Path::new("/home/user/myproject");
320 assert_eq!(repo_id(path), "b6dd9f32aba035a6");
321 }
322
323 #[test]
324 fn repo_id_differs_across_paths() {
325 let a = repo_id(Path::new("/home/user/proj-a"));
326 let b = repo_id(Path::new("/home/user/proj-b"));
327 assert_ne!(a, b);
328 }
329}