use std::collections::BTreeMap;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::objects::{HashAlgo, ObjectId};
use crate::repo::Repository;
use super::{valid_branch_name, CheckoutError, CheckoutInfo, GraftLog, Result};
#[must_use]
pub fn valid_checkout_id(id: &str) -> bool {
id.len() == 20
&& id
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutStore {
dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReservedName {
pub name: String,
pub next: u64,
}
impl CheckoutStore {
#[must_use]
pub fn for_repository(repo: &Repository) -> Self {
Self::at(repo.git_dir().join("grit-serve").join("checkouts"))
}
#[must_use]
pub fn at(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
#[must_use]
pub fn has_checkouts(&self) -> bool {
self.dir.is_dir()
}
#[must_use]
pub fn graft_log(&self) -> GraftLog {
GraftLog::at(self.dir.join("grafts"))
}
#[must_use]
pub fn record(&self, id: &str) -> Option<CheckoutRecord> {
valid_checkout_id(id).then(|| CheckoutRecord {
id: id.to_owned(),
path: self.dir.join(format!("{id}.json")),
})
}
pub fn reserve_work_branch(
&self,
repo: &Repository,
wanted: &str,
start: u64,
) -> Result<ReservedName> {
if !valid_branch_name(wanted) {
return Err(CheckoutError::InvalidBranchName(wanted.to_owned()));
}
let mut i = start.max(1);
loop {
let name = if i == 1 {
wanted.to_owned()
} else {
format!("{wanted}-{i}")
};
i += 1;
let has_branch =
crate::refs::resolve_ref(repo.git_dir(), &format!("refs/heads/{name}")).is_ok();
if !has_branch && self.reserve(&name)? {
return Ok(ReservedName { name, next: i });
}
}
}
fn reserve(&self, name: &str) -> Result<bool> {
let key = hex::encode(HashAlgo::Sha1.checksum_digest(name.as_bytes()));
let path = self.dir.join("names").join(&key[..2]).join(&key[2..]);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
file.write_all(format!("{name}\n").as_bytes())?;
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
Err(e) => Err(e.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredCheckout {
#[serde(flatten)]
pub info: CheckoutInfo,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub set_last: BTreeMap<String, String>,
}
impl StoredCheckout {
#[must_use]
pub fn new(info: CheckoutInfo) -> Self {
Self {
info,
set_last: BTreeMap::new(),
}
}
#[must_use]
pub fn last_set(&self, branch: &str) -> Option<ObjectId> {
self.set_last
.get(branch)
.and_then(|tip| ObjectId::from_hex(tip).ok())
}
pub fn record_set(&mut self, branch: &str, tip: ObjectId) {
self.set_last.insert(branch.to_owned(), tip.to_hex());
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutRecord {
id: String,
path: PathBuf,
}
impl CheckoutRecord {
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<Option<StoredCheckout>> {
let bytes = match std::fs::read(&self.path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| CheckoutError::DamagedRecord {
id: self.id.clone(),
path: self.path.clone(),
detail: e.to_string(),
})
}
pub fn save(&mut self, checkout: &StoredCheckout) -> Result<()> {
let dir = self.path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(dir)?;
let bytes = serde_json::to_vec_pretty(checkout)
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut tmp = tempfile::Builder::new()
.prefix(&format!("{}.", self.id))
.suffix(".tmp")
.tempfile_in(dir)?;
tmp.write_all(&bytes)?;
tmp.as_file().sync_all()?;
tmp.persist(&self.path).map_err(|e| e.error)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repo::InitOptions;
use crate::snapshot_checkout::SnapshotRoot;
fn info(id: &str) -> CheckoutInfo {
CheckoutInfo {
id: id.to_owned(),
name: "demo".to_owned(),
branch: "main".to_owned(),
paths: vec!["src".to_owned()],
base: "1".repeat(40),
root: "2".repeat(40),
root_object: "tree …".to_owned(),
tree: "3".repeat(40),
roots: vec![SnapshotRoot {
commit: "2".repeat(40),
base: "1".repeat(40),
}],
files: 3,
bytes: 12,
bundle_bytes: 100,
work: Some("alice/amber-brook".to_owned()),
}
}
#[test]
fn checkout_ids_are_server_made_hex() {
assert!(valid_checkout_id("0123456789abcdef0123"));
assert!(!valid_checkout_id("0123456789ABCDEF0123"));
assert!(!valid_checkout_id("../../etc/passwd...."));
assert!(!valid_checkout_id("abc"));
let store = CheckoutStore::at("/nowhere");
assert!(store.record("../x").is_none());
assert_eq!(
store
.record("0123456789abcdef0123")
.map(|r| r.id().to_owned()),
Some("0123456789abcdef0123".to_owned())
);
}
#[test]
fn records_round_trip_and_damage_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let store = CheckoutStore::at(dir.path().join("checkouts"));
assert!(!store.has_checkouts());
let id = "00112233445566778899";
let mut record = store.record(id).expect("record");
assert_eq!(record.load().expect("load"), None);
let mut checkout = StoredCheckout::new(info(id));
let tip = ObjectId::from_hex(&"a".repeat(40)).expect("oid");
checkout.record_set("alice/amber-brook", tip);
record.save(&checkout).expect("save");
assert!(store.has_checkouts());
let loaded = record.load().expect("load").expect("record");
assert_eq!(loaded, checkout);
assert_eq!(loaded.last_set("alice/amber-brook"), Some(tip));
assert_eq!(loaded.last_set("other"), None);
let json: serde_json::Value =
serde_json::from_slice(&std::fs::read(record.path()).expect("read")).expect("json");
assert_eq!(json["id"], id);
assert_eq!(json["set_last"]["alice/amber-brook"], tip.to_hex());
let files: Vec<_> = std::fs::read_dir(store.dir())
.expect("dir")
.filter_map(|e| e.ok())
.map(|e| e.file_name())
.collect();
assert_eq!(files, vec![std::ffi::OsString::from(format!("{id}.json"))]);
std::fs::write(record.path(), b"{not json").expect("damage");
assert!(matches!(
record.load(),
Err(CheckoutError::DamagedRecord { .. })
));
}
#[test]
fn work_branch_names_are_never_given_out_twice() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
let store = CheckoutStore::for_repository(&repo);
let first = store
.reserve_work_branch(&repo, "bob/calm-fern", 1)
.expect("reserve");
assert_eq!(first.name, "bob/calm-fern");
let second = store
.reserve_work_branch(&repo, "bob/calm-fern", 1)
.expect("reserve");
assert_eq!(second.name, "bob/calm-fern-2");
assert_eq!(second.next, 3);
let again = CheckoutStore::for_repository(&repo)
.reserve_work_branch(&repo, "bob/calm-fern", 1)
.expect("reserve");
assert_eq!(again.name, "bob/calm-fern-3");
let commit = repo
.odb()
.write(
crate::objects::ObjectKind::Commit,
format!(
"tree {}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nx\n",
repo.odb()
.write(crate::objects::ObjectKind::Tree, b"")
.expect("tree")
)
.as_bytes(),
)
.expect("commit");
crate::refs::write_ref(repo.git_dir(), "refs/heads/alice/x", &commit).expect("ref");
assert_eq!(
store
.reserve_work_branch(&repo, "alice/x", 1)
.expect("reserve")
.name,
"alice/x-2"
);
assert!(matches!(
store.reserve_work_branch(&repo, "bad..name", 1),
Err(CheckoutError::InvalidBranchName(_))
));
}
}