Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions src/handlers/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -123,26 +132,26 @@ 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;
if (readdirSync(repoAbs).length > 0) return null;
} 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)');
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 15 additions & 11 deletions test/git-auto-init.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand All @@ -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));

Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);

Expand Down