253 lines
7.7 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|