Skip to content

feat(git): auto-init a bare repo on first push to an empty path#466

Merged
melvincarvalho merged 2 commits into
gh-pagesfrom
feat-git-auto-init-on-push
May 16, 2026
Merged

feat(git): auto-init a bare repo on first push to an empty path#466
melvincarvalho merged 2 commits into
gh-pagesfrom
feat-git-auto-init-on-push

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Resolves #465.

Summary

Today git push http://localhost:5444/public/apps/penny main to a fresh path 404s — there's no .git yet and no HTTP path to create one. The only workaround has been to shell into the data directory and git init --bare manually, 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 push to a fresh pod path Just Works™, GitHub-style.

Safety conditions

Auto-init runs only when all of:

  • Request is git-receive-pack (push). Fetches to a missing path still 404.
  • ACL Write has already been verified by the existing preHandler hook in server.js (no change to authorization).
  • Target path is empty or non-existent. A directory containing non-.git files (e.g. /public/apps/foo with index.html) stays a 404 — we never risk promoting a user-content directory into a git repo.
  • Path resolves inside the data root (existing check above the new code).

Implementation notes

  • New helper tryAutoInitBareRepo(repoAbs) near findGitDir; documented with the safety conditions and the caller's responsibility (ACL must be checked upstream).
  • Uses spawnSync('git', [...]) with an arg array — no shell interpolation. Errors swallowed; caller falls through to the standard 404. A missing git binary or a permission issue surfaces as 404, not 500.
  • 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.

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.js exercises:

  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
  4. ✅ Fetch (git-upload-pack) to a missing path → 404, no filesystem side effect
  5. ✅ Second push to a path with an existing repo → 200, no re-init (HEAD inode unchanged)

These are the first tests of the git HTTP backend, period — no prior coverage existed.

Run with: node --test test/git-auto-init.test.js

What this unblocks

  • jspod's `jspod install ` story — once you can push to a fresh path, the install flow is a local clone + git push instead of needing filesystem access on the pod machine
  • The broader apps-in-pods direction — pods become composable git remotes
  • Any downstream wrapper that wants to deploy app code over the existing JSS git HTTP backend

Refs

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.
@melvincarvalho

Copy link
Copy Markdown
Contributor Author

Self-reviewed and pushed a17d38b addressing the two cheapest items from the review:

1. Diagnostic logging. `tryAutoInitBareRepo` now takes an optional `request.log` and emits:

  • `info`: `git auto-init: bare repo created on first push` on success
  • `warn`: with `{status, stderr}` on non-zero git exit
  • `warn`: with `{err}` on filesystem exceptions

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:

  • Spins up a separate server without `public: true`
  • Sends an unauthenticated `git-receive-pack` advertise
  • Asserts 401/403 status AND that no directory was created on disk

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):

  • TOCTOU between empty-check and `git init --bare` — narrow window, requires concurrent authenticated writer, end state is messy but not corrupt. Acceptable for v1.
  • No per-route rate limit for git auto-init specifically — relies on existing ACL gate + generic limits. Could add a follow-up if the threat surface grows.
  • Symlink-escape (the caveat documented above) — needs cross-handler hardening, separate PR.

Ready for human review.

@melvincarvalho
melvincarvalho merged commit ab04a2b into gh-pages May 16, 2026
1 check passed
@melvincarvalho
melvincarvalho deleted the feat-git-auto-init-on-push branch May 16, 2026 15:55
melvincarvalho added a commit to JavaScriptSolidServer/jspod that referenced this pull request May 16, 2026
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
melvincarvalho added a commit that referenced this pull request May 16, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Git HTTP backend: add an init primitive so pod paths can become git repos remotely

1 participant