feat(git): auto-init a bare repo on first push to an empty path#466
Conversation
Today `git push http://localhost:5444/public/apps/penny main` to a
fresh path 404s because there's no `.git` there yet — leaving no
HTTP path to *create* a repo. The only fix has been to shell into
the data directory and run `git init --bare` manually, which defeats
the point of having a git HTTP backend.
This adds auto-init on first push, gated narrowly so it can't
corrupt user content:
- Only fires for `git-receive-pack` (push), never `git-upload-pack`
(fetch). A clone of a non-existent repo still 404s as before.
- Only fires when the target path is empty or does not exist.
A directory containing non-`.git` files (e.g. `/public/apps/foo`
with an `index.html`) remains a 404 — we don't risk promoting a
user-content directory into a git repo.
- ACL Write is already verified by the existing preHandler hook in
`server.js` before `handleGit` runs, so authorization is enforced
the same way pushes to existing repos are.
- Path traversal is already blocked by `isPathWithinDataRoot` above
the new code.
- Uses `spawnSync('git', [...])` with an arg array — no shell
interpolation. Errors are swallowed and the caller falls through
to the normal 404, so a missing `git` binary or a permission issue
doesn't surface as a 500.
New helper `tryAutoInitBareRepo` is documented with the safety
conditions and the caller's responsibility (ACL must be checked
upstream). Concurrent first pushes to the same path are benign:
`mkdirSync({ recursive: true })` is idempotent and a second `git
init --bare` no-ops on an existing bare repo.
New test file `test/git-auto-init.test.js` exercises five paths:
1. Non-existent path → 200 + bare repo materialized
2. Empty existing directory → 200 + bare repo materialized
3. Non-empty directory (has `index.html`) → 404, file untouched,
no `.git`/`HEAD`/`objects` introduced
4. Fetch (`git-upload-pack`) to a missing path → 404, no
filesystem side effect
5. Second push to a path now containing a repo → 200, no re-init
(HEAD inode unchanged)
All five pass against the modified handler.
Refs #465
…uth-contract test In response to self-review of #466: - tryAutoInitBareRepo now takes an optional Fastify request.log so successful inits get a `git auto-init: bare repo created` info line and failures (non-zero git exit, filesystem error) get a warn line with the underlying cause. Loses no behavior; gains diagnosability. Operators can now answer "when did this repo appear?" from logs. - JSDoc now calls out the pre-existing symlink caveat: isPathWithinDataRoot uses string containment, not realpath, so a symlink placed under dataRoot by an authenticated writer can dereference outside. Not introduced here; flagged for a cross-handler hardening PR. - New test verifies the ACL contract independently of the public:true bypass used by the other tests. Spins up a server without `public: true` and asserts an unauthenticated push returns 401/403 AND does NOT create the directory. This catches a future regression where a preHandler refactor lets unauth requests reach handleGit. All 6 tests pass.
|
Self-reviewed and pushed 1. Diagnostic logging. `tryAutoInitBareRepo` now takes an optional `request.log` and emits:
Catches the silent-catch concern — operators can now answer "when did this repo appear?" from logs. 2. Symlink caveat documented in the JSDoc. The pre-existing weakness (`isPathWithinDataRoot` uses string containment, not `realpath`) is now called out so reviewers / future maintainers see it. Fixing it requires realpath normalization across every write path in this handler — out of scope here. 3. New auth-contract test. This was the one self-review item with real teeth, surfaced by @melvincarvalho's gut-check: my original tests used `public: true` to skip WAC, which means they didn't actually verify that ACL Write is enforced before `handleGit` runs. Added `refuses auto-init when ACL Write is not granted (unauthenticated)` which:
If a future refactor of the preHandler hook lets an unauth request reach `handleGit`, this test fails loudly. All 6 tests pass. Self-review notes I deliberately didn't act on (out of scope but worth tracking):
Ready for human review. |
JSS 0.0.195 (JavaScriptSolidServer/JavaScriptSolidServer#466) adds auto-init on first push, which closes the last gap that prevented the git HTTP backend from being useful out of the box. This PR turns on JSS's git backend by default in jspod, so every fresh `npx jspod` produces a pod that is also a real git remote: - `git clone http://localhost:5444/public/apps/<name>` for any public-read path - `git push http://localhost:5444/public/apps/<name>` for any owner-write path (auto-creates the bare repo on first push) - `git pull` to stay current Implementation: - package.json: bump javascript-solid-server from ^0.0.194 to ^0.0.195 - options.git defaults true; new --no-git CLI flag for opt-out (matches existing --no-auth / --no-open shape) - --git or --no-git is always passed explicitly to JSS so the spawn intent is unambiguous regardless of JSS's own default - docs.html grows a "Git remote" section explaining clone / push / auth ergonomics with a link to the upstream Known follow-up (out of scope here): git CLI doesn't speak DPoP natively, so push from the CLI today needs `git -c http.extraHeader="Authorization: DPoP <token>"`. Worth a dedicated jspod helper script + dedicated doc; tracked separately. Refs #1 #26
…ed (#469) Resolves #468. Follow-up to #466. #466 chose `git init --bare` for auto-init, which is correct for the "pod is a git remote" use case (clone / fetch / push all work) but breaks the symmetrical "install an app via git push" use case (the original motivation in #465 + #464): pushed files end up in pack objects, with no working tree on disk, so a pushed `index.html` is not servable as an HTTP static resource. Switch auto-init to non-bare. The existing handler already runs `git config receive.denyCurrentBranch updateInstead` for non-bare repos on every push, which auto-extracts the working tree. Pushed files now appear at the corresponding pod URL — `git push pod main` becomes a working app deploy. Code change is minimal: drop `--bare` from one `spawnSync` arg list. The helper is renamed `tryAutoInitBareRepo` → `tryAutoInitRepo` and its JSDoc + nearby handler comment updated to describe the new semantics (and why non-bare is the right choice for the "apps live in pods" pattern). Tests updated to assert the regular-repo shape (`.git/HEAD`, `.git/objects`, `.git/refs`) instead of bare-repo files at the root. The negative test that ensures no auto-init happens on a non-empty directory now also asserts `.git/` doesn't appear. Full suite (876 tests) passes. Repro that motivated this PR — pushing Hub from https://github.com/solid-apps/hub.git: git push pod HEAD:main # succeeds: * [new branch] HEAD -> main After this fix, `curl http://localhost:5444/public/apps/hub/` returns Hub's actual content instead of the LDP container listing for a bare repo's internals.
Resolves #465.
Summary
Today
git push http://localhost:5444/public/apps/penny mainto a fresh path 404s — there's no.gityet and no HTTP path to create one. The only workaround has been to shell into the data directory andgit init --baremanually, which defeats the point of having a git HTTP backend.This PR adds auto-init on first push, gated narrowly so it can't corrupt user content. After this lands,
git pushto a fresh pod path Just Works™, GitHub-style.Safety conditions
Auto-init runs only when all of:
git-receive-pack(push). Fetches to a missing path still 404.server.js(no change to authorization)..gitfiles (e.g./public/apps/foowithindex.html) stays a 404 — we never risk promoting a user-content directory into a git repo.Implementation notes
tryAutoInitBareRepo(repoAbs)nearfindGitDir; documented with the safety conditions and the caller's responsibility (ACL must be checked upstream).spawnSync('git', [...])with an arg array — no shell interpolation. Errors swallowed; caller falls through to the standard 404. A missinggitbinary or a permission issue surfaces as 404, not 500.mkdirSync({ recursive: true })is idempotent and a secondgit init --bareno-ops on an existing bare repo.Diff size
src/handlers/git.js: 52 lines added (40 are JSDoc / comments explaining the safety contract).test/git-auto-init.test.js: new file, 5 tests.Tests
New
test/git-auto-init.test.jsexercises:index.html) → 404, file untouched, no.git/HEAD/objectsgit-upload-pack) to a missing path → 404, no filesystem side effectThese are the first tests of the git HTTP backend, period — no prior coverage existed.
Run with:
node --test test/git-auto-init.test.jsWhat this unblocks
git pushinstead of needing filesystem access on the pod machineRefs