Skip to content
15 changes: 15 additions & 0 deletions apps/server/src/routes/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
createGetAvailableEditorsHandler,
createRefreshEditorsHandler,
} from './routes/open-in-editor.js';
import { createOpenInTerminalHandler } from './routes/open-in-terminal.js';
import { createInitGitHandler } from './routes/init-git.js';
import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
Expand All @@ -41,6 +42,7 @@ import {
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js';
import type { SettingsService } from '../../services/settings-service.js';

export function createWorktreeRoutes(
Expand Down Expand Up @@ -97,6 +99,11 @@ export function createWorktreeRoutes(
);
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.post(
'/open-in-terminal',
validatePathParams('worktreePath'),
createOpenInTerminalHandler()
);
router.get('/default-editor', createGetDefaultEditorHandler());
router.get('/available-editors', createGetAvailableEditorsHandler());
router.post('/refresh-editors', createRefreshEditorsHandler());
Expand Down Expand Up @@ -125,5 +132,13 @@ export function createWorktreeRoutes(
createRunInitScriptHandler(events)
);

// Discard changes route
router.post(
'/discard-changes',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createDiscardChangesHandler()
);

return router;
}
5 changes: 4 additions & 1 deletion apps/server/src/routes/worktree/routes/diffs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export function createDiffsHandler() {
}

// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
Comment on lines +42 to +45
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This featureId sanitization logic is duplicated in multiple places. This reduces maintainability, as any change to the sanitization rule would need to be updated in all locations. Consider creating a shared helper function to centralize this logic.

This logic is repeated in:

  • apps/server/src/routes/worktree/routes/file-diff.ts
  • apps/server/src/routes/worktree/routes/info.ts
  • apps/server/src/routes/worktree/routes/status.ts
  • apps/server/src/services/auto-mode-service.ts

A shared function like getWorktreePath(projectPath, featureId) could be created in a common utility file.


try {
// Check if worktree exists
Expand Down
112 changes: 112 additions & 0 deletions apps/server/src/routes/worktree/routes/discard-changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
*
* This performs a destructive operation that:
* 1. Resets staged changes (git reset HEAD)
* 2. Discards modified tracked files (git checkout .)
* 3. Removes untracked files and directories (git clean -fd)
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/

import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';

const execAsync = promisify(exec);

export function createDiscardChangesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};

if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}

// Check for uncommitted changes first
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});

if (!status.trim()) {
res.json({
success: true,
result: {
discarded: false,
message: 'No changes to discard',
},
});
return;
}

// Count the files that will be affected
const lines = status.trim().split('\n').filter(Boolean);
const fileCount = lines.length;

// Get branch name before discarding
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchName = branchOutput.trim();

// Discard all changes:
// 1. Reset any staged changes
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there's nothing staged
});

// 2. Discard changes in tracked files
await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no tracked changes
});

// 3. Remove untracked files and directories
await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no untracked files
});

// Verify all changes were discarded
const { stdout: finalStatus } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});

if (finalStatus.trim()) {
// Some changes couldn't be discarded (possibly ignored files or permission issues)
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
res.json({
success: true,
result: {
discarded: true,
filesDiscarded: fileCount - remainingCount,
filesRemaining: remainingCount,
branch: branchName,
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
},
});
} else {
res.json({
success: true,
result: {
discarded: true,
filesDiscarded: fileCount,
filesRemaining: 0,
branch: branchName,
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
},
});
}
} catch (error) {
logError(error, 'Discard changes failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
5 changes: 4 additions & 1 deletion apps/server/src/routes/worktree/routes/file-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export function createFileDiffHandler() {
}

// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);

try {
await secureFs.access(worktreePath);
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/routes/worktree/routes/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export function createInfoHandler() {
}

// Check if worktree exists (git worktrees are stored in project directory)
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(worktreePath);
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
Expand Down
50 changes: 50 additions & 0 deletions apps/server/src/routes/worktree/routes/open-in-terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* POST /open-in-terminal endpoint - Open a terminal in a worktree directory
*
* This module uses @automaker/platform for cross-platform terminal launching.
*/

import type { Request, Response } from 'express';
import { isAbsolute } from 'path';
import { openInTerminal } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';

export function createOpenInTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};

if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}

// Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}

// Use the platform utility to open in terminal
const result = await openInTerminal(worktreePath);
res.json({
success: true,
result: {
message: `Opened terminal in ${worktreePath}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
5 changes: 4 additions & 1 deletion apps/server/src/routes/worktree/routes/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export function createStatusHandler() {
}

// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);

try {
await secureFs.access(worktreePath);
Expand Down
16 changes: 12 additions & 4 deletions apps/server/src/services/auto-mode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1504,7 +1504,9 @@ Address the follow-up instructions above. Review the previous work and make the
*/
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
// Worktrees are in project dir
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
let workDir = projectPath;

try {
Expand Down Expand Up @@ -1585,7 +1587,9 @@ Address the follow-up instructions above. Review the previous work and make the
}
} else {
// Fallback: try to find worktree at legacy location
const legacyWorktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const legacyWorktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
Expand Down Expand Up @@ -1790,22 +1794,25 @@ Format your response as a structured markdown document.`;
provider?: ModelProvider;
title?: string;
description?: string;
branchName?: string;
}>
> {
const agents = await Promise.all(
Array.from(this.runningFeatures.values()).map(async (rf) => {
// Try to fetch feature data to get title and description
// Try to fetch feature data to get title, description, and branchName
let title: string | undefined;
let description: string | undefined;
let branchName: string | undefined;

try {
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
if (feature) {
title = feature.title;
description = feature.description;
branchName = feature.branchName;
}
} catch (error) {
// Silently ignore errors - title/description are optional
// Silently ignore errors - title/description/branchName are optional
}

return {
Expand All @@ -1817,6 +1824,7 @@ Format your response as a structured markdown document.`;
provider: rf.provider,
title,
description,
branchName,
};
})
);
Expand Down
Loading
Loading