Skip to main content

arrow_graph_git/
refs.rs

1//! Refs — branch and HEAD management.
2//!
3//! A Refs table maps ref_name → commit_id. HEAD is the currently active branch.
4//! Branches are lightweight pointers — just a name and a commit ID.
5
6use arrow::array::{BooleanArray, RecordBatch, StringArray, TimestampMillisecondArray};
7use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
8use std::sync::Arc;
9
10/// Schema for the Refs table.
11pub fn refs_schema() -> Schema {
12    Schema::new(vec![
13        Field::new("ref_name", DataType::Utf8, false),
14        Field::new("commit_id", DataType::Utf8, false),
15        Field::new("ref_type", DataType::Utf8, false), // "branch" or "tag"
16        Field::new("is_head", DataType::Boolean, false),
17        Field::new(
18            "created_at",
19            DataType::Timestamp(TimeUnit::Millisecond, Some("UTC".into())),
20            false,
21        ),
22    ])
23}
24
25/// A single ref (branch or tag).
26#[derive(Debug, Clone)]
27pub struct Ref {
28    pub ref_name: String,
29    pub commit_id: String,
30    pub ref_type: RefType,
31    pub is_head: bool,
32    pub created_at_ms: i64,
33}
34
35/// Type of ref.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum RefType {
38    Branch,
39    Tag,
40}
41
42impl RefType {
43    pub fn as_str(&self) -> &'static str {
44        match self {
45            RefType::Branch => "branch",
46            RefType::Tag => "tag",
47        }
48    }
49}
50
51/// The refs table — manages branches and HEAD.
52pub struct RefsTable {
53    refs: Vec<Ref>,
54}
55
56impl RefsTable {
57    /// Create a new refs table with a "main" branch (no commit yet).
58    pub fn new() -> Self {
59        RefsTable { refs: Vec::new() }
60    }
61
62    /// Initialize with a first commit on "main".
63    pub fn init_main(&mut self, commit_id: &str) {
64        let now_ms = chrono::Utc::now().timestamp_millis();
65        self.refs.push(Ref {
66            ref_name: "main".to_string(),
67            commit_id: commit_id.to_string(),
68            ref_type: RefType::Branch,
69            is_head: true,
70            created_at_ms: now_ms,
71        });
72    }
73
74    /// Get the current HEAD ref.
75    pub fn head(&self) -> Option<&Ref> {
76        self.refs.iter().find(|r| r.is_head)
77    }
78
79    /// Get a ref by name.
80    pub fn get(&self, name: &str) -> Option<&Ref> {
81        self.refs.iter().find(|r| r.ref_name == name)
82    }
83
84    /// Get the commit ID for a ref name.
85    pub fn resolve(&self, name: &str) -> Option<&str> {
86        self.get(name).map(|r| r.commit_id.as_str())
87    }
88
89    /// Create a new branch at the given commit.
90    pub fn create_branch(&mut self, name: &str, commit_id: &str) -> Result<(), RefsError> {
91        if self.get(name).is_some() {
92            return Err(RefsError::RefExists(name.to_string()));
93        }
94        let now_ms = chrono::Utc::now().timestamp_millis();
95        self.refs.push(Ref {
96            ref_name: name.to_string(),
97            commit_id: commit_id.to_string(),
98            ref_type: RefType::Branch,
99            is_head: false,
100            created_at_ms: now_ms,
101        });
102        Ok(())
103    }
104
105    /// Switch HEAD to a different branch.
106    pub fn switch_head(&mut self, name: &str) -> Result<(), RefsError> {
107        if self.get(name).is_none() {
108            return Err(RefsError::RefNotFound(name.to_string()));
109        }
110        for r in &mut self.refs {
111            r.is_head = r.ref_name == name;
112        }
113        Ok(())
114    }
115
116    /// Update a branch to point to a new commit.
117    ///
118    /// Tags are immutable and cannot be updated — use `create_tag` instead.
119    pub fn update_ref(&mut self, name: &str, commit_id: &str) -> Result<(), RefsError> {
120        let r = self
121            .refs
122            .iter_mut()
123            .find(|r| r.ref_name == name)
124            .ok_or_else(|| RefsError::RefNotFound(name.to_string()))?;
125        if r.ref_type == RefType::Tag {
126            return Err(RefsError::TagImmutable(name.to_string()));
127        }
128        r.commit_id = commit_id.to_string();
129        Ok(())
130    }
131
132    /// Delete a branch by name.
133    ///
134    /// Cannot delete the HEAD branch. Cannot delete a nonexistent branch.
135    /// Deleting a branch does NOT delete the commits it pointed to.
136    pub fn delete_branch(&mut self, name: &str) -> Result<(), RefsError> {
137        let r = self
138            .refs
139            .iter()
140            .find(|r| r.ref_name == name)
141            .ok_or_else(|| RefsError::RefNotFound(name.to_string()))?;
142
143        if r.is_head {
144            return Err(RefsError::DeleteHead(name.to_string()));
145        }
146
147        if r.ref_type != RefType::Branch {
148            return Err(RefsError::NotABranch(name.to_string()));
149        }
150
151        self.refs.retain(|r| r.ref_name != name);
152        Ok(())
153    }
154
155    /// Create an immutable tag pointing to a commit.
156    ///
157    /// Tags cannot be overwritten or moved once created.
158    pub fn create_tag(&mut self, name: &str, commit_id: &str) -> Result<(), RefsError> {
159        if self.get(name).is_some() {
160            return Err(RefsError::RefExists(name.to_string()));
161        }
162        let now_ms = chrono::Utc::now().timestamp_millis();
163        self.refs.push(Ref {
164            ref_name: name.to_string(),
165            commit_id: commit_id.to_string(),
166            ref_type: RefType::Tag,
167            is_head: false,
168            created_at_ms: now_ms,
169        });
170        Ok(())
171    }
172
173    /// List all tags.
174    pub fn tags(&self) -> Vec<&Ref> {
175        self.refs
176            .iter()
177            .filter(|r| r.ref_type == RefType::Tag)
178            .collect()
179    }
180
181    /// List all branches.
182    pub fn branches(&self) -> Vec<&Ref> {
183        self.refs
184            .iter()
185            .filter(|r| r.ref_type == RefType::Branch)
186            .collect()
187    }
188
189    /// Convert to Arrow RecordBatch.
190    pub fn to_record_batch(&self) -> Result<RecordBatch, arrow::error::ArrowError> {
191        let schema = Arc::new(refs_schema());
192        if self.refs.is_empty() {
193            return Ok(RecordBatch::new_empty(schema));
194        }
195
196        let names: Vec<&str> = self.refs.iter().map(|r| r.ref_name.as_str()).collect();
197        let commits: Vec<&str> = self.refs.iter().map(|r| r.commit_id.as_str()).collect();
198        let types: Vec<&str> = self.refs.iter().map(|r| r.ref_type.as_str()).collect();
199        let heads: Vec<bool> = self.refs.iter().map(|r| r.is_head).collect();
200        let times: Vec<i64> = self.refs.iter().map(|r| r.created_at_ms).collect();
201
202        RecordBatch::try_new(
203            schema,
204            vec![
205                Arc::new(StringArray::from(names)),
206                Arc::new(StringArray::from(commits)),
207                Arc::new(StringArray::from(types)),
208                Arc::new(BooleanArray::from(heads)),
209                Arc::new(TimestampMillisecondArray::from(times).with_timezone("UTC")),
210            ],
211        )
212    }
213}
214
215impl Default for RefsTable {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221/// Errors from refs operations.
222#[derive(Debug, thiserror::Error)]
223pub enum RefsError {
224    #[error("Ref already exists: {0}")]
225    RefExists(String),
226
227    #[error("Ref not found: {0}")]
228    RefNotFound(String),
229
230    #[error("Cannot delete HEAD branch: {0}")]
231    DeleteHead(String),
232
233    #[error("Tags are immutable and cannot be moved: {0}")]
234    TagImmutable(String),
235
236    #[error("Ref is not a branch: {0}")]
237    NotABranch(String),
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_init_main_and_head() {
246        let mut refs = RefsTable::new();
247        refs.init_main("c1");
248
249        let head = refs.head().unwrap();
250        assert_eq!(head.ref_name, "main");
251        assert_eq!(head.commit_id, "c1");
252        assert!(head.is_head);
253    }
254
255    #[test]
256    fn test_create_branch_and_switch() {
257        let mut refs = RefsTable::new();
258        refs.init_main("c1");
259        refs.create_branch("feature", "c1").unwrap();
260
261        assert_eq!(refs.branches().len(), 2);
262
263        refs.switch_head("feature").unwrap();
264        assert_eq!(refs.head().unwrap().ref_name, "feature");
265    }
266
267    #[test]
268    fn test_duplicate_branch_fails() {
269        let mut refs = RefsTable::new();
270        refs.init_main("c1");
271        let result = refs.create_branch("main", "c1");
272        assert!(result.is_err());
273    }
274
275    #[test]
276    fn test_switch_nonexistent_branch_fails() {
277        let mut refs = RefsTable::new();
278        refs.init_main("c1");
279        let result = refs.switch_head("nonexistent");
280        assert!(result.is_err());
281    }
282
283    #[test]
284    fn test_update_ref() {
285        let mut refs = RefsTable::new();
286        refs.init_main("c1");
287        refs.update_ref("main", "c2").unwrap();
288        assert_eq!(refs.resolve("main"), Some("c2"));
289    }
290
291    #[test]
292    fn test_to_record_batch() {
293        let mut refs = RefsTable::new();
294        refs.init_main("c1");
295        refs.create_branch("dev", "c1").unwrap();
296
297        let batch = refs.to_record_batch().unwrap();
298        assert_eq!(batch.num_rows(), 2);
299        assert_eq!(batch.num_columns(), 5);
300    }
301
302    // --- delete_branch tests ---
303
304    #[test]
305    fn test_delete_branch_works() {
306        let mut refs = RefsTable::new();
307        refs.init_main("c1");
308        refs.create_branch("feature", "c1").unwrap();
309        assert_eq!(refs.branches().len(), 2);
310
311        refs.delete_branch("feature").unwrap();
312        assert_eq!(refs.branches().len(), 1);
313        assert!(refs.get("feature").is_none());
314    }
315
316    #[test]
317    fn test_delete_head_branch_fails() {
318        let mut refs = RefsTable::new();
319        refs.init_main("c1");
320        let result = refs.delete_branch("main");
321        assert!(result.is_err());
322        match result.unwrap_err() {
323            RefsError::DeleteHead(name) => assert_eq!(name, "main"),
324            other => panic!("Expected DeleteHead, got: {other:?}"),
325        }
326    }
327
328    #[test]
329    fn test_delete_nonexistent_branch_fails() {
330        let mut refs = RefsTable::new();
331        refs.init_main("c1");
332        let result = refs.delete_branch("ghost");
333        assert!(result.is_err());
334        match result.unwrap_err() {
335            RefsError::RefNotFound(name) => assert_eq!(name, "ghost"),
336            other => panic!("Expected RefNotFound, got: {other:?}"),
337        }
338    }
339
340    // --- tag tests ---
341
342    #[test]
343    fn test_create_tag() {
344        let mut refs = RefsTable::new();
345        refs.init_main("c1");
346        refs.create_tag("v1.0", "c1").unwrap();
347
348        let tags = refs.tags();
349        assert_eq!(tags.len(), 1);
350        assert_eq!(tags[0].ref_name, "v1.0");
351        assert_eq!(tags[0].commit_id, "c1");
352        assert_eq!(tags[0].ref_type, RefType::Tag);
353    }
354
355    #[test]
356    fn test_duplicate_tag_fails() {
357        let mut refs = RefsTable::new();
358        refs.init_main("c1");
359        refs.create_tag("v1.0", "c1").unwrap();
360        let result = refs.create_tag("v1.0", "c2");
361        assert!(result.is_err());
362    }
363
364    #[test]
365    fn test_tag_survives_branch_delete() {
366        let mut refs = RefsTable::new();
367        refs.init_main("c1");
368        refs.create_branch("feature", "c2").unwrap();
369        refs.create_tag("v1.0", "c2").unwrap();
370
371        refs.delete_branch("feature").unwrap();
372
373        // Tag should still resolve
374        assert_eq!(refs.resolve("v1.0"), Some("c2"));
375        assert_eq!(refs.tags().len(), 1);
376    }
377
378    #[test]
379    fn test_update_ref_rejects_tag() {
380        let mut refs = RefsTable::new();
381        refs.init_main("c1");
382        refs.create_tag("v1.0", "c1").unwrap();
383        let result = refs.update_ref("v1.0", "c2");
384        assert!(result.is_err());
385        match result.unwrap_err() {
386            RefsError::TagImmutable(name) => assert_eq!(name, "v1.0"),
387            other => panic!("Expected TagImmutable, got: {other:?}"),
388        }
389        // Tag should still point to original commit
390        assert_eq!(refs.resolve("v1.0"), Some("c1"));
391    }
392
393    #[test]
394    fn test_delete_branch_rejects_tag() {
395        let mut refs = RefsTable::new();
396        refs.init_main("c1");
397        refs.create_tag("v1.0", "c1").unwrap();
398        let result = refs.delete_branch("v1.0");
399        assert!(result.is_err());
400        match result.unwrap_err() {
401            RefsError::NotABranch(name) => assert_eq!(name, "v1.0"),
402            other => panic!("Expected NotABranch, got: {other:?}"),
403        }
404    }
405
406    #[test]
407    fn test_tags_not_in_branches() {
408        let mut refs = RefsTable::new();
409        refs.init_main("c1");
410        refs.create_tag("v1.0", "c1").unwrap();
411
412        // Tags should not appear in branches()
413        assert_eq!(refs.branches().len(), 1);
414        assert_eq!(refs.tags().len(), 1);
415    }
416}