diff --git a/src/handlers/git.js b/src/handlers/git.js index 3b79e502..2eced00c 100644 --- a/src/handlers/git.js +++ b/src/handlers/git.js @@ -90,11 +90,20 @@ function findGitDir(repoPath) { } /** - * Auto-initialize a bare git repo at repoAbs to accept a first push, but - * only when it's safe to do so. The caller must invoke this *after* the - * standard ACL Write check has passed (i.e. inside the existing - * preHandler-gated path for `git-receive-pack`), so authorization is - * already enforced. + * Auto-initialize a regular (non-bare) git repo at repoAbs to accept a + * first push, but only when it's safe to do so. The caller must invoke + * this *after* the standard ACL Write check has passed (i.e. inside the + * existing preHandler-gated path for `git-receive-pack`), so + * authorization is already enforced. + * + * Regular (not bare): the repo has a `.git/` subdirectory plus a + * working tree. Combined with the `receive.denyCurrentBranch + * updateInstead` config the main handler sets on every push, the + * working tree is auto-extracted on each push. This means pushed files + * appear as static resources at the corresponding pod URL — the "apps + * live in pods" install pattern works end-to-end. (Bare repos store + * content in pack files only, so a pushed `index.html` wouldn't be + * servable as HTTP.) * * Safe iff one of: * - the target path does not exist (we create it), or @@ -123,7 +132,7 @@ function findGitDir(repoPath) { * @param {object} [log] - optional Fastify request logger for diagnostics * @returns {{gitDir: string, isRegular: boolean}|null} */ -function tryAutoInitBareRepo(repoAbs, log) { +function tryAutoInitRepo(repoAbs, log) { try { if (existsSync(repoAbs)) { if (!statSync(repoAbs).isDirectory()) return null; @@ -131,18 +140,18 @@ function tryAutoInitBareRepo(repoAbs, log) { } else { mkdirSync(repoAbs, { recursive: true }); } - const result = spawnSync('git', ['init', '--bare', repoAbs], { + const result = spawnSync('git', ['init', repoAbs], { stdio: ['ignore', 'pipe', 'pipe'] }); if (result.status !== 0) { log?.warn?.( { repoAbs, status: result.status, stderr: result.stderr?.toString?.().slice(0, 500) }, - 'git auto-init: `git init --bare` exited non-zero' + 'git auto-init: `git init` exited non-zero' ); return null; } const info = findGitDir(repoAbs); - if (info) log?.info?.({ repoAbs }, 'git auto-init: bare repo created on first push'); + if (info) log?.info?.({ repoAbs }, 'git auto-init: repo created on first push'); return info; } catch (err) { log?.warn?.({ err, repoAbs }, 'git auto-init: refusing to init (filesystem error)'); @@ -213,14 +222,17 @@ export async function handleGit(request, reply) { } // Find git directory. On a push (`git-receive-pack`) to a path that - // doesn't yet contain a repo, auto-init a bare one if the location is - // safe to claim — the standard preHandler has already verified ACL - // Write on this path, so authorization is enforced. See - // tryAutoInitBareRepo for the safety conditions (empty / non-existent - // path only; refuses to clobber existing files). + // doesn't yet contain a repo, auto-init one if the location is safe + // to claim — the standard preHandler has already verified ACL Write + // on this path, so authorization is enforced. See tryAutoInitRepo + // for the safety conditions (empty / non-existent path only; refuses + // to clobber existing files). Auto-init creates a regular (non-bare) + // repo so the `denyCurrentBranch updateInstead` config below + // auto-extracts the working tree on each push — pushed files become + // static resources at the corresponding pod URL. let gitInfo = findGitDir(repoAbs); if (!gitInfo && isGitWriteOperation(request.url)) { - gitInfo = tryAutoInitBareRepo(repoAbs, request.log); + gitInfo = tryAutoInitRepo(repoAbs, request.log); } if (!gitInfo) { setGitCorsHeaders(reply); diff --git a/test/git-auto-init.test.js b/test/git-auto-init.test.js index a47debd2..f55bdfb8 100644 --- a/test/git-auto-init.test.js +++ b/test/git-auto-init.test.js @@ -56,7 +56,7 @@ describe('Git auto-init on first push', () => { await fs.remove(DATA_DIR); }); - it('auto-inits a bare repo on push-advertise to a non-existent path', async () => { + it('auto-inits a regular repo on push-advertise to a non-existent path', async () => { const repoPath = 'public/apps/penny'; const url = `${baseUrl}/${repoPath}/info/refs?service=git-receive-pack`; @@ -66,13 +66,18 @@ describe('Git auto-init on first push', () => { const repoAbs = path.resolve(DATA_DIR, repoPath); assert.ok(existsSync(repoAbs), 'directory must be created'); assert.ok(statSync(repoAbs).isDirectory(), 'created path must be a directory'); - // Bare repo: HEAD + objects/ + refs/ live at the path itself. - assert.ok(existsSync(path.join(repoAbs, 'HEAD')), 'bare repo HEAD must exist'); - assert.ok(existsSync(path.join(repoAbs, 'objects')), 'bare repo objects/ must exist'); - assert.ok(existsSync(path.join(repoAbs, 'refs')), 'bare repo refs/ must exist'); + // Regular (non-bare) repo: .git/ subdirectory holds the internals. + // Working tree lives at the path itself so pushed files become + // static resources. + const dotGit = path.join(repoAbs, '.git'); + assert.ok(existsSync(dotGit), '.git subdirectory must exist'); + assert.ok(statSync(dotGit).isDirectory(), '.git must be a directory'); + assert.ok(existsSync(path.join(dotGit, 'HEAD')), '.git/HEAD must exist'); + assert.ok(existsSync(path.join(dotGit, 'objects')), '.git/objects must exist'); + assert.ok(existsSync(path.join(dotGit, 'refs')), '.git/refs must exist'); }); - it('auto-inits a bare repo when the target directory exists but is empty', async () => { + it('auto-inits a regular repo when the target directory exists but is empty', async () => { const repoPath = 'public/empty-dir'; await fs.ensureDir(path.resolve(DATA_DIR, repoPath)); @@ -81,7 +86,7 @@ describe('Git auto-init on first push', () => { assert.strictEqual(res.status, 200); const repoAbs = path.resolve(DATA_DIR, repoPath); - assert.ok(existsSync(path.join(repoAbs, 'HEAD')), 'bare repo HEAD must exist'); + assert.ok(existsSync(path.join(repoAbs, '.git', 'HEAD')), '.git/HEAD must exist'); }); it('refuses to auto-init when the target directory contains user content', async () => { @@ -96,8 +101,7 @@ describe('Git auto-init on first push', () => { // User's file must be intact and no .git should have been created. assert.ok(existsSync(path.join(repoAbs, 'index.html')), 'user file must survive'); - assert.ok(!existsSync(path.join(repoAbs, 'HEAD')), 'no bare-repo files should appear'); - assert.ok(!existsSync(path.join(repoAbs, 'objects')), 'no bare-repo objects/ should appear'); + assert.ok(!existsSync(path.join(repoAbs, '.git')), 'no .git directory should appear'); }); it('does NOT auto-init on a fetch (`git-upload-pack`)', async () => { @@ -151,8 +155,8 @@ describe('Git auto-init on first push', () => { const repoPath = 'public/apps/penny'; const repoAbs = path.resolve(DATA_DIR, repoPath); // Repo was created by the first test in this suite; capture its - // initial HEAD inode/mtime to verify we don't recreate the repo. - const headPath = path.join(repoAbs, 'HEAD'); + // initial HEAD inode to verify we don't recreate the repo. + const headPath = path.join(repoAbs, '.git', 'HEAD'); assert.ok(existsSync(headPath), 'prereq: repo from earlier test still exists'); const before = statSync(headPath);