Merge main into massive-terminal-upgrade

Resolves merge conflicts:
- apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger
- apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions
- apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling)
- apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes
- apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:27:44 -05:00
393 changed files with 32473 additions and 17974 deletions

View File

@@ -2,18 +2,14 @@
* Common utilities for worktree routes
*/
import { createLogger } from "../../lib/logger.js";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import {
getErrorMessage as getErrorMessageShared,
createLogError,
} from "../common.js";
import { FeatureLoader } from "../../services/feature-loader.js";
import { createLogger } from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger("Worktree");
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
const featureLoader = new FeatureLoader();
@@ -28,10 +24,10 @@ export const MAX_BRANCH_NAME_LENGTH = 250;
// Extended PATH configuration for Electron apps
// ============================================================================
const pathSeparator = process.platform === "win32" ? ";" : ":";
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const additionalPaths: string[] = [];
if (process.platform === "win32") {
if (process.platform === 'win32') {
// Windows paths
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
@@ -39,23 +35,22 @@ if (process.platform === "win32") {
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
}
if (process.env["ProgramFiles(x86)"]) {
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`);
if (process.env['ProgramFiles(x86)']) {
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
}
} else {
// Unix/Mac paths
additionalPaths.push(
"/opt/homebrew/bin", // Homebrew on Apple Silicon
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
`${process.env.HOME}/.local/bin`, // pipx, other user installs
'/opt/homebrew/bin', // Homebrew on Apple Silicon
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
'/home/linuxbrew/.linuxbrew/bin', // Linuxbrew
`${process.env.HOME}/.local/bin` // pipx, other user installs
);
}
const extendedPath = [
process.env.PATH,
...additionalPaths.filter(Boolean),
].filter(Boolean).join(pathSeparator);
const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
.filter(Boolean)
.join(pathSeparator);
/**
* Environment variables with extended PATH for executing shell commands.
@@ -85,9 +80,7 @@ export function isValidBranchName(name: string): boolean {
*/
export async function isGhCliAvailable(): Promise<boolean> {
try {
const checkCommand = process.platform === "win32"
? "where gh"
: "command -v gh";
const checkCommand = process.platform === 'win32' ? 'where gh' : 'command -v gh';
await execAsync(checkCommand, { env: execEnv });
return true;
} catch {
@@ -95,8 +88,7 @@ export async function isGhCliAvailable(): Promise<boolean> {
}
}
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
"chore: automaker initial commit";
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = 'chore: automaker initial commit';
/**
* Normalize path separators to forward slashes for cross-platform consistency.
@@ -104,7 +96,7 @@ export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
* from git commands (which may use forward slashes).
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
return p.replace(/\\/g, '/');
}
/**
@@ -112,7 +104,7 @@ export function normalizePath(p: string): string {
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
return true;
} catch {
return false;
@@ -124,30 +116,21 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
* These are expected in test environments with mock paths
*/
export function isENOENT(error: unknown): boolean {
return (
error !== null &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
);
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
}
/**
* Check if a path is a mock/test path that doesn't exist
*/
export function isMockPath(worktreePath: string): boolean {
return worktreePath.startsWith("/mock/") || worktreePath.includes("/mock/");
return worktreePath.startsWith('/mock/') || worktreePath.includes('/mock/');
}
/**
* Conditionally log worktree errors - suppress ENOENT for mock paths
* to reduce noise in test output
*/
export function logWorktreeError(
error: unknown,
message: string,
worktreePath?: string
): void {
export function logWorktreeError(error: unknown, message: string, worktreePath?: string): void {
// Don't log ENOENT errors for mock paths (expected in tests)
if (isENOENT(error) && worktreePath && isMockPath(worktreePath)) {
return;
@@ -165,17 +148,14 @@ export const logError = createLogError(logger);
*/
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --verify HEAD", { cwd: repoPath });
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}`
);
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);

View File

@@ -2,59 +2,64 @@
* Worktree routes - HTTP API for git worktree operations
*/
import { Router } from "express";
import { createInfoHandler } from "./routes/info.js";
import { createStatusHandler } from "./routes/status.js";
import { createListHandler } from "./routes/list.js";
import { createDiffsHandler } from "./routes/diffs.js";
import { createFileDiffHandler } from "./routes/file-diff.js";
import { createMergeHandler } from "./routes/merge.js";
import { createCreateHandler } from "./routes/create.js";
import { createDeleteHandler } from "./routes/delete.js";
import { createCreatePRHandler } from "./routes/create-pr.js";
import { createPRInfoHandler } from "./routes/pr-info.js";
import { createCommitHandler } from "./routes/commit.js";
import { createPushHandler } from "./routes/push.js";
import { createPullHandler } from "./routes/pull.js";
import { createCheckoutBranchHandler } from "./routes/checkout-branch.js";
import { createListBranchesHandler } from "./routes/list-branches.js";
import { createSwitchBranchHandler } from "./routes/switch-branch.js";
import { Router } from 'express';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createInfoHandler } from './routes/info.js';
import { createStatusHandler } from './routes/status.js';
import { createListHandler } from './routes/list.js';
import { createDiffsHandler } from './routes/diffs.js';
import { createFileDiffHandler } from './routes/file-diff.js';
import { createMergeHandler } from './routes/merge.js';
import { createCreateHandler } from './routes/create.js';
import { createDeleteHandler } from './routes/delete.js';
import { createCreatePRHandler } from './routes/create-pr.js';
import { createPRInfoHandler } from './routes/pr-info.js';
import { createCommitHandler } from './routes/commit.js';
import { createPushHandler } from './routes/push.js';
import { createPullHandler } from './routes/pull.js';
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
import { createListBranchesHandler } from './routes/list-branches.js';
import { createSwitchBranchHandler } from './routes/switch-branch.js';
import {
createOpenInEditorHandler,
createGetDefaultEditorHandler,
} from "./routes/open-in-editor.js";
import { createInitGitHandler } from "./routes/init-git.js";
import { createMigrateHandler } from "./routes/migrate.js";
import { createStartDevHandler } from "./routes/start-dev.js";
import { createStopDevHandler } from "./routes/stop-dev.js";
import { createListDevServersHandler } from "./routes/list-dev-servers.js";
} from './routes/open-in-editor.js';
import { createInitGitHandler } from './routes/init-git.js';
import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
export function createWorktreeRoutes(): Router {
const router = Router();
router.post("/info", createInfoHandler());
router.post("/status", createStatusHandler());
router.post("/list", createListHandler());
router.post("/diffs", createDiffsHandler());
router.post("/file-diff", createFileDiffHandler());
router.post("/merge", createMergeHandler());
router.post("/create", createCreateHandler());
router.post("/delete", createDeleteHandler());
router.post("/create-pr", createCreatePRHandler());
router.post("/pr-info", createPRInfoHandler());
router.post("/commit", createCommitHandler());
router.post("/push", createPushHandler());
router.post("/pull", createPullHandler());
router.post("/checkout-branch", createCheckoutBranchHandler());
router.post("/list-branches", createListBranchesHandler());
router.post("/switch-branch", createSwitchBranchHandler());
router.post("/open-in-editor", createOpenInEditorHandler());
router.get("/default-editor", createGetDefaultEditorHandler());
router.post("/init-git", createInitGitHandler());
router.post("/migrate", createMigrateHandler());
router.post("/start-dev", createStartDevHandler());
router.post("/stop-dev", createStopDevHandler());
router.post("/list-dev-servers", createListDevServersHandler());
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
router.post('/status', validatePathParams('projectPath'), createStatusHandler());
router.post('/list', createListHandler());
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
router.post('/merge', validatePathParams('projectPath'), createMergeHandler());
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
router.post('/commit', validatePathParams('worktreePath'), createCommitHandler());
router.post('/push', validatePathParams('worktreePath'), createPushHandler());
router.post('/pull', validatePathParams('worktreePath'), createPullHandler());
router.post('/checkout-branch', createCheckoutBranchHandler());
router.post('/list-branches', validatePathParams('worktreePath'), createListBranchesHandler());
router.post('/switch-branch', createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.get('/default-editor', createGetDefaultEditorHandler());
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
router.post('/migrate', createMigrateHandler());
router.post(
'/start-dev',
validatePathParams('projectPath', 'worktreePath'),
createStartDevHandler()
);
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
return router;
}

View File

@@ -5,12 +5,9 @@
* can switch between branches even after worktrees are removed.
*/
import { readFile, writeFile } from "fs/promises";
import path from "path";
import {
getBranchTrackingPath,
ensureAutomakerDir,
} from "../../../lib/automaker-paths.js";
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
export interface TrackedBranch {
name: string;
@@ -25,19 +22,17 @@ interface BranchTrackingData {
/**
* Read tracked branches from file
*/
export async function getTrackedBranches(
projectPath: string
): Promise<TrackedBranch[]> {
export async function getTrackedBranches(projectPath: string): Promise<TrackedBranch[]> {
try {
const filePath = getBranchTrackingPath(projectPath);
const content = await readFile(filePath, "utf-8");
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
const data: BranchTrackingData = JSON.parse(content);
return data.branches || [];
} catch (error: any) {
if (error.code === "ENOENT") {
if (error.code === 'ENOENT') {
return [];
}
console.warn("[branch-tracking] Failed to read tracked branches:", error);
console.warn('[branch-tracking] Failed to read tracked branches:', error);
return [];
}
}
@@ -45,23 +40,17 @@ export async function getTrackedBranches(
/**
* Save tracked branches to file
*/
async function saveTrackedBranches(
projectPath: string,
branches: TrackedBranch[]
): Promise<void> {
async function saveTrackedBranches(projectPath: string, branches: TrackedBranch[]): Promise<void> {
const automakerDir = await ensureAutomakerDir(projectPath);
const filePath = path.join(automakerDir, "active-branches.json");
const filePath = path.join(automakerDir, 'active-branches.json');
const data: BranchTrackingData = { branches };
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
await secureFs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
/**
* Add a branch to tracking
*/
export async function trackBranch(
projectPath: string,
branchName: string
): Promise<void> {
export async function trackBranch(projectPath: string, branchName: string): Promise<void> {
const branches = await getTrackedBranches(projectPath);
// Check if already tracked
@@ -82,10 +71,7 @@ export async function trackBranch(
/**
* Remove a branch from tracking
*/
export async function untrackBranch(
projectPath: string,
branchName: string
): Promise<void> {
export async function untrackBranch(projectPath: string, branchName: string): Promise<void> {
const branches = await getTrackedBranches(projectPath);
const filtered = branches.filter((b) => b.name !== branchName);
@@ -114,10 +100,7 @@ export async function updateBranchActivation(
/**
* Check if a branch is tracked
*/
export async function isBranchTracked(
projectPath: string,
branchName: string
): Promise<boolean> {
export async function isBranchTracked(projectPath: string, branchName: string): Promise<boolean> {
const branches = await getTrackedBranches(projectPath);
return branches.some((b) => b.name === branchName);
}

View File

@@ -7,19 +7,19 @@
* 3. Only creates a new worktree if none exists for the branch
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import { mkdir } from "fs/promises";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import {
isGitRepo,
getErrorMessage,
logError,
normalizePath,
ensureInitialCommit,
} from "../common.js";
import { trackBranch } from "./branch-tracking.js";
} from '../common.js';
import { trackBranch } from './branch-tracking.js';
const execAsync = promisify(exec);
@@ -31,20 +31,20 @@ async function findExistingWorktreeForBranch(
branchName: string
): Promise<{ path: string; branch: string } | null> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
const lines = stdout.split("\n");
const lines = stdout.split('\n');
let currentPath: string | null = null;
let currentBranch: string | null = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
if (line.startsWith('worktree ')) {
currentPath = line.slice(9);
} else if (line.startsWith("branch ")) {
currentBranch = line.slice(7).replace("refs/heads/", "");
} else if (line === "" && currentPath && currentBranch) {
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
} else if (line === '' && currentPath && currentBranch) {
// End of a worktree entry
if (currentBranch === branchName) {
// Resolve to absolute path - git may return relative paths
@@ -86,7 +86,7 @@ export function createCreateHandler() {
if (!projectPath || !branchName) {
res.status(400).json({
success: false,
error: "projectPath and branchName required",
error: 'projectPath and branchName required',
});
return;
}
@@ -94,7 +94,7 @@ export function createCreateHandler() {
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
error: "Not a git repository",
error: 'Not a git repository',
});
return;
}
@@ -107,7 +107,9 @@ export function createCreateHandler() {
if (existingWorktree) {
// Worktree already exists, return it as success (not an error)
// This handles manually created worktrees or worktrees from previous runs
console.log(`[Worktree] Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}`);
console.log(
`[Worktree] Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}`
);
// Track the branch so it persists in the UI
await trackBranch(projectPath, branchName);
@@ -124,12 +126,12 @@ export function createCreateHandler() {
}
// Sanitize branch name for directory usage
const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
const worktreesDir = path.join(projectPath, ".worktrees");
const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreesDir = path.join(projectPath, '.worktrees');
const worktreePath = path.join(worktreesDir, sanitizedName);
// Create worktrees directory if it doesn't exist
await mkdir(worktreesDir, { recursive: true });
await secureFs.mkdir(worktreesDir, { recursive: true });
// Check if branch exists
let branchExists = false;
@@ -149,7 +151,7 @@ export function createCreateHandler() {
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
} else {
// Create new branch from base or HEAD
const base = baseBranch || "HEAD";
const base = baseBranch || 'HEAD';
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
}
@@ -174,7 +176,7 @@ export function createCreateHandler() {
},
});
} catch (error) {
logError(error, "Create worktree failed");
logError(error, 'Create worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,11 @@
* POST /delete endpoint - Delete a git worktree
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { isGitRepo, getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
@@ -21,7 +22,7 @@ export function createDeleteHandler() {
if (!projectPath || !worktreePath) {
res.status(400).json({
success: false,
error: "projectPath and worktreePath required",
error: 'projectPath and worktreePath required',
});
return;
}
@@ -29,7 +30,7 @@ export function createDeleteHandler() {
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
error: "Not a git repository",
error: 'Not a git repository',
});
return;
}
@@ -37,7 +38,7 @@ export function createDeleteHandler() {
// Get branch name before removing worktree
let branchName: string | null = null;
try {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
branchName = stdout.trim();
@@ -52,11 +53,11 @@ export function createDeleteHandler() {
});
} catch (error) {
// Try with prune if remove fails
await execAsync("git worktree prune", { cwd: projectPath });
await execAsync('git worktree prune', { cwd: projectPath });
}
// Optionally delete the branch
if (deleteBranch && branchName && branchName !== "main" && branchName !== "master") {
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
try {
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch {
@@ -72,7 +73,7 @@ export function createDeleteHandler() {
},
});
} catch (error) {
logError(error, "Delete worktree failed");
logError(error, 'Delete worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,11 @@
* POST /diffs endpoint - Get diffs for a worktree
*/
import type { Request, Response } from "express";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
import { getGitRepositoryDiffs } from "../../common.js";
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
import { getGitRepositoryDiffs } from '../../common.js';
export function createDiffsHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -17,21 +17,19 @@ export function createDiffsHandler() {
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId required",
});
res.status(400).json({
success: false,
error: 'projectPath and featureId required',
});
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, ".worktrees", featureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
// Check if worktree exists
await fs.access(worktreePath);
await secureFs.access(worktreePath);
// Get diffs from worktree
const result = await getGitRepositoryDiffs(worktreePath);
@@ -43,7 +41,7 @@ export function createDiffsHandler() {
});
} catch (innerError) {
// Worktree doesn't exist - fallback to main project path
logError(innerError, "Worktree access failed, falling back to main project");
logError(innerError, 'Worktree access failed, falling back to main project');
try {
const result = await getGitRepositoryDiffs(projectPath);
@@ -54,12 +52,12 @@ export function createDiffsHandler() {
hasChanges: result.hasChanges,
});
} catch (fallbackError) {
logError(fallbackError, "Fallback to main project also failed");
res.json({ success: true, diff: "", files: [], hasChanges: false });
logError(fallbackError, 'Fallback to main project also failed');
res.json({ success: true, diff: '', files: [], hasChanges: false });
}
}
} catch (error) {
logError(error, "Get worktree diffs failed");
logError(error, 'Get worktree diffs failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,13 +2,13 @@
* POST /file-diff endpoint - Get diff for a specific file
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
import { generateSyntheticDiffForNewFile } from "../../common.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
import { generateSyntheticDiffForNewFile } from '../../common.js';
const execAsync = promisify(exec);
@@ -24,24 +24,23 @@ export function createFileDiffHandler() {
if (!projectPath || !featureId || !filePath) {
res.status(400).json({
success: false,
error: "projectPath, featureId, and filePath required",
error: 'projectPath, featureId, and filePath required',
});
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, ".worktrees", featureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await fs.access(worktreePath);
await secureFs.access(worktreePath);
// First check if the file is untracked
const { stdout: status } = await execAsync(
`git status --porcelain -- "${filePath}"`,
{ cwd: worktreePath }
);
const { stdout: status } = await execAsync(`git status --porcelain -- "${filePath}"`, {
cwd: worktreePath,
});
const isUntracked = status.trim().startsWith("??");
const isUntracked = status.trim().startsWith('??');
let diff: string;
if (isUntracked) {
@@ -49,23 +48,20 @@ export function createFileDiffHandler() {
diff = await generateSyntheticDiffForNewFile(worktreePath, filePath);
} else {
// Use regular git diff for tracked files
const result = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
}
);
const result = await execAsync(`git diff HEAD -- "${filePath}"`, {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
});
diff = result.stdout;
}
res.json({ success: true, diff, filePath });
} catch (innerError) {
logError(innerError, "Worktree file diff failed");
res.json({ success: true, diff: "", filePath });
logError(innerError, 'Worktree file diff failed');
res.json({ success: true, diff: '', filePath });
}
} catch (error) {
logError(error, "Get worktree file diff failed");
logError(error, 'Get worktree file diff failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /info endpoint - Get worktree info
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError, normalizePath } from "../common.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError, normalizePath } from '../common.js';
const execAsync = promisify(exec);
@@ -20,20 +20,18 @@ export function createInfoHandler() {
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId required",
});
res.status(400).json({
success: false,
error: 'projectPath and featureId required',
});
return;
}
// Check if worktree exists (git worktrees are stored in project directory)
const worktreePath = path.join(projectPath, ".worktrees", featureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await fs.access(worktreePath);
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
await secureFs.access(worktreePath);
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
res.json({
@@ -45,7 +43,7 @@ export function createInfoHandler() {
res.json({ success: true, worktreePath: null, branchName: null });
}
} catch (error) {
logError(error, "Get worktree info failed");
logError(error, 'Get worktree info failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /init-git endpoint - Initialize a git repository in a directory
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { existsSync } from "fs";
import { join } from "path";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as secureFs from '../../../lib/secure-fs.js';
import { join } from 'path';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
@@ -21,39 +21,42 @@ export function createInitGitHandler() {
if (!projectPath) {
res.status(400).json({
success: false,
error: "projectPath required",
error: 'projectPath required',
});
return;
}
// Check if .git already exists
const gitDirPath = join(projectPath, ".git");
if (existsSync(gitDirPath)) {
const gitDirPath = join(projectPath, '.git');
try {
await secureFs.access(gitDirPath);
// .git exists
res.json({
success: true,
result: {
initialized: false,
message: "Git repository already exists",
message: 'Git repository already exists',
},
});
return;
} catch {
// .git doesn't exist, continue with initialization
}
// Initialize git and create an initial empty commit
await execAsync(
`git init && git commit --allow-empty -m "Initial commit"`,
{ cwd: projectPath }
);
await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, {
cwd: projectPath,
});
res.json({
success: true,
result: {
initialized: true,
message: "Git repository initialized with initial commit",
message: 'Git repository initialized with initial commit',
},
});
} catch (error) {
logError(error, "Init git failed");
logError(error, 'Init git failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -5,12 +5,13 @@
* Does NOT include tracked branches - only real worktrees with separate directories.
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { existsSync } from "fs";
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
import { readAllWorktreeMetadata, type WorktreePRInfo } from "../../../lib/worktree-metadata.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
const execAsync = promisify(exec);
@@ -27,10 +28,10 @@ interface WorktreeInfo {
async function getCurrentBranch(cwd: string): Promise<string> {
try {
const { stdout } = await execAsync("git branch --show-current", { cwd });
const { stdout } = await execAsync('git branch --show-current', { cwd });
return stdout.trim();
} catch {
return "";
return '';
}
}
@@ -43,7 +44,7 @@ export function createListHandler() {
};
if (!projectPath) {
res.status(400).json({ success: false, error: "projectPath required" });
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
@@ -56,28 +57,35 @@ export function createListHandler() {
const currentBranch = await getCurrentBranch(projectPath);
// Get actual worktrees from git
const { stdout } = await execAsync("git worktree list --porcelain", {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
const worktrees: WorktreeInfo[] = [];
const removedWorktrees: Array<{ path: string; branch: string }> = [];
const lines = stdout.split("\n");
const lines = stdout.split('\n');
let current: { path?: string; branch?: string } = {};
let isFirst = true;
// First pass: detect removed worktrees
for (const line of lines) {
if (line.startsWith("worktree ")) {
if (line.startsWith('worktree ')) {
current.path = normalizePath(line.slice(9));
} else if (line.startsWith("branch ")) {
current.branch = line.slice(7).replace("refs/heads/", "");
} else if (line === "") {
} else if (line.startsWith('branch ')) {
current.branch = line.slice(7).replace('refs/heads/', '');
} else if (line === '') {
if (current.path && current.branch) {
const isMainWorktree = isFirst;
// Check if the worktree directory actually exists
// Skip checking/pruning the main worktree (projectPath itself)
if (!isMainWorktree && !existsSync(current.path)) {
let worktreeExists = false;
try {
await secureFs.access(current.path);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!isMainWorktree && !worktreeExists) {
// Worktree directory doesn't exist - it was manually deleted
removedWorktrees.push({
path: current.path,
@@ -102,7 +110,7 @@ export function createListHandler() {
// Prune removed worktrees from git (only if any were detected)
if (removedWorktrees.length > 0) {
try {
await execAsync("git worktree prune", { cwd: projectPath });
await execAsync('git worktree prune', { cwd: projectPath });
} catch {
// Prune failed, but we'll still report the removed worktrees
}
@@ -115,13 +123,12 @@ export function createListHandler() {
if (includeDetails) {
for (const worktree of worktrees) {
try {
const { stdout: statusOutput } = await execAsync(
"git status --porcelain",
{ cwd: worktree.path }
);
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: worktree.path,
});
const changedFiles = statusOutput
.trim()
.split("\n")
.split('\n')
.filter((line) => line.trim());
worktree.hasChanges = changedFiles.length > 0;
worktree.changedFilesCount = changedFiles.length;
@@ -140,13 +147,13 @@ export function createListHandler() {
}
}
res.json({
success: true,
res.json({
success: true,
worktrees,
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
});
} catch (error) {
logError(error, "List worktrees failed");
logError(error, 'List worktrees failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -5,8 +5,8 @@
* any migration since .automaker is now stored in the project directory.
*/
import type { Request, Response } from "express";
import { getAutomakerDir } from "../../../lib/automaker-paths.js";
import type { Request, Response } from 'express';
import { getAutomakerDir } from '@automaker/platform';
export function createMigrateHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,7 +15,7 @@ export function createMigrateHandler() {
if (!projectPath) {
res.status(400).json({
success: false,
error: "projectPath is required",
error: 'projectPath is required',
});
return;
}
@@ -25,7 +25,7 @@ export function createMigrateHandler() {
res.json({
success: true,
migrated: false,
message: "No migration needed - .automaker is stored in project directory",
message: 'No migration needed - .automaker is stored in project directory',
path: automakerDir,
});
};

View File

@@ -2,12 +2,12 @@
* POST /status endpoint - Get worktree status
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
@@ -20,53 +20,50 @@ export function createStatusHandler() {
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId required",
});
res.status(400).json({
success: false,
error: 'projectPath and featureId required',
});
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, ".worktrees", featureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await fs.access(worktreePath);
const { stdout: status } = await execAsync("git status --porcelain", {
await secureFs.access(worktreePath);
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
const files = status
.split("\n")
.split('\n')
.filter(Boolean)
.map((line) => line.slice(3));
const { stdout: diffStat } = await execAsync("git diff --stat", {
const { stdout: diffStat } = await execAsync('git diff --stat', {
cwd: worktreePath,
});
const { stdout: logOutput } = await execAsync('git log --oneline -5 --format="%h %s"', {
cwd: worktreePath,
});
const { stdout: logOutput } = await execAsync(
'git log --oneline -5 --format="%h %s"',
{ cwd: worktreePath }
);
res.json({
success: true,
modifiedFiles: files.length,
files,
diffStat: diffStat.trim(),
recentCommits: logOutput.trim().split("\n").filter(Boolean),
recentCommits: logOutput.trim().split('\n').filter(Boolean),
});
} catch {
res.json({
success: true,
modifiedFiles: 0,
files: [],
diffStat: "",
diffStat: '',
recentCommits: [],
});
}
} catch (error) {
logError(error, "Get worktree status failed");
logError(error, 'Get worktree status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};