forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
17.9 KiB
1//! Bare repository layout and basic queries.
2
3use std::path::{Path, PathBuf};
4
5use crate::command::{GitCommand, GitEnv};
6use crate::error::{Error, Result};
7
8/// Crockford base32 alphabet used by ULIDs, lowercase.
9const ULID_ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
10
11/// `data_dir/repos/<id[0..2]>/<id[2..4]>/<id>.git`, with the ULID lowercased.
12/// Fails with `Error::InvalidInput` unless `repo_id` is a 26-character ULID.
13pub fn repo_path(data_dir: &Path, repo_id: &str) -> Result<PathBuf> {
14 let id = repo_id.to_ascii_lowercase();
15 if id.len() != 26 || !id.bytes().all(|b| ULID_ALPHABET.contains(&b)) {
16 return Err(Error::InvalidInput(format!("not a ULID: {repo_id:?}")));
17 }
18 Ok(data_dir
19 .join("repos")
20 .join(&id[0..2])
21 .join(&id[2..4])
22 .join(format!("{id}.git")))
23}
24
25/// `data_dir/repos/<id[0..2]>/<id[2..4]>/<id>.wiki.git`: the repo's wiki, a second bare repo next
26/// to the main one. Same ULID check as [`repo_path`].
27pub fn wiki_path(data_dir: &Path, repo_id: &str) -> Result<PathBuf> {
28 let main = repo_path(data_dir, repo_id)?;
29 Ok(main.with_extension("").with_extension("wiki.git"))
30}
31
32/// Directory under `data_dir` that holds deleted repos until they are purged.
33pub const TRASH_DIR: &str = "trash";
34
35/// One directory in the trash, named `<repo_id>-<unix_ts>.git`. It is a plain bare repo.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TrashEntry {
38 pub path: PathBuf,
39 pub repo_id: String,
40 /// Unix seconds at which the repo was moved to trash.
41 pub deleted_at: u64,
42 /// True for a wiki (`<id>.wiki-<ts>.git`), false for the code repo (`<id>-<ts>.git`).
43 pub wiki: bool,
44}
45
46fn unix_now() -> u64 {
47 std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .map(|d| d.as_secs())
50 .unwrap_or(0)
51}
52
53/// Renames the bare repo for `repo_id` into `data_dir/trash/<id>-<unix_ts>.git` and returns the
54/// new path. A rename stays on one filesystem, so nothing is copied and nothing is deleted.
55/// Fails without touching anything when the repo directory is missing or the target exists.
56pub async fn move_to_trash(data_dir: &Path, repo_id: &str) -> Result<PathBuf> {
57 let src = repo_path(data_dir, repo_id)?;
58 let name = format!("{}-{}.git", repo_id.to_ascii_lowercase(), unix_now());
59 trash_dir(data_dir, &src, &name).await
60}
61
62/// Renames the wiki repo of `repo_id`, if it has one, into `trash/<id>.wiki-<unix_ts>.git`.
63/// Returns `None` when the repo has no wiki, so nothing is touched.
64pub async fn move_wiki_to_trash(data_dir: &Path, repo_id: &str) -> Result<Option<PathBuf>> {
65 let src = wiki_path(data_dir, repo_id)?;
66 if !tokio::fs::try_exists(&src).await? {
67 return Ok(None);
68 }
69 let name = format!("{}.wiki-{}.git", repo_id.to_ascii_lowercase(), unix_now());
70 trash_dir(data_dir, &src, &name).await.map(Some)
71}
72
73/// Moves `src` to `trash/<name>`. Fails when `src` is missing or the target already exists.
74async fn trash_dir(data_dir: &Path, src: &Path, name: &str) -> Result<PathBuf> {
75 if !tokio::fs::try_exists(src).await? {
76 return Err(Error::Io(std::io::Error::new(
77 std::io::ErrorKind::NotFound,
78 format!("repo directory is missing: {}", src.display()),
79 )));
80 }
81 let trash = data_dir.join(TRASH_DIR);
82 tokio::fs::create_dir_all(&trash).await?;
83 let dest = trash.join(name);
84 if tokio::fs::try_exists(&dest).await? {
85 return Err(Error::Failed(format!(
86 "trash entry already exists: {}",
87 dest.display()
88 )));
89 }
90 tokio::fs::rename(src, &dest).await?;
91 Ok(dest)
92}
93
94/// True when the bare repo directory for `repo_id` is confirmed absent (`NotFound` from
95/// `symlink_metadata`). Any other error, such as a permission failure, is returned so that callers
96/// can abort instead of treating the directory as missing.
97pub fn repo_dir_missing(data_dir: &Path, repo_id: &str) -> Result<bool> {
98 let src = repo_path(data_dir, repo_id)?;
99 match std::fs::symlink_metadata(&src) {
100 Ok(_) => Ok(false),
101 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
102 Err(e) => Err(e.into()),
103 }
104}
105
106/// Moves a trashed repo back to its place under `data_dir/repos`. Used to undo `move_to_trash`
107/// when the database delete fails. Refuses to overwrite an existing repo directory.
108pub async fn restore_from_trash(data_dir: &Path, trashed: &Path, repo_id: &str) -> Result<()> {
109 let dest = repo_path(data_dir, repo_id)?;
110 restore_to(&dest, trashed).await
111}
112
113/// Moves a trashed wiki back to its place. Used to undo `move_wiki_to_trash`.
114pub async fn restore_wiki_from_trash(data_dir: &Path, trashed: &Path, repo_id: &str) -> Result<()> {
115 let dest = wiki_path(data_dir, repo_id)?;
116 restore_to(&dest, trashed).await
117}
118
119async fn restore_to(dest: &Path, trashed: &Path) -> Result<()> {
120 if tokio::fs::try_exists(dest).await? {
121 return Err(Error::Failed(format!(
122 "repo directory already exists: {}",
123 dest.display()
124 )));
125 }
126 if let Some(parent) = dest.parent() {
127 tokio::fs::create_dir_all(parent).await?;
128 }
129 tokio::fs::rename(trashed, &dest).await?;
130 Ok(())
131}
132
133/// Lists trash entries, oldest first. Directories whose names do not parse are skipped.
134pub fn list_trash(data_dir: &Path) -> Result<Vec<TrashEntry>> {
135 let dir = data_dir.join(TRASH_DIR);
136 if !dir.is_dir() {
137 return Ok(Vec::new());
138 }
139 let mut entries = Vec::new();
140 for item in std::fs::read_dir(&dir)? {
141 let item = item?;
142 let path = item.path();
143 if !item.file_type()?.is_dir() {
144 continue;
145 }
146 let Some(stem) = path
147 .file_name()
148 .and_then(|n| n.to_str())
149 .and_then(|n| n.strip_suffix(".git"))
150 else {
151 continue;
152 };
153 let Some((id, ts)) = stem.rsplit_once('-') else {
154 continue;
155 };
156 let Ok(deleted_at) = ts.parse::<u64>() else {
157 continue;
158 };
159 let (id, wiki) = match id.strip_suffix(".wiki") {
160 Some(base) => (base, true),
161 None => (id, false),
162 };
163 entries.push(TrashEntry {
164 path: path.clone(),
165 repo_id: id.to_owned(),
166 deleted_at,
167 wiki,
168 });
169 }
170 entries.sort_by_key(|e| e.deleted_at);
171 Ok(entries)
172}
173
174/// Permanently removes trash entries deleted at least `older_than_secs` before `now`.
175/// Returns the removed paths. Only entries that `list_trash` recognises are removed.
176pub fn purge_trash(data_dir: &Path, older_than_secs: u64, now: u64) -> Result<Vec<PathBuf>> {
177 let mut removed = Vec::new();
178 for entry in list_trash(data_dir)? {
179 if entry.deleted_at.saturating_add(older_than_secs) <= now {
180 std::fs::remove_dir_all(&entry.path)?;
181 removed.push(entry.path);
182 }
183 }
184 Ok(removed)
185}
186
187/// Checks a branch name with `git check-ref-format --branch`.
188async fn check_branch(env: &GitEnv, name: &str) -> Result<()> {
189 let cmd = GitCommand::new(env, "check-ref-format")
190 .arg("--branch")
191 .user_arg(name)?;
192 match cmd.status().await {
193 Ok(()) => Ok(()),
194 Err(Error::Failed(_)) => Err(Error::InvalidInput(format!(
195 "invalid branch name: {name:?}"
196 ))),
197 Err(e) => Err(e),
198 }
199}
200
201/// Runs `git init --bare` at `path` with the given default branch.
202pub async fn init_bare(env: &GitEnv, path: &Path, default_branch: &str) -> Result<()> {
203 check_branch(env, default_branch).await?;
204 let path_str = path
205 .to_str()
206 .ok_or_else(|| Error::InvalidInput("repo path is not valid UTF-8".into()))?;
207 if let Some(parent) = path.parent() {
208 tokio::fs::create_dir_all(parent).await?;
209 }
210 GitCommand::new(env, "init")
211 .arg("--bare")
212 .user_opt("--initial-branch", default_branch)?
213 .paths(&[path_str])
214 .status()
215 .await
216}
217
218/// Lists branch names in the repo at `path`.
219pub async fn list_branches(env: &GitEnv, path: &Path) -> Result<Vec<String>> {
220 let out = GitCommand::new(env, "for-each-ref")
221 .git_dir(path)
222 .arg("--format=%(refname:short)")
223 .arg("refs/heads/")
224 .output()
225 .await?;
226 Ok(String::from_utf8_lossy(&out)
227 .lines()
228 .filter(|l| !l.is_empty())
229 .map(str::to_owned)
230 .collect())
231}
232
233/// Copies `src` (a bare repo) to the new bare repo `dst` for a fork. Objects are hardlinked
234/// (`--local`), which is safe because objects are immutable. Only `refs/heads/*` and `refs/tags/*`
235/// survive; anything else (such as `refs/pull/*`) is deleted, and an `origin` remote is removed if
236/// the clone added one. `dst` must not exist. On failure after the clone starts, `dst` is removed.
237pub async fn fork_bare(env: &GitEnv, src: &Path, dst: &Path) -> Result<()> {
238 if tokio::fs::try_exists(dst).await? {
239 return Err(Error::InvalidInput(format!(
240 "fork target already exists: {}",
241 dst.display()
242 )));
243 }
244 let src_str = src
245 .to_str()
246 .ok_or_else(|| Error::InvalidInput("source path is not valid UTF-8".into()))?;
247 let dst_str = dst
248 .to_str()
249 .ok_or_else(|| Error::InvalidInput("fork path is not valid UTF-8".into()))?;
250 if let Some(parent) = dst.parent() {
251 tokio::fs::create_dir_all(parent).await?;
252 }
253 // `src` is a bare repo from repo_path(), so the local transport is allowed for this call only.
254 GitCommand::new(env, "clone")
255 .allow_local_transport()
256 .arg("--bare")
257 .arg("--local")
258 .arg("--no-checkout")
259 .paths(&[src_str, dst_str])
260 .status()
261 .await?;
262 if let Err(e) = prune_fork(env, dst).await {
263 // The clone created `dst` (it did not exist before), so removing it leaves nothing behind.
264 let _ = tokio::fs::remove_dir_all(dst).await;
265 return Err(e);
266 }
267 Ok(())
268}
269
270/// The clone-after steps of `fork_bare`: keep only branches and tags, and drop a clone-added origin.
271async fn prune_fork(env: &GitEnv, dst: &Path) -> Result<()> {
272 let out = GitCommand::new(env, "for-each-ref")
273 .git_dir(dst)
274 .arg("--format=%(refname)")
275 .output()
276 .await?;
277 let refs = String::from_utf8_lossy(&out).to_string();
278 for name in refs.lines().filter(|l| !l.is_empty()) {
279 if name.starts_with("refs/heads/") || name.starts_with("refs/tags/") {
280 continue;
281 }
282 GitCommand::new(env, "update-ref")
283 .git_dir(dst)
284 .arg("-d")
285 .user_arg(name)?
286 .status()
287 .await?;
288 }
289 let remotes = GitCommand::new(env, "remote").git_dir(dst).output().await?;
290 if String::from_utf8_lossy(&remotes)
291 .lines()
292 .any(|r| r == "origin")
293 {
294 GitCommand::new(env, "remote")
295 .git_dir(dst)
296 .arg("remove")
297 .arg("origin")
298 .status()
299 .await?;
300 }
301 Ok(())
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 const ID: &str = "01m4yv0we1e0yew2ras7jnb9sk";
309
310 fn make_repo(data_dir: &Path, id: &str) -> PathBuf {
311 let path = repo_path(data_dir, id).unwrap();
312 std::fs::create_dir_all(path.join("objects")).unwrap();
313 std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
314 path
315 }
316
317 #[tokio::test]
318 async fn move_to_trash_renames_and_restore_brings_it_back() {
319 let dir = tempfile::tempdir().unwrap();
320 let src = make_repo(dir.path(), ID);
321 let trashed = move_to_trash(dir.path(), ID).await.unwrap();
322
323 assert!(!src.exists());
324 assert!(trashed.join("HEAD").is_file());
325 assert_eq!(trashed.parent().unwrap(), dir.path().join(TRASH_DIR));
326 let name = trashed.file_name().unwrap().to_str().unwrap();
327 assert!(
328 name.starts_with(&format!("{ID}-")) && name.ends_with(".git"),
329 "{name}"
330 );
331
332 restore_from_trash(dir.path(), &trashed, ID).await.unwrap();
333 assert!(src.join("HEAD").is_file());
334 assert!(!trashed.exists());
335 }
336
337 #[tokio::test]
338 async fn move_to_trash_fails_without_touching_anything_when_missing() {
339 let dir = tempfile::tempdir().unwrap();
340 assert!(move_to_trash(dir.path(), ID).await.is_err());
341 assert!(list_trash(dir.path()).unwrap().is_empty());
342 }
343
344 #[tokio::test]
345 async fn restore_refuses_to_overwrite_an_existing_repo() {
346 let dir = tempfile::tempdir().unwrap();
347 make_repo(dir.path(), ID);
348 let trashed = dir.path().join(TRASH_DIR).join(format!("{ID}-1.git"));
349 std::fs::create_dir_all(&trashed).unwrap();
350 assert!(restore_from_trash(dir.path(), &trashed, ID).await.is_err());
351 assert!(trashed.is_dir());
352 }
353
354 #[tokio::test]
355 async fn list_and_purge_respect_age_and_skip_unknown_names() {
356 let dir = tempfile::tempdir().unwrap();
357 let trash = dir.path().join(TRASH_DIR);
358 std::fs::create_dir_all(trash.join(format!("{ID}-1000.git"))).unwrap();
359 std::fs::create_dir_all(trash.join("not-a-trash-entry")).unwrap();
360 std::fs::write(trash.join(format!("{ID}-9999.git")), "file, not dir").unwrap();
361 std::fs::create_dir_all(trash.join(format!("{ID}-5000.git"))).unwrap();
362
363 let listed = list_trash(dir.path()).unwrap();
364 assert_eq!(
365 listed.iter().map(|e| e.deleted_at).collect::<Vec<_>>(),
366 vec![1000, 5000]
367 );
368 assert_eq!(listed[0].repo_id, ID);
369
370 // Cutoff: anything deleted at or before now - 3000 goes.
371 let removed = purge_trash(dir.path(), 3000, 5000).unwrap();
372 assert_eq!(removed, vec![trash.join(format!("{ID}-1000.git"))]);
373 assert!(!trash.join(format!("{ID}-1000.git")).exists());
374 assert!(trash.join(format!("{ID}-5000.git")).exists());
375 assert!(trash.join("not-a-trash-entry").exists());
376 }
377
378 #[test]
379 fn list_trash_without_directory_is_empty() {
380 let dir = tempfile::tempdir().unwrap();
381 assert!(list_trash(dir.path()).unwrap().is_empty());
382 }
383
384 /// Runs `git` in `cwd` with a fixed identity and returns stdout. Panics on failure (tests only).
385 fn git_in(cwd: &Path, args: &[&str]) -> String {
386 let out = std::process::Command::new("/usr/bin/git")
387 .current_dir(cwd)
388 .args(args)
389 .env_clear()
390 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
391 .env("HOME", cwd)
392 .env("GIT_CONFIG_NOSYSTEM", "1")
393 .env("GIT_AUTHOR_NAME", "t")
394 .env("GIT_AUTHOR_EMAIL", "t@example.test")
395 .env("GIT_COMMITTER_NAME", "t")
396 .env("GIT_COMMITTER_EMAIL", "t@example.test")
397 .output()
398 .expect("run git");
399 assert!(
400 out.status.success(),
401 "git {args:?}: {}",
402 String::from_utf8_lossy(&out.stderr)
403 );
404 String::from_utf8_lossy(&out.stdout).into_owned()
405 }
406
407 #[tokio::test]
408 async fn fork_bare_keeps_only_branches_and_tags() {
409 let dir = tempfile::tempdir().unwrap();
410 // The real managed gitconfig, so the test sees `protocol.file.allow = never`.
411 crate::setup::write_managed_gitconfig(dir.path()).unwrap();
412 let env = GitEnv {
413 git_bin: "/usr/bin/git".into(),
414 data_dir: dir.path().to_path_buf(),
415 };
416 let src = dir.path().join("src.git");
417 init_bare(&env, &src, "main").await.unwrap();
418
419 let work = dir.path().join("work");
420 std::fs::create_dir_all(&work).unwrap();
421 git_in(&work, &["init", "-q", "-b", "main"]);
422 std::fs::write(work.join("a.txt"), "hello\n").unwrap();
423 git_in(&work, &["add", "a.txt"]);
424 git_in(&work, &["commit", "-q", "-m", "one"]);
425 git_in(&work, &["tag", "v1"]);
426 let src_str = src.to_str().unwrap();
427 git_in(&work, &["push", "-q", src_str, "main", "v1"]);
428 // A pull ref that a fork must not inherit.
429 git_in(&src, &["update-ref", "refs/pull/1/head", "refs/heads/main"]);
430
431 let dst = repo_path(dir.path(), ID).unwrap();
432 fork_bare(&env, &src, &dst).await.unwrap();
433
434 let refs = git_in(&dst, &["for-each-ref", "--format=%(refname)"]);
435 let refs: Vec<&str> = refs.lines().collect();
436 assert_eq!(refs, vec!["refs/heads/main", "refs/tags/v1"]);
437 assert_eq!(list_branches(&env, &dst).await.unwrap(), vec!["main"]);
438 assert!(git_in(&dst, &["remote"]).trim().is_empty());
439 assert!(git_in(&dst, &["log", "--format=%s", "main"]).contains("one"));
440 }
441
442 #[tokio::test]
443 async fn fork_bare_refuses_existing_target() {
444 let dir = tempfile::tempdir().unwrap();
445 crate::setup::write_managed_gitconfig(dir.path()).unwrap();
446 let env = GitEnv {
447 git_bin: "/usr/bin/git".into(),
448 data_dir: dir.path().to_path_buf(),
449 };
450 let src = dir.path().join("src.git");
451 init_bare(&env, &src, "main").await.unwrap();
452 let dst = dir.path().join("taken.git");
453 std::fs::create_dir_all(&dst).unwrap();
454 assert!(fork_bare(&env, &src, &dst).await.is_err());
455 assert!(dst.is_dir(), "existing target must be left alone");
456 }
457}
458
459#[cfg(test)]
460mod wiki_trash_tests {
461 use super::*;
462
463 const ID: &str = "01arz3ndektsv4rrffq69g5fav";
464
465 #[tokio::test]
466 async fn wiki_moves_to_trash_and_back() {
467 let dir = tempfile::tempdir().unwrap();
468 let wiki = wiki_path(dir.path(), ID).unwrap();
469 assert!(move_wiki_to_trash(dir.path(), ID).await.unwrap().is_none());
470
471 std::fs::create_dir_all(&wiki).unwrap();
472 let trashed = move_wiki_to_trash(dir.path(), ID)
473 .await
474 .unwrap()
475 .expect("wiki moved");
476 assert!(!wiki.exists());
477 let listed = list_trash(dir.path()).unwrap();
478 assert_eq!(listed.len(), 1);
479 assert!(listed[0].wiki);
480 assert_eq!(listed[0].repo_id, ID);
481
482 restore_wiki_from_trash(dir.path(), &trashed, ID)
483 .await
484 .unwrap();
485 assert!(wiki.is_dir());
486 assert!(list_trash(dir.path()).unwrap().is_empty());
487 }
488}