From 2bfd5c6ca8b4a3c3ef908681fcba97cd1717f7ee Mon Sep 17 00:00:00 2001 From: Joris Neuteboom Date: Mon, 5 Oct 2026 15:07:02 +0200 Subject: [PATCH] fix(core): keep the recorded project name inside a git worktree The project name came from the directory name. In a linked worktree (`git worktree add ../my-app-feature`) the first command treated the new directory as a rename: it restamped `projectName` in the committed manifest, pushed the real name into `previousNames`, and fed the worktree directory name into everything derived from ctx.projectName, including the package.json name, the compose `name:`, the AGENTS.md title and the port hashes. Runtime checks then looked for containers under a compose project that the committed docker-compose.yml did not use, and `cleanup` in the worktree could offer to remove the main checkout's containers as "detached" leftovers. resolveProjectName returns the manifest's recorded name inside a linked worktree and the directory name everywhere else. writeManifest, buildRunContext, reconcileProjectName and the CLI's own context builders (add/remove, upgrade, describe, cleanup) all go through it, so a rename of the main checkout is still detected. A worktree is told apart by a `commondir` file in the directory its `.git` file points to. Checking for a `.git` file alone would also match submodules. --- docs/troubleshooting.md | 4 + packages/cli/src/commands/add-remove.ts | 19 ++-- packages/cli/src/commands/bump.ts | 6 +- packages/cli/src/commands/cleanup.ts | 3 +- packages/cli/src/commands/describe.ts | 3 +- packages/core/src/index.ts | 1 + packages/core/src/manifest.ts | 6 +- packages/core/src/project-name.ts | 29 ++++++ packages/core/src/run-context.ts | 4 +- packages/core/test/project-name.test.ts | 126 ++++++++++++++++++++++++ 10 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/project-name.ts create mode 100644 packages/core/test/project-name.test.ts diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1439dc5..1116081 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -347,6 +347,10 @@ battlestack cleanup old-name Reconciles what was registered under the old name: Docker resources, hosts entries, gateway routes. +A git worktree (`git worktree add`) is not a rename. Inside one, battlestack +keeps the project name from the manifest, so the worktree shares the main +checkout's Docker Compose project and port defaults. + ### Removing everything for a project ```bash diff --git a/packages/cli/src/commands/add-remove.ts b/packages/cli/src/commands/add-remove.ts index efff453..70812b9 100644 --- a/packages/cli/src/commands/add-remove.ts +++ b/packages/cli/src/commands/add-remove.ts @@ -21,6 +21,8 @@ import { type ParsedArgs, type ReservedCommand, type RunContext, + resolveProjectName, + type ProjectManifest, } from '@battlestack/core' import { applyEnv, collectEnvForFeature } from '@battlestack/preset-nuxt4' import { ui } from '@battlestack/tui' @@ -93,7 +95,7 @@ export async function addCommand(args: ParsedArgs, loader: Ora, registries: Batt const pm = manifest.packageManager as PackageManager const fullEnabled = [...manifest.features.map((f) => f.id), fqid] - const ctx = buildCtx(projectRoot, manifest.framework, manifest.template, fullEnabled, pm, args, registries) + const ctx = buildCtx(projectRoot, manifest, fullEnabled, pm, args, registries) // Pre-primed so writeManifest preserves existing entries. Keyed bare. for (const f of manifest.features) { if (!registries.features.has(f.id)) continue @@ -251,8 +253,7 @@ export async function removeCommand(args: ParsedArgs, _loader: Ora, registries: const pm = manifest.packageManager as PackageManager const ctxForDeps = buildCtx( projectRoot, - manifest.framework, - manifest.template, + manifest, manifest.features.map((f) => f.id), pm, args, @@ -265,8 +266,7 @@ export async function removeCommand(args: ParsedArgs, _loader: Ora, registries: manifest.updatedAt = new Date().toISOString() const ctx = buildCtx( projectRoot, - manifest.framework, - manifest.template, + manifest, manifest.features.map((f) => f.id), pm, args, @@ -355,18 +355,17 @@ async function requireProjectRoot(): Promise { function buildCtx( projectRoot: string, - frameworkId: string, - templateId: string, + manifest: Pick, enabled: string[], pm: PackageManager, args: ParsedArgs, registries: BattlestackRegistries, ): RunContext { return { - projectName: path.basename(projectRoot), + projectName: resolveProjectName(projectRoot, manifest), projectDir: projectRoot, - framework: registries.frameworks.get(frameworkId), - template: registries.templates.get(templateId), + framework: registries.frameworks.get(manifest.framework), + template: registries.templates.get(manifest.template), enabledFeatures: new Set(enabled), state: { packageManager: pm, skipInstall: args.skipInstall }, debug: args.debug, diff --git a/packages/cli/src/commands/bump.ts b/packages/cli/src/commands/bump.ts index 517f5af..1e0f490 100644 --- a/packages/cli/src/commands/bump.ts +++ b/packages/cli/src/commands/bump.ts @@ -1,4 +1,3 @@ -import path from 'node:path' import type { Ora } from 'ora' import { ui } from '@battlestack/tui' import { @@ -13,6 +12,7 @@ import { type ParsedArgs, type ReservedCommand, type RunContext, + resolveProjectName, } from '@battlestack/core' /** Static metadata only. `run` is built per-dispatch in `project.ts`. */ @@ -90,13 +90,13 @@ export async function bumpCommand(args: ParsedArgs, _loader: Ora, registries: Ba function buildCtx( projectRoot: string, - manifest: { framework: string, template: string, features: Array<{ id: string }> }, + manifest: { framework: string, template: string, projectName?: string, features: Array<{ id: string }> }, pm: PackageManager, args: ParsedArgs, registries: BattlestackRegistries, ): RunContext { return { - projectName: path.basename(projectRoot), + projectName: resolveProjectName(projectRoot, manifest), projectDir: projectRoot, framework: registries.frameworks.get(manifest.framework), template: registries.templates.get(manifest.template), diff --git a/packages/cli/src/commands/cleanup.ts b/packages/cli/src/commands/cleanup.ts index 78c955b..5be6779 100644 --- a/packages/cli/src/commands/cleanup.ts +++ b/packages/cli/src/commands/cleanup.ts @@ -15,6 +15,7 @@ import { type ParsedArgs, type ProjectManifest, type ReservedCommand, + resolveProjectName, } from '@battlestack/core' export const cleanupReservedMeta: Omit = { @@ -215,7 +216,7 @@ async function cleanupDockerLeftovers( manifest: ProjectManifest, args: ParsedArgs, ): Promise { - const current = path.basename(projectRoot) + const current = resolveProjectName(projectRoot, manifest) const names = new Set(manifest.previousNames ?? []) // Escape hatch for renames predating the `previousNames` field. const positional = String(args.secondPositional ?? '').trim() diff --git a/packages/cli/src/commands/describe.ts b/packages/cli/src/commands/describe.ts index 5d4616f..6dd3387 100644 --- a/packages/cli/src/commands/describe.ts +++ b/packages/cli/src/commands/describe.ts @@ -16,6 +16,7 @@ import { type ParsedArgs, type PortKind, type ReservedCommand, + resolveProjectName, } from '@battlestack/core' export const describeReservedMeta: Omit = { @@ -53,7 +54,7 @@ export async function describeCommand( `No manifest at ${projectDir}/.battlestack/manifest.json`, ) } - const projectName = path.basename(projectDir) + const projectName = resolveProjectName(projectDir, manifest) const enabled = new Set(manifest.features.map((f) => f.id)) const local = await readLocalState(projectDir) const envExists = await exists(path.join(projectDir, '.env')) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 39955f7..830f984 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export * from './host-services.js' export * from './local-state.js' export * from './project-root.js' export * from './manifest.js' +export * from './project-name.js' export * from './run-context.js' export * from './orchestrator.js' diff --git a/packages/core/src/manifest.ts b/packages/core/src/manifest.ts index 713ab5e..726832b 100644 --- a/packages/core/src/manifest.ts +++ b/packages/core/src/manifest.ts @@ -10,6 +10,7 @@ import type { RunContext } from './types/run-context.js' import type { BattlestackRegistries } from './registry.js' import { getUiPort } from './ui-port.js' import { migrateStateDir, STATE_DIR } from './utils/state-dir.js' +import { isLinkedWorktree, resolveProjectName } from './project-name.js' export const MANIFEST_PATH = `${STATE_DIR}/manifest.json` @@ -181,7 +182,7 @@ export async function writeManifest( framework: ctx.framework.id, template: ctx.template.id, packageManager: String(ctx.state.packageManager ?? 'pnpm'), - projectName: path.basename(ctx.projectDir), + projectName: resolveProjectName(ctx.projectDir, previous), ...(previous?.previousNames?.length ? { previousNames: previous.previousNames } : {}), createdAt: previous?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -196,7 +197,7 @@ export async function writeManifest( await writeJson(target, manifest) } -/** Restamps `projectName` after a directory rename. Returns the previous name, or null. */ +/** Restamps `projectName` after a directory rename. Returns the previous name, or null. A linked worktree is never a rename. */ export async function reconcileProjectName( projectDir: string, manifest: ProjectManifest, @@ -204,6 +205,7 @@ export async function reconcileProjectName( const current = path.basename(projectDir) const recorded = manifest.projectName if (recorded === current) return null + if (recorded && isLinkedWorktree(projectDir)) return null manifest.projectName = current if (recorded) { const prev = new Set(manifest.previousNames ?? []) diff --git a/packages/core/src/project-name.ts b/packages/core/src/project-name.ts new file mode 100644 index 0000000..ded6375 --- /dev/null +++ b/packages/core/src/project-name.ts @@ -0,0 +1,29 @@ +import { readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import type { ProjectManifest } from './types/project-manifest.js' + +/** True when `dir` sits inside a linked git worktree (`git worktree add`), not the main checkout. */ +export function isLinkedWorktree(dir: string): boolean { + let current = path.resolve(dir) + while (true) { + const dotGit = path.join(current, '.git') + const stat = statSync(dotGit, { throwIfNoEntry: false }) + if (stat?.isDirectory()) return false + if (stat?.isFile()) { + const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf8')) + if (!match) return false + const gitDir = path.resolve(current, match[1].trim()) + return statSync(path.join(gitDir, 'commondir'), { throwIfNoEntry: false })?.isFile() ?? false + } + const parent = path.dirname(current) + if (parent === current) return false + current = parent + } +} + +/** The project's name: the directory name, except in a linked worktree, where the recorded name wins. */ +export function resolveProjectName(projectDir: string, manifest?: Pick | null): string { + const recorded = manifest?.projectName + if (recorded && isLinkedWorktree(projectDir)) return recorded + return path.basename(projectDir) +} diff --git a/packages/core/src/run-context.ts b/packages/core/src/run-context.ts index 9099670..2abc937 100644 --- a/packages/core/src/run-context.ts +++ b/packages/core/src/run-context.ts @@ -1,10 +1,10 @@ -import path from 'node:path' import type { Ora } from 'ora' import type { BattlestackRegistries } from './registry.js' import type { RunContext } from './types/run-context.js' import type { ProjectManifest } from './types/project-manifest.js' import type { FeatureState } from './types/feature.js' import type { PackageManager } from './types/package-manager.js' +import { resolveProjectName } from './project-name.js' /** No-op `Ora` stand-in for callers that need a loader-shaped object but no spinner. */ export function silentLoader(): Ora { @@ -38,7 +38,7 @@ interface BuildOpts { /** Builds a `RunContext` from a project manifest. `registries` is stashed on the result. */ export function buildRunContext(opts: BuildOpts, registries: BattlestackRegistries): RunContext { return { - projectName: path.basename(opts.projectDir), + projectName: resolveProjectName(opts.projectDir, opts.manifest), projectDir: opts.projectDir, framework: registries.frameworks.get(opts.manifest.framework), template: registries.templates.get(opts.manifest.template), diff --git a/packages/core/test/project-name.test.ts b/packages/core/test/project-name.test.ts new file mode 100644 index 0000000..502d949 --- /dev/null +++ b/packages/core/test/project-name.test.ts @@ -0,0 +1,126 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MANIFEST_PATH, readManifest, reconcileProjectName, writeManifest } from '../src/manifest.js' +import { isLinkedWorktree, resolveProjectName } from '../src/project-name.js' +import { BattlestackRegistries } from '../src/registry.js' +import { buildRunContext } from '../src/run-context.js' +import type { ProjectManifest } from '../src/types/project-manifest.js' + +const origin = { plugin: 'test-plugin', namespace: 'demo' } + +let root: string +let mainDir: string +let worktreeDir: string +let registries: BattlestackRegistries + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', args, { + cwd, + stdio: 'ignore', + env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }, + }) +} + +const manifest: ProjectManifest = { + schemaVersion: 1, + cliVersion: '1.0.0', + framework: 'manifest-test', + template: 'manifest-test', + packageManager: 'pnpm', + projectName: 'my-app', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + features: [], +} + +async function readRaw(dir: string): Promise { + return JSON.parse(await readFile(path.join(dir, MANIFEST_PATH), 'utf8')) as ProjectManifest +} + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'battlestack-project-name-')) + mainDir = path.join(root, 'my-app') + worktreeDir = path.join(root, 'my-app-feature') + await mkdir(path.join(mainDir, path.dirname(MANIFEST_PATH)), { recursive: true }) + await writeFile(path.join(mainDir, MANIFEST_PATH), JSON.stringify(manifest)) + git(mainDir, 'init', '-q') + git(mainDir, 'add', '-A') + git(mainDir, 'commit', '-q', '-m', 'init') + git(mainDir, 'worktree', 'add', '-q', worktreeDir) + + registries = new BattlestackRegistries() + registries.frameworks.register({ id: 'manifest-test', label: 'manifest-test', supportedFeatures: [] }, origin) + registries.templates.register({ + id: 'manifest-test', label: 'manifest-test', framework: 'manifest-test', requiredFeatures: [], optionalFeatures: [], + }, origin) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('isLinkedWorktree', () => { + it('is true in a linked worktree and its subdirectories', async () => { + expect(isLinkedWorktree(worktreeDir)).toBe(true) + expect(isLinkedWorktree(path.join(worktreeDir, path.dirname(MANIFEST_PATH)))).toBe(true) + }) + + it('is false in the main checkout and outside any repo', () => { + expect(isLinkedWorktree(mainDir)).toBe(false) + expect(isLinkedWorktree(root)).toBe(false) + }) + + it('is false in a submodule, which also has a .git file', async () => { + const fakeModuleGitDir = path.join(root, 'modules', 'sub') + const subDir = path.join(root, 'sub') + await mkdir(fakeModuleGitDir, { recursive: true }) + await mkdir(subDir) + await writeFile(path.join(subDir, '.git'), `gitdir: ${fakeModuleGitDir}\n`) + expect(isLinkedWorktree(subDir)).toBe(false) + }) +}) + +describe('project name in a linked worktree', () => { + it('resolves to the recorded name, not the worktree directory', async () => { + const m = (await readManifest(worktreeDir, registries)) as ProjectManifest + expect(resolveProjectName(worktreeDir, m)).toBe('my-app') + expect(buildRunContext({ projectDir: worktreeDir, manifest: m }, registries).projectName).toBe('my-app') + }) + + it('falls back to the directory name when the manifest has no recorded name', () => { + expect(resolveProjectName(worktreeDir, { projectName: undefined })).toBe('my-app-feature') + }) + + it('reconcileProjectName leaves the manifest alone', async () => { + const m = (await readManifest(worktreeDir, registries)) as ProjectManifest + expect(await reconcileProjectName(worktreeDir, m)).toBeNull() + const after = await readRaw(worktreeDir) + expect(after.projectName).toBe('my-app') + expect(after.previousNames).toBeUndefined() + }) + + it('writeManifest keeps the recorded name', async () => { + const m = (await readManifest(worktreeDir, registries)) as ProjectManifest + const ctx = buildRunContext({ projectDir: worktreeDir, manifest: m }, registries) + await writeManifest(ctx, { cliVersion: '2.0.0' }) + const after = await readRaw(worktreeDir) + expect(after.cliVersion).toBe('2.0.0') + expect(after.projectName).toBe('my-app') + }) +}) + +describe('project name in the main checkout', () => { + it('still restamps after a real rename', async () => { + const renamed = path.join(root, 'my-renamed-app') + await rename(mainDir, renamed) + const m = (await readManifest(renamed, registries)) as ProjectManifest + expect(resolveProjectName(renamed, m)).toBe('my-renamed-app') + expect(await reconcileProjectName(renamed, m)).toBe('my-app') + const after = await readRaw(renamed) + expect(after.projectName).toBe('my-renamed-app') + expect(after.previousNames).toEqual(['my-app']) + }) +})