Skip to content

Commit 18a2ed2

Browse files
committed
feat: implement initial commit creation for empty git repositories
- Added a function to ensure that a git repository has at least one commit before executing worktree commands. This function creates an empty initial commit with a predefined message if the repository is empty. - Updated the create route handler to call this function, ensuring smooth operation when adding worktrees to repositories without existing commits. - Introduced integration tests to verify the creation of the initial commit when no commits are present in the repository.
1 parent 340e76c commit 18a2ed2

3 files changed

Lines changed: 109 additions & 1 deletion

File tree

apps/server/src/routes/worktree/common.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
const logger = createLogger("Worktree");
1414
const execAsync = promisify(exec);
1515

16+
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
17+
"chore: automaker initial commit";
18+
1619
/**
1720
* Normalize path separators to forward slashes for cross-platform consistency.
1821
* This ensures paths from `path.join()` (backslashes on Windows) match paths
@@ -73,3 +76,30 @@ export function logWorktreeError(
7376
// Re-export shared utilities
7477
export { getErrorMessageShared as getErrorMessage };
7578
export const logError = createLogError(logger);
79+
80+
/**
81+
* Ensure the repository has at least one commit so git commands that rely on HEAD work.
82+
* Returns true if an empty commit was created, false if the repo already had commits.
83+
*/
84+
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
85+
try {
86+
await execAsync("git rev-parse --verify HEAD", { cwd: repoPath });
87+
return false;
88+
} catch {
89+
try {
90+
await execAsync(
91+
`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`,
92+
{ cwd: repoPath }
93+
);
94+
logger.info(
95+
`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`
96+
);
97+
return true;
98+
} catch (error) {
99+
const reason = getErrorMessageShared(error);
100+
throw new Error(
101+
`Failed to create initial git commit. Please commit manually and retry. ${reason}`
102+
);
103+
}
104+
}
105+
}

apps/server/src/routes/worktree/routes/create.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import { exec } from "child_process";
1212
import { promisify } from "util";
1313
import path from "path";
1414
import { mkdir } from "fs/promises";
15-
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
15+
import {
16+
isGitRepo,
17+
getErrorMessage,
18+
logError,
19+
normalizePath,
20+
ensureInitialCommit,
21+
} from "../common.js";
1622
import { trackBranch } from "./branch-tracking.js";
1723

1824
const execAsync = promisify(exec);
@@ -93,6 +99,9 @@ export function createCreateHandler() {
9399
return;
94100
}
95101

102+
// Ensure the repository has at least one commit so worktree commands referencing HEAD succeed
103+
await ensureInitialCommit(projectPath);
104+
96105
// First, check if git already has a worktree for this branch (anywhere)
97106
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);
98107
if (existingWorktree) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { createCreateHandler } from "@/routes/worktree/routes/create.js";
3+
import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from "@/routes/worktree/common.js";
4+
import { exec } from "child_process";
5+
import { promisify } from "util";
6+
import * as fs from "fs/promises";
7+
import * as os from "os";
8+
import * as path from "path";
9+
10+
const execAsync = promisify(exec);
11+
12+
describe("worktree create route - repositories without commits", () => {
13+
let repoPath: string | null = null;
14+
15+
async function initRepoWithoutCommit() {
16+
repoPath = await fs.mkdtemp(
17+
path.join(os.tmpdir(), "automaker-no-commit-")
18+
);
19+
await execAsync("git init", { cwd: repoPath });
20+
await execAsync('git config user.email "test@example.com"', {
21+
cwd: repoPath,
22+
});
23+
await execAsync('git config user.name "Test User"', { cwd: repoPath });
24+
// Intentionally skip creating an initial commit
25+
}
26+
27+
afterEach(async () => {
28+
if (!repoPath) {
29+
return;
30+
}
31+
await fs.rm(repoPath, { recursive: true, force: true });
32+
repoPath = null;
33+
});
34+
35+
it("creates an initial commit before adding a worktree when HEAD is missing", async () => {
36+
await initRepoWithoutCommit();
37+
const handler = createCreateHandler();
38+
39+
const json = vi.fn();
40+
const status = vi.fn().mockReturnThis();
41+
const req = {
42+
body: { projectPath: repoPath, branchName: "feature/no-head" },
43+
} as any;
44+
const res = {
45+
json,
46+
status,
47+
} as any;
48+
49+
await handler(req, res);
50+
51+
expect(status).not.toHaveBeenCalled();
52+
expect(json).toHaveBeenCalled();
53+
const payload = json.mock.calls[0][0];
54+
expect(payload.success).toBe(true);
55+
56+
const { stdout: commitCount } = await execAsync(
57+
"git rev-list --count HEAD",
58+
{ cwd: repoPath! }
59+
);
60+
expect(Number(commitCount.trim())).toBeGreaterThan(0);
61+
62+
const { stdout: latestMessage } = await execAsync(
63+
"git log -1 --pretty=%B",
64+
{ cwd: repoPath! }
65+
);
66+
expect(latestMessage.trim()).toBe(AUTOMAKER_INITIAL_COMMIT_MESSAGE);
67+
});
68+
});
69+

0 commit comments

Comments
 (0)