| 1 | |
| 2 | |
| 3 | use std::path::{Path, PathBuf}; |
| 4 | |
| 5 | use crate::command::{GitCommand, GitEnv}; |
| 6 | use crate::error::{Error, Result}; |
| 7 | |
| 8 | |
| 9 | const ULID_ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; |
| 10 | |
| 11 | |
| 12 | |
| 13 | pub 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 | |
| 26 | |
| 27 | pub 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 | |
| 33 | pub const TRASH_DIR: &str = "trash"; |
| 34 | |
| 35 | |
| 36 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 37 | pub struct TrashEntry { |
| 38 | pub path: PathBuf, |
| 39 | pub repo_id: String, |
| 40 | |
| 41 | pub deleted_at: u64, |
| 42 | |
| 43 | pub wiki: bool, |
| 44 | } |
| 45 | |
| 46 | fn 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 | |
| 54 | |
| 55 | |
| 56 | pub 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 | |
| 63 | |
| 64 | pub 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 | |
| 74 | async 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 | |
| 95 | |
| 96 | |
| 97 | pub 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 | |
| 107 | |
| 108 | pub 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 | |
| 114 | pub 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 | |
| 119 | async 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 | |
| 134 | pub 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 | |
| 175 | |
| 176 | pub 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 | |
| 188 | async 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 | |
| 202 | pub 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 | |
| 219 | pub 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 | |
| 234 | |
| 235 | |
| 236 | |
| 237 | pub 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 | |
| 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 | |
| 264 | let _ = tokio::fs::remove_dir_all(dst).await; |
| 265 | return Err(e); |
| 266 | } |
| 267 | Ok(()) |
| 268 | } |
| 269 | |
| 270 | |
| 271 | async 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)] |
| 305 | mod 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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)] |
| 460 | mod 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 | } |