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/// On-disk path of an agent session's terminal transcript
37/// (`<sessions_dir>/<session_id>.log`). Append-only: the supervisor writes the
38/// same bytes the browser sees, so a finished session can be replayed without
39/// the container.
40pub fn session_transcript_path(sessions_dir: &Path, session_id: i64) -> PathBuf {
41 sessions_dir.join(format!("{session_id}.log"))
42}
43
44/// Create a new bare repository on disk, returning the opened handle.
45///
46/// `HEAD` is pointed at `refs/heads/<default_branch>` so the on-disk repository
47/// agrees with the forge's recorded default branch (gix otherwise defaults to
48/// whatever `init.defaultBranch` resolves to, often `master`).
49///
50/// Fails if a repository already exists at the target path. The owner
51/// directory is created as needed.
52pub fn create_bare(
53 repositories_dir: &Path,
54 owner: &str,
55 name: &str,
56 default_branch: &str,
57) -> Result<gix::Repository> {
58 let path = repo_path(repositories_dir, owner, name);
59 if path.exists() {
60 return Err(Error::AlreadyExists(format!(
61 "repository on disk at {}",
62 path.display()
63 )));
64 }
65 if let Some(parent) = path.parent() {
66 std::fs::create_dir_all(parent)?;
67 }
68 let repo = gix::init_bare(&path)
69 .map_err(|e| Error::Storage(format!("init bare {}: {e}", path.display())))?;
70 set_head_branch(&repo, default_branch)?;
71 Ok(repo)
72}
73
74/// Point `HEAD` at `refs/heads/<branch>` as a symbolic reference.
75fn set_head_branch(repo: &gix::Repository, branch: &str) -> Result<()> {
76 use gix::refs::{
77 Target,
78 transaction::{
79 Change,
80 LogChange,
81 PreviousValue,
82 RefEdit,
83 },
84 };
85
86 let target_name: gix::refs::FullName = format!("refs/heads/{branch}")
87 .try_into()
88 .map_err(|e| Error::Storage(format!("invalid branch name {branch:?}: {e}")))?;
89 let head_name: gix::refs::FullName = "HEAD"
90 .try_into()
91 .map_err(|e| Error::Storage(format!("HEAD ref name: {e}")))?;
92
93 repo.edit_reference(RefEdit {
94 change: Change::Update {
95 log: LogChange::default(),
96 expected: PreviousValue::Any,
97 new: Target::Symbolic(target_name),
98 },
99 name: head_name,
100 deref: false,
101 })
102 .map_err(|e| Error::Storage(format!("set HEAD to {branch}: {e}")))?;
103 Ok(())
104}
105
106/// Total size in bytes of all regular files under `path`, recursively. A
107/// missing or unreadable directory counts as 0, and symlinks are not followed
108/// (so cycles can't trap the walk). Used for disk-usage accounting.
109pub fn dir_size(path: &Path) -> u64 {
110 let Ok(entries) = std::fs::read_dir(path) else {
111 return 0;
112 };
113 let mut total = 0;
114 for entry in entries.flatten() {
115 let Ok(file_type) = entry.file_type() else {
116 continue;
117 };
118 if file_type.is_dir() {
119 total += dir_size(&entry.path());
120 } else if file_type.is_file() {
121 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
122 }
123 }
124 total
125}
126
127/// Open an existing bare repository.
128pub fn open(repositories_dir: &Path, owner: &str, name: &str) -> Result<gix::Repository> {
129 let path = repo_path(repositories_dir, owner, name);
130 gix::open(&path).map_err(|e| Error::Storage(format!("open {}: {e}", path.display())))
131}