From 9e26a4f53d996bf2acd11597323e1bee07eecb03 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 23 Apr 2026 19:42:01 +0900 Subject: [PATCH 1/8] Bundle Nuxt runtime from dist Build the Nuxt server runtime files as tsdown entry points and point the Nuxt module at the generated JavaScript under dist. This keeps installed consumers from feeding TypeScript runtime sources into Nitro's Rollup pipeline. Add a package-level self-import test so the Node test path exercises @fedify/nuxt through the package exports and catches missing built runtime files before publishing. Fixes https://github.com/fedify-dev/fedify/issues/716 Assisted-by: Codex:gpt-5.4 --- packages/nuxt/package.json | 2 +- packages/nuxt/src/module.test.ts | 34 ++++++++++++- packages/nuxt/src/module.ts | 22 +++++++-- packages/nuxt/src/package.test.ts | 81 +++++++++++++++++++++++++++++++ packages/nuxt/tsdown.config.ts | 6 ++- 5 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 packages/nuxt/src/package.test.ts diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index a5918cd60..41aa113dd 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -46,7 +46,6 @@ }, "files": [ "dist/", - "src/runtime/", "package.json" ], "peerDependencies": { @@ -67,6 +66,7 @@ "build": "pnpm --filter @fedify/nuxt... run build:self", "prepack": "pnpm build", "prepublish": "pnpm build", + "pretest": "pnpm build", "test": "node --experimental-transform-types --test" } } diff --git a/packages/nuxt/src/module.test.ts b/packages/nuxt/src/module.test.ts index 77d024c3c..31440435b 100644 --- a/packages/nuxt/src/module.test.ts +++ b/packages/nuxt/src/module.test.ts @@ -1,7 +1,11 @@ import { test } from "@fedify/fixture"; -import { equal, ok, throws } from "node:assert/strict"; +import { deepEqual, equal, ok, throws } from "node:assert/strict"; import { isAbsolute } from "node:path"; -import { buildContextFactoryResolver, resolveModulePath } from "./module.ts"; +import { + buildContextFactoryResolver, + resolveModulePath, + resolveRuntimeServerPath, +} from "./module.ts"; test( "relative module path must resolve to absolute path", @@ -47,6 +51,32 @@ test( }, ); +test( + "runtime server files must resolve to compiled JavaScript output", + () => { + const requestedPaths: string[] = []; + const resolver = { + resolve(path: string): string { + requestedPaths.push(path); + return `/package/${path}`; + }, + }; + + equal( + resolveRuntimeServerPath(resolver, "middleware.js"), + "/package/../dist/runtime/server/middleware.js", + ); + equal( + resolveRuntimeServerPath(resolver, "plugin.js"), + "/package/../dist/runtime/server/plugin.js", + ); + deepEqual(requestedPaths, [ + "../dist/runtime/server/middleware.js", + "../dist/runtime/server/plugin.js", + ]); + }, +); + test( "missing exports must throw, not silently return undefined", () => { diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 958ba79c1..956a94d4e 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -54,6 +54,17 @@ export function resolveModulePath( return resolved; } +interface RuntimeResolver { + resolve(path: string): string; +} + +export function resolveRuntimeServerPath( + resolver: RuntimeResolver, + fileName: "middleware.js" | "plugin.js", +): string { + return resolver.resolve(`../dist/runtime/server/${fileName}`); +} + export function buildContextFactoryResolver( contextDataFactoryModule: string | null, ): string { @@ -106,6 +117,11 @@ const fedifyNuxtModule: NuxtModule = ); const middlewareFilename = "fedify-nuxt-options.mjs"; + const middlewareModule = resolveRuntimeServerPath( + resolver, + "middleware.js", + ); + const pluginModule = resolveRuntimeServerPath(resolver, "plugin.js"); addServerTemplate({ filename: middlewareFilename, @@ -115,9 +131,7 @@ const fedifyNuxtModule: NuxtModule = JSON.stringify(federationModule) };`, `import { createFedifyMiddleware } from ${ - JSON.stringify( - resolver.resolve("../src/runtime/server/middleware.ts"), - ) + JSON.stringify(middlewareModule) };`, ]; @@ -145,7 +159,7 @@ const fedifyNuxtModule: NuxtModule = handler: middlewareFilename, }); - addServerPlugin(resolver.resolve("../src/runtime/server/plugin.ts")); + addServerPlugin(pluginModule); }, }); diff --git a/packages/nuxt/src/package.test.ts b/packages/nuxt/src/package.test.ts new file mode 100644 index 000000000..1775dc4af --- /dev/null +++ b/packages/nuxt/src/package.test.ts @@ -0,0 +1,81 @@ +import { test } from "@fedify/fixture"; +import fedifyNuxtModule from "@fedify/nuxt"; +import { runWithNuxtContext } from "@nuxt/kit"; +import type { Nuxt } from "@nuxt/schema"; +import { equal, match, ok } from "node:assert/strict"; + +interface TestNuxt { + options: { + rootDir: string; + alias: Record; + _requiredModules?: Record; + experimental: Record; + serverHandlers: Array<{ + route: string; + middleware: boolean; + handler: string; + method?: string; + }>; + devServerHandlers: unknown[]; + nitro: { + virtual?: Record string>; + plugins?: string[]; + }; + }; + hooks: { + addHooks(): void; + callHook(): void; + }; + hook(): void; + callHook(): void; +} + +function createNuxtFixture(): TestNuxt { + return { + options: { + rootDir: "/app", + alias: { "~": "/app", "@": "/app" }, + experimental: {}, + serverHandlers: [], + devServerHandlers: [], + nitro: {}, + }, + hooks: { + addHooks: () => undefined, + callHook: () => undefined, + }, + hook: () => undefined, + callHook: () => undefined, + }; +} + +test("package import registers built runtime files", async () => { + const nuxt = createNuxtFixture(); + const nuxtContext = nuxt as unknown as Nuxt; + + await runWithNuxtContext( + nuxtContext, + () => + fedifyNuxtModule({ federationModule: "#server/federation" }, nuxtContext), + ); + + equal(nuxt.options.serverHandlers.length, 1); + equal(nuxt.options.serverHandlers[0].handler, "fedify-nuxt-options.mjs"); + + const getContents = nuxt.options.nitro.virtual?.["fedify-nuxt-options.mjs"]; + if (getContents == null) { + throw new TypeError("Expected fedify-nuxt-options.mjs to be registered."); + } + + const contents = getContents(); + match( + contents, + /import \{ createFedifyMiddleware \} from ".+\/dist\/runtime\/server\/middleware\.js";/, + ); + ok(!contents.includes("src/runtime")); + ok(!contents.includes("middleware.ts")); + + const [plugin] = nuxt.options.nitro.plugins ?? []; + ok(plugin != null); + match(plugin, /\/dist\/runtime\/server\/plugin\.js$/); +}); diff --git a/packages/nuxt/tsdown.config.ts b/packages/nuxt/tsdown.config.ts index bf33f512d..cbe9fa72b 100644 --- a/packages/nuxt/tsdown.config.ts +++ b/packages/nuxt/tsdown.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/mod.ts"], + entry: [ + "src/mod.ts", + "src/runtime/server/middleware.ts", + "src/runtime/server/plugin.ts", + ], dts: true, format: ["esm", "cjs"], platform: "node", From c1a3f223869f4a8bb321ce0c3b54b7ba2f74256b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 23 Apr 2026 19:53:37 +0900 Subject: [PATCH 2/8] Update generated Biome config Generated projects now use the Biome 2 configuration shape for import organization, matching the @biomejs/biome version installed by fedify init. Add a scaffold-level regression test that creates an npm project and checks the generated Biome schema version against the exact installed Biome package version. https://github.com/fedify-dev/fedify/issues/716 Assisted-by: Codex:gpt-5.4 --- CHANGES.md | 7 ++ packages/init/src/action/configs.test.ts | 94 ++++++++++++++++++++++++ packages/init/src/json/biome.json | 11 ++- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 77b424aaf..1e90f5a71 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -148,6 +148,12 @@ To be released. ### @fedify/init + - Fixed generated *biome.json* files to use Biome 2 configuration syntax, + matching the `@biomejs/biome` version that `fedify init` installs. + Generated projects now enable import organization through Biome's + `assist.actions.source.organizeImports` setting instead of the removed + top-level `organizeImports` option. [[#716]] + - Fixed errors when using `fedify init` with certain web framework integration packages (Astro, ElysiaJS, Nitro) alongside `@fedify/mysql`. Environment variables are now properly loaded at runtime, resolving the @@ -161,6 +167,7 @@ To be released. [#649]: https://github.com/fedify-dev/fedify/issues/649 [#656]: https://github.com/fedify-dev/fedify/pull/656 [#675]: https://github.com/fedify-dev/fedify/pull/675 +[#716]: https://github.com/fedify-dev/fedify/issues/716 ### Docs diff --git a/packages/init/src/action/configs.test.ts b/packages/init/src/action/configs.test.ts index 51ba1cb46..a03f5a263 100644 --- a/packages/init/src/action/configs.test.ts +++ b/packages/init/src/action/configs.test.ts @@ -1,8 +1,14 @@ import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { message } from "@optique/core"; +import { kvStores, messageQueues } from "../lib.ts"; import type { InitCommandData } from "../types.ts"; +import bareBonesDescription from "../webframeworks/bare-bones.ts"; import { loadDenoConfig } from "./configs.ts"; +import { patchFiles } from "./patch.ts"; function createInitData(): InitCommandData { const data = { @@ -94,3 +100,91 @@ test("loadDenoConfig keeps unstable.temporal before Deno 2.7.0", () => { restoreDeno(originalDeno); } }); + +test("patchFiles creates a Biome config matching the npm package version", async () => { + const dir = await mkdtemp(join(tmpdir(), "fedify-init-biome-")); + + try { + const data = await createNpmInitData(dir); + await patchFiles(data); + + const packageJson = JSON.parse( + await readFile(join(dir, "package.json"), "utf8"), + ) as { + devDependencies?: Record; + }; + const biomeConfig = JSON.parse( + await readFile(join(dir, "biome.json"), "utf8"), + ) as Record; + + const biomeVersion = packageJson.devDependencies?.["@biomejs/biome"]; + const schema = biomeConfig.$schema; + assert.ok(typeof biomeVersion === "string"); + assert.ok(typeof schema === "string"); + assert.equal(getSchemaVersion(schema), getPackageVersion(biomeVersion)); + assert.equal(getOrganizeImportsSetting(biomeConfig), "on"); + assert.equal( + "organizeImports" in biomeConfig, + false, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function createNpmInitData(dir: string): Promise { + const initializer = await bareBonesDescription.init({ + command: "init", + projectName: "example", + packageManager: "npm", + webFramework: "bare-bones", + kvStore: "in-memory", + messageQueue: "in-process", + dryRun: false, + testMode: false, + dir, + }); + + const data = { + command: "init", + projectName: "example", + packageManager: "npm", + webFramework: "bare-bones", + kvStore: "in-memory", + messageQueue: "in-process", + dryRun: false, + testMode: false, + dir, + initializer, + kv: kvStores["in-memory"], + mq: messageQueues["in-process"], + env: {}, + } satisfies InitCommandData; + return data; +} + +function getSchemaVersion(schema: string): string { + const match = schema.match(/\/schemas\/(\d+\.\d+\.\d+)\//); + assert.ok(match, `Unexpected Biome schema URL: ${schema}`); + return match[1]; +} + +function getPackageVersion(version: string): string { + const match = version.match(/\d+\.\d+\.\d+/); + assert.ok(match, `Unexpected Biome package version: ${version}`); + return match[0]; +} + +function getOrganizeImportsSetting(config: Record): unknown { + const assist = config.assist; + assert.ok(isRecord(assist)); + const actions = assist.actions; + assert.ok(isRecord(actions)); + const source = actions.source; + assert.ok(isRecord(source)); + return source.organizeImports; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null; +} diff --git a/packages/init/src/json/biome.json b/packages/init/src/json/biome.json index 902345891..2753ca99f 100644 --- a/packages/init/src/json/biome.json +++ b/packages/init/src/json/biome.json @@ -1,7 +1,12 @@ { - "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", - "organizeImports": { - "enabled": true + "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } }, "formatter": { "enabled": true, From a9b67c5e37cb953bffa450c4a516ba0e47ea91d1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 23 Apr 2026 20:08:04 +0900 Subject: [PATCH 3/8] Allow init in unborn Git repos Treat a target directory that only contains a freshly initialized Git repository as empty, while still rejecting repositories whose HEAD resolves to a commit or whose .git metadata shows existing refs. Add regression coverage for unborn repositories, committed repositories, packed refs, detached HEADs, .git files, and additional project files. https://github.com/fedify-dev/fedify/issues/716 Assisted-by: Codex:gpt-5.4 --- CHANGES.md | 6 ++ packages/init/src/lib.test.ts | 136 ++++++++++++++++++++++++++++++++++ packages/init/src/lib.ts | 128 ++++++++++++++++++++++++++++++-- 3 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 packages/init/src/lib.test.ts diff --git a/CHANGES.md b/CHANGES.md index 1e90f5a71..4be5cecc6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -148,6 +148,12 @@ To be released. ### @fedify/init + - Fixed `fedify init` so that a directory containing only a freshly + initialized Git repository is treated as empty. Directories whose Git + `HEAD` already resolves to a commit, or that contain any files besides + *.git*, still require the existing non-empty-directory confirmation. + [[#716]] + - Fixed generated *biome.json* files to use Biome 2 configuration syntax, matching the `@biomejs/biome` version that `fedify init` installs. Generated projects now enable import organization through Biome's diff --git a/packages/init/src/lib.test.ts b/packages/init/src/lib.test.ts new file mode 100644 index 000000000..728f93250 --- /dev/null +++ b/packages/init/src/lib.test.ts @@ -0,0 +1,136 @@ +import { strictEqual } from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { isDirectoryEmpty } from "./lib.ts"; +import { runSubCommand } from "./utils.ts"; + +test("isDirectoryEmpty allows an unborn Git repository", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + + strictEqual(await isDirectoryEmpty(dir), true); + }); +}); + +test("isDirectoryEmpty allows a freshly initialized Git repository", async (t) => { + if (!await isGitAvailable()) { + t.skip("git is not installed"); + return; + } + + await withTempDir(async (dir) => { + await runGit(dir, ["init"]); + + strictEqual(await isDirectoryEmpty(dir), true); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with a branch ref", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile( + join(dir, ".git", "refs", "heads", "main"), + "0000000000000000000000000000000000000000\n", + ); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with a HEAD commit", async (t) => { + if (!await isGitAvailable()) { + t.skip("git is not installed"); + return; + } + + await withTempDir(async (dir) => { + await runGit(dir, ["init"]); + await runGit(dir, [ + "-c", + "user.name=Fedify Test", + "-c", + "user.email=fedify@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "Initial commit", + ]); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with a packed ref", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile( + join(dir, ".git", "packed-refs"), + "0000000000000000000000000000000000000000 refs/heads/main\n", + ); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a detached Git HEAD", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile( + join(dir, ".git", "HEAD"), + "0000000000000000000000000000000000000000\n", + ); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects additional files beside .git", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile(join(dir, "package.json"), "{}\n"); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a .git file", async () => { + await withTempDir(async (dir) => { + await writeFile(join(dir, ".git"), "gitdir: ../.git/worktrees/example\n"); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +async function createUnbornGitRepository(dir: string): Promise { + await mkdir(join(dir, ".git", "objects"), { recursive: true }); + await mkdir(join(dir, ".git", "refs", "heads"), { recursive: true }); + await writeFile(join(dir, ".git", "HEAD"), "ref: refs/heads/main\n"); +} + +async function isGitAvailable(): Promise { + try { + await runSubCommand(["git", "--version"], {}); + return true; + } catch { + return false; + } +} + +async function runGit(dir: string, args: string[]): Promise { + await runSubCommand(["git", "-C", dir, ...args], {}); +} + +async function withTempDir( + fn: (dir: string) => Promise, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "fedify-init-dir-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/packages/init/src/lib.ts b/packages/init/src/lib.ts index 8133093a1..347961b0b 100644 --- a/packages/init/src/lib.ts +++ b/packages/init/src/lib.ts @@ -12,7 +12,14 @@ import { import { getLogger } from "@logtape/logtape"; import { toMerged } from "es-toolkit"; import { readFileSync } from "node:fs"; -import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { + access, + mkdir, + readdir, + readFile, + stat, + writeFile, +} from "node:fs/promises"; import { dirname, join as joinPath } from "node:path"; import process from "node:process"; import metadata from "../deno.json" with { type: "json" }; @@ -27,7 +34,7 @@ import type { PackageManagers, Runtimes, } from "./types.ts"; -import { isNotFoundError } from "./utils.ts"; +import { CommandError, isNotFoundError, runSubCommand } from "./utils.ts"; /** The current `@fedify/init` package version, read from *deno.json*. */ export const PACKAGE_VERSION = metadata.version; @@ -191,21 +198,132 @@ const isNotExistsError = (e: unknown) => export const throwUnlessNotExists = throwIf(negate(isNotExistsError)); /** - * Checks whether a directory is empty or does not exist. - * Returns `true` if the directory has no entries or does not exist yet. + * Checks whether a directory is safe to initialize as an empty project. + * Returns `true` if the directory does not exist, has no entries, or only + * contains an unborn Git repository created by `git init`. */ export const isDirectoryEmpty = async ( path: string, ): Promise => { try { const files = await readdir(path); - return files.length === 0; + if (files.length === 0) return true; + if (files.length === 1 && files[0] === ".git") { + return await isUnbornGitRepository(path); + } + return false; } catch (e) { throwUnlessNotExists(e); return true; } }; +const isUnbornGitRepository = async (path: string): Promise => { + if (await hasGitHeadCommit(path)) return false; + return await looksLikeUnbornGitRepository(path); +}; + +const hasGitHeadCommit = async (path: string): Promise => { + try { + await runSubCommand([ + "git", + "-C", + path, + "rev-parse", + "--verify", + "HEAD^{commit}", + ], {}); + return true; + } catch (e) { + if (isNotFoundError(e) || e instanceof CommandError) return false; + logger.debug( + "Failed to resolve Git HEAD in {path}: {error}", + { path, error: e }, + ); + return false; + } +}; + +const looksLikeUnbornGitRepository = async ( + path: string, +): Promise => { + const gitDir = joinPath(path, ".git"); + if (!await isDirectory(gitDir)) return false; + if (!await isDirectory(joinPath(gitDir, "objects"))) return false; + if (!await isDirectory(joinPath(gitDir, "refs"))) return false; + + const head = await readGitFile(joinPath(gitDir, "HEAD")); + if (head == null) return false; + const ref = parseHeadRef(head); + if (ref == null) return false; + if (await pathExists(joinPath(gitDir, ...ref.split("/")))) return false; + if (await hasPackedRef(gitDir, ref)) return false; + return true; +}; + +const parseHeadRef = (head: string): string | null => { + const match = head.trim().match(/^ref: (refs\/heads\/\S+)$/); + if (match == null) return null; + const ref = match[1]; + return ref.includes("..") ? null : ref; +}; + +const hasPackedRef = async ( + gitDir: string, + ref: string, +): Promise => { + let packedRefs: string; + try { + packedRefs = await readFile(joinPath(gitDir, "packed-refs"), "utf8"); + } catch (e) { + if (isNotFoundError(e)) return false; + logger.debug( + "Failed to read Git packed refs in {path}: {error}", + { path: gitDir, error: e }, + ); + return true; + } + + return packedRefs.split(/\r?\n/).some((line) => { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("^")) { + return false; + } + return trimmed.split(/\s+/)[1] === ref; + }); +}; + +const readGitFile = async (path: string): Promise => { + try { + return await readFile(path, "utf8"); + } catch (e) { + if (!isNotFoundError(e)) { + logger.debug( + "Failed to read Git file {path}: {error}", + { path, error: e }, + ); + } + return null; + } +}; + +const pathExists = async (path: string): Promise => { + try { + await access(path); + return true; + } catch (e) { + return !isNotFoundError(e); + } +}; + +const isDirectory = async (path: string): Promise => { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +}; + /** Returns `true` if the current run is in test mode. */ export const isTest: < T extends { testMode: boolean }, From 8c78646f5a5c871944f63a6c941ff68e53bba76e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 23 Apr 2026 20:16:09 +0900 Subject: [PATCH 4/8] Add non-empty init option Allow fedify init to run in non-empty directories when explicitly requested, but check the files Fedify would generate before running any framework scaffolding command. The command now fails before making changes if a generated file already exists, so unrelated files can coexist without accidental merges or appends. Document the option and add regression coverage for the conflict checks. https://github.com/fedify-dev/fedify/issues/716 Assisted-by: Codex:gpt-5.4 --- CHANGES.md | 5 ++ docs/cli.md | 19 +++++ packages/init/src/action/configs.test.ts | 3 + packages/init/src/action/mod.ts | 7 +- packages/init/src/action/patch.test.ts | 103 +++++++++++++++++++++++ packages/init/src/action/patch.ts | 62 +++++++++++++- packages/init/src/ask/dir.ts | 3 +- packages/init/src/command.ts | 6 +- packages/init/src/package.test.ts | 1 + 9 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 packages/init/src/action/patch.test.ts diff --git a/CHANGES.md b/CHANGES.md index 4be5cecc6..4ba842202 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -148,6 +148,11 @@ To be released. ### @fedify/init + - Added a `--allow-non-empty` option to `fedify init` for automated + scaffolding in directories that already contain unrelated files. The + command still fails before making changes if any file that Fedify would + generate already exists, avoiding accidental merges or appends. [[#716]] + - Fixed `fedify init` so that a directory containing only a freshly initialized Git repository is treated as empty. Directories whose Git `HEAD` already resolves to a commit, or that contain any files besides diff --git a/docs/cli.md b/docs/cli.md index 907cdb10b..b8dd4dcff 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -339,6 +339,25 @@ When using `--dry-run`, the command will: This option works with all other initialization options, allowing you to preview different configurations before making a decision. +### `--allow-non-empty`: Initialize in a non-empty directory + +*This option is available since Fedify 2.2.0.* + +By default, `fedify init` asks for confirmation before using a directory that +already contains files. This prompt protects you from accidentally +initializing a project in the wrong directory. In non-interactive scripts or +CI jobs, use the `--allow-non-empty` option to allow a non-empty target +directory: + +~~~~ sh +fedify init . --allow-non-empty +~~~~ + +This option does not overwrite existing project files. Before making changes, +`fedify init` checks the files it would generate and fails if any of them +already exist. Unrelated files, such as *README.md* or a freshly initialized +*.git* directory, can remain in the target directory. + `fedify lookup`: Looking up an ActivityPub object ------------------------------------------------- diff --git a/packages/init/src/action/configs.test.ts b/packages/init/src/action/configs.test.ts index a03f5a263..c63d31730 100644 --- a/packages/init/src/action/configs.test.ts +++ b/packages/init/src/action/configs.test.ts @@ -19,6 +19,7 @@ function createInitData(): InitCommandData { kvStore: "denokv", messageQueue: "denokv", dryRun: false, + allowNonEmpty: false, testMode: false, dir: "/tmp/example", initializer: { @@ -141,6 +142,7 @@ async function createNpmInitData(dir: string): Promise { kvStore: "in-memory", messageQueue: "in-process", dryRun: false, + allowNonEmpty: false, testMode: false, dir, }); @@ -153,6 +155,7 @@ async function createNpmInitData(dir: string): Promise { kvStore: "in-memory", messageQueue: "in-process", dryRun: false, + allowNonEmpty: false, testMode: false, dir, initializer, diff --git a/packages/init/src/action/mod.ts b/packages/init/src/action/mod.ts index 0e6b44f70..79ce01550 100644 --- a/packages/init/src/action/mod.ts +++ b/packages/init/src/action/mod.ts @@ -12,7 +12,11 @@ import { noticeOptions, noticePrecommand, } from "./notice.ts"; -import { patchFiles, recommendPatchFiles } from "./patch.ts"; +import { + assertNoGeneratedFileConflicts, + patchFiles, + recommendPatchFiles, +} from "./patch.ts"; import recommendDependencies from "./recommend.ts"; import setData from "./set.ts"; import { @@ -69,6 +73,7 @@ const handleHydRun = (data: InitCommandData) => pipe( data, tap(makeDirIfHyd), + tap(assertNoGeneratedFileConflicts), tap(when(hasCommand, runPrecommand)), tap(patchFiles), tap(installDependencies), diff --git a/packages/init/src/action/patch.test.ts b/packages/init/src/action/patch.test.ts new file mode 100644 index 000000000..3a606f2bd --- /dev/null +++ b/packages/init/src/action/patch.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { message } from "@optique/core"; +import type { InitCommandData } from "../types.ts"; +import { + assertNoGeneratedFileConflicts, + GeneratedFileConflictError, +} from "./patch.ts"; + +test("assertNoGeneratedFileConflicts allows unrelated files", async () => { + await withTempDir(async (dir) => { + await writeFile(join(dir, "README.md"), "# Example\n"); + + await assert.doesNotReject(() => + assertNoGeneratedFileConflicts(createInitData(dir, true)) + ); + }); +}); + +test("assertNoGeneratedFileConflicts rejects existing generated files", async () => { + await withTempDir(async (dir) => { + await mkdir(join(dir, "src"), { recursive: true }); + await writeFile(join(dir, "package.json"), "{}\n"); + await writeFile(join(dir, "src", "main.ts"), ""); + + await assert.rejects( + () => assertNoGeneratedFileConflicts(createInitData(dir, true)), + (error) => { + assert.ok(error instanceof GeneratedFileConflictError); + assert.deepEqual(error.conflicts, ["src/main.ts", "package.json"]); + assert.match(error.message, /src\/main\.ts/); + assert.match(error.message, /package\.json/); + return true; + }, + ); + }); +}); + +test("assertNoGeneratedFileConflicts skips checks without allowNonEmpty", async () => { + await withTempDir(async (dir) => { + await writeFile(join(dir, "package.json"), "{}\n"); + + await assert.doesNotReject(() => + assertNoGeneratedFileConflicts(createInitData(dir, false)) + ); + }); +}); + +function createInitData( + dir: string, + allowNonEmpty: boolean, +): InitCommandData { + const data = { + command: "init", + projectName: "example", + packageManager: "npm", + webFramework: "bare-bones", + kvStore: "in-memory", + messageQueue: "in-process", + dryRun: false, + allowNonEmpty, + testMode: false, + dir, + initializer: { + federationFile: "src/federation.ts", + loggingFile: "src/logging.ts", + instruction: message`done`, + tasks: {}, + compilerOptions: {}, + files: { + "src/main.ts": "", + }, + }, + kv: { + label: "In-Memory", + packageManagers: ["npm"], + imports: {}, + object: "new MemoryKvStore()", + }, + mq: { + label: "In-Process", + packageManagers: ["npm"], + imports: {}, + object: "new InProcessMessageQueue()", + }, + env: {}, + } satisfies InitCommandData; + return data; +} + +async function withTempDir( + fn: (dir: string) => Promise, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "fedify-init-patch-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/packages/init/src/action/patch.ts b/packages/init/src/action/patch.ts index 764722b50..6fe2fdd70 100644 --- a/packages/init/src/action/patch.ts +++ b/packages/init/src/action/patch.ts @@ -1,6 +1,7 @@ import { always, apply, entries, map, pipe, pipeLazy, tap } from "@fxts/core"; import { toMerged } from "es-toolkit"; -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; +import { join as joinPath } from "node:path"; import { createFile, throwUnlessNotExists } from "../lib.ts"; import type { InitCommandData } from "../types.ts"; import { formatJson, merge, replaceAll, set } from "../utils.ts"; @@ -43,6 +44,27 @@ export const recommendPatchFiles = (data: InitCommandData) => recommendFiles, ); +/** + * Verifies that `--allow-non-empty` will not modify files that already + * existed before any framework scaffolding command runs. + */ +export async function assertNoGeneratedFileConflicts( + data: InitCommandData, +): Promise { + if (!data.allowNonEmpty) return; + const conflicts = await getExistingGeneratedFiles(data); + if (conflicts.length > 0) { + throw new GeneratedFileConflictError(conflicts); + } +} + +export class GeneratedFileConflictError extends Error { + constructor(public readonly conflicts: readonly string[]) { + super(formatConflictMessage(conflicts)); + this.name = "GeneratedFileConflictError"; + } +} + /** * Generates text-based files (TypeScript, environment files) for the project. * Creates federation configuration, logging setup, environment variables, and @@ -92,6 +114,44 @@ const getJsons = < [devToolConfigs["vscExt"].path]: devToolConfigs["vscExt"].data, }; +const getGeneratedFilePaths = (data: InitCommandData): string[] => [ + data.initializer.federationFile, + data.initializer.loggingFile, + ".env", + ...Object.keys(data.initializer.files ?? {}), + ...Object.keys(getJsons(data)), +]; + +const getExistingGeneratedFiles = async ( + data: InitCommandData, +): Promise => { + const conflicts: string[] = []; + for (const path of new Set(getGeneratedFilePaths(data))) { + if (await pathExists(joinPath(data.dir, ...path.split("/")))) { + conflicts.push(path); + } + } + return conflicts; +}; + +const pathExists = async (path: string): Promise => { + try { + await access(path); + return true; + } catch (e) { + throwUnlessNotExists(e); + return false; + } +}; + +const formatConflictMessage = (conflicts: readonly string[]): string => + [ + "Cannot initialize in a non-empty directory because these generated files", + "already exist:", + ...conflicts.map((path) => ` - ${path}`), + "Remove the conflicting files or choose another directory.", + ].join("\n"); + /** * Handles dry-run mode by recommending files to be created without actually * creating them. diff --git a/packages/init/src/ask/dir.ts b/packages/init/src/ask/dir.ts index d09e10136..bd8b81fac 100644 --- a/packages/init/src/ask/dir.ts +++ b/packages/init/src/ask/dir.ts @@ -16,10 +16,11 @@ import { getCwd, getOsType } from "../utils.ts"; * @param options - Initialization options possibly containing a directory * @returns A promise resolving to options with a guaranteed directory */ -const fillDir: ( +const fillDir: ( options: T, ) => Promise = async (options) => { const dir = options.dir ?? await askDir(getCwd()); + if (options.allowNonEmpty) return { ...options, dir }; return await askIfNonEmpty(dir) ? { ...options, dir } : await fillDir(options); diff --git a/packages/init/src/command.ts b/packages/init/src/command.ts index 2d8c9d700..6c21c973b 100644 --- a/packages/init/src/command.ts +++ b/packages/init/src/command.ts @@ -59,7 +59,7 @@ const messageQueue = optional(option( /** * The `@optique/core` option schema for the `fedify init` command. * Defines `dir`, `webFramework`, `packageManager`, `kvStore`, `messageQueue`, - * and `dryRun` options that the CLI parser will accept. + * `dryRun`, and `allowNonEmpty` options that the CLI parser will accept. */ export const initOptions = object("Initialization options", { dir: optional(argument(path({ metavar: "DIR" }), { @@ -73,6 +73,10 @@ export const initOptions = object("Initialization options", { dryRun: option("--dry-run", { description: message`Perform a trial run with no changes made.`, }), + allowNonEmpty: option("--allow-non-empty", { + description: + message`Allow initializing in a non-empty directory, failing if any generated file already exists.`, + }), }); /** diff --git a/packages/init/src/package.test.ts b/packages/init/src/package.test.ts index cd178f4a9..6e545b7fe 100644 --- a/packages/init/src/package.test.ts +++ b/packages/init/src/package.test.ts @@ -45,6 +45,7 @@ test( command: "init", dir: packageDir, dryRun: true, + allowNonEmpty: false, kvStore: "in-memory", messageQueue: "in-process", packageManager: "bun", From fee5abcfbe21554730be6506e512cabde6deb2d8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 23 Apr 2026 20:18:01 +0900 Subject: [PATCH 5/8] Add PR links to the changelog --- CHANGES.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 4ba842202..0b0493cfd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -151,19 +151,20 @@ To be released. - Added a `--allow-non-empty` option to `fedify init` for automated scaffolding in directories that already contain unrelated files. The command still fails before making changes if any file that Fedify would - generate already exists, avoiding accidental merges or appends. [[#716]] + generate already exists, avoiding accidental merges or appends. + [[#716], [#717]] - Fixed `fedify init` so that a directory containing only a freshly initialized Git repository is treated as empty. Directories whose Git `HEAD` already resolves to a commit, or that contain any files besides *.git*, still require the existing non-empty-directory confirmation. - [[#716]] + [[#716], [#717]] - Fixed generated *biome.json* files to use Biome 2 configuration syntax, matching the `@biomejs/biome` version that `fedify init` installs. Generated projects now enable import organization through Biome's `assist.actions.source.organizeImports` setting instead of the removed - top-level `organizeImports` option. [[#716]] + top-level `organizeImports` option. [[#716], [#717]] - Fixed errors when using `fedify init` with certain web framework integration packages (Astro, ElysiaJS, Nitro) alongside `@fedify/mysql`. @@ -179,6 +180,7 @@ To be released. [#656]: https://github.com/fedify-dev/fedify/pull/656 [#675]: https://github.com/fedify-dev/fedify/pull/675 [#716]: https://github.com/fedify-dev/fedify/issues/716 +[#717]: https://github.com/fedify-dev/fedify/pull/717 ### Docs From dc16653cbde61b991505148b6663901739acc6b2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 23 Apr 2026 20:33:07 +0900 Subject: [PATCH 6/8] Handle init review feedback Parallelize generated-file conflict checks while preserving path splitting for Windows-compatible path joins. Treat any existing Git ref as evidence that a .git-only directory is not fresh, including refs outside the current HEAD branch and any packed ref. References https://github.com/fedify-dev/fedify/pull/717#discussion_r3130411571 References https://github.com/fedify-dev/fedify/pull/717#discussion_r3130434137 Assisted-by: Codex:gpt-5.4 --- packages/init/src/action/patch.ts | 15 +++++---- packages/init/src/lib.test.ts | 17 +++++++++- packages/init/src/lib.ts | 56 ++++++++++++++++++------------- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/packages/init/src/action/patch.ts b/packages/init/src/action/patch.ts index 6fe2fdd70..97118ff4c 100644 --- a/packages/init/src/action/patch.ts +++ b/packages/init/src/action/patch.ts @@ -125,13 +125,14 @@ const getGeneratedFilePaths = (data: InitCommandData): string[] => [ const getExistingGeneratedFiles = async ( data: InitCommandData, ): Promise => { - const conflicts: string[] = []; - for (const path of new Set(getGeneratedFilePaths(data))) { - if (await pathExists(joinPath(data.dir, ...path.split("/")))) { - conflicts.push(path); - } - } - return conflicts; + const paths = [...new Set(getGeneratedFilePaths(data))]; + const results = await Promise.all( + paths.map(async (path) => { + const exists = await pathExists(joinPath(data.dir, ...path.split("/"))); + return exists ? path : null; + }), + ); + return results.filter((path): path is string => path != null); }; const pathExists = async (path: string): Promise => { diff --git a/packages/init/src/lib.test.ts b/packages/init/src/lib.test.ts index 728f93250..2506ddf5f 100644 --- a/packages/init/src/lib.test.ts +++ b/packages/init/src/lib.test.ts @@ -39,6 +39,21 @@ test("isDirectoryEmpty rejects a Git repository with a branch ref", async () => }); }); +test("isDirectoryEmpty rejects a Git repository with another ref", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await mkdir(join(dir, ".git", "refs", "remotes", "origin"), { + recursive: true, + }); + await writeFile( + join(dir, ".git", "refs", "remotes", "origin", "main"), + "0000000000000000000000000000000000000000\n", + ); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + test("isDirectoryEmpty rejects a Git repository with a HEAD commit", async (t) => { if (!await isGitAvailable()) { t.skip("git is not installed"); @@ -69,7 +84,7 @@ test("isDirectoryEmpty rejects a Git repository with a packed ref", async () => await createUnbornGitRepository(dir); await writeFile( join(dir, ".git", "packed-refs"), - "0000000000000000000000000000000000000000 refs/heads/main\n", + "0000000000000000000000000000000000000000 refs/tags/v1.0.0\n", ); strictEqual(await isDirectoryEmpty(dir), false); diff --git a/packages/init/src/lib.ts b/packages/init/src/lib.ts index 347961b0b..d2543aa9a 100644 --- a/packages/init/src/lib.ts +++ b/packages/init/src/lib.ts @@ -11,15 +11,8 @@ import { } from "@fxts/core"; import { getLogger } from "@logtape/logtape"; import { toMerged } from "es-toolkit"; -import { readFileSync } from "node:fs"; -import { - access, - mkdir, - readdir, - readFile, - stat, - writeFile, -} from "node:fs/promises"; +import { type Dirent, readFileSync } from "node:fs"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { dirname, join as joinPath } from "node:path"; import process from "node:process"; import metadata from "../deno.json" with { type: "json" }; @@ -256,8 +249,8 @@ const looksLikeUnbornGitRepository = async ( if (head == null) return false; const ref = parseHeadRef(head); if (ref == null) return false; - if (await pathExists(joinPath(gitDir, ...ref.split("/")))) return false; - if (await hasPackedRef(gitDir, ref)) return false; + if (await hasAnyLooseRef(gitDir)) return false; + if (await hasAnyPackedRef(gitDir)) return false; return true; }; @@ -268,9 +261,35 @@ const parseHeadRef = (head: string): string | null => { return ref.includes("..") ? null : ref; }; -const hasPackedRef = async ( +const hasAnyLooseRef = async (gitDir: string): Promise => + await hasAnyRefFile(joinPath(gitDir, "refs")); + +const hasAnyRefFile = async (dir: string): Promise => { + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (e) { + if (isNotFoundError(e)) return false; + logger.debug( + "Failed to read Git refs in {path}: {error}", + { path: dir, error: e }, + ); + return true; + } + + for (const entry of entries) { + const path = joinPath(dir, entry.name); + if (entry.isDirectory()) { + if (await hasAnyRefFile(path)) return true; + } else { + return true; + } + } + return false; +}; + +const hasAnyPackedRef = async ( gitDir: string, - ref: string, ): Promise => { let packedRefs: string; try { @@ -289,7 +308,7 @@ const hasPackedRef = async ( if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("^")) { return false; } - return trimmed.split(/\s+/)[1] === ref; + return true; }); }; @@ -307,15 +326,6 @@ const readGitFile = async (path: string): Promise => { } }; -const pathExists = async (path: string): Promise => { - try { - await access(path); - return true; - } catch (e) { - return !isNotFoundError(e); - } -}; - const isDirectory = async (path: string): Promise => { try { return (await stat(path)).isDirectory(); From 53dc63ce35af30a2dfaa21f736746515838ca841 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 23 Apr 2026 20:57:33 +0900 Subject: [PATCH 7/8] Address init review followups Clarify the changelog wording for Git refs in freshly initialized repositories, simplify HEAD ref validation to a boolean predicate, and use path.join() directly when checking generated-file conflicts. References https://github.com/fedify-dev/fedify/pull/717#discussion_r3130532000 References https://github.com/fedify-dev/fedify/pull/717#discussion_r3130532015 References https://github.com/fedify-dev/fedify/pull/717#issuecomment-4303970597 Assisted-by: Codex:gpt-5.4 --- CHANGES.md | 6 +++--- packages/init/src/action/patch.ts | 2 +- packages/init/src/lib.ts | 10 ++++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0b0493cfd..010cc33d9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -156,9 +156,9 @@ To be released. - Fixed `fedify init` so that a directory containing only a freshly initialized Git repository is treated as empty. Directories whose Git - `HEAD` already resolves to a commit, or that contain any files besides - *.git*, still require the existing non-empty-directory confirmation. - [[#716], [#717]] + `HEAD` already resolves to a commit, whose Git metadata contains loose or + packed refs, or that contain any files besides *.git*, still require the + existing non-empty-directory confirmation. [[#716], [#717]] - Fixed generated *biome.json* files to use Biome 2 configuration syntax, matching the `@biomejs/biome` version that `fedify init` installs. diff --git a/packages/init/src/action/patch.ts b/packages/init/src/action/patch.ts index 97118ff4c..377a184bf 100644 --- a/packages/init/src/action/patch.ts +++ b/packages/init/src/action/patch.ts @@ -128,7 +128,7 @@ const getExistingGeneratedFiles = async ( const paths = [...new Set(getGeneratedFilePaths(data))]; const results = await Promise.all( paths.map(async (path) => { - const exists = await pathExists(joinPath(data.dir, ...path.split("/"))); + const exists = await pathExists(joinPath(data.dir, path)); return exists ? path : null; }), ); diff --git a/packages/init/src/lib.ts b/packages/init/src/lib.ts index d2543aa9a..ee5f52423 100644 --- a/packages/init/src/lib.ts +++ b/packages/init/src/lib.ts @@ -247,18 +247,16 @@ const looksLikeUnbornGitRepository = async ( const head = await readGitFile(joinPath(gitDir, "HEAD")); if (head == null) return false; - const ref = parseHeadRef(head); - if (ref == null) return false; + if (!isValidHeadRef(head)) return false; if (await hasAnyLooseRef(gitDir)) return false; if (await hasAnyPackedRef(gitDir)) return false; return true; }; -const parseHeadRef = (head: string): string | null => { +const isValidHeadRef = (head: string): boolean => { const match = head.trim().match(/^ref: (refs\/heads\/\S+)$/); - if (match == null) return null; - const ref = match[1]; - return ref.includes("..") ? null : ref; + if (match == null) return false; + return !match[1].includes(".."); }; const hasAnyLooseRef = async (gitDir: string): Promise => From 11970a0da2f07d96e4b3eb7a67fc03b3ed547abd Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 23 Apr 2026 22:08:14 +0900 Subject: [PATCH 8/8] Reject stale Git metadata in init Treat an unborn Git repository as empty only when it has no refs, stored objects, index, reflogs, or other common state paths. This keeps fedify init from skipping the non-empty-directory safeguard for repositories with deleted refs but remaining Git state. References https://github.com/fedify-dev/fedify/pull/717#discussion_r3130599351 Assisted-by: Codex:gpt-5.4 --- CHANGES.md | 5 +-- packages/init/src/lib.test.ts | 29 +++++++++++++++++ packages/init/src/lib.ts | 61 ++++++++++++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 010cc33d9..7307dd7c9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -157,8 +157,9 @@ To be released. - Fixed `fedify init` so that a directory containing only a freshly initialized Git repository is treated as empty. Directories whose Git `HEAD` already resolves to a commit, whose Git metadata contains loose or - packed refs, or that contain any files besides *.git*, still require the - existing non-empty-directory confirmation. [[#716], [#717]] + packed refs, stored objects, an index, or reflogs, or that contain any + files besides *.git*, still require the existing non-empty-directory + confirmation. [[#716], [#717]] - Fixed generated *biome.json* files to use Biome 2 configuration syntax, matching the `@biomejs/biome` version that `fedify init` installs. diff --git a/packages/init/src/lib.test.ts b/packages/init/src/lib.test.ts index 2506ddf5f..4c1f40673 100644 --- a/packages/init/src/lib.test.ts +++ b/packages/init/src/lib.test.ts @@ -91,6 +91,35 @@ test("isDirectoryEmpty rejects a Git repository with a packed ref", async () => }); }); +test("isDirectoryEmpty rejects a Git repository with stored objects", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await mkdir(join(dir, ".git", "objects", "01"), { recursive: true }); + await writeFile(join(dir, ".git", "objects", "01", "2345"), "object"); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with an index", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await writeFile(join(dir, ".git", "index"), ""); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + +test("isDirectoryEmpty rejects a Git repository with reflogs", async () => { + await withTempDir(async (dir) => { + await createUnbornGitRepository(dir); + await mkdir(join(dir, ".git", "logs"), { recursive: true }); + await writeFile(join(dir, ".git", "logs", "HEAD"), ""); + + strictEqual(await isDirectoryEmpty(dir), false); + }); +}); + test("isDirectoryEmpty rejects a detached Git HEAD", async () => { await withTempDir(async (dir) => { await createUnbornGitRepository(dir); diff --git a/packages/init/src/lib.ts b/packages/init/src/lib.ts index ee5f52423..cd7a08f59 100644 --- a/packages/init/src/lib.ts +++ b/packages/init/src/lib.ts @@ -250,6 +250,8 @@ const looksLikeUnbornGitRepository = async ( if (!isValidHeadRef(head)) return false; if (await hasAnyLooseRef(gitDir)) return false; if (await hasAnyPackedRef(gitDir)) return false; + if (await hasAnyObjectFile(gitDir)) return false; + if (await hasAnyGitStatePath(gitDir)) return false; return true; }; @@ -260,17 +262,23 @@ const isValidHeadRef = (head: string): boolean => { }; const hasAnyLooseRef = async (gitDir: string): Promise => - await hasAnyRefFile(joinPath(gitDir, "refs")); + await hasAnyFile(joinPath(gitDir, "refs"), "Git refs"); -const hasAnyRefFile = async (dir: string): Promise => { +const hasAnyObjectFile = async (gitDir: string): Promise => + await hasAnyFile(joinPath(gitDir, "objects"), "Git objects"); + +const hasAnyFile = async ( + dir: string, + description: string, +): Promise => { let entries: Dirent[]; try { entries = await readdir(dir, { withFileTypes: true }); } catch (e) { if (isNotFoundError(e)) return false; logger.debug( - "Failed to read Git refs in {path}: {error}", - { path: dir, error: e }, + "Failed to read {description} in {path}: {error}", + { description, path: dir, error: e }, ); return true; } @@ -278,7 +286,7 @@ const hasAnyRefFile = async (dir: string): Promise => { for (const entry of entries) { const path = joinPath(dir, entry.name); if (entry.isDirectory()) { - if (await hasAnyRefFile(path)) return true; + if (await hasAnyFile(path, description)) return true; } else { return true; } @@ -286,6 +294,49 @@ const hasAnyRefFile = async (dir: string): Promise => { return false; }; +const GIT_STATE_PATHS = [ + "AUTO_MERGE", + "BISECT_LOG", + "CHERRY_PICK_HEAD", + "FETCH_HEAD", + "MERGE_HEAD", + "MERGE_MODE", + "MERGE_MSG", + "ORIG_HEAD", + "REBASE_HEAD", + "REVERT_HEAD", + "SQUASH_MSG", + "index", + "logs", + "modules", + "rebase-apply", + "rebase-merge", + "sequencer", + "shallow", + "worktrees", +] as const; + +const hasAnyGitStatePath = async (gitDir: string): Promise => { + for (const path of GIT_STATE_PATHS) { + if (await pathExists(joinPath(gitDir, path))) return true; + } + return false; +}; + +const pathExists = async (path: string): Promise => { + try { + await stat(path); + return true; + } catch (e) { + if (isNotFoundError(e)) return false; + logger.debug( + "Failed to stat Git state path {path}: {error}", + { path, error: e }, + ); + return true; + } +}; + const hasAnyPackedRef = async ( gitDir: string, ): Promise => {