Skip to main content

gitcortex_store/
branch.rs

1use 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
11// ── Branch name sanitization ──────────────────────────────────────────────────
12
13/// Sanitize a branch name so it can be used as a KuzuDB table name prefix.
14///
15/// Rules applied (in order):
16/// - `/`  → `__`  (preserves branch hierarchy visibility)
17/// - any remaining non-alphanumeric char → `_`
18/// - leading digit → prefix with `b_` (table names can't start with a digit)
19///
20/// Examples:
21/// - `main`           → `main`
22/// - `feat/auth`      → `feat__auth`
23/// - `feat/auth-v2`   → `feat__auth_v2`
24/// - `release/v1.0`   → `release__v1_0`
25pub 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
44// ── Repository identity ───────────────────────────────────────────────────────
45
46/// Derive a stable 16-hex-character ID from the repo's absolute path.
47///
48/// BLAKE3 is deliberately specified here instead of `DefaultHasher`, whose
49/// algorithm is an implementation detail and may change between Rust releases.
50pub 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
61/// Resolve the ID used on disk, retaining access to stores created before the
62/// stable BLAKE3 ID was introduced. New repositories always use [`repo_id`].
63pub 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
77// ── Platform data and cache paths ─────────────────────────────────────────────
78
79fn 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
86/// Machine-local durable data root.
87///
88/// `GCX_STORE_PATH` is the explicit application override. Otherwise the native
89/// platform data directory is used (`$XDG_DATA_HOME` on Linux and
90/// `~/Library/Application Support` on macOS). Existing macOS installations in
91/// `~/.local/share/gitcortex` continue using that location until moved.
92pub 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
111/// Root data directory for a repository.
112pub fn data_dir(repo_id: &str) -> PathBuf {
113    data_root().join(repo_id)
114}
115
116/// Cross-process ownership guard for operations that may open or mutate one
117/// repository's embedded graph. The operating system releases it on crashes.
118pub struct RepositoryLock {
119    file: File,
120}
121
122impl RepositoryLock {
123    /// Try to claim repository ownership without waiting.
124    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    /// Read the diagnostic owner text left by the current or previous owner.
142    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    /// Replace diagnostic owner text after ownership has been acquired.
151    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
168/// Machine-local cache root. Downloadable model weights are cache data, not
169/// durable application state.
170pub 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
182/// Shared model cache directory. On first use, migrate the legacy model cache
183/// out of the durable data directory when a same-filesystem rename is possible.
184pub 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
198/// Path to the single KuzuDB file for a repo (all branches, namespaced by table prefix).
199pub fn db_path(repo_id: &str) -> PathBuf {
200    data_dir(repo_id).join("graph.kuzu")
201}
202
203/// Path to the last-indexed SHA file for a specific branch.
204pub fn last_sha_path(repo_id: &str, branch: &str) -> PathBuf {
205    data_dir(repo_id).join(format!("{}.sha", sanitize(branch)))
206}
207
208/// Path to the persisted schema version marker for a repo.
209pub fn schema_version_path(repo_id: &str) -> PathBuf {
210    data_dir(repo_id).join("schema_version")
211}
212
213/// Read the persisted schema version, returning 0 if not present.
214pub 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
222/// Write the schema version marker.
223pub 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
231/// Whether a repository data directory contains anything other than its
232/// reusable ownership lock file.
233pub 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
247/// Wipe per-repository graph data while preserving the advisory ownership
248/// lock inode. Keeping the lock file in place prevents a new daemon from
249/// slipping through while an explicit clean or schema rebuild is in progress.
250pub 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
272// ── last_sha persistence ──────────────────────────────────────────────────────
273
274pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
292
293#[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}