Skip to content
183 changes: 183 additions & 0 deletions apps/server/src/lib/worktree-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Worktree metadata storage utilities
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
*/

import * as fs from "fs/promises";
import * as path from "path";

/** Maximum length for sanitized branch names in filesystem paths */
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;

export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}

export interface WorktreeMetadata {
branch: string;
createdAt: string;
pr?: WorktreePRInfo;
}

/**
* Sanitize branch name for cross-platform filesystem safety
*/
function sanitizeBranchName(branch: string): string {
// Replace characters that are invalid or problematic on various filesystems:
// - Forward and backslashes (path separators)
// - Windows invalid chars: : * ? " < > |
// - Other potentially problematic chars
let safeBranch = branch
.replace(/[/\\:*?"<>|]/g, "-") // Replace invalid chars with dash
.replace(/\s+/g, "_") // Replace spaces with underscores
.replace(/\.+$/g, "") // Remove trailing dots (Windows issue)
.replace(/-+/g, "-") // Collapse multiple dashes
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes

// Truncate to safe length (leave room for path components)
safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH);

// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
safeBranch = `_${safeBranch || "branch"}`;
}

return safeBranch;
}

/**
* Get the path to the worktree metadata directory
*/
function getWorktreeMetadataDir(projectPath: string, branch: string): string {
const safeBranch = sanitizeBranchName(branch);
return path.join(projectPath, ".automaker", "worktrees", safeBranch);
}

/**
* Get the path to the worktree metadata file
*/
function getWorktreeMetadataPath(projectPath: string, branch: string): string {
return path.join(getWorktreeMetadataDir(projectPath, branch), "worktree.json");
}

/**
* Read worktree metadata for a branch
*/
export async function readWorktreeMetadata(
projectPath: string,
branch: string
): Promise<WorktreeMetadata | null> {
try {
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
const content = await fs.readFile(metadataPath, "utf-8");
return JSON.parse(content) as WorktreeMetadata;
} catch (error) {
// File doesn't exist or can't be read
return null;
}
}

/**
* Write worktree metadata for a branch
*/
export async function writeWorktreeMetadata(
projectPath: string,
branch: string,
metadata: WorktreeMetadata
): Promise<void> {
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
const metadataPath = getWorktreeMetadataPath(projectPath, branch);

// Ensure directory exists
await fs.mkdir(metadataDir, { recursive: true });

// Write metadata
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
}

/**
* Update PR info in worktree metadata
*/
export async function updateWorktreePRInfo(
projectPath: string,
branch: string,
prInfo: WorktreePRInfo
): Promise<void> {
// Read existing metadata or create new
let metadata = await readWorktreeMetadata(projectPath, branch);

if (!metadata) {
metadata = {
branch,
createdAt: new Date().toISOString(),
};
}

// Update PR info
metadata.pr = prInfo;

// Write back
await writeWorktreeMetadata(projectPath, branch, metadata);
}

/**
* Get PR info for a branch from metadata
*/
export async function getWorktreePRInfo(
projectPath: string,
branch: string
): Promise<WorktreePRInfo | null> {
const metadata = await readWorktreeMetadata(projectPath, branch);
return metadata?.pr || null;
}

/**
* Read all worktree metadata for a project
*/
export async function readAllWorktreeMetadata(
projectPath: string
): Promise<Map<string, WorktreeMetadata>> {
const result = new Map<string, WorktreeMetadata>();
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");

try {
const dirs = await fs.readdir(worktreesDir, { withFileTypes: true });

for (const dir of dirs) {
if (dir.isDirectory()) {
const metadataPath = path.join(worktreesDir, dir.name, "worktree.json");
try {
const content = await fs.readFile(metadataPath, "utf-8");
const metadata = JSON.parse(content) as WorktreeMetadata;
result.set(metadata.branch, metadata);
} catch {
// Skip if file doesn't exist or can't be read
}
}
}
} catch {
// Directory doesn't exist
}

return result;
}

/**
* Delete worktree metadata for a branch
*/
export async function deleteWorktreeMetadata(
projectPath: string,
branch: string
): Promise<void> {
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
try {
await fs.rm(metadataDir, { recursive: true, force: true });
} catch {
// Ignore errors if directory doesn't exist
}
}
80 changes: 79 additions & 1 deletion apps/server/src/routes/worktree/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,87 @@ import {
import { FeatureLoader } from "../../services/feature-loader.js";

const logger = createLogger("Worktree");
const execAsync = promisify(exec);
export const execAsync = promisify(exec);
const featureLoader = new FeatureLoader();

// ============================================================================
// Constants
// ============================================================================

/** Maximum allowed length for git branch names */
export const MAX_BRANCH_NAME_LENGTH = 250;

// ============================================================================
// Extended PATH configuration for Electron apps
// ============================================================================

const pathSeparator = process.platform === "win32" ? ";" : ":";
const additionalPaths: string[] = [];

if (process.platform === "win32") {
// Windows paths
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
}
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\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
);
Comment on lines +46 to +52
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against undefined process.env.HOME.

Line 51 uses ${process.env.HOME}/.local/bin in a template literal. If process.env.HOME is undefined (which can happen in some environments), this produces the string "undefined/.local/bin" which passes the .filter(Boolean) check but is an invalid path.

🔎 Proposed fix
 } 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
   );
+  if (process.env.HOME) {
+    additionalPaths.push(`${process.env.HOME}/.local/bin`);
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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
);
// 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
);
if (process.env.HOME) {
additionalPaths.push(`${process.env.HOME}/.local/bin`); // pipx, other user installs
}
🤖 Prompt for AI Agents
In apps/server/src/routes/worktree/common.ts around lines 46 to 52, the template
literal `${process.env.HOME}/.local/bin` can produce "undefined/.local/bin" when
process.env.HOME is not set; change the code to guard against undefined by using
a reliable home directory (e.g. require('os').homedir()) or only push the path
when process.env.HOME is truthy — update the additionalPaths push to either use
os.homedir() + '/.local/bin' or wrap the `${process.env.HOME}/.local/bin` entry
in a conditional so it is added only if process.env.HOME is defined.

}

const extendedPath = [
process.env.PATH,
...additionalPaths.filter(Boolean),
].filter(Boolean).join(pathSeparator);

/**
* Environment variables with extended PATH for executing shell commands.
* Electron apps don't inherit the user's shell PATH, so we need to add
* common tool installation locations.
*/
export const execEnv = {
...process.env,
PATH: extendedPath,
};

// ============================================================================
// Validation utilities
// ============================================================================

/**
* Validate branch name to prevent command injection.
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
* We also reject shell metacharacters for safety.
*/
export function isValidBranchName(name: string): boolean {
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
}

/**
* Check if gh CLI is available on the system
*/
export async function isGhCliAvailable(): Promise<boolean> {
try {
const checkCommand = process.platform === "win32"
? "where gh"
: "command -v gh";
await execAsync(checkCommand, { env: execEnv });
return true;
} catch {
return false;
}
}

export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
"chore: automaker initial commit";

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/routes/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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";
Expand Down Expand Up @@ -40,6 +41,7 @@ export function createWorktreeRoutes(): Router {
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());
Expand Down
Loading