LeVCS/crates/levcs-core/src/refs.rs

253 lines
7.7 KiB
Rust

//! References. Stored under `.levcs/refs/` as small text files containing one
//! hex hash and a trailing newline. `HEAD` is at the top level of `.levcs/`.
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{Error, IoExt, Result};
use crate::hash::ObjectId;
#[derive(Clone, Debug)]
pub struct Refs {
pub levcs_dir: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Head {
/// HEAD points at a branch (e.g., `refs/branches/main`).
Branch(String),
/// Detached HEAD pointing directly at a commit.
Detached(ObjectId),
}
impl Refs {
pub fn new(levcs_dir: impl Into<PathBuf>) -> Self {
Self {
levcs_dir: levcs_dir.into(),
}
}
pub fn refs_dir(&self) -> PathBuf {
self.levcs_dir.join("refs")
}
pub fn head_path(&self) -> PathBuf {
self.levcs_dir.join("HEAD")
}
pub fn ref_path(&self, name: &str) -> Result<PathBuf> {
validate_ref_name(name)?;
Ok(self.levcs_dir.join(name))
}
pub fn read(&self, name: &str) -> Result<Option<ObjectId>> {
let path = self.ref_path(name)?;
match fs::read_to_string(&path) {
Ok(s) => Ok(Some(parse_ref_value(&s)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::Io {
path: Some(path),
source: e,
}),
}
}
pub fn write(&self, name: &str, id: ObjectId) -> Result<()> {
let path = self.ref_path(name)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ctx(parent.to_path_buf())?;
}
atomic_write(&path, format!("{}\n", id.to_hex()).as_bytes())
}
pub fn delete(&self, name: &str) -> Result<()> {
let path = self.ref_path(name)?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::Io {
path: Some(path),
source: e,
}),
}
}
pub fn read_head(&self) -> Result<Option<Head>> {
let path = self.head_path();
match fs::read_to_string(&path) {
Ok(s) => Ok(Some(parse_head(&s)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::Io {
path: Some(path),
source: e,
}),
}
}
pub fn write_head(&self, head: &Head) -> Result<()> {
let s = match head {
Head::Branch(name) => {
validate_ref_name(name)?;
format!("ref: {}\n", name)
}
Head::Detached(id) => format!("{}\n", id.to_hex()),
};
atomic_write(&self.head_path(), s.as_bytes())
}
/// Resolve HEAD to a commit hash, if any. None if HEAD points to a branch
/// that does not exist (i.e., empty repository).
pub fn resolve_head(&self) -> Result<Option<ObjectId>> {
match self.read_head()? {
None => Ok(None),
Some(Head::Detached(id)) => Ok(Some(id)),
Some(Head::Branch(name)) => self.read(&name),
}
}
/// List every ref under `refs/`. Returns `(name, id)` pairs.
pub fn list_all(&self) -> Result<Vec<(String, ObjectId)>> {
let mut out = Vec::new();
let dir = self.refs_dir();
if !dir.is_dir() {
return Ok(out);
}
walk(&dir, &dir, &mut out)?;
return Ok(out);
fn walk(base: &Path, dir: &Path, out: &mut Vec<(String, ObjectId)>) -> Result<()> {
for ent in fs::read_dir(dir).ctx(dir.to_path_buf())? {
let ent = ent.ctx(dir.to_path_buf())?;
let path = ent.path();
if path.is_dir() {
walk(base, &path, out)?;
} else {
let rel = path.strip_prefix(base.parent().unwrap()).unwrap();
let name = rel.to_string_lossy().replace('\\', "/").to_string();
let txt = fs::read_to_string(&path).ctx(path.clone())?;
if let Ok(id) = parse_ref_value(&txt) {
out.push((name, id));
}
}
}
Ok(())
}
}
pub fn list_branches(&self) -> Result<Vec<(String, ObjectId)>> {
let dir = self.refs_dir().join("branches");
let mut out = Vec::new();
if !dir.is_dir() {
return Ok(out);
}
for ent in fs::read_dir(&dir).ctx(dir.clone())? {
let ent = ent.ctx(dir.clone())?;
let name = ent.file_name().to_string_lossy().to_string();
let txt = fs::read_to_string(ent.path()).ctx(ent.path())?;
if let Ok(id) = parse_ref_value(&txt) {
out.push((name, id));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
pub fn list_releases(&self) -> Result<Vec<(String, ObjectId)>> {
let dir = self.refs_dir().join("releases");
let mut out = Vec::new();
if !dir.is_dir() {
return Ok(out);
}
for ent in fs::read_dir(&dir).ctx(dir.clone())? {
let ent = ent.ctx(dir.clone())?;
let name = ent.file_name().to_string_lossy().to_string();
let txt = fs::read_to_string(ent.path()).ctx(ent.path())?;
if let Ok(id) = parse_ref_value(&txt) {
out.push((name, id));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
}
pub fn validate_ref_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::InvalidReference("empty".into()));
}
for comp in name.split('/') {
if comp.is_empty() {
return Err(Error::InvalidReference(format!(
"empty component in {name}"
)));
}
if comp == "." || comp == ".." {
return Err(Error::InvalidReference(format!(
"reserved component: {comp}"
)));
}
if comp.contains('\0') {
return Err(Error::InvalidReference("null byte".into()));
}
}
if name.contains("//") || name.starts_with('/') || name.ends_with('/') {
return Err(Error::InvalidReference(format!("malformed path: {name}")));
}
Ok(())
}
fn parse_ref_value(s: &str) -> Result<ObjectId> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(Error::InvalidReference("empty ref body".into()));
}
ObjectId::from_hex(trimmed)
}
fn parse_head(s: &str) -> Result<Head> {
let trimmed = s.trim();
if let Some(rest) = trimmed.strip_prefix("ref:") {
let name = rest.trim();
validate_ref_name(name)?;
Ok(Head::Branch(name.to_string()))
} else {
Ok(Head::Detached(ObjectId::from_hex(trimmed)?))
}
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| Error::Other(format!("ref path has no parent: {path:?}")))?;
fs::create_dir_all(parent).ctx(parent.to_path_buf())?;
let tmp = parent.join(format!(
".tmp.{}",
path.file_name().unwrap().to_string_lossy()
));
fs::write(&tmp, bytes).ctx(tmp.clone())?;
fs::rename(&tmp, path).ctx(path.to_path_buf())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_names_rejected() {
for n in ["", ".", "..", "a/", "/a", "a//b", "a/.."] {
assert!(validate_ref_name(n).is_err(), "should reject: {n}");
}
}
#[test]
fn valid_names_accepted() {
for n in [
"refs/branches/main",
"refs/releases/v1.0",
"refs/authority/current",
] {
validate_ref_name(n).unwrap();
}
}
}