max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+76 insertions,
-1 deletion
| @@ -120,7 +120,6 @@ | |||
| 120 | 120 | Some(repo) => repo, | |
| 121 | 121 | None => { | |
| 122 | 122 | // Auto-create on push if the authenticated user owns the namespace. | |
| 123 | - | // Only register in the DB, the caller creates the bare repo on disk. | |
| 124 | 123 | if !matches!(operation, GitOperation::ReceivePack) || user_id != owner_user.id { | |
| 125 | 124 | anyhow::bail!("repository not found"); | |
| 126 | 125 | } | |
| @@ -157,6 +156,8 @@ | |||
| 157 | 156 | // push paths share one guard (fuzz 2026-07-06 M1). | |
| 158 | 157 | let owner_dir = git_repos_root().join(owner_username.as_ref()); | |
| 159 | 158 | crate::git::enforce_disk_quota(owner_dir).await?; | |
| 159 | + | ||
| 160 | + | ensure_bare_repo_on_disk(&git_repos_root(), owner_username.as_ref(), repo_name)?; | |
| 160 | 161 | } | |
| 161 | 162 | GitOperation::UploadPack | GitOperation::Archive => { | |
| 162 | 163 | if repo.visibility == db::Visibility::Private && !is_owner { | |
| @@ -191,6 +192,60 @@ | |||
| 191 | 192 | run_git_shell(&sanitized_cmd).await | |
| 192 | 193 | } | |
| 193 | 194 | ||
| 195 | + | /// Create the bare repo a push is about to write into, if it is not there. | |
| 196 | + | /// | |
| 197 | + | /// Registering the repo in the database and creating it on disk are two steps, | |
| 198 | + | /// and until 2026-08-01 this path did only the first. The comment above the | |
| 199 | + | /// `create_repo` call said the caller made the directory; no caller did. | |
| 200 | + | /// `crate::git::init_bare_repo` calls itself the single production path for | |
| 201 | + | /// creating a bare repo, and the only thing reaching it was the web API. | |
| 202 | + | /// | |
| 203 | + | /// What that cost: a first push to a name nobody had pushed before registered | |
| 204 | + | /// the repo, listed it on `/git` as public, and then handed `git-shell` a path | |
| 205 | + | /// that did not exist. `git-receive-pack` neither served the push nor failed, | |
| 206 | + | /// so the client sat there until [`GIT_SSH_OP_TIMEOUT_SECS`] killed it, fifteen | |
| 207 | + | /// minutes later. Every retry did the same, because the row existed by then and | |
| 208 | + | /// the branch that would have created anything was no longer taken. The repo | |
| 209 | + | /// stayed listed, empty, and unpushable, with the web UI the only way to | |
| 210 | + | /// remove it. | |
| 211 | + | /// | |
| 212 | + | /// Idempotent by the `exists` check, which also repairs the repos already in | |
| 213 | + | /// that state: the row is there, the directory is not, and the next push makes | |
| 214 | + | /// it. `mnw-cli`'s russh transport does the same thing at the same point for | |
| 215 | + | /// the same reason (`src/ssh/handler.rs`), and this is the door that is | |
| 216 | + | /// actually live for `ssh.makenot.work`. | |
| 217 | + | /// | |
| 218 | + | /// [`GIT_SSH_OP_TIMEOUT_SECS`]: crate::constants::GIT_SSH_OP_TIMEOUT_SECS | |
| 219 | + | /// Takes the root rather than reading `GIT_REPOS_PATH` itself, so the test | |
| 220 | + | /// below can point it at a temp directory without mutating process env. | |
| 221 | + | fn ensure_bare_repo_on_disk( | |
| 222 | + | root: &std::path::Path, | |
| 223 | + | owner: &str, | |
| 224 | + | repo_name: &str, | |
| 225 | + | ) -> anyhow::Result<()> { | |
| 226 | + | let owner_dir = root.join(owner); | |
| 227 | + | let repo_dir = owner_dir.join(format!("{repo_name}.git")); | |
| 228 | + | if repo_dir.exists() { | |
| 229 | + | return Ok(()); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | tracing::info!(path = %repo_dir.display(), "creating bare repository on disk"); | |
| 233 | + | std::fs::create_dir_all(&owner_dir)?; | |
| 234 | + | crate::git::init_bare_repo(&repo_dir)?; | |
| 235 | + | ||
| 236 | + | // Build triggers are optional, and a repo that pushes without firing one is | |
| 237 | + | // a working repo. Warn rather than fail: refusing the push over a missing | |
| 238 | + | // hook would trade an empty repo for an unpushable one. | |
| 239 | + | if let Ok(token) = std::env::var("BUILD_TRIGGER_TOKEN") { | |
| 240 | + | let hook = crate::build_runner::post_receive_hook(&token, owner, repo_name); | |
| 241 | + | if let Err(error) = install_hook_for_repo(&repo_dir, &hook) { | |
| 242 | + | tracing::warn!(error = ?error, path = %repo_dir.display(), "post-receive hook not installed"); | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | Ok(()) | |
| 247 | + | } | |
| 248 | + | ||
| 194 | 249 | /// The configured git repository root (`GIT_REPOS_PATH`, default `/opt/git`). | |
| 195 | 250 | fn git_repos_root() -> std::path::PathBuf { | |
| 196 | 251 | std::path::PathBuf::from( | |
| @@ -344,6 +399,26 @@ | |||
| 344 | 399 | ||
| 345 | 400 | // ── parse_ssh_command ── | |
| 346 | 401 | ||
| 402 | + | // The push path's half of repo creation. Registering the row was never the | |
| 403 | + | // part that broke; this is. | |
| 404 | + | #[test] | |
| 405 | + | fn a_first_push_gets_a_bare_repo_on_disk() { | |
| 406 | + | let root = tempfile::tempdir().unwrap(); | |
| 407 | + | let repo_dir = root.path().join("max").join("shop.git"); | |
| 408 | + | ||
| 409 | + | ensure_bare_repo_on_disk(root.path(), "max", "shop").unwrap(); | |
| 410 | + | assert!( | |
| 411 | + | git2::Repository::open_bare(&repo_dir).is_ok(), | |
| 412 | + | "a push to a name with no repo has somewhere to write", | |
| 413 | + | ); | |
| 414 | + | ||
| 415 | + | // Idempotent, because this runs on every push and not only the first. | |
| 416 | + | // It is also what repairs a repo registered before the fix: the row is | |
| 417 | + | // there, the directory is not, and the branch above makes it. | |
| 418 | + | ensure_bare_repo_on_disk(root.path(), "max", "shop").unwrap(); | |
| 419 | + | assert!(git2::Repository::open_bare(&repo_dir).is_ok()); | |
| 420 | + | } | |
| 421 | + | ||
| 347 | 422 | #[test] | |
| 348 | 423 | fn parse_upload_pack() { | |
| 349 | 424 | let (op, path) = parse_ssh_command("git-upload-pack '/user/repo.git'").unwrap(); |