worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // Repository lifecycle on disk. |
| 2 | // |
| 3 | // Hooks are symlinks into `$DATA_DIR/hooks/`, installed through git's |
| 4 | // `init.templateDir` at creation. Symlinks rather than copies so upgrading a hook |
| 5 | // is atomic across every existing repository. |
| 6 | // |
| 7 | // The hooks contain no policy: each is a one-line `exec` into this binary. The ref |
| 8 | // rule therefore lives in Rust rather than in shell, where it would have been a |
| 9 | // second implementation that drifts. It has one caller — the `update` hook — because |
| 10 | // there is no API path that moves a ref; git is the only writer. |
| 11 | |
| 12 | use crate::brand; |
| 13 | use crate::error::{Error, Result}; |
| 14 | use crate::git::exec::Git; |
| 15 | use crate::git::validate::{self, Name}; |
| 16 | use anyhow::Context; |
| 17 | use std::path::{Path, PathBuf}; |
| 18 | |
| 19 | /// Hooks installed into every repository. Each delegates to `<binary> hook <name>` |
| 20 | /// so policy lives in Rust rather than in shell. |
| 21 | const HOOKS: &[&str] = &["update", "post-receive"]; |
| 22 | |
| 23 | #[derive(Debug, Clone)] |
| 24 | pub struct GitStore { |
| 25 | git_root: PathBuf, |
| 26 | hooks_dir: PathBuf, |
| 27 | template_dir: PathBuf, |
| 28 | deleted_dir: PathBuf, |
| 29 | /// Mirrors the transport's cap so `receive.maxInputSize` cannot disagree with |
| 30 | /// the byte limit the HTTP layer enforces. |
| 31 | max_pack_bytes: u64, |
| 32 | } |
| 33 | |
| 34 | impl GitStore { |
| 35 | pub fn open(data_dir: &Path, max_pack_bytes: u64) -> Result<Self> { |
| 36 | let store = GitStore { |
| 37 | git_root: data_dir.join("git"), |
| 38 | hooks_dir: data_dir.join("hooks"), |
| 39 | template_dir: data_dir.join("template"), |
| 40 | deleted_dir: data_dir.join("tmp").join("deleted"), |
| 41 | max_pack_bytes, |
| 42 | }; |
| 43 | store.install_hooks()?; |
| 44 | Ok(store) |
| 45 | } |
| 46 | |
| 47 | pub fn git_root(&self) -> &Path { |
| 48 | &self.git_root |
| 49 | } |
| 50 | |
| 51 | /// Write hook shims and point the template's `hooks/` at them. |
| 52 | /// |
| 53 | /// Idempotent, and rerun on every boot so an upgraded binary lands its hook |
| 54 | /// changes across repositories that already exist. |
| 55 | fn install_hooks(&self) -> Result<()> { |
| 56 | for dir in [&self.git_root, &self.hooks_dir, &self.deleted_dir] { |
| 57 | std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; |
| 58 | } |
| 59 | let template_hooks = self.template_dir.join("hooks"); |
| 60 | std::fs::create_dir_all(&template_hooks) |
| 61 | .with_context(|| format!("create {}", template_hooks.display()))?; |
| 62 | |
| 63 | let binary = std::env::current_exe().context("locate own executable")?; |
| 64 | |
| 65 | for name in HOOKS { |
| 66 | let target = self.hooks_dir.join(name); |
| 67 | let body = format!( |
| 68 | "#!/bin/sh\n# Installed by {}. Policy lives in the binary.\nexec {} hook {name} \"$@\"\n", |
| 69 | brand::NAME, |
| 70 | shell_quote(&binary.to_string_lossy()) |
| 71 | ); |
| 72 | std::fs::write(&target, body) |
| 73 | .with_context(|| format!("write hook {}", target.display()))?; |
| 74 | set_executable(&target)?; |
| 75 | |
| 76 | let link = template_hooks.join(name); |
| 77 | match std::fs::remove_file(&link) { |
| 78 | Ok(()) => {} |
| 79 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} |
| 80 | Err(e) => { |
| 81 | return Err(Error::Internal( |
| 82 | anyhow::Error::from(e).context(format!("unlink {}", link.display())), |
| 83 | )) |
| 84 | } |
| 85 | } |
| 86 | symlink(&target, &link).with_context(|| format!("link {}", link.display()))?; |
| 87 | } |
| 88 | Ok(()) |
| 89 | } |
| 90 | |
| 91 | pub fn repo_path(&self, account: &Name, repo: &Name) -> Result<PathBuf> { |
| 92 | validate::repo_path(&self.git_root, account, repo) |
| 93 | } |
| 94 | |
| 95 | pub fn exists(&self, account: &Name, repo: &Name) -> Result<bool> { |
| 96 | Ok(self.repo_path(account, repo)?.join("HEAD").is_file()) |
| 97 | } |
| 98 | |
| 99 | /// Initialise a bare repository with hooks installed and HEAD pointed at the |
| 100 | /// default branch. |
| 101 | /// |
| 102 | /// HEAD must be a symref to the branch even though it does not exist yet, or |
| 103 | /// cloning the empty repo leaves the client on its own `init.defaultBranch`. |
| 104 | pub fn create(&self, account: &Name, repo: &Name, default_branch: &str) -> Result<PathBuf> { |
| 105 | validate::ref_name(default_branch)?; |
| 106 | let path = self.repo_path(account, repo)?; |
| 107 | |
| 108 | let parent = path.parent().expect("repo path always has a parent"); |
| 109 | std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; |
| 110 | |
| 111 | let path_str = path |
| 112 | .to_str() |
| 113 | .ok_or_else(|| Error::invalid("invalid-name", "repository path is not valid UTF-8"))?; |
| 114 | |
| 115 | Git::plain().run(&[ |
| 116 | "init", |
| 117 | "--bare", |
| 118 | "--quiet", |
| 119 | &format!("--template={}", self.template_dir.display()), |
| 120 | &format!( |
| 121 | "--initial-branch={}", |
| 122 | default_branch |
| 123 | .strip_prefix("refs/heads/") |
| 124 | .unwrap_or(default_branch) |
| 125 | ), |
| 126 | path_str, |
| 127 | ])?; |
| 128 | |
| 129 | self.apply_repo_config(&path)?; |
| 130 | Ok(path) |
| 131 | } |
| 132 | |
| 133 | /// Config every repository must carry. Reapplied on open so an upgrade reaches |
| 134 | /// repositories created before a setting existed. |
| 135 | fn apply_repo_config(&self, path: &Path) -> Result<()> { |
| 136 | let git = Git::at(path); |
| 137 | let max_pack = self.max_pack_bytes.to_string(); |
| 138 | |
| 139 | for setting in [ |
| 140 | // Reject malformed and unreachable objects at receive time rather than |
| 141 | // storing them and failing later on read. |
| 142 | ["receive.fsckObjects", "true"].as_slice(), |
| 143 | // Git's own bound, applied at index-pack before the pack reaches disk. |
| 144 | // Taken from configuration so it cannot disagree with the transport cap. |
| 145 | ["receive.maxInputSize", &max_pack].as_slice(), |
| 146 | ["core.logAllRefUpdates", "true"].as_slice(), |
| 147 | // Maintenance is scheduled, not opportunistic: git's own auto-gc |
| 148 | // otherwise fires inside a push, at a moment nobody chose and |
| 149 | // unsynchronised with everything else. See `jobs::gc`. |
| 150 | ["gc.auto", "0"].as_slice(), |
| 151 | ["receive.autogc", "false"].as_slice(), |
| 152 | ] { |
| 153 | git.run(&["config", setting[0], setting[1]])?; |
| 154 | } |
| 155 | Ok(()) |
| 156 | } |
| 157 | |
| 158 | /// Apply our config and hooks to a repository that arrived from elsewhere. |
| 159 | /// |
| 160 | /// An imported repository is untrusted input and must end up indistinguishable |
| 161 | /// from one we created: same hooks, same receive limits, same maintenance |
| 162 | /// posture. `clone --mirror` brings none of that. |
| 163 | pub fn adopt(&self, account: &Name, repo: &Name) -> Result<()> { |
| 164 | let path = self.repo_path(account, repo)?; |
| 165 | self.apply_repo_config(&path)?; |
| 166 | |
| 167 | let hooks = path.join("hooks"); |
| 168 | std::fs::create_dir_all(&hooks).with_context(|| format!("create {}", hooks.display()))?; |
| 169 | |
| 170 | for name in HOOKS { |
| 171 | let link = hooks.join(name); |
| 172 | let _ = std::fs::remove_file(&link); |
| 173 | symlink(&self.hooks_dir.join(name), &link) |
| 174 | .with_context(|| format!("link {}", link.display()))?; |
| 175 | } |
| 176 | Ok(()) |
| 177 | } |
| 178 | |
| 179 | /// The branch HEAD points at, if it is a symbolic ref. |
| 180 | pub fn head_ref(&self, account: &Name, repo: &Name) -> Option<String> { |
| 181 | let path = self.repo_path(account, repo).ok()?; |
| 182 | let head = Git::at(&path) |
| 183 | .text(&["symbolic-ref", "--quiet", "HEAD"]) |
| 184 | .ok()?; |
| 185 | (!head.is_empty()).then_some(head) |
| 186 | } |
| 187 | |
| 188 | /// Move a repository aside. Recoverable until the sweep runs. |
| 189 | pub fn soft_delete(&self, account: &Name, repo: &Name) -> Result<bool> { |
| 190 | let path = self.repo_path(account, repo)?; |
| 191 | if !path.exists() { |
| 192 | return Ok(false); |
| 193 | } |
| 194 | let stamp = std::time::SystemTime::now() |
| 195 | .duration_since(std::time::UNIX_EPOCH) |
| 196 | .map(|d| d.as_millis()) |
| 197 | .unwrap_or(0); |
| 198 | let grave = self |
| 199 | .deleted_dir |
| 200 | .join(format!("{account}__{repo}__{stamp}.git")); |
| 201 | std::fs::rename(&path, &grave) |
| 202 | .with_context(|| format!("move {} to {}", path.display(), grave.display()))?; |
| 203 | Ok(true) |
| 204 | } |
| 205 | |
| 206 | /// List refs under `refs/heads/`, `refs/tags/` and `refs/notes/`. |
| 207 | pub fn list_refs(&self, account: &Name, repo: &Name) -> Result<Vec<(String, String)>> { |
| 208 | let path = self.repo_path(account, repo)?; |
| 209 | let out = Git::at(&path).run(&[ |
| 210 | "for-each-ref", |
| 211 | "--format=%(refname) %(objectname)", |
| 212 | "refs/heads/", |
| 213 | "refs/tags/", |
| 214 | "refs/notes/", |
| 215 | ])?; |
| 216 | |
| 217 | Ok(String::from_utf8_lossy(&out) |
| 218 | .lines() |
| 219 | .filter_map(|line| { |
| 220 | let (name, sha) = line.split_once(' ')?; |
| 221 | Some((name.to_string(), sha.to_string())) |
| 222 | }) |
| 223 | .collect()) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | /// The all-zero object id git uses for "this ref does not exist". |
| 228 | /// |
| 229 | /// Length varies with the repository's hash algorithm, so this matches on content |
| 230 | /// rather than against a hardcoded 40-character SHA-1 sentinel. |
| 231 | pub fn is_null_oid(oid: &str) -> bool { |
| 232 | !oid.is_empty() && oid.bytes().all(|b| b == b'0') |
| 233 | } |
| 234 | |
| 235 | /// Git config key holding the refs that may not be rewritten. |
| 236 | fn protected_key() -> String { |
| 237 | brand::git_config_key("protected") |
| 238 | } |
| 239 | |
| 240 | /// Refs protected from rewriting, read from the repository's own git config. |
| 241 | /// |
| 242 | /// Kept in git config rather than in our metadata so the hook can read it with no |
| 243 | /// data directory, no environment and no lookup — and so the setting travels with |
| 244 | /// the repository if it is ever copied or restored from a backup. |
| 245 | pub fn protected_refs(git_dir: &Path) -> Vec<String> { |
| 246 | let Ok(Some(out)) = Git::at(git_dir).query(&["config", "--get-all", &protected_key()]) else { |
| 247 | return Vec::new(); |
| 248 | }; |
| 249 | String::from_utf8_lossy(&out) |
| 250 | .lines() |
| 251 | .map(str::trim) |
| 252 | .filter(|line| !line.is_empty()) |
| 253 | .map(String::from) |
| 254 | .collect() |
| 255 | } |
| 256 | |
| 257 | /// Replace the protected-ref list. |
| 258 | pub fn set_protected_refs(git_dir: &Path, refs: &[String]) -> Result<()> { |
| 259 | for name in refs { |
| 260 | validate::ref_name(name)?; |
| 261 | } |
| 262 | |
| 263 | let key = protected_key(); |
| 264 | let git = Git::at(git_dir); |
| 265 | // `--unset-all` exits 5 when the key is absent, which is not a failure here. |
| 266 | git.ignore(&["config", "--unset-all", &key]); |
| 267 | |
| 268 | for name in refs { |
| 269 | git.run(&["config", "--add", &key, name])?; |
| 270 | } |
| 271 | Ok(()) |
| 272 | } |
| 273 | |
| 274 | /// Point HEAD at a branch. |
| 275 | /// |
| 276 | /// A symref rather than a detached id, so a clone of the repository checks the |
| 277 | /// branch out by name. |
| 278 | pub fn set_head(git_dir: &Path, branch: &str) -> Result<()> { |
| 279 | validate::ref_name(branch)?; |
| 280 | Git::at(git_dir) |
| 281 | .run(&["symbolic-ref", "HEAD", branch]) |
| 282 | .map(|_| ()) |
| 283 | } |
| 284 | |
| 285 | /// Whether a ref may move from `old` to `new`. |
| 286 | /// |
| 287 | /// **Rewriting is allowed by default.** There is one agent and no reviewer here, so |
| 288 | /// a blanket non-fast-forward block protects nobody while breaking `rebase`, |
| 289 | /// `commit --amend` and `filter-branch` — ordinary git that this service exists to |
| 290 | /// serve. Recovery is git's own reflog, enabled on every repository at creation. |
| 291 | /// |
| 292 | /// Protection is opt-in per ref, for the branch something deploys from. Its one |
| 293 | /// caller is the `update` hook, reached through `<binary> hook update`. |
| 294 | pub fn check_ref_move(git_dir: &Path, ref_name: &str, old: &str, new: &str) -> Result<()> { |
| 295 | // Creating or deleting a ref is not a rewrite. |
| 296 | if is_null_oid(old) || is_null_oid(new) { |
| 297 | return Ok(()); |
| 298 | } |
| 299 | if !protected_refs(git_dir).iter().any(|p| p == ref_name) { |
| 300 | return Ok(()); |
| 301 | } |
| 302 | |
| 303 | if Git::at(git_dir).succeeds(&["merge-base", "--is-ancestor", old, new]) { |
| 304 | return Ok(()); |
| 305 | } |
| 306 | |
| 307 | Err(Error::conflict( |
| 308 | "non-fast-forward", |
| 309 | format!("{ref_name} is protected; non-fast-forward update rejected"), |
| 310 | )) |
| 311 | } |
| 312 | |
| 313 | /// Single-quote a path for embedding in a `sh` script. |
| 314 | fn shell_quote(value: &str) -> String { |
| 315 | format!("'{}'", value.replace('\'', r"'\''")) |
| 316 | } |
| 317 | |
| 318 | #[cfg(unix)] |
| 319 | fn set_executable(path: &Path) -> Result<()> { |
| 320 | use std::os::unix::fs::PermissionsExt; |
| 321 | let mut perms = std::fs::metadata(path) |
| 322 | .with_context(|| format!("stat {}", path.display()))? |
| 323 | .permissions(); |
| 324 | perms.set_mode(0o755); |
| 325 | std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?; |
| 326 | Ok(()) |
| 327 | } |
| 328 | |
| 329 | #[cfg(not(unix))] |
| 330 | fn set_executable(_path: &Path) -> Result<()> { |
| 331 | Ok(()) |
| 332 | } |
| 333 | |
| 334 | #[cfg(unix)] |
| 335 | fn symlink(target: &Path, link: &Path) -> anyhow::Result<()> { |
| 336 | std::os::unix::fs::symlink(target, link)?; |
| 337 | Ok(()) |
| 338 | } |
| 339 | |
| 340 | #[cfg(not(unix))] |
| 341 | fn symlink(target: &Path, link: &Path) -> anyhow::Result<()> { |
| 342 | std::fs::copy(target, link)?; |
| 343 | Ok(()) |
| 344 | } |
| 345 | |
| 346 | #[cfg(all(test, unix))] |
| 347 | mod tests { |
| 348 | use super::*; |
| 349 | use crate::git::validate::name; |
| 350 | // Tests build commits directly, which needs stdin; the Git helper is |
| 351 | // deliberately stdin-less because no production call site needs it. |
| 352 | use std::process::Command; |
| 353 | |
| 354 | fn store() -> (tempfile::TempDir, GitStore) { |
| 355 | let dir = tempfile::tempdir().unwrap(); |
| 356 | let store = GitStore::open(dir.path(), 512 * 1024 * 1024).unwrap(); |
| 357 | (dir, store) |
| 358 | } |
| 359 | |
| 360 | fn make(store: &GitStore, account: &str, repo: &str) -> PathBuf { |
| 361 | store |
| 362 | .create( |
| 363 | &name(account).unwrap(), |
| 364 | &name(repo).unwrap(), |
| 365 | "refs/heads/main", |
| 366 | ) |
| 367 | .unwrap() |
| 368 | } |
| 369 | |
| 370 | #[test] |
| 371 | fn creates_a_bare_repo_with_head_pointed_at_the_default_branch() { |
| 372 | let (_dir, store) = store(); |
| 373 | let path = make(&store, "alice", "site"); |
| 374 | |
| 375 | assert!(path.join("HEAD").is_file()); |
| 376 | assert!(!path.join(".git").exists(), "repo must be bare"); |
| 377 | assert_eq!( |
| 378 | std::fs::read_to_string(path.join("HEAD")).unwrap().trim(), |
| 379 | "ref: refs/heads/main" |
| 380 | ); |
| 381 | } |
| 382 | |
| 383 | #[test] |
| 384 | fn every_repo_carries_the_pack_safety_config() { |
| 385 | let (_dir, store) = store(); |
| 386 | let path = make(&store, "alice", "site"); |
| 387 | |
| 388 | for (key, expected) in [ |
| 389 | ("receive.fsckObjects", "true"), |
| 390 | ("receive.maxInputSize", "536870912"), |
| 391 | ] { |
| 392 | assert_eq!( |
| 393 | Git::at(&path).text(&["config", "--get", key]).unwrap(), |
| 394 | expected, |
| 395 | "{key} must be set at create" |
| 396 | ); |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | #[test] |
| 401 | fn hooks_are_executable_symlinks_that_delegate_to_the_binary() { |
| 402 | let (dir, store) = store(); |
| 403 | let path = make(&store, "alice", "site"); |
| 404 | |
| 405 | for name in HOOKS { |
| 406 | let hook = path.join("hooks").join(name); |
| 407 | assert!( |
| 408 | std::fs::symlink_metadata(&hook) |
| 409 | .unwrap() |
| 410 | .file_type() |
| 411 | .is_symlink(), |
| 412 | "{name} must be a symlink" |
| 413 | ); |
| 414 | assert_eq!( |
| 415 | std::fs::read_link(&hook).unwrap(), |
| 416 | dir.path().join("hooks").join(name) |
| 417 | ); |
| 418 | |
| 419 | let body = std::fs::read_to_string(&hook).unwrap(); |
| 420 | assert!( |
| 421 | body.contains(&format!("hook {name}")), |
| 422 | "{name} must delegate to the binary, not carry policy in shell" |
| 423 | ); |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | #[test] |
| 428 | fn a_hook_upgrade_reaches_repos_that_already_exist() { |
| 429 | let dir = tempfile::tempdir().unwrap(); |
| 430 | let store = GitStore::open(dir.path(), 512 * 1024 * 1024).unwrap(); |
| 431 | let path = make(&store, "alice", "site"); |
| 432 | |
| 433 | std::fs::write( |
| 434 | dir.path().join("hooks").join("update"), |
| 435 | "#!/bin/sh\nexit 7\n", |
| 436 | ) |
| 437 | .unwrap(); |
| 438 | let body = std::fs::read_to_string(path.join("hooks").join("update")).unwrap(); |
| 439 | assert!( |
| 440 | body.contains("exit 7"), |
| 441 | "the symlink must carry the upgrade" |
| 442 | ); |
| 443 | } |
| 444 | |
| 445 | #[test] |
| 446 | fn a_case_variant_resolves_to_the_same_repository() { |
| 447 | let (_dir, store) = store(); |
| 448 | let created = make(&store, "alice", "site"); |
| 449 | let variant = store |
| 450 | .repo_path(&name("Alice").unwrap(), &name("SITE").unwrap()) |
| 451 | .unwrap(); |
| 452 | assert_eq!(created, variant); |
| 453 | } |
| 454 | |
| 455 | #[test] |
| 456 | fn rejects_a_default_branch_that_is_not_a_valid_ref() { |
| 457 | let (_dir, store) = store(); |
| 458 | assert!(store |
| 459 | .create( |
| 460 | &name("alice").unwrap(), |
| 461 | &name("site").unwrap(), |
| 462 | "refs/heads/../config" |
| 463 | ) |
| 464 | .is_err()); |
| 465 | } |
| 466 | |
| 467 | #[test] |
| 468 | fn soft_delete_moves_the_repo_aside_rather_than_removing_it() { |
| 469 | let (dir, store) = store(); |
| 470 | let path = make(&store, "alice", "site"); |
| 471 | let (alice, site) = (name("alice").unwrap(), name("site").unwrap()); |
| 472 | |
| 473 | assert!(store.soft_delete(&alice, &site).unwrap()); |
| 474 | assert!(!path.exists()); |
| 475 | assert_eq!( |
| 476 | std::fs::read_dir(dir.path().join("tmp").join("deleted")) |
| 477 | .unwrap() |
| 478 | .count(), |
| 479 | 1, |
| 480 | "repo must be recoverable after delete" |
| 481 | ); |
| 482 | assert!(!store.soft_delete(&alice, &site).unwrap()); |
| 483 | } |
| 484 | |
| 485 | #[test] |
| 486 | fn an_empty_repo_lists_no_refs() { |
| 487 | let (_dir, store) = store(); |
| 488 | make(&store, "alice", "site"); |
| 489 | assert!(store |
| 490 | .list_refs(&name("alice").unwrap(), &name("site").unwrap()) |
| 491 | .unwrap() |
| 492 | .is_empty()); |
| 493 | } |
| 494 | |
| 495 | #[test] |
| 496 | fn the_null_oid_is_recognised_at_any_hash_length() { |
| 497 | assert!(is_null_oid(&"0".repeat(40)), "sha-1 sentinel"); |
| 498 | assert!(is_null_oid(&"0".repeat(64)), "sha-256 sentinel"); |
| 499 | assert!(!is_null_oid("")); |
| 500 | assert!(!is_null_oid("0123")); |
| 501 | } |
| 502 | |
| 503 | #[test] |
| 504 | fn ref_creation_and_deletion_are_always_allowed() { |
| 505 | let (_dir, store) = store(); |
| 506 | let path = make(&store, "alice", "site"); |
| 507 | let zero = "0".repeat(40); |
| 508 | |
| 509 | check_ref_move(&path, "refs/heads/main", &zero, "abc").unwrap(); |
| 510 | check_ref_move(&path, "refs/heads/main", "abc", &zero).unwrap(); |
| 511 | } |
| 512 | |
| 513 | #[test] |
| 514 | fn rewriting_an_unprotected_ref_is_allowed() { |
| 515 | let (_dir, store) = store(); |
| 516 | let path = make(&store, "alice", "site"); |
| 517 | |
| 518 | // Two unrelated commits: neither is an ancestor of the other. |
| 519 | let first = commit(&path, "one"); |
| 520 | let second = commit(&path, "two"); |
| 521 | |
| 522 | check_ref_move(&path, "refs/heads/main", &second, &first).unwrap_or_else(|e| { |
| 523 | panic!("rebase and amend are ordinary git and must not be blocked: {e}") |
| 524 | }); |
| 525 | } |
| 526 | |
| 527 | #[test] |
| 528 | fn rewriting_a_protected_ref_is_refused() { |
| 529 | let (_dir, store) = store(); |
| 530 | let path = make(&store, "alice", "site"); |
| 531 | set_protected_refs(&path, &["refs/heads/main".to_string()]).unwrap(); |
| 532 | |
| 533 | let first = commit(&path, "one"); |
| 534 | let second = commit(&path, "two"); |
| 535 | |
| 536 | let err = check_ref_move(&path, "refs/heads/main", &second, &first).unwrap_err(); |
| 537 | assert_eq!(err.status(), 409); |
| 538 | |
| 539 | // A different branch is untouched by main's protection. |
| 540 | check_ref_move(&path, "refs/heads/topic", &second, &first).unwrap(); |
| 541 | } |
| 542 | |
| 543 | #[test] |
| 544 | fn a_fast_forward_is_allowed_even_when_protected() { |
| 545 | let (_dir, store) = store(); |
| 546 | let path = make(&store, "alice", "site"); |
| 547 | set_protected_refs(&path, &["refs/heads/main".to_string()]).unwrap(); |
| 548 | |
| 549 | let first = commit(&path, "one"); |
| 550 | let second = commit_onto(&path, &first, "two"); |
| 551 | check_ref_move(&path, "refs/heads/main", &first, &second).unwrap(); |
| 552 | } |
| 553 | |
| 554 | #[test] |
| 555 | fn protected_refs_round_trip_through_the_repository_config() { |
| 556 | let (_dir, store) = store(); |
| 557 | let path = make(&store, "alice", "site"); |
| 558 | |
| 559 | assert!( |
| 560 | protected_refs(&path).is_empty(), |
| 561 | "nothing is protected by default" |
| 562 | ); |
| 563 | |
| 564 | let wanted = vec!["refs/heads/main".to_string(), "refs/tags/v1".to_string()]; |
| 565 | set_protected_refs(&path, &wanted).unwrap(); |
| 566 | assert_eq!(protected_refs(&path), wanted); |
| 567 | |
| 568 | set_protected_refs(&path, &[]).unwrap(); |
| 569 | assert!(protected_refs(&path).is_empty()); |
| 570 | } |
| 571 | |
| 572 | #[test] |
| 573 | fn a_protected_ref_name_is_validated_before_it_reaches_git_config() { |
| 574 | let (_dir, store) = store(); |
| 575 | let path = make(&store, "alice", "site"); |
| 576 | assert!(set_protected_refs(&path, &["../../config".to_string()]).is_err()); |
| 577 | } |
| 578 | |
| 579 | /// Create a root commit directly in the bare repo, returning its oid. |
| 580 | fn commit(git_dir: &Path, message: &str) -> String { |
| 581 | let tree = git(git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]); |
| 582 | let out = Command::new("git") |
| 583 | .arg("--git-dir") |
| 584 | .arg(git_dir) |
| 585 | .args(["commit-tree", &tree, "-m", message]) |
| 586 | .env("GIT_AUTHOR_NAME", "t") |
| 587 | .env("GIT_AUTHOR_EMAIL", "t@e") |
| 588 | .env("GIT_COMMITTER_NAME", "t") |
| 589 | .env("GIT_COMMITTER_EMAIL", "t@e") |
| 590 | .env("GIT_AUTHOR_DATE", format!("@{} +0000", message.len())) |
| 591 | .output() |
| 592 | .unwrap(); |
| 593 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 594 | } |
| 595 | |
| 596 | fn commit_onto(git_dir: &Path, parent: &str, message: &str) -> String { |
| 597 | let tree = git(git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]); |
| 598 | let out = Command::new("git") |
| 599 | .arg("--git-dir") |
| 600 | .arg(git_dir) |
| 601 | .args(["commit-tree", &tree, "-p", parent, "-m", message]) |
| 602 | .env("GIT_AUTHOR_NAME", "t") |
| 603 | .env("GIT_AUTHOR_EMAIL", "t@e") |
| 604 | .env("GIT_COMMITTER_NAME", "t") |
| 605 | .env("GIT_COMMITTER_EMAIL", "t@e") |
| 606 | .output() |
| 607 | .unwrap(); |
| 608 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 609 | } |
| 610 | |
| 611 | fn git(git_dir: &Path, args: &[&str]) -> String { |
| 612 | use std::io::Write; |
| 613 | use std::process::Stdio; |
| 614 | let mut child = Command::new("git") |
| 615 | .arg("--git-dir") |
| 616 | .arg(git_dir) |
| 617 | .args(args) |
| 618 | .stdin(Stdio::piped()) |
| 619 | .stdout(Stdio::piped()) |
| 620 | .spawn() |
| 621 | .unwrap(); |
| 622 | child.stdin.take().unwrap().write_all(b"").unwrap(); |
| 623 | let out = child.wait_with_output().unwrap(); |
| 624 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 625 | } |
| 626 | } |