Skip to main content

git_claw/infra/
registry.rs

1//! Slot registry persistence in `<git-common-dir>/claw/slots.json` and auto-GC.
2
3use crate::infra::error::RegistryError;
4use crate::infra::git::git_worktree_prune;
5use serde::{Deserialize, Serialize};
6use std::fs::{self, File};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11/// Single allocated slot record.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SlotRecord {
14    pub id: u32,
15    pub name: String,
16    pub branch_type: String,
17    pub branch: String,
18    pub path: String,
19    pub allocated_at: String,
20}
21
22/// Root JSON object of `slots.json`.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct SlotRegistry {
25    pub version: u32,
26    pub slots: Vec<SlotRecord>,
27}
28
29impl SlotRegistry {
30    pub const CURRENT_VERSION: u32 = 1;
31    pub const CREATION_GRACE_PERIOD: Duration = Duration::from_secs(30);
32
33    /// Creates an empty registry with version 1.
34    pub fn empty() -> Self {
35        Self {
36            version: Self::CURRENT_VERSION,
37            slots: Vec::new(),
38        }
39    }
40
41    /// Loads registry from path, or returns an empty registry if the file does not exist.
42    pub fn load_or_empty(path: &Path) -> Result<Self, RegistryError> {
43        if !path.exists() {
44            return Ok(Self::empty());
45        }
46
47        let content = fs::read_to_string(path).map_err(|e| RegistryError::IoError {
48            path: path.to_string_lossy().to_string(),
49            source: e,
50        })?;
51
52        let registry: SlotRegistry =
53            serde_json::from_str(&content).map_err(|e| RegistryError::JsonError {
54                path: path.to_string_lossy().to_string(),
55                source: e,
56            })?;
57
58        Ok(registry)
59    }
60
61    /// Saves the registry atomically to disk using a temporary file and rename.
62    pub fn save_atomic(&self, path: &Path) -> Result<(), RegistryError> {
63        if let Some(parent) = path.parent() {
64            fs::create_dir_all(parent).map_err(|e| RegistryError::IoError {
65                path: parent.to_string_lossy().to_string(),
66                source: e,
67            })?;
68        }
69
70        let tmp_path = format!("{}.tmp.{}", path.display(), std::process::id());
71        let tmp_path = PathBuf::from(tmp_path);
72
73        let json = serde_json::to_string_pretty(self).map_err(|e| RegistryError::JsonError {
74            path: path.to_string_lossy().to_string(),
75            source: e,
76        })?;
77
78        let mut file = File::create(&tmp_path).map_err(|e| RegistryError::IoError {
79            path: tmp_path.to_string_lossy().to_string(),
80            source: e,
81        })?;
82
83        file.write_all(json.as_bytes())
84            .map_err(|e| RegistryError::IoError {
85                path: tmp_path.to_string_lossy().to_string(),
86                source: e,
87            })?;
88
89        file.sync_all().map_err(|e| RegistryError::IoError {
90            path: tmp_path.to_string_lossy().to_string(),
91            source: e,
92        })?;
93
94        fs::rename(&tmp_path, path).map_err(|e| RegistryError::IoError {
95            path: path.to_string_lossy().to_string(),
96            source: e,
97        })?;
98
99        Ok(())
100    }
101
102    /// Finds a slot by ID.
103    pub fn find_by_id(&self, id: u32) -> Option<&SlotRecord> {
104        self.slots.iter().find(|s| s.id == id)
105    }
106
107    /// Finds a slot by leaf name.
108    pub fn find_by_name(&self, name: &str) -> Option<&SlotRecord> {
109        self.slots.iter().find(|s| s.name == name)
110    }
111
112    /// Returns all currently occupied Slot IDs.
113    pub fn occupied_ids(&self) -> Vec<u32> {
114        self.slots.iter().map(|s| s.id).collect()
115    }
116
117    /// Removes a slot by leaf name, returning true if removed.
118    pub fn remove_by_name(&mut self, name: &str) -> bool {
119        let initial_len = self.slots.len();
120        self.slots.retain(|s| s.name != name);
121        self.slots.len() < initial_len
122    }
123
124    /// Removes a slot by ID, returning true if removed.
125    pub fn remove_by_id(&mut self, id: u32) -> bool {
126        let initial_len = self.slots.len();
127        self.slots.retain(|s| s.id != id);
128        self.slots.len() < initial_len
129    }
130
131    /// Auto-GC: inspects recorded worktrees, removes entries whose directory does not exist on disk
132    /// (ignoring records within the grace period), saves the registry atomically, and invokes git worktree prune.
133    pub fn auto_gc(
134        &mut self,
135        registry_path: &Path,
136        repo_root: Option<&Path>,
137        respect_grace_period: bool,
138    ) -> Result<usize, RegistryError> {
139        let now = SystemTime::now()
140            .duration_since(UNIX_EPOCH)
141            .unwrap_or_default()
142            .as_secs();
143
144        let initial_count = self.slots.len();
145        self.slots.retain(|slot| {
146            let path = Path::new(&slot.path);
147            if path.exists() {
148                return true;
149            }
150
151            if respect_grace_period {
152                if let Ok(ts) = slot.allocated_at.parse::<u64>() {
153                    if now.saturating_sub(ts) < Self::CREATION_GRACE_PERIOD.as_secs() {
154                        return true;
155                    }
156                }
157            }
158
159            false
160        });
161
162        let purged = initial_count - self.slots.len();
163        if purged > 0 {
164            self.save_atomic(registry_path)?;
165            let _ = git_worktree_prune(repo_root);
166        }
167
168        Ok(purged)
169    }
170}