//! Bare repository layout and basic queries. use std::path::{Path, PathBuf}; use crate::command::{GitCommand, GitEnv}; use crate::error::{Error, Result}; /// Crockford base32 alphabet used by ULIDs, lowercase. const ULID_ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; /// `data_dir/repos///.git`, with the ULID lowercased. /// Fails with `Error::InvalidInput` unless `repo_id` is a 26-character ULID. pub fn repo_path(data_dir: &Path, repo_id: &str) -> Result { let id = repo_id.to_ascii_lowercase(); if id.len() != 26 || !id.bytes().all(|b| ULID_ALPHABET.contains(&b)) { return Err(Error::InvalidInput(format!("not a ULID: {repo_id:?}"))); } Ok(data_dir .join("repos") .join(&id[0..2]) .join(&id[2..4]) .join(format!("{id}.git"))) } /// `data_dir/repos///.wiki.git`: the repo's wiki, a second bare repo next /// to the main one. Same ULID check as [`repo_path`]. pub fn wiki_path(data_dir: &Path, repo_id: &str) -> Result { let main = repo_path(data_dir, repo_id)?; Ok(main.with_extension("").with_extension("wiki.git")) } /// Directory under `data_dir` that holds deleted repos until they are purged. pub const TRASH_DIR: &str = "trash"; /// One directory in the trash, named `-.git`. It is a plain bare repo. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrashEntry { pub path: PathBuf, pub repo_id: String, /// Unix seconds at which the repo was moved to trash. pub deleted_at: u64, /// True for a wiki (`.wiki-.git`), false for the code repo (`-.git`). pub wiki: bool, } fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } /// Renames the bare repo for `repo_id` into `data_dir/trash/-.git` and returns the /// new path. A rename stays on one filesystem, so nothing is copied and nothing is deleted. /// Fails without touching anything when the repo directory is missing or the target exists. pub async fn move_to_trash(data_dir: &Path, repo_id: &str) -> Result { let src = repo_path(data_dir, repo_id)?; let name = format!("{}-{}.git", repo_id.to_ascii_lowercase(), unix_now()); trash_dir(data_dir, &src, &name).await } /// Renames the wiki repo of `repo_id`, if it has one, into `trash/.wiki-.git`. /// Returns `None` when the repo has no wiki, so nothing is touched. pub async fn move_wiki_to_trash(data_dir: &Path, repo_id: &str) -> Result> { let src = wiki_path(data_dir, repo_id)?; if !tokio::fs::try_exists(&src).await? { return Ok(None); } let name = format!("{}.wiki-{}.git", repo_id.to_ascii_lowercase(), unix_now()); trash_dir(data_dir, &src, &name).await.map(Some) } /// Moves `src` to `trash/`. Fails when `src` is missing or the target already exists. async fn trash_dir(data_dir: &Path, src: &Path, name: &str) -> Result { if !tokio::fs::try_exists(src).await? { return Err(Error::Io(std::io::Error::new( std::io::ErrorKind::NotFound, format!("repo directory is missing: {}", src.display()), ))); } let trash = data_dir.join(TRASH_DIR); tokio::fs::create_dir_all(&trash).await?; let dest = trash.join(name); if tokio::fs::try_exists(&dest).await? { return Err(Error::Failed(format!( "trash entry already exists: {}", dest.display() ))); } tokio::fs::rename(src, &dest).await?; Ok(dest) } /// True when the bare repo directory for `repo_id` is confirmed absent (`NotFound` from /// `symlink_metadata`). Any other error, such as a permission failure, is returned so that callers /// can abort instead of treating the directory as missing. pub fn repo_dir_missing(data_dir: &Path, repo_id: &str) -> Result { let src = repo_path(data_dir, repo_id)?; match std::fs::symlink_metadata(&src) { Ok(_) => Ok(false), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true), Err(e) => Err(e.into()), } } /// Moves a trashed repo back to its place under `data_dir/repos`. Used to undo `move_to_trash` /// when the database delete fails. Refuses to overwrite an existing repo directory. pub async fn restore_from_trash(data_dir: &Path, trashed: &Path, repo_id: &str) -> Result<()> { let dest = repo_path(data_dir, repo_id)?; restore_to(&dest, trashed).await } /// Moves a trashed wiki back to its place. Used to undo `move_wiki_to_trash`. pub async fn restore_wiki_from_trash(data_dir: &Path, trashed: &Path, repo_id: &str) -> Result<()> { let dest = wiki_path(data_dir, repo_id)?; restore_to(&dest, trashed).await } async fn restore_to(dest: &Path, trashed: &Path) -> Result<()> { if tokio::fs::try_exists(dest).await? { return Err(Error::Failed(format!( "repo directory already exists: {}", dest.display() ))); } if let Some(parent) = dest.parent() { tokio::fs::create_dir_all(parent).await?; } tokio::fs::rename(trashed, &dest).await?; Ok(()) } /// Lists trash entries, oldest first. Directories whose names do not parse are skipped. pub fn list_trash(data_dir: &Path) -> Result> { let dir = data_dir.join(TRASH_DIR); if !dir.is_dir() { return Ok(Vec::new()); } let mut entries = Vec::new(); for item in std::fs::read_dir(&dir)? { let item = item?; let path = item.path(); if !item.file_type()?.is_dir() { continue; } let Some(stem) = path .file_name() .and_then(|n| n.to_str()) .and_then(|n| n.strip_suffix(".git")) else { continue; }; let Some((id, ts)) = stem.rsplit_once('-') else { continue; }; let Ok(deleted_at) = ts.parse::() else { continue; }; let (id, wiki) = match id.strip_suffix(".wiki") { Some(base) => (base, true), None => (id, false), }; entries.push(TrashEntry { path: path.clone(), repo_id: id.to_owned(), deleted_at, wiki, }); } entries.sort_by_key(|e| e.deleted_at); Ok(entries) } /// Permanently removes trash entries deleted at least `older_than_secs` before `now`. /// Returns the removed paths. Only entries that `list_trash` recognises are removed. pub fn purge_trash(data_dir: &Path, older_than_secs: u64, now: u64) -> Result> { let mut removed = Vec::new(); for entry in list_trash(data_dir)? { if entry.deleted_at.saturating_add(older_than_secs) <= now { std::fs::remove_dir_all(&entry.path)?; removed.push(entry.path); } } Ok(removed) } /// Checks a branch name with `git check-ref-format --branch`. async fn check_branch(env: &GitEnv, name: &str) -> Result<()> { let cmd = GitCommand::new(env, "check-ref-format") .arg("--branch") .user_arg(name)?; match cmd.status().await { Ok(()) => Ok(()), Err(Error::Failed(_)) => Err(Error::InvalidInput(format!( "invalid branch name: {name:?}" ))), Err(e) => Err(e), } } /// Runs `git init --bare` at `path` with the given default branch. pub async fn init_bare(env: &GitEnv, path: &Path, default_branch: &str) -> Result<()> { check_branch(env, default_branch).await?; let path_str = path .to_str() .ok_or_else(|| Error::InvalidInput("repo path is not valid UTF-8".into()))?; if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } GitCommand::new(env, "init") .arg("--bare") .user_opt("--initial-branch", default_branch)? .paths(&[path_str]) .status() .await } /// Lists branch names in the repo at `path`. pub async fn list_branches(env: &GitEnv, path: &Path) -> Result> { let out = GitCommand::new(env, "for-each-ref") .git_dir(path) .arg("--format=%(refname:short)") .arg("refs/heads/") .output() .await?; Ok(String::from_utf8_lossy(&out) .lines() .filter(|l| !l.is_empty()) .map(str::to_owned) .collect()) } /// Copies `src` (a bare repo) to the new bare repo `dst` for a fork. Objects are hardlinked /// (`--local`), which is safe because objects are immutable. Only `refs/heads/*` and `refs/tags/*` /// survive; anything else (such as `refs/pull/*`) is deleted, and an `origin` remote is removed if /// the clone added one. `dst` must not exist. On failure after the clone starts, `dst` is removed. pub async fn fork_bare(env: &GitEnv, src: &Path, dst: &Path) -> Result<()> { if tokio::fs::try_exists(dst).await? { return Err(Error::InvalidInput(format!( "fork target already exists: {}", dst.display() ))); } let src_str = src .to_str() .ok_or_else(|| Error::InvalidInput("source path is not valid UTF-8".into()))?; let dst_str = dst .to_str() .ok_or_else(|| Error::InvalidInput("fork path is not valid UTF-8".into()))?; if let Some(parent) = dst.parent() { tokio::fs::create_dir_all(parent).await?; } // `src` is a bare repo from repo_path(), so the local transport is allowed for this call only. GitCommand::new(env, "clone") .allow_local_transport() .arg("--bare") .arg("--local") .arg("--no-checkout") .paths(&[src_str, dst_str]) .status() .await?; if let Err(e) = prune_fork(env, dst).await { // The clone created `dst` (it did not exist before), so removing it leaves nothing behind. let _ = tokio::fs::remove_dir_all(dst).await; return Err(e); } Ok(()) } /// The clone-after steps of `fork_bare`: keep only branches and tags, and drop a clone-added origin. async fn prune_fork(env: &GitEnv, dst: &Path) -> Result<()> { let out = GitCommand::new(env, "for-each-ref") .git_dir(dst) .arg("--format=%(refname)") .output() .await?; let refs = String::from_utf8_lossy(&out).to_string(); for name in refs.lines().filter(|l| !l.is_empty()) { if name.starts_with("refs/heads/") || name.starts_with("refs/tags/") { continue; } GitCommand::new(env, "update-ref") .git_dir(dst) .arg("-d") .user_arg(name)? .status() .await?; } let remotes = GitCommand::new(env, "remote").git_dir(dst).output().await?; if String::from_utf8_lossy(&remotes) .lines() .any(|r| r == "origin") { GitCommand::new(env, "remote") .git_dir(dst) .arg("remove") .arg("origin") .status() .await?; } Ok(()) } #[cfg(test)] mod tests { use super::*; const ID: &str = "01m4yv0we1e0yew2ras7jnb9sk"; fn make_repo(data_dir: &Path, id: &str) -> PathBuf { let path = repo_path(data_dir, id).unwrap(); std::fs::create_dir_all(path.join("objects")).unwrap(); std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap(); path } #[tokio::test] async fn move_to_trash_renames_and_restore_brings_it_back() { let dir = tempfile::tempdir().unwrap(); let src = make_repo(dir.path(), ID); let trashed = move_to_trash(dir.path(), ID).await.unwrap(); assert!(!src.exists()); assert!(trashed.join("HEAD").is_file()); assert_eq!(trashed.parent().unwrap(), dir.path().join(TRASH_DIR)); let name = trashed.file_name().unwrap().to_str().unwrap(); assert!( name.starts_with(&format!("{ID}-")) && name.ends_with(".git"), "{name}" ); restore_from_trash(dir.path(), &trashed, ID).await.unwrap(); assert!(src.join("HEAD").is_file()); assert!(!trashed.exists()); } #[tokio::test] async fn move_to_trash_fails_without_touching_anything_when_missing() { let dir = tempfile::tempdir().unwrap(); assert!(move_to_trash(dir.path(), ID).await.is_err()); assert!(list_trash(dir.path()).unwrap().is_empty()); } #[tokio::test] async fn restore_refuses_to_overwrite_an_existing_repo() { let dir = tempfile::tempdir().unwrap(); make_repo(dir.path(), ID); let trashed = dir.path().join(TRASH_DIR).join(format!("{ID}-1.git")); std::fs::create_dir_all(&trashed).unwrap(); assert!(restore_from_trash(dir.path(), &trashed, ID).await.is_err()); assert!(trashed.is_dir()); } #[tokio::test] async fn list_and_purge_respect_age_and_skip_unknown_names() { let dir = tempfile::tempdir().unwrap(); let trash = dir.path().join(TRASH_DIR); std::fs::create_dir_all(trash.join(format!("{ID}-1000.git"))).unwrap(); std::fs::create_dir_all(trash.join("not-a-trash-entry")).unwrap(); std::fs::write(trash.join(format!("{ID}-9999.git")), "file, not dir").unwrap(); std::fs::create_dir_all(trash.join(format!("{ID}-5000.git"))).unwrap(); let listed = list_trash(dir.path()).unwrap(); assert_eq!( listed.iter().map(|e| e.deleted_at).collect::>(), vec![1000, 5000] ); assert_eq!(listed[0].repo_id, ID); // Cutoff: anything deleted at or before now - 3000 goes. let removed = purge_trash(dir.path(), 3000, 5000).unwrap(); assert_eq!(removed, vec![trash.join(format!("{ID}-1000.git"))]); assert!(!trash.join(format!("{ID}-1000.git")).exists()); assert!(trash.join(format!("{ID}-5000.git")).exists()); assert!(trash.join("not-a-trash-entry").exists()); } #[test] fn list_trash_without_directory_is_empty() { let dir = tempfile::tempdir().unwrap(); assert!(list_trash(dir.path()).unwrap().is_empty()); } /// Runs `git` in `cwd` with a fixed identity and returns stdout. Panics on failure (tests only). fn git_in(cwd: &Path, args: &[&str]) -> String { let out = std::process::Command::new("/usr/bin/git") .current_dir(cwd) .args(args) .env_clear() .env("PATH", "/usr/local/bin:/usr/bin:/bin") .env("HOME", cwd) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@example.test") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@example.test") .output() .expect("run git"); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).into_owned() } #[tokio::test] async fn fork_bare_keeps_only_branches_and_tags() { let dir = tempfile::tempdir().unwrap(); // The real managed gitconfig, so the test sees `protocol.file.allow = never`. crate::setup::write_managed_gitconfig(dir.path()).unwrap(); let env = GitEnv { git_bin: "/usr/bin/git".into(), data_dir: dir.path().to_path_buf(), }; let src = dir.path().join("src.git"); init_bare(&env, &src, "main").await.unwrap(); let work = dir.path().join("work"); std::fs::create_dir_all(&work).unwrap(); git_in(&work, &["init", "-q", "-b", "main"]); std::fs::write(work.join("a.txt"), "hello\n").unwrap(); git_in(&work, &["add", "a.txt"]); git_in(&work, &["commit", "-q", "-m", "one"]); git_in(&work, &["tag", "v1"]); let src_str = src.to_str().unwrap(); git_in(&work, &["push", "-q", src_str, "main", "v1"]); // A pull ref that a fork must not inherit. git_in(&src, &["update-ref", "refs/pull/1/head", "refs/heads/main"]); let dst = repo_path(dir.path(), ID).unwrap(); fork_bare(&env, &src, &dst).await.unwrap(); let refs = git_in(&dst, &["for-each-ref", "--format=%(refname)"]); let refs: Vec<&str> = refs.lines().collect(); assert_eq!(refs, vec!["refs/heads/main", "refs/tags/v1"]); assert_eq!(list_branches(&env, &dst).await.unwrap(), vec!["main"]); assert!(git_in(&dst, &["remote"]).trim().is_empty()); assert!(git_in(&dst, &["log", "--format=%s", "main"]).contains("one")); } #[tokio::test] async fn fork_bare_refuses_existing_target() { let dir = tempfile::tempdir().unwrap(); crate::setup::write_managed_gitconfig(dir.path()).unwrap(); let env = GitEnv { git_bin: "/usr/bin/git".into(), data_dir: dir.path().to_path_buf(), }; let src = dir.path().join("src.git"); init_bare(&env, &src, "main").await.unwrap(); let dst = dir.path().join("taken.git"); std::fs::create_dir_all(&dst).unwrap(); assert!(fork_bare(&env, &src, &dst).await.is_err()); assert!(dst.is_dir(), "existing target must be left alone"); } } #[cfg(test)] mod wiki_trash_tests { use super::*; const ID: &str = "01arz3ndektsv4rrffq69g5fav"; #[tokio::test] async fn wiki_moves_to_trash_and_back() { let dir = tempfile::tempdir().unwrap(); let wiki = wiki_path(dir.path(), ID).unwrap(); assert!(move_wiki_to_trash(dir.path(), ID).await.unwrap().is_none()); std::fs::create_dir_all(&wiki).unwrap(); let trashed = move_wiki_to_trash(dir.path(), ID) .await .unwrap() .expect("wiki moved"); assert!(!wiki.exists()); let listed = list_trash(dir.path()).unwrap(); assert_eq!(listed.len(), 1); assert!(listed[0].wiki); assert_eq!(listed[0].repo_id, ID); restore_wiki_from_trash(dir.path(), &trashed, ID) .await .unwrap(); assert!(wiki.is_dir()); assert!(list_trash(dir.path()).unwrap().is_empty()); } }