refactor: replace fs with secureFs for improved file handling

This commit updates various modules to utilize the secure file system operations from the secureFs module instead of the native fs module. Key changes include:

- Replaced fs imports with secureFs in multiple route handlers and services to enhance security and consistency in file operations.
- Added centralized validation for working directories in the sdk-options module to ensure all AI model invocations are secure.

These changes aim to improve the security and maintainability of file handling across the application.
This commit is contained in:
Test User
2025-12-21 01:32:26 -05:00
parent 2b5479ae0d
commit 077a63b03b
62 changed files with 4866 additions and 3350 deletions

View File

@@ -2,18 +2,14 @@
* Common utilities for worktree routes
*/
import { createLogger } from "@automaker/utils";
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

@@ -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 "@automaker/platform";
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,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,13 +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 } from "@automaker/git-utils";
import { 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);
@@ -28,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 '';
}
}
@@ -44,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;
}
@@ -57,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,
@@ -103,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
}
@@ -116,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;
@@ -141,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

@@ -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) });
}
};