anvilsign in

collin/anvil

main / crates / anvil-core / src / storage.rs
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/// Move a bare repository out of the way, returning the path it now sits at
75/// (`None` if there was nothing on disk).
76///
77/// Deletion moves before it removes so the two halves — the database row and
78/// the directory — can't disagree in the direction that hurts. A rename is
79/// atomic and cheap; once it succeeds the name is free for re-creation, and a
80/// crash before the removal leaves only unreferenced bytes, which an operator
81/// can delete at leisure. Removing first would risk the opposite: a live row
82/// pointing at a repository that no longer exists.
83///
84/// `repo_id` names the staging directory, so two deletions can't collide (ids
85/// are never reused).
86pub fn stage_removal(
87 repositories_dir: &Path,
88 owner: &str,
89 name: &str,
90 repo_id: i64,
91) -> Result<Option<PathBuf>> {
92 let path = repo_path(repositories_dir, owner, name);
93 if !path.exists() {
94 return Ok(None);
95 }
96 let staged = path.with_file_name(format!("{name}.git.deleted-{repo_id}"));
97 std::fs::rename(&path, &staged).map_err(|e| {
98 Error::Storage(format!(
99 "move {} aside to {}: {e}",
100 path.display(),
101 staged.display()
102 ))
103 })?;
104 Ok(Some(staged))
105}
106
107/// Remove a directory tree, logging rather than failing: every caller is past
108/// the point where the forge has already forgotten what the bytes were, so a
109/// leftover directory is an operator cleanup, not an error to report.
110pub fn remove_tree(path: &Path) {
111 if !path.exists() {
112 return;
113 }
114 if let Err(e) = std::fs::remove_dir_all(path) {
115 tracing::warn!("could not remove {}: {e}", path.display());
116 }
117}
118
119/// Point `HEAD` at `refs/heads/<branch>` as a symbolic reference.
120fn set_head_branch(repo: &gix::Repository, branch: &str) -> Result<()> {
121 use gix::refs::{
122 Target,
123 transaction::{
124 Change,
125 LogChange,
126 PreviousValue,
127 RefEdit,
128 },
129 };
130
131 let target_name: gix::refs::FullName = format!("refs/heads/{branch}")
132 .try_into()
133 .map_err(|e| Error::Storage(format!("invalid branch name {branch:?}: {e}")))?;
134 let head_name: gix::refs::FullName = "HEAD"
135 .try_into()
136 .map_err(|e| Error::Storage(format!("HEAD ref name: {e}")))?;
137
138 repo.edit_reference(RefEdit {
139 change: Change::Update {
140 log: LogChange::default(),
141 expected: PreviousValue::Any,
142 new: Target::Symbolic(target_name),
143 },
144 name: head_name,
145 deref: false,
146 })
147 .map_err(|e| Error::Storage(format!("set HEAD to {branch}: {e}")))?;
148 Ok(())
149}
150
151/// Total size in bytes of all regular files under `path`, recursively. A
152/// missing or unreadable directory counts as 0, and symlinks are not followed
153/// (so cycles can't trap the walk). Used for disk-usage accounting.
154pub fn dir_size(path: &Path) -> u64 {
155 let Ok(entries) = std::fs::read_dir(path) else {
156 return 0;
157 };
158 let mut total = 0;
159 for entry in entries.flatten() {
160 let Ok(file_type) = entry.file_type() else {
161 continue;
162 };
163 if file_type.is_dir() {
164 total += dir_size(&entry.path());
165 } else if file_type.is_file() {
166 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
167 }
168 }
169 total
170}
171
172/// Open an existing bare repository.
173pub fn open(repositories_dir: &Path, owner: &str, name: &str) -> Result<gix::Repository> {
174 let path = repo_path(repositories_dir, owner, name);
175 gix::open(&path).map_err(|e| Error::Storage(format!("open {}: {e}", path.display())))
176}