Skip to main content

shard_core/
branch.rs

1use anyhow::Result;
2use std::fs;
3use std::path::Path;
4
5/// A branch entry: (name, tip_commit_id).
6pub type BranchEntry = (String, String);
7
8/// Resolve the current HEAD.
9/// Returns `(branch_name, commit_id)`.
10/// - On a branch: `(Some("main"), Some("abc..."))` or `(Some("main"), None)` (no commits yet)
11/// - Detached: `(None, Some("abc..."))`
12/// - No HEAD: `(None, None)`
13pub fn resolve_head(shard_dir: &Path) -> Result<(Option<String>, Option<String>)> {
14    let head_path = shard_dir.join("HEAD");
15    if !head_path.exists() {
16        return Ok((None, None));
17    }
18    let head = fs::read_to_string(&head_path)?;
19    let head = head.trim().to_string();
20
21    if let Some(branch_name) = head.strip_prefix("ref: refs/heads/") {
22        let branch_path = shard_dir.join("refs").join("heads").join(branch_name);
23        let commit_id = if branch_path.exists() {
24            Some(fs::read_to_string(&branch_path)?.trim().to_string())
25        } else {
26            None
27        };
28        Ok((Some(branch_name.to_string()), commit_id))
29    } else {
30        // Bare commit id (detached HEAD)
31        Ok((None, Some(head)))
32    }
33}
34
35/// Set HEAD to point to a branch (`ref: refs/heads/<branch>`).
36pub fn set_head_branch(shard_dir: &Path, branch: &str) -> Result<()> {
37    fs::write(
38        shard_dir.join("HEAD"),
39        format!("ref: refs/heads/{}", branch),
40    )?;
41    Ok(())
42}
43
44/// Set HEAD to a bare commit id (detached).
45pub fn set_head_commit(shard_dir: &Path, commit_id: &str) -> Result<()> {
46    fs::write(shard_dir.join("HEAD"), commit_id)?;
47    Ok(())
48}
49
50/// Update a branch ref to point to a commit.
51pub fn update_branch_ref(shard_dir: &Path, branch: &str, commit_id: &str) -> Result<()> {
52    let branch_path = shard_dir.join("refs").join("heads").join(branch);
53    fs::create_dir_all(branch_path.parent().unwrap())?;
54    fs::write(&branch_path, commit_id)?;
55    Ok(())
56}
57
58/// Create a new branch pointing to the given commit.
59pub fn create_branch(shard_dir: &Path, name: &str, commit_id: &str) -> Result<()> {
60    let branch_path = shard_dir.join("refs").join("heads").join(name);
61    if branch_path.exists() {
62        anyhow::bail!("Branch '{}' already exists", name);
63    }
64    update_branch_ref(shard_dir, name, commit_id)?;
65    println!(
66        "Created branch '{}' at {}",
67        name,
68        &commit_id[..8.min(commit_id.len())]
69    );
70    Ok(())
71}
72
73/// Delete a branch.
74pub fn delete_branch(shard_dir: &Path, name: &str) -> Result<()> {
75    let branch_path = shard_dir.join("refs").join("heads").join(name);
76    if !branch_path.exists() {
77        anyhow::bail!("Branch '{}' not found", name);
78    }
79    let (current, _) = resolve_head(shard_dir)?;
80    if current.as_deref() == Some(name) {
81        anyhow::bail!(
82            "Cannot delete branch '{}' — it is currently checked out",
83            name
84        );
85    }
86    fs::remove_file(&branch_path)?;
87    println!("Deleted branch '{}'", name);
88    Ok(())
89}
90
91/// List all branches. Returns (current_branch, all_branches).
92pub fn list_branches(shard_dir: &Path) -> Result<(Option<String>, Vec<BranchEntry>)> {
93    let current = resolve_head(shard_dir)?.0;
94    let refs_dir = shard_dir.join("refs").join("heads");
95    if !refs_dir.exists() {
96        return Ok((current, Vec::new()));
97    }
98    let mut branches = Vec::new();
99    let mut entries: Vec<_> = fs::read_dir(&refs_dir)?
100        .filter_map(|e| e.ok())
101        .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
102        .collect();
103    entries.sort_by_key(|e| e.file_name());
104    for entry in entries {
105        let name = entry.file_name().to_string_lossy().to_string();
106        let commit_id = fs::read_to_string(entry.path())?.trim().to_string();
107        branches.push((name, commit_id));
108    }
109    Ok((current, branches))
110}
111
112/// Resolve a branch or commit string to a commit id.
113/// If `name` matches a branch, returns its tip. Otherwise treats it as a commit id.
114pub fn resolve_rev(shard_dir: &Path, name: &str) -> Result<String> {
115    let branch_path = shard_dir.join("refs").join("heads").join(name);
116    if branch_path.exists() {
117        return Ok(fs::read_to_string(&branch_path)?.trim().to_string());
118    }
119    // Treat as bare commit id
120    Ok(name.to_string())
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use tempfile::tempdir;
127
128    fn init_shard(dir: &Path) {
129        fs::create_dir_all(dir.join("refs/heads")).unwrap();
130        set_head_branch(dir, "main").unwrap();
131    }
132
133    #[test]
134    fn test_resolve_head_empty() {
135        let dir = tempdir().unwrap();
136        let (branch, commit) = resolve_head(dir.path()).unwrap();
137
138        assert!(branch.is_none());
139        assert!(commit.is_none());
140    }
141
142    #[test]
143    fn test_set_head_branch_and_resolve() {
144        let dir = tempdir().unwrap();
145        fs::create_dir_all(dir.path().join("refs/heads")).unwrap();
146        set_head_branch(dir.path(), "main").unwrap();
147        update_branch_ref(dir.path(), "main", "abc123").unwrap();
148        let (branch, commit) = resolve_head(dir.path()).unwrap();
149        assert_eq!(branch.as_deref(), Some("main"));
150        assert_eq!(commit.as_deref(), Some("abc123"));
151    }
152
153    #[test]
154    fn test_resolve_head_detached() {
155        let dir = tempdir().unwrap();
156        set_head_commit(dir.path(), "detachedhash").unwrap();
157        let (branch, commit) = resolve_head(dir.path()).unwrap();
158        assert!(branch.is_none());
159        assert_eq!(commit.as_deref(), Some("detachedhash"));
160    }
161
162    #[test]
163    fn test_resolve_rev_branch() {
164        let dir = tempdir().unwrap();
165        fs::create_dir_all(dir.path().join("refs/heads")).unwrap();
166        update_branch_ref(dir.path(), "feature", "featurehash").unwrap();
167        let result = resolve_rev(dir.path(), "feature").unwrap();
168        assert_eq!(result, "featurehash");
169    }
170
171    #[test]
172    fn test_resolve_rev_commit_id() {
173        let dir = tempdir().unwrap();
174        let result = resolve_rev(dir.path(), "abc123").unwrap();
175        assert_eq!(result, "abc123");
176    }
177
178    #[test]
179    fn test_create_and_delete_branch() {
180        let dir = tempdir().unwrap();
181        let shard = dir.path();
182        init_shard(shard);
183        // Set a HEAD commit so branch deletion doesn't fail
184        update_branch_ref(shard, "main", "somecommit").unwrap();
185
186        create_branch(shard, "test-branch", "testcommit").unwrap();
187        // Creating duplicate should fail
188        assert!(create_branch(shard, "test-branch", "other").is_err());
189
190        delete_branch(shard, "test-branch").unwrap();
191        // Deleting non-existent should fail
192        assert!(delete_branch(shard, "nonexistent").is_err());
193    }
194
195    #[test]
196    fn test_delete_current_branch_fails() {
197        let dir = tempdir().unwrap();
198        let shard = dir.path();
199        init_shard(shard);
200        update_branch_ref(shard, "main", "commit1").unwrap();
201        let result = delete_branch(shard, "main");
202        assert!(result.is_err());
203        assert!(result
204            .unwrap_err()
205            .to_string()
206            .contains("currently checked out"));
207    }
208
209    #[test]
210    fn test_list_branches() {
211        let dir = tempdir().unwrap();
212        let shard = dir.path();
213        init_shard(shard);
214        update_branch_ref(shard, "main", "hash1").unwrap();
215        update_branch_ref(shard, "dev", "hash2").unwrap();
216
217        let (current, branches) = list_branches(shard).unwrap();
218        assert_eq!(current.as_deref(), Some("main"));
219        assert!(branches.iter().any(|(n, _)| n == "main"));
220        assert!(branches.iter().any(|(n, _)| n == "dev"));
221    }
222
223    #[test]
224    fn test_resolve_head_branch_no_commits() {
225        let dir = tempdir().unwrap();
226        let shard = dir.path();
227        set_head_branch(shard, "main").unwrap();
228        let (branch, commit) = resolve_head(shard).unwrap();
229        assert_eq!(branch.as_deref(), Some("main"));
230        assert!(commit.is_none());
231    }
232}