mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
Comprehensive set of mobile and all improvements phase 1
This commit is contained in:
@@ -1,27 +1,47 @@
|
||||
/**
|
||||
* POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
|
||||
* POST /discard-changes endpoint - Discard 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)
|
||||
* Supports two modes:
|
||||
* 1. Discard ALL changes (when no files array is provided)
|
||||
* - Resets staged changes (git reset HEAD)
|
||||
* - Discards modified tracked files (git checkout .)
|
||||
* - Removes untracked files and directories (git clean -fd)
|
||||
*
|
||||
* 2. Discard SELECTED files (when files array is provided)
|
||||
* - Unstages selected staged files (git reset HEAD -- <files>)
|
||||
* - Reverts selected tracked file changes (git checkout -- <files>)
|
||||
* - Removes selected untracked files (git clean -fd -- <files>)
|
||||
*
|
||||
* 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 { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Validate that a file path does not escape the worktree directory.
|
||||
* Prevents path traversal attacks (e.g., ../../etc/passwd).
|
||||
*/
|
||||
function validateFilePath(filePath: string, worktreePath: string): boolean {
|
||||
// Resolve the full path relative to the worktree
|
||||
const resolved = path.resolve(worktreePath, filePath);
|
||||
const normalizedWorktree = path.resolve(worktreePath);
|
||||
// Ensure the resolved path starts with the worktree path
|
||||
return resolved.startsWith(normalizedWorktree + path.sep) || resolved === normalizedWorktree;
|
||||
}
|
||||
|
||||
export function createDiscardChangesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
const { worktreePath, files } = req.body as {
|
||||
worktreePath: string;
|
||||
files?: string[];
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -33,7 +53,7 @@ export function createDiscardChangesHandler() {
|
||||
}
|
||||
|
||||
// Check for uncommitted changes first
|
||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
||||
const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
@@ -48,61 +68,197 @@ export function createDiscardChangesHandler() {
|
||||
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 { stdout: branchOutput } = await execFileAsync(
|
||||
'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
|
||||
// Parse the status output to categorize files
|
||||
const statusLines = status.trim().split('\n').filter(Boolean);
|
||||
const allFiles = statusLines.map((line) => {
|
||||
const fileStatus = line.substring(0, 2).trim();
|
||||
const filePath = line.substring(3).trim();
|
||||
return { status: fileStatus, path: filePath };
|
||||
});
|
||||
|
||||
// 2. Discard changes in tracked files
|
||||
await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
|
||||
// Ignore errors - might fail if there are no tracked changes
|
||||
});
|
||||
// Determine which files to discard
|
||||
const isSelectiveDiscard = files && files.length > 0 && files.length < allFiles.length;
|
||||
|
||||
// 3. Remove untracked files and directories
|
||||
await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
|
||||
// Ignore errors - might fail if there are no untracked files
|
||||
});
|
||||
if (isSelectiveDiscard) {
|
||||
// Selective discard: only discard the specified files
|
||||
const filesToDiscard = new Set(files);
|
||||
|
||||
// Verify all changes were discarded
|
||||
const { stdout: finalStatus } = await execAsync('git status --porcelain', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
// Validate all requested file paths stay within the worktree
|
||||
const invalidPaths = files.filter((f) => !validateFilePath(f, worktreePath));
|
||||
if (invalidPaths.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid file paths detected (path traversal): ${invalidPaths.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate files into categories for proper git operations
|
||||
const trackedModified: string[] = []; // Modified/deleted tracked files
|
||||
const stagedFiles: string[] = []; // Files that are staged
|
||||
const untrackedFiles: string[] = []; // Untracked files (?)
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const file of allFiles) {
|
||||
if (!filesToDiscard.has(file.path)) continue;
|
||||
|
||||
if (file.status === '?') {
|
||||
untrackedFiles.push(file.path);
|
||||
} else {
|
||||
// Check if the file has staged changes (first character of status)
|
||||
const indexStatus = statusLines
|
||||
.find((l) => l.substring(3).trim() === file.path)
|
||||
?.charAt(0);
|
||||
if (indexStatus && indexStatus !== ' ' && indexStatus !== '?') {
|
||||
stagedFiles.push(file.path);
|
||||
}
|
||||
// Check for working tree changes (tracked files)
|
||||
if (file.status === 'M' || file.status === 'D' || file.status === 'A') {
|
||||
trackedModified.push(file.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Unstage selected staged files (using execFile to bypass shell)
|
||||
if (stagedFiles.length > 0) {
|
||||
try {
|
||||
await execFileAsync('git', ['reset', 'HEAD', '--', ...stagedFiles], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logError(error, `Failed to unstage files: ${msg}`);
|
||||
warnings.push(`Failed to unstage some files: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Revert selected tracked file changes
|
||||
if (trackedModified.length > 0) {
|
||||
try {
|
||||
await execFileAsync('git', ['checkout', '--', ...trackedModified], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logError(error, `Failed to revert tracked files: ${msg}`);
|
||||
warnings.push(`Failed to revert some tracked files: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove selected untracked files
|
||||
if (untrackedFiles.length > 0) {
|
||||
try {
|
||||
await execFileAsync('git', ['clean', '-fd', '--', ...untrackedFiles], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logError(error, `Failed to clean untracked files: ${msg}`);
|
||||
warnings.push(`Failed to remove some untracked files: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const fileCount = files.length;
|
||||
|
||||
// Verify the remaining state
|
||||
const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
const remainingCount = finalStatus.trim()
|
||||
? finalStatus.trim().split('\n').filter(Boolean).length
|
||||
: 0;
|
||||
const actualDiscarded = allFiles.length - remainingCount;
|
||||
|
||||
let message =
|
||||
actualDiscarded < fileCount
|
||||
? `Discarded ${actualDiscarded} of ${fileCount} selected files, ${remainingCount} files remaining`
|
||||
: `Discarded ${actualDiscarded} ${actualDiscarded === 1 ? 'file' : 'files'}`;
|
||||
|
||||
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,
|
||||
filesDiscarded: actualDiscarded,
|
||||
filesRemaining: remainingCount,
|
||||
branch: branchName,
|
||||
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
|
||||
message,
|
||||
...(warnings.length > 0 && { warnings }),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
discarded: true,
|
||||
filesDiscarded: fileCount,
|
||||
filesRemaining: 0,
|
||||
branch: branchName,
|
||||
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
|
||||
},
|
||||
// Discard ALL changes (original behavior)
|
||||
const fileCount = allFiles.length;
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1. Reset any staged changes
|
||||
try {
|
||||
await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath });
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logError(error, `git reset HEAD failed: ${msg}`);
|
||||
warnings.push(`Failed to unstage changes: ${msg}`);
|
||||
}
|
||||
|
||||
// 2. Discard changes in tracked files
|
||||
try {
|
||||
await execFileAsync('git', ['checkout', '.'], { cwd: worktreePath });
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logError(error, `git checkout . failed: ${msg}`);
|
||||
warnings.push(`Failed to revert tracked changes: ${msg}`);
|
||||
}
|
||||
|
||||
// 3. Remove untracked files and directories
|
||||
try {
|
||||
await execFileAsync('git', ['clean', '-fd'], { cwd: worktreePath });
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logError(error, `git clean -fd failed: ${msg}`);
|
||||
warnings.push(`Failed to remove untracked files: ${msg}`);
|
||||
}
|
||||
|
||||
// Verify all changes were discarded
|
||||
const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
if (finalStatus.trim()) {
|
||||
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`,
|
||||
...(warnings.length > 0 && { warnings }),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
discarded: true,
|
||||
filesDiscarded: fileCount,
|
||||
filesRemaining: 0,
|
||||
branch: branchName,
|
||||
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
|
||||
...(warnings.length > 0 && { warnings }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Discard changes failed');
|
||||
|
||||
Reference in New Issue
Block a user