anvilsign in

collin/anvil

1//! On-disk repository storage.
2//!
3//! Repositories are bare git repositories laid out as
4//! `<repositories_dir>/<owner>/<name>.git`. This module owns the mapping
5//! between forge identities and filesystem paths, plus creation/opening via
6//! `gix`. No database access happens here.
7
8use std::path::{
9 Path,
10 PathBuf,
11};
12
13use crate::error::{
14 Error,
15 Result,
16};
17
18/// Compute the on-disk path of a bare repository.
19pub fn repo_path(repositories_dir: &Path, owner: &str, name: &str) -> PathBuf {
20 repositories_dir.join(owner).join(format!("{name}.git"))
21}
22
23/// Directory holding one commit's CI artifacts
24/// (`<artifacts_dir>/<repo_id>/<commit>` — see `docs/ci-artifacts.md`).
25pub fn artifact_commit_dir(artifacts_dir: &Path, repo_id: i64, commit: &str) -> PathBuf {
26 artifacts_dir.join(repo_id.to_string()).join(commit)
27}
28
29/// On-disk path of an uploaded attachment
30/// (`<attachments_dir>/<repo_id>/<hash>`). Content-addressed, so the file is
31/// immutable once written. `hash` is validated hex by the caller.
32pub fn attachment_path(attachments_dir: &Path, repo_id: i64, hash: &str) -> PathBuf {
33 attachments_dir.join(repo_id.to_string()).join(hash)
34}
35
36/// Create a new bare repository on disk, returning the opened handle.
37///
38/// `HEAD` is pointed at `refs/heads/<default_branch>` so the on-disk repository
39/// agrees with the forge's recorded default branch (gix otherwise defaults to
40/// whatever `init.defaultBranch` resolves to, often `master`).
41///
42/// Fails if a repository already exists at the target path. The owner
43/// directory is created as needed.
44pub fn create_bare(
45 repositories_dir: &Path,
46 owner: &str,
47 name: &str,
48 default_branch: &str,
49) -> Result<gix::Repository> {
50 let path = repo_path(repositories_dir, owner, name);
51 if path.exists() {
52 return Err(Error::AlreadyExists(format!(
53 "repository on disk at {}",
54 path.display()
55 )));
56 }
57 if let Some(parent) = path.parent() {
58 std::fs::create_dir_all(parent)?;
59 }
60 let repo = gix::init_bare(&path)
61 .map_err(|e| Error::Storage(format!("init bare {}: {e}", path.display())))?;
62 set_head_branch(&repo, default_branch)?;
63 Ok(repo)
64}
65
66/// Point `HEAD` at `refs/heads/<branch>` as a symbolic reference.
67fn set_head_branch(repo: &gix::Repository, branch: &str) -> Result<()> {
68 use gix::refs::{
69 Target,
70 transaction::{
71 Change,
72 LogChange,
73 PreviousValue,
74 RefEdit,
75 },
76 };
77
78 let target_name: gix::refs::FullName = format!("refs/heads/{branch}")
79 .try_into()
80 .map_err(|e| Error::Storage(format!("invalid branch name {branch:?}: {e}")))?;
81 let head_name: gix::refs::FullName = "HEAD"
82 .try_into()
83 .map_err(|e| Error::Storage(format!("HEAD ref name: {e}")))?;
84
85 repo.edit_reference(RefEdit {
86 change: Change::Update {
87 log: LogChange::default(),
88 expected: PreviousValue::Any,
89 new: Target::Symbolic(target_name),
90 },
91 name: head_name,
92 deref: false,
93 })
94 .map_err(|e| Error::Storage(format!("set HEAD to {branch}: {e}")))?;
95 Ok(())
96}
97
98/// Total size in bytes of all regular files under `path`, recursively. A
99/// missing or unreadable directory counts as 0, and symlinks are not followed
100/// (so cycles can't trap the walk). Used for disk-usage accounting.
101pub fn dir_size(path: &Path) -> u64 {
102 let Ok(entries) = std::fs::read_dir(path) else {
103 return 0;
104 };
105 let mut total = 0;
106 for entry in entries.flatten() {
107 let Ok(file_type) = entry.file_type() else {
108 continue;
109 };
110 if file_type.is_dir() {
111 total += dir_size(&entry.path());
112 } else if file_type.is_file() {
113 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
114 }
115 }
116 total
117}
118
119/// Open an existing bare repository.
120pub fn open(repositories_dir: &Path, owner: &str, name: &str) -> Result<gix::Repository> {
121 let path = repo_path(repositories_dir, owner, name);
122 gix::open(&path).map_err(|e| Error::Storage(format!("open {}: {e}", path.display())))
123}