mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge branch 'main' into refactor/frontend
This commit is contained in:
@@ -17,6 +17,9 @@ const logger = createLogger("Worktree");
|
||||
const execAsync = promisify(exec);
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
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
|
||||
@@ -77,3 +80,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<boolean> {
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -520,29 +520,28 @@ export class AutoModeService {
|
||||
}
|
||||
|
||||
// Derive workDir from feature.branchName
|
||||
// If no branchName, derive from feature ID: feature/{featureId}
|
||||
// Worktrees should already be created when the feature is added/edited
|
||||
let worktreePath: string | null = null;
|
||||
const branchName = feature.branchName || `feature/${featureId}`;
|
||||
const branchName = feature.branchName;
|
||||
|
||||
if (useWorktrees && branchName) {
|
||||
// Try to find existing worktree for this branch
|
||||
// Worktree should already exist (created when feature was added/edited)
|
||||
worktreePath = await this.findExistingWorktreeForBranch(
|
||||
projectPath,
|
||||
branchName
|
||||
);
|
||||
|
||||
if (!worktreePath) {
|
||||
// Create worktree for this branch
|
||||
worktreePath = await this.setupWorktree(
|
||||
projectPath,
|
||||
featureId,
|
||||
branchName
|
||||
if (worktreePath) {
|
||||
console.log(
|
||||
`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`
|
||||
);
|
||||
} else {
|
||||
// Worktree doesn't exist - log warning and continue with project path
|
||||
console.warn(
|
||||
`[AutoMode] Worktree for branch "${branchName}" not found, using project path`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure workDir is always an absolute path for cross-platform compatibility
|
||||
@@ -552,7 +551,7 @@ export class AutoModeService {
|
||||
|
||||
// Update running feature with actual worktree info
|
||||
tempRunningFeature.worktreePath = worktreePath;
|
||||
tempRunningFeature.branchName = branchName;
|
||||
tempRunningFeature.branchName = branchName ?? null;
|
||||
|
||||
// Update feature status to in_progress
|
||||
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
|
||||
@@ -1479,60 +1478,6 @@ Format your response as a structured markdown document.`;
|
||||
}
|
||||
}
|
||||
|
||||
private async setupWorktree(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
branchName: string
|
||||
): Promise<string> {
|
||||
// First, check if git already has a worktree for this branch (anywhere)
|
||||
const existingWorktree = await this.findExistingWorktreeForBranch(
|
||||
projectPath,
|
||||
branchName
|
||||
);
|
||||
if (existingWorktree) {
|
||||
// Path is already resolved to absolute in findExistingWorktreeForBranch
|
||||
console.log(
|
||||
`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`
|
||||
);
|
||||
return existingWorktree;
|
||||
}
|
||||
|
||||
// Git worktrees stay in project directory
|
||||
const worktreesDir = path.join(projectPath, ".worktrees");
|
||||
const worktreePath = path.join(worktreesDir, featureId);
|
||||
|
||||
await fs.mkdir(worktreesDir, { recursive: true });
|
||||
|
||||
// Check if worktree directory already exists (might not be linked to branch)
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
// Return absolute path for cross-platform compatibility
|
||||
return path.resolve(worktreePath);
|
||||
} catch {
|
||||
// Create new worktree
|
||||
}
|
||||
|
||||
// Create branch if it doesn't exist
|
||||
try {
|
||||
await execAsync(`git branch ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
// Branch may already exist
|
||||
}
|
||||
|
||||
// Create worktree
|
||||
try {
|
||||
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
// Return absolute path for cross-platform compatibility
|
||||
return path.resolve(worktreePath);
|
||||
} catch (error) {
|
||||
// Worktree creation failed, fall back to direct execution
|
||||
console.error(`[AutoMode] Worktree creation failed:`, error);
|
||||
return path.resolve(projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFeature(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
} from "../helpers/git-test-repo.js";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
vi.mock("@/providers/provider-factory.js");
|
||||
|
||||
@@ -43,13 +47,24 @@ describe("auto-mode-service.ts (integration)", () => {
|
||||
});
|
||||
|
||||
describe("worktree operations", () => {
|
||||
it("should create git worktree for feature", async () => {
|
||||
// Create a test feature
|
||||
it("should use existing git worktree for feature", async () => {
|
||||
const branchName = "feature/test-feature-1";
|
||||
|
||||
// Create a test feature with branchName set
|
||||
await createTestFeature(testRepo.path, "test-feature-1", {
|
||||
id: "test-feature-1",
|
||||
category: "test",
|
||||
description: "Test feature",
|
||||
status: "pending",
|
||||
branchName: branchName,
|
||||
});
|
||||
|
||||
// Create worktree before executing (worktrees are now created when features are added/edited)
|
||||
const worktreesDir = path.join(testRepo.path, ".worktrees");
|
||||
const worktreePath = path.join(worktreesDir, "test-feature-1");
|
||||
await fs.mkdir(worktreesDir, { recursive: true });
|
||||
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
|
||||
// Mock provider to complete quickly
|
||||
@@ -82,9 +97,20 @@ describe("auto-mode-service.ts (integration)", () => {
|
||||
false // isAutoMode
|
||||
);
|
||||
|
||||
// Verify branch was created
|
||||
// Verify branch exists (was created when worktree was created)
|
||||
const branches = await listBranches(testRepo.path);
|
||||
expect(branches).toContain("feature/test-feature-1");
|
||||
expect(branches).toContain(branchName);
|
||||
|
||||
// Verify worktree exists and is being used
|
||||
// The service should have found and used the worktree (check via logs)
|
||||
// We can verify the worktree exists by checking git worktree list
|
||||
const worktrees = await listWorktrees(testRepo.path);
|
||||
expect(worktrees.length).toBeGreaterThan(0);
|
||||
// Verify that at least one worktree path contains our feature ID
|
||||
const worktreePathsMatch = worktrees.some(wt =>
|
||||
wt.includes("test-feature-1") || wt.includes(".worktrees")
|
||||
);
|
||||
expect(worktreePathsMatch).toBe(true);
|
||||
|
||||
// Note: Worktrees are not automatically cleaned up by the service
|
||||
// This is expected behavior - manual cleanup is required
|
||||
|
||||
Reference in New Issue
Block a user