1use 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
20fn 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum HeadRef {
31 Symbolic(String),
33 Detached(CommitCid),
35}
36
37const HEAD_FILE: &str = "HEAD";
38const REFS_HEADS_DIR: &str = "refs/heads";
39const SYMBOLIC_PREFIX: &str = "ref: refs/heads/";
40
41fn 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
106fn head_path(void_dir: &Utf8Path) -> Utf8PathBuf {
108 void_dir.join(HEAD_FILE)
109}
110
111fn branch_path(void_dir: &Utf8Path, name: &str) -> Utf8PathBuf {
113 void_dir.join(REFS_HEADS_DIR).join(name)
114}
115
116pub 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 let cid_obj = cid::parse(content)?;
139 Ok(Some(HeadRef::Detached(CommitCid::from_bytes(cid_obj.to_bytes()))))
140 }
141}
142
143pub 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
157pub 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
173pub 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
194pub 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
218pub 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 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
238pub 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
253pub 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
270fn 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
299fn validate_tag_name(name: &str) -> Result<()> {
305 validate_branch_name(name).map_err(|e| match e {
307 VoidError::InvalidBranchName(msg) => VoidError::InvalidTagName(msg),
308 other => other,
309 })
310}
311
312fn tag_path(void_dir: &Utf8Path, name: &str) -> Utf8PathBuf {
314 void_dir.join(REFS_TAGS_DIR).join(name)
315}
316
317pub 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
341pub 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 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
361pub 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
376pub 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
393fn push_marker_path(void_dir: &Utf8Path, backend: &str) -> Utf8PathBuf {
399 void_dir.join(REFS_PUSHED_DIR).join(backend)
400}
401
402pub 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
422pub 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
437fn 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}