diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index 0b2446fc..c58b8f08 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -13,6 +13,9 @@ import { const logger = createLogger("Worktree"); const execAsync = promisify(exec); +export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = + "chore: automaker initial commit"; + /** * Normalize path separators to forward slashes for cross-platform consistency. * This ensures paths from `path.join()` (backslashes on Windows) match paths @@ -73,3 +76,30 @@ export function logWorktreeError( // Re-export shared utilities export { getErrorMessageShared as getErrorMessage }; export const logError = createLogError(logger); + +/** + * Ensure the repository has at least one commit so git commands that rely on HEAD work. + * Returns true if an empty commit was created, false if the repo already had commits. + */ +export async function ensureInitialCommit(repoPath: string): Promise { + try { + await execAsync("git rev-parse --verify HEAD", { cwd: repoPath }); + return false; + } catch { + try { + await execAsync( + `git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, + { cwd: repoPath } + ); + logger.info( + `[Worktree] Created initial empty commit to enable worktrees in ${repoPath}` + ); + return true; + } catch (error) { + const reason = getErrorMessageShared(error); + throw new Error( + `Failed to create initial git commit. Please commit manually and retry. ${reason}` + ); + } + } +} diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index ab44374b..690afe48 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -12,7 +12,13 @@ import { exec } from "child_process"; import { promisify } from "util"; import path from "path"; import { mkdir } from "fs/promises"; -import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js"; +import { + isGitRepo, + getErrorMessage, + logError, + normalizePath, + ensureInitialCommit, +} from "../common.js"; import { trackBranch } from "./branch-tracking.js"; const execAsync = promisify(exec); @@ -93,6 +99,9 @@ export function createCreateHandler() { return; } + // Ensure the repository has at least one commit so worktree commands referencing HEAD succeed + await ensureInitialCommit(projectPath); + // First, check if git already has a worktree for this branch (anywhere) const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName); if (existingWorktree) { diff --git a/apps/server/tests/integration/routes/worktree/create.integration.test.ts b/apps/server/tests/integration/routes/worktree/create.integration.test.ts new file mode 100644 index 00000000..03b85e7e --- /dev/null +++ b/apps/server/tests/integration/routes/worktree/create.integration.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { createCreateHandler } from "@/routes/worktree/routes/create.js"; +import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from "@/routes/worktree/common.js"; +import { exec } from "child_process"; +import { promisify } from "util"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + +const execAsync = promisify(exec); + +describe("worktree create route - repositories without commits", () => { + let repoPath: string | null = null; + + async function initRepoWithoutCommit() { + repoPath = await fs.mkdtemp( + path.join(os.tmpdir(), "automaker-no-commit-") + ); + await execAsync("git init", { cwd: repoPath }); + await execAsync('git config user.email "test@example.com"', { + cwd: repoPath, + }); + await execAsync('git config user.name "Test User"', { cwd: repoPath }); + // Intentionally skip creating an initial commit + } + + afterEach(async () => { + if (!repoPath) { + return; + } + await fs.rm(repoPath, { recursive: true, force: true }); + repoPath = null; + }); + + it("creates an initial commit before adding a worktree when HEAD is missing", async () => { + await initRepoWithoutCommit(); + const handler = createCreateHandler(); + + const json = vi.fn(); + const status = vi.fn().mockReturnThis(); + const req = { + body: { projectPath: repoPath, branchName: "feature/no-head" }, + } as any; + const res = { + json, + status, + } as any; + + await handler(req, res); + + expect(status).not.toHaveBeenCalled(); + expect(json).toHaveBeenCalled(); + const payload = json.mock.calls[0][0]; + expect(payload.success).toBe(true); + + const { stdout: commitCount } = await execAsync( + "git rev-list --count HEAD", + { cwd: repoPath! } + ); + expect(Number(commitCount.trim())).toBeGreaterThan(0); + + const { stdout: latestMessage } = await execAsync( + "git log -1 --pretty=%B", + { cwd: repoPath! } + ); + expect(latestMessage.trim()).toBe(AUTOMAKER_INITIAL_COMMIT_MESSAGE); + }); +}); +