Skip to main content

void_core/
refs.rs

1//! Git-style refs for branches and HEAD.
2//!
3//! HEAD can be:
4//! - Symbolic: `ref: refs/heads/trunk` (pointing to a branch)
5//! - Detached: raw CID string (pointing directly to a commit)
6//!
7//! Branch refs are stored in `.void/refs/heads/<name>` as raw CID strings.
8
9use std::fs;
10
11use camino::{Utf8Path, Utf8PathBuf};
12
13use void_crypto::CommitCid;
14
15use crate::{cid, Result, VoidError};
16
17const REFS_TAGS_DIR: &str = "refs/tags";
18const REFS_PUSHED_DIR: &str = "refs/pushed";
19
20/// Atomically write content to a file using temp + rename.
21fn atomic_write(path: &Utf8Path, content: &[u8]) -> Result<()> {
22    let temp_path = Utf8PathBuf::from(format!("{}.tmp", path));
23    fs::write(&temp_path, content)?;
24    fs::rename(&temp_path, path)?;
25    Ok(())
26}
27
28/// HEAD reference - either symbolic (pointing to a branch) or detached (direct CID).
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum HeadRef {
31    /// Symbolic ref pointing to a branch name (e.g., "trunk")
32    Symbolic(String),
33    /// Detached HEAD pointing directly to a commit CID
34    Detached(CommitCid),
35}
36
37const HEAD_FILE: &str = "HEAD";
38const REFS_HEADS_DIR: &str = "refs/heads";
39const SYMBOLIC_PREFIX: &str = "ref: refs/heads/";
40
41/// Validate a branch name using git-like rules.
42fn validate_branch_name(name: &str) -> Result<()> {
43    if name.is_empty() {
44        return Err(VoidError::InvalidBranchName("empty name".into()));
45    }
46    if name.starts_with('/') || name.ends_with('/') {
47        return Err(VoidError::InvalidBranchName(
48            "cannot start or end with /".into(),
49        ));
50    }
51    if name.starts_with('-') {
52        return Err(VoidError::InvalidBranchName("cannot start with '-'".into()));
53    }
54    if name.contains("..") {
55        return Err(VoidError::InvalidBranchName("cannot contain ..".into()));
56    }
57    if name.contains("@{") {
58        return Err(VoidError::InvalidBranchName("cannot contain @{".into()));
59    }
60    if name.contains("//") {
61        return Err(VoidError::InvalidBranchName(
62            "cannot contain consecutive slashes".into(),
63        ));
64    }
65
66    for component in name.split('/') {
67        if component.is_empty() {
68            return Err(VoidError::InvalidBranchName("empty path component".into()));
69        }
70        if component == "." || component == ".." {
71            return Err(VoidError::InvalidBranchName(
72                "invalid path component".into(),
73            ));
74        }
75        if component.starts_with('.') {
76            return Err(VoidError::InvalidBranchName(
77                "component cannot start with '.'".into(),
78            ));
79        }
80        if component.ends_with('.') {
81            return Err(VoidError::InvalidBranchName(
82                "component cannot end with '.'".into(),
83            ));
84        }
85        if component.ends_with(".lock") {
86            return Err(VoidError::InvalidBranchName(
87                "component cannot end with .lock".into(),
88            ));
89        }
90    }
91
92    for c in name.chars() {
93        if c.is_control()
94            || c.is_whitespace()
95            || matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
96        {
97            return Err(VoidError::InvalidBranchName(format!(
98                "invalid character: {c}"
99            )));
100        }
101    }
102
103    Ok(())
104}
105
106/// Path to the HEAD file.
107fn head_path(void_dir: &Utf8Path) -> Utf8PathBuf {
108    void_dir.join(HEAD_FILE)
109}
110
111/// Path to a branch ref file.
112fn branch_path(void_dir: &Utf8Path, name: &str) -> Utf8PathBuf {
113    void_dir.join(REFS_HEADS_DIR).join(name)
114}
115
116/// Read HEAD, returns None if HEAD doesn't exist.
117///
118/// # Errors
119///
120/// Returns `VoidError::Io` if reading the HEAD file fails.
121pub fn read_head(void_dir: &Utf8Path) -> Result<Option<HeadRef>> {
122    let path = head_path(void_dir);
123    if !path.exists() {
124        return Ok(None);
125    }
126
127    let content = fs::read_to_string(&path)?;
128    let content = content.trim();
129
130    if content.is_empty() {
131        return Ok(None);
132    }
133
134    if let Some(branch) = content.strip_prefix(SYMBOLIC_PREFIX) {
135        Ok(Some(HeadRef::Symbolic(branch.to_string())))
136    } else {
137        // Try to parse as CID
138        let cid_obj = cid::parse(content)?;
139        Ok(Some(HeadRef::Detached(CommitCid::from_bytes(cid_obj.to_bytes()))))
140    }
141}
142
143/// Resolve HEAD to a CID (follows symbolic ref if needed).
144/// Returns None if HEAD doesn't exist or points to non-existent branch.
145///
146/// # Errors
147///
148/// Returns `VoidError::Io` if file operations fail, or `VoidError::CidError` if CID parsing fails.
149pub fn resolve_head(void_dir: &Utf8Path) -> Result<Option<CommitCid>> {
150    match read_head(void_dir)? {
151        None => Ok(None),
152        Some(HeadRef::Detached(cid)) => Ok(Some(cid)),
153        Some(HeadRef::Symbolic(branch)) => read_branch(void_dir, &branch),
154    }
155}
156
157/// Resolve HEAD to a CID with split directories for workspaces.
158///
159/// `head_dir` is where HEAD lives (per-workspace state directory).
160/// `refs_dir` is where branch refs live (shared `.void` directory).
161/// For the main workspace, both are the same.
162pub fn resolve_head_split(
163    head_dir: &Utf8Path,
164    refs_dir: &Utf8Path,
165) -> Result<Option<CommitCid>> {
166    match read_head(head_dir)? {
167        None => Ok(None),
168        Some(HeadRef::Detached(cid)) => Ok(Some(cid)),
169        Some(HeadRef::Symbolic(branch)) => read_branch(refs_dir, &branch),
170    }
171}
172
173/// Write HEAD (symbolic or detached).
174///
175/// # Errors
176///
177/// Returns `VoidError::InvalidBranchName` if branch name is invalid, or `VoidError::Io` if write fails.
178pub fn write_head(void_dir: &Utf8Path, head: &HeadRef) -> Result<()> {
179    let content = match head {
180        HeadRef::Symbolic(branch) => {
181            validate_branch_name(branch)?;
182            format!("{SYMBOLIC_PREFIX}{branch}\n")
183        }
184        HeadRef::Detached(commit_cid) => {
185            let cid_obj = cid::from_bytes(commit_cid.as_bytes())?;
186            format!("{}\n", cid_obj.to_string())
187        }
188    };
189
190    atomic_write(&head_path(void_dir), content.as_bytes())?;
191    Ok(())
192}
193
194/// Read a branch ref. Returns None if branch doesn't exist.
195///
196/// # Errors
197///
198/// Returns `VoidError::InvalidBranchName` if name is invalid, `VoidError::CidError` if CID parsing fails, or `VoidError::Io` if read fails.
199pub fn read_branch(void_dir: &Utf8Path, name: &str) -> Result<Option<CommitCid>> {
200    validate_branch_name(name)?;
201
202    let path = branch_path(void_dir, name);
203    if !path.exists() {
204        return Ok(None);
205    }
206
207    let content = fs::read_to_string(&path)?;
208    let content = content.trim();
209
210    if content.is_empty() {
211        return Ok(None);
212    }
213
214    let cid_obj = cid::parse(content)?;
215    Ok(Some(CommitCid::from_bytes(cid_obj.to_bytes())))
216}
217
218/// Write a branch ref.
219///
220/// # Errors
221///
222/// Returns `VoidError::InvalidBranchName` if name is invalid, `VoidError::CidError` if CID is invalid, or `VoidError::Io` if write fails.
223pub fn write_branch(void_dir: &Utf8Path, name: &str, commit_cid: &CommitCid) -> Result<()> {
224    validate_branch_name(name)?;
225
226    let path = branch_path(void_dir, name);
227
228    // Create parent directories for namespaced branches (e.g., feature/foo)
229    if let Some(parent) = path.parent() {
230        fs::create_dir_all(parent)?;
231    }
232
233    let cid_obj = cid::from_bytes(commit_cid.as_bytes())?;
234    atomic_write(&path, format!("{}\n", cid_obj.to_string()).as_bytes())?;
235    Ok(())
236}
237
238/// Delete a branch ref.
239///
240/// # Errors
241///
242/// Returns `VoidError::InvalidBranchName` if name is invalid, or `VoidError::Io` if delete fails.
243pub fn delete_branch(void_dir: &Utf8Path, name: &str) -> Result<()> {
244    validate_branch_name(name)?;
245
246    let path = branch_path(void_dir, name);
247    if path.exists() {
248        fs::remove_file(path)?;
249    }
250    Ok(())
251}
252
253/// List all branch names.
254///
255/// # Errors
256///
257/// Returns `VoidError::Io` if directory traversal fails.
258pub fn list_branches(void_dir: &Utf8Path) -> Result<Vec<String>> {
259    let refs_dir = void_dir.join(REFS_HEADS_DIR);
260    if !refs_dir.exists() {
261        return Ok(Vec::new());
262    }
263
264    let mut branches = Vec::new();
265    collect_branches(&refs_dir, "", &mut branches)?;
266    branches.sort();
267    Ok(branches)
268}
269
270/// Recursively collect branch names (handles namespaced branches).
271fn collect_branches(dir: &Utf8Path, prefix: &str, branches: &mut Vec<String>) -> Result<()> {
272    for entry in fs::read_dir(dir)? {
273        let entry = entry?;
274        let path = Utf8PathBuf::try_from(entry.path())
275            .map_err(|e| VoidError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
276
277        let name = path.file_name().ok_or_else(|| {
278            VoidError::Io(std::io::Error::new(
279                std::io::ErrorKind::InvalidData,
280                "invalid path",
281            ))
282        })?;
283
284        let full_name = if prefix.is_empty() {
285            name.to_string()
286        } else {
287            format!("{prefix}/{name}")
288        };
289
290        if path.is_dir() {
291            collect_branches(&path, &full_name, branches)?;
292        } else {
293            branches.push(full_name);
294        }
295    }
296    Ok(())
297}
298
299// ============================================================================
300// Tag operations (lightweight refs at refs/tags/<name>)
301// ============================================================================
302
303/// Validate a tag name using the same rules as branch names.
304fn validate_tag_name(name: &str) -> Result<()> {
305    // Tags use the same validation as branches
306    validate_branch_name(name).map_err(|e| match e {
307        VoidError::InvalidBranchName(msg) => VoidError::InvalidTagName(msg),
308        other => other,
309    })
310}
311
312/// Path to a tag ref file.
313fn tag_path(void_dir: &Utf8Path, name: &str) -> Utf8PathBuf {
314    void_dir.join(REFS_TAGS_DIR).join(name)
315}
316
317/// Read a tag ref. Returns None if tag doesn't exist.
318///
319/// # Errors
320///
321/// Returns `VoidError::InvalidTagName` if name is invalid, `VoidError::CidError` if CID parsing fails, or `VoidError::Io` if read fails.
322pub fn read_tag(void_dir: &Utf8Path, name: &str) -> Result<Option<CommitCid>> {
323    validate_tag_name(name)?;
324
325    let path = tag_path(void_dir, name);
326    if !path.exists() {
327        return Ok(None);
328    }
329
330    let content = fs::read_to_string(&path)?;
331    let content = content.trim();
332
333    if content.is_empty() {
334        return Ok(None);
335    }
336
337    let cid_obj = cid::parse(content)?;
338    Ok(Some(CommitCid::from_bytes(cid_obj.to_bytes())))
339}
340
341/// Write a tag ref.
342///
343/// # Errors
344///
345/// Returns `VoidError::InvalidTagName` if name is invalid, `VoidError::CidError` if CID is invalid, or `VoidError::Io` if write fails.
346pub fn write_tag(void_dir: &Utf8Path, name: &str, commit_cid: &CommitCid) -> Result<()> {
347    validate_tag_name(name)?;
348
349    let path = tag_path(void_dir, name);
350
351    // Create parent directories for namespaced tags (e.g., release/v1.0)
352    if let Some(parent) = path.parent() {
353        fs::create_dir_all(parent)?;
354    }
355
356    let cid_obj = cid::from_bytes(commit_cid.as_bytes())?;
357    atomic_write(&path, format!("{}\n", cid_obj.to_string()).as_bytes())?;
358    Ok(())
359}
360
361/// Delete a tag ref.
362///
363/// # Errors
364///
365/// Returns `VoidError::InvalidTagName` if name is invalid, or `VoidError::Io` if delete fails.
366pub fn delete_tag(void_dir: &Utf8Path, name: &str) -> Result<()> {
367    validate_tag_name(name)?;
368
369    let path = tag_path(void_dir, name);
370    if path.exists() {
371        fs::remove_file(path)?;
372    }
373    Ok(())
374}
375
376/// List all tag names.
377///
378/// # Errors
379///
380/// Returns `VoidError::Io` if directory traversal fails.
381pub fn list_tags(void_dir: &Utf8Path) -> Result<Vec<String>> {
382    let refs_dir = void_dir.join(REFS_TAGS_DIR);
383    if !refs_dir.exists() {
384        return Ok(Vec::new());
385    }
386
387    let mut tags = Vec::new();
388    collect_refs(&refs_dir, "", &mut tags)?;
389    tags.sort();
390    Ok(tags)
391}
392
393// ============================================================================
394// Push marker operations (refs at refs/pushed/<backend>)
395// ============================================================================
396
397/// Path to a push marker ref file.
398fn push_marker_path(void_dir: &Utf8Path, backend: &str) -> Utf8PathBuf {
399    void_dir.join(REFS_PUSHED_DIR).join(backend)
400}
401
402/// Read the last-pushed commit CID for a backend. Returns None if no marker exists.
403pub fn read_push_marker(void_dir: &Utf8Path, backend: &str) -> Result<Option<CommitCid>> {
404    validate_branch_name(backend)?;
405
406    let path = push_marker_path(void_dir, backend);
407    if !path.exists() {
408        return Ok(None);
409    }
410
411    let content = fs::read_to_string(&path)?;
412    let content = content.trim();
413
414    if content.is_empty() {
415        return Ok(None);
416    }
417
418    let cid_obj = cid::parse(content)?;
419    Ok(Some(CommitCid::from_bytes(cid_obj.to_bytes())))
420}
421
422/// Write the last-pushed commit CID for a backend.
423pub fn write_push_marker(void_dir: &Utf8Path, backend: &str, commit_cid: &CommitCid) -> Result<()> {
424    validate_branch_name(backend)?;
425
426    let path = push_marker_path(void_dir, backend);
427
428    if let Some(parent) = path.parent() {
429        fs::create_dir_all(parent)?;
430    }
431
432    let cid_obj = cid::from_bytes(commit_cid.as_bytes())?;
433    atomic_write(&path, format!("{}\n", cid_obj.to_string()).as_bytes())?;
434    Ok(())
435}
436
437/// Recursively collect ref names (shared helper for tags and branches).
438fn collect_refs(dir: &Utf8Path, prefix: &str, refs: &mut Vec<String>) -> Result<()> {
439    for entry in fs::read_dir(dir)? {
440        let entry = entry?;
441        let path = Utf8PathBuf::try_from(entry.path())
442            .map_err(|e| VoidError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
443
444        let name = path.file_name().ok_or_else(|| {
445            VoidError::Io(std::io::Error::new(
446                std::io::ErrorKind::InvalidData,
447                "invalid path",
448            ))
449        })?;
450
451        let full_name = if prefix.is_empty() {
452            name.to_string()
453        } else {
454            format!("{prefix}/{name}")
455        };
456
457        if path.is_dir() {
458            collect_refs(&path, &full_name, refs)?;
459        } else {
460            refs.push(full_name);
461        }
462    }
463    Ok(())
464}