Skip to main content

gitcortex_store/
branch.rs

1use std::{
2    fs,
3    hash::{DefaultHasher, Hash, Hasher},
4    path::{Path, PathBuf},
5};
6
7use gitcortex_core::error::{GitCortexError, Result};
8
9// ── Branch name sanitization ──────────────────────────────────────────────────
10
11/// Sanitize a branch name so it can be used as a KuzuDB table name prefix.
12///
13/// Rules applied (in order):
14/// - `/`  → `__`  (preserves branch hierarchy visibility)
15/// - any remaining non-alphanumeric char → `_`
16/// - leading digit → prefix with `b_` (table names can't start with a digit)
17///
18/// Examples:
19/// - `main`           → `main`
20/// - `feat/auth`      → `feat__auth`
21/// - `feat/auth-v2`   → `feat__auth_v2`
22/// - `release/v1.0`   → `release__v1_0`
23pub fn sanitize(branch: &str) -> String {
24    let expanded = branch.replace('/', "__");
25    let mut s: String = expanded
26        .chars()
27        .map(|c| {
28            if c.is_alphanumeric() || c == '_' {
29                c
30            } else {
31                '_'
32            }
33        })
34        .collect();
35
36    if s.starts_with(|c: char| c.is_ascii_digit()) {
37        s.insert_str(0, "b_");
38    }
39    s
40}
41
42// ── Repository identity ───────────────────────────────────────────────────────
43
44/// Derive a stable 16-hex-char ID from the absolute repo root path.
45/// Used to namespace per-repo data directories without path encoding issues.
46pub fn repo_id(repo_root: &Path) -> String {
47    let mut hasher = DefaultHasher::new();
48    repo_root.to_string_lossy().hash(&mut hasher);
49    format!("{:016x}", hasher.finish())
50}
51
52// ── XDG data paths ────────────────────────────────────────────────────────────
53
54/// Root data directory for a repo: `$XDG_DATA_HOME/gitcortex/{repo_id}/`
55pub fn data_dir(repo_id: &str) -> PathBuf {
56    let base = std::env::var("XDG_DATA_HOME")
57        .map(PathBuf::from)
58        .unwrap_or_else(|_| home_dir().join(".local/share"));
59    base.join("gitcortex").join(repo_id)
60}
61
62fn home_dir() -> PathBuf {
63    std::env::var("HOME")
64        .map(PathBuf::from)
65        .unwrap_or_else(|_| PathBuf::from("."))
66}
67
68/// Path to the single KuzuDB file for a repo (all branches, namespaced by table prefix).
69pub fn db_path(repo_id: &str) -> PathBuf {
70    data_dir(repo_id).join("graph.kuzu")
71}
72
73/// Path to the last-indexed SHA file for a specific branch.
74pub fn last_sha_path(repo_id: &str, branch: &str) -> PathBuf {
75    data_dir(repo_id).join(format!("{}.sha", sanitize(branch)))
76}
77
78/// Path to the persisted schema version marker for a repo.
79pub fn schema_version_path(repo_id: &str) -> PathBuf {
80    data_dir(repo_id).join("schema_version")
81}
82
83/// Read the persisted schema version, returning 0 if not present.
84pub fn read_schema_version(repo_id: &str) -> u32 {
85    let path = schema_version_path(repo_id);
86    std::fs::read_to_string(&path)
87        .ok()
88        .and_then(|s| s.trim().parse().ok())
89        .unwrap_or(0)
90}
91
92/// Write the schema version marker.
93pub fn write_schema_version(repo_id: &str, version: u32) -> Result<()> {
94    let path = schema_version_path(repo_id);
95    if let Some(parent) = path.parent() {
96        std::fs::create_dir_all(parent)?;
97    }
98    std::fs::write(&path, version.to_string()).map_err(GitCortexError::Io)
99}
100
101/// Wipe all per-repo data (DB + SHA files) so a fresh full index can run.
102pub fn wipe_repo_data(repo_id: &str) {
103    let dir = data_dir(repo_id);
104    let _ = std::fs::remove_dir_all(&dir);
105}
106
107// ── last_sha persistence ──────────────────────────────────────────────────────
108
109pub fn read_last_sha(repo_id: &str, branch: &str) -> Result<Option<String>> {
110    let path = last_sha_path(repo_id, branch);
111    match fs::read_to_string(&path) {
112        Ok(s) => Ok(Some(s.trim().to_owned())),
113        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
114        Err(e) => Err(GitCortexError::Io(e)),
115    }
116}
117
118pub fn write_last_sha(repo_id: &str, branch: &str, sha: &str) -> Result<()> {
119    let path = last_sha_path(repo_id, branch);
120    if let Some(parent) = path.parent() {
121        fs::create_dir_all(parent)?;
122    }
123    fs::write(&path, sha).map_err(GitCortexError::Io)
124}
125
126// ── Tests ─────────────────────────────────────────────────────────────────────
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn sanitize_plain() {
134        assert_eq!(sanitize("main"), "main");
135    }
136
137    #[test]
138    fn sanitize_slash_becomes_double_underscore() {
139        assert_eq!(sanitize("feat/auth"), "feat__auth");
140    }
141
142    #[test]
143    fn sanitize_dash_and_dot() {
144        assert_eq!(sanitize("release/v1.0-rc"), "release__v1_0_rc");
145    }
146
147    #[test]
148    fn sanitize_leading_digit() {
149        assert_eq!(sanitize("1-hotfix"), "b_1_hotfix");
150    }
151
152    #[test]
153    fn repo_id_is_stable() {
154        let path = Path::new("/home/user/myproject");
155        assert_eq!(repo_id(path), repo_id(path));
156    }
157
158    #[test]
159    fn repo_id_differs_across_paths() {
160        let a = repo_id(Path::new("/home/user/proj-a"));
161        let b = repo_id(Path::new("/home/user/proj-b"));
162        assert_ne!(a, b);
163    }
164}