Skip to main content

max / makenotwork

Create the bare repo an SSH push is about to write into A first push to a name nobody had pushed before registered the repo in the database, listed it on /git as public, and then handed git-shell a path that did not exist. git-receive-pack neither served the push nor failed, so the client sat there until GIT_SSH_OP_TIMEOUT_SECS killed it fifteen minutes later. Every retry did the same: the row existed by then, so the branch that would have created anything was no longer taken. The repo stayed listed, empty, and unpushable, with the web UI the only way to remove it. The comment above create_repo said the caller made the directory on disk. No caller did. git::init_bare_repo calls itself the single production path for creating a bare repo and the only thing reaching it was the web API, which is why repos made through the site have always been fine and this only bites push-create. ensure_bare_repo_on_disk runs after the permission and quota checks, so an unauthorized push still cannot make a directory. The exists check makes it idempotent, which is also what repairs the repos already stuck: the row is there, the directory is not, and the next push makes it. mnw-cli's russh transport already does this at the same point (ssh/handler.rs); this brings the sshd door that ssh.makenot.work actually uses in line with it. Found pushing shop for the Alloy terminal swap, which had been blocked on it since 2026-07-31.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-01 18:18 UTC
Signed with PGP, not checked
Commit: 27cea711688036411a70a506cc78bcb2b8b36dca
Parent: 87fb3e9
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();