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
4 changes: 4 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 9 additions & 10 deletions packages/cli/src/commands/add-remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -355,18 +355,17 @@ async function requireProjectRoot(): Promise<string> {

function buildCtx(
projectRoot: string,
frameworkId: string,
templateId: string,
manifest: Pick<ProjectManifest, 'framework' | 'template' | 'projectName'>,
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,
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/bump.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import path from 'node:path'
import type { Ora } from 'ora'
import { ui } from '@battlestack/tui'
import {
Expand All @@ -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`. */
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type ParsedArgs,
type ProjectManifest,
type ReservedCommand,
resolveProjectName,
} from '@battlestack/core'

export const cleanupReservedMeta: Omit<ReservedCommand, 'run'> = {
Expand Down Expand Up @@ -215,7 +216,7 @@ async function cleanupDockerLeftovers(
manifest: ProjectManifest,
args: ParsedArgs,
): Promise<boolean> {
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()
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type ParsedArgs,
type PortKind,
type ReservedCommand,
resolveProjectName,
} from '@battlestack/core'

export const describeReservedMeta: Omit<ReservedCommand, 'run'> = {
Expand Down Expand Up @@ -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'))
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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(),
Expand All @@ -196,14 +197,15 @@ 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,
): Promise<string | null> {
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 ?? [])
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/project-name.ts
Original file line number Diff line number Diff line change
@@ -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<ProjectManifest, 'projectName'> | null): string {
const recorded = manifest?.projectName
if (recorded && isLinkedWorktree(projectDir)) return recorded
return path.basename(projectDir)
}
4 changes: 2 additions & 2 deletions packages/core/src/run-context.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
126 changes: 126 additions & 0 deletions packages/core/test/project-name.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProjectManifest> {
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'])
})
})
Loading