mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
Feature: File Editor (#789)
* feat: Add file management feature * feat: Add auto-save functionality to file editor * fix: Replace HardDriveDownload icon with Save icon for consistency * fix: Prevent recursive copy/move and improve shell injection prevention * refactor: Extract editor settings form into separate component
This commit is contained in:
@@ -20,6 +20,9 @@ import { createImageHandler } from './routes/image.js';
|
||||
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
|
||||
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
|
||||
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
|
||||
import { createCopyHandler } from './routes/copy.js';
|
||||
import { createMoveHandler } from './routes/move.js';
|
||||
import { createDownloadHandler } from './routes/download.js';
|
||||
|
||||
export function createFsRoutes(_events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
@@ -39,6 +42,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
|
||||
router.post('/save-board-background', createSaveBoardBackgroundHandler());
|
||||
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
|
||||
router.post('/browse-project-files', createBrowseProjectFilesHandler());
|
||||
router.post('/copy', createCopyHandler());
|
||||
router.post('/move', createMoveHandler());
|
||||
router.post('/download', createDownloadHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* POST /copy endpoint - Copy file or directory to a new location
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { mkdirSafe } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Recursively copy a directory and its contents
|
||||
*/
|
||||
async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
|
||||
await mkdirSafe(dest);
|
||||
const entries = await secureFs.readdir(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectoryRecursive(srcPath, destPath);
|
||||
} else {
|
||||
await secureFs.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createCopyHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sourcePath, destinationPath, overwrite } = req.body as {
|
||||
sourcePath: string;
|
||||
destinationPath: string;
|
||||
overwrite?: boolean;
|
||||
};
|
||||
|
||||
if (!sourcePath || !destinationPath) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'sourcePath and destinationPath are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent copying a folder into itself or its own descendant (infinite recursion)
|
||||
const resolvedSrc = path.resolve(sourcePath);
|
||||
const resolvedDest = path.resolve(destinationPath);
|
||||
if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot copy a folder into itself or one of its own descendants',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if destination already exists
|
||||
try {
|
||||
await secureFs.stat(destinationPath);
|
||||
// Destination exists
|
||||
if (!overwrite) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'Destination already exists',
|
||||
exists: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If overwrite is true, remove the existing destination first to avoid merging
|
||||
await secureFs.rm(destinationPath, { recursive: true });
|
||||
} catch {
|
||||
// Destination doesn't exist - good to proceed
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
|
||||
|
||||
// Check if source is a directory
|
||||
const stats = await secureFs.stat(sourcePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await copyDirectoryRecursive(sourcePath, destinationPath);
|
||||
} else {
|
||||
await secureFs.copyFile(sourcePath, destinationPath);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Copy file failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
142
apps/server/src/routes/fs/routes/download.ts
Normal file
142
apps/server/src/routes/fs/routes/download.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* POST /download endpoint - Download a file, or GET /download for streaming
|
||||
* For folders, creates a zip archive on the fly
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createReadStream } from 'fs';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Get total size of a directory recursively
|
||||
*/
|
||||
async function getDirectorySize(dirPath: string): Promise<number> {
|
||||
let totalSize = 0;
|
||||
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
totalSize += await getDirectorySize(entryPath);
|
||||
} else {
|
||||
const stats = await secureFs.stat(entryPath);
|
||||
totalSize += Number(stats.size);
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
export function createDownloadHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await secureFs.stat(filePath);
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// For directories, create a zip archive
|
||||
const dirSize = await getDirectorySize(filePath);
|
||||
const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit
|
||||
|
||||
if (dirSize > MAX_DIR_SIZE) {
|
||||
res.status(413).json({
|
||||
success: false,
|
||||
error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`,
|
||||
size: dirSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary zip file
|
||||
const zipFileName = `${fileName}.zip`;
|
||||
const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`);
|
||||
|
||||
try {
|
||||
// Use system zip command (available on macOS and Linux)
|
||||
// Use execFile to avoid shell injection via user-provided paths
|
||||
await execFileAsync('zip', ['-r', tmpZipPath, fileName], {
|
||||
cwd: path.dirname(filePath),
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const zipStats = await secureFs.stat(tmpZipPath);
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
|
||||
res.setHeader('Content-Length', zipStats.size.toString());
|
||||
res.setHeader('X-Directory-Size', dirSize.toString());
|
||||
|
||||
const stream = createReadStream(tmpZipPath);
|
||||
stream.pipe(res);
|
||||
|
||||
stream.on('end', async () => {
|
||||
// Cleanup temp file
|
||||
try {
|
||||
await secureFs.rm(tmpZipPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', async (err) => {
|
||||
logError(err, 'Download stream error');
|
||||
try {
|
||||
await secureFs.rm(tmpZipPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Stream error during download' });
|
||||
}
|
||||
});
|
||||
} catch (zipError) {
|
||||
// Cleanup on zip failure
|
||||
try {
|
||||
await secureFs.rm(tmpZipPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
throw zipError;
|
||||
}
|
||||
} else {
|
||||
// For individual files, stream directly
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
res.setHeader('Content-Length', stats.size.toString());
|
||||
|
||||
const stream = createReadStream(filePath);
|
||||
stream.pipe(res);
|
||||
|
||||
stream.on('error', (err) => {
|
||||
logError(err, 'Download stream error');
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Stream error during download' });
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Download failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/fs/routes/move.ts
Normal file
79
apps/server/src/routes/fs/routes/move.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* POST /move endpoint - Move (rename) file or directory to a new location
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { mkdirSafe } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createMoveHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sourcePath, destinationPath, overwrite } = req.body as {
|
||||
sourcePath: string;
|
||||
destinationPath: string;
|
||||
overwrite?: boolean;
|
||||
};
|
||||
|
||||
if (!sourcePath || !destinationPath) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'sourcePath and destinationPath are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent moving to same location or into its own descendant
|
||||
const resolvedSrc = path.resolve(sourcePath);
|
||||
const resolvedDest = path.resolve(destinationPath);
|
||||
if (resolvedDest === resolvedSrc) {
|
||||
// No-op: source and destination are the same
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
if (resolvedDest.startsWith(resolvedSrc + path.sep)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot move a folder into one of its own descendants',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if destination already exists
|
||||
try {
|
||||
await secureFs.stat(destinationPath);
|
||||
// Destination exists
|
||||
if (!overwrite) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'Destination already exists',
|
||||
exists: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If overwrite is true, remove the existing destination first
|
||||
await secureFs.rm(destinationPath, { recursive: true });
|
||||
} catch {
|
||||
// Destination doesn't exist - good to proceed
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
|
||||
|
||||
// Use rename for the move operation
|
||||
await secureFs.rename(sourcePath, destinationPath);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Move file failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createDiffsHandler } from './routes/diffs.js';
|
||||
import { createFileDiffHandler } from './routes/file-diff.js';
|
||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||
import { createDetailsHandler } from './routes/details.js';
|
||||
import { createEnhancedStatusHandler } from './routes/enhanced-status.js';
|
||||
|
||||
export function createGitRoutes(): Router {
|
||||
const router = Router();
|
||||
@@ -18,6 +20,8 @@ export function createGitRoutes(): Router {
|
||||
validatePathParams('projectPath', 'files[]'),
|
||||
createStageFilesHandler()
|
||||
);
|
||||
router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler());
|
||||
router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
248
apps/server/src/routes/git/routes/details.ts
Normal file
248
apps/server/src/routes/git/routes/details.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* POST /details endpoint - Get detailed git info for a file or project
|
||||
* Returns branch, last commit info, diff stats, and conflict status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface GitFileDetails {
|
||||
branch: string;
|
||||
lastCommitHash: string;
|
||||
lastCommitMessage: string;
|
||||
lastCommitAuthor: string;
|
||||
lastCommitTimestamp: string;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
export function createDetailsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, filePath } = req.body as {
|
||||
projectPath: string;
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current branch
|
||||
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
const branch = branchRaw.trim();
|
||||
|
||||
if (!filePath) {
|
||||
// Project-level details - just return branch info
|
||||
res.json({
|
||||
success: true,
|
||||
details: { branch },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get last commit info for this file
|
||||
let lastCommitHash = '';
|
||||
let lastCommitMessage = '';
|
||||
let lastCommitAuthor = '';
|
||||
let lastCommitTimestamp = '';
|
||||
|
||||
try {
|
||||
const { stdout: logOutput } = await execFileAsync(
|
||||
'git',
|
||||
['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (logOutput.trim()) {
|
||||
const parts = logOutput.trim().split('|');
|
||||
lastCommitHash = parts[0] || '';
|
||||
lastCommitMessage = parts[1] || '';
|
||||
lastCommitAuthor = parts[2] || '';
|
||||
lastCommitTimestamp = parts[3] || '';
|
||||
}
|
||||
} catch {
|
||||
// File may not have any commits yet
|
||||
}
|
||||
|
||||
// Get diff stats (lines added/removed)
|
||||
let linesAdded = 0;
|
||||
let linesRemoved = 0;
|
||||
|
||||
try {
|
||||
// Check if file is untracked first
|
||||
const { stdout: statusLine } = await execFileAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (statusLine.trim().startsWith('??')) {
|
||||
// Untracked file - count all lines as added using Node.js instead of shell
|
||||
try {
|
||||
const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString();
|
||||
const lines = fileContent.split('\n');
|
||||
// Don't count trailing empty line from final newline
|
||||
linesAdded =
|
||||
lines.length > 0 && lines[lines.length - 1] === ''
|
||||
? lines.length - 1
|
||||
: lines.length;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
const { stdout: diffStatRaw } = await execFileAsync(
|
||||
'git',
|
||||
['diff', '--numstat', 'HEAD', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (diffStatRaw.trim()) {
|
||||
const parts = diffStatRaw.trim().split('\t');
|
||||
linesAdded = parseInt(parts[0], 10) || 0;
|
||||
linesRemoved = parseInt(parts[1], 10) || 0;
|
||||
}
|
||||
|
||||
// Also check staged diff stats
|
||||
const { stdout: stagedDiffStatRaw } = await execFileAsync(
|
||||
'git',
|
||||
['diff', '--numstat', '--cached', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (stagedDiffStatRaw.trim()) {
|
||||
const parts = stagedDiffStatRaw.trim().split('\t');
|
||||
linesAdded += parseInt(parts[0], 10) || 0;
|
||||
linesRemoved += parseInt(parts[1], 10) || 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Diff might not be available
|
||||
}
|
||||
|
||||
// Get conflict and staging status
|
||||
let isConflicted = false;
|
||||
let isStaged = false;
|
||||
let isUnstaged = false;
|
||||
let statusLabel = '';
|
||||
|
||||
try {
|
||||
const { stdout: statusOutput } = await execFileAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (statusOutput.trim()) {
|
||||
const indexStatus = statusOutput[0];
|
||||
const workTreeStatus = statusOutput[1];
|
||||
|
||||
// Check for conflicts (both modified, unmerged states)
|
||||
if (
|
||||
indexStatus === 'U' ||
|
||||
workTreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||
) {
|
||||
isConflicted = true;
|
||||
statusLabel = 'Conflicted';
|
||||
} else {
|
||||
// Staged changes (index has a status)
|
||||
if (indexStatus !== ' ' && indexStatus !== '?') {
|
||||
isStaged = true;
|
||||
}
|
||||
// Unstaged changes (work tree has a status)
|
||||
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
|
||||
isUnstaged = true;
|
||||
}
|
||||
|
||||
// Build status label
|
||||
if (isStaged && isUnstaged) {
|
||||
statusLabel = 'Staged + Modified';
|
||||
} else if (isStaged) {
|
||||
statusLabel = 'Staged';
|
||||
} else {
|
||||
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||
switch (statusChar) {
|
||||
case 'M':
|
||||
statusLabel = 'Modified';
|
||||
break;
|
||||
case 'A':
|
||||
statusLabel = 'Added';
|
||||
break;
|
||||
case 'D':
|
||||
statusLabel = 'Deleted';
|
||||
break;
|
||||
case 'R':
|
||||
statusLabel = 'Renamed';
|
||||
break;
|
||||
case 'C':
|
||||
statusLabel = 'Copied';
|
||||
break;
|
||||
case '?':
|
||||
statusLabel = 'Untracked';
|
||||
break;
|
||||
default:
|
||||
statusLabel = statusChar || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Status might not be available
|
||||
}
|
||||
|
||||
const details: GitFileDetails = {
|
||||
branch,
|
||||
lastCommitHash,
|
||||
lastCommitMessage,
|
||||
lastCommitAuthor,
|
||||
lastCommitTimestamp,
|
||||
linesAdded,
|
||||
linesRemoved,
|
||||
isConflicted,
|
||||
isStaged,
|
||||
isUnstaged,
|
||||
statusLabel,
|
||||
};
|
||||
|
||||
res.json({ success: true, details });
|
||||
} catch (innerError) {
|
||||
logError(innerError, 'Git details failed');
|
||||
res.json({
|
||||
success: true,
|
||||
details: {
|
||||
branch: '',
|
||||
lastCommitHash: '',
|
||||
lastCommitMessage: '',
|
||||
lastCommitAuthor: '',
|
||||
lastCommitTimestamp: '',
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
isConflicted: false,
|
||||
isStaged: false,
|
||||
isUnstaged: false,
|
||||
statusLabel: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Get git details failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* POST /enhanced-status endpoint - Get enhanced git status with diff stats per file
|
||||
* Returns per-file status with lines added/removed and staged/unstaged differentiation
|
||||
*/
|
||||
|
||||
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);
|
||||
|
||||
interface EnhancedFileStatus {
|
||||
path: string;
|
||||
indexStatus: string;
|
||||
workTreeStatus: string;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
function getStatusLabel(indexStatus: string, workTreeStatus: string): string {
|
||||
// Check for conflicts
|
||||
if (
|
||||
indexStatus === 'U' ||
|
||||
workTreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||
) {
|
||||
return 'Conflicted';
|
||||
}
|
||||
|
||||
const hasStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||
const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||
|
||||
if (hasStaged && hasUnstaged) return 'Staged + Modified';
|
||||
if (hasStaged) return 'Staged';
|
||||
|
||||
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||
switch (statusChar) {
|
||||
case 'M':
|
||||
return 'Modified';
|
||||
case 'A':
|
||||
return 'Added';
|
||||
case 'D':
|
||||
return 'Deleted';
|
||||
case 'R':
|
||||
return 'Renamed';
|
||||
case 'C':
|
||||
return 'Copied';
|
||||
case '?':
|
||||
return 'Untracked';
|
||||
default:
|
||||
return statusChar || '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createEnhancedStatusHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current branch
|
||||
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
const branch = branchRaw.trim();
|
||||
|
||||
// Get porcelain status for all files
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
// Get diff numstat for working tree changes
|
||||
let workTreeStats: Record<string, { added: number; removed: number }> = {};
|
||||
try {
|
||||
const { stdout: numstatRaw } = await execAsync('git diff --numstat', {
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
for (const line of numstatRaw.trim().split('\n').filter(Boolean)) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length >= 3) {
|
||||
const added = parseInt(parts[0], 10) || 0;
|
||||
const removed = parseInt(parts[1], 10) || 0;
|
||||
workTreeStats[parts[2]] = { added, removed };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Get diff numstat for staged changes
|
||||
let stagedStats: Record<string, { added: number; removed: number }> = {};
|
||||
try {
|
||||
const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', {
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length >= 3) {
|
||||
const added = parseInt(parts[0], 10) || 0;
|
||||
const removed = parseInt(parts[1], 10) || 0;
|
||||
stagedStats[parts[2]] = { added, removed };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Parse status and build enhanced file list
|
||||
const files: EnhancedFileStatus[] = [];
|
||||
|
||||
for (const line of statusOutput.split('\n').filter(Boolean)) {
|
||||
if (line.length < 4) continue;
|
||||
|
||||
const indexStatus = line[0];
|
||||
const workTreeStatus = line[1];
|
||||
const filePath = line.substring(3).trim();
|
||||
|
||||
// Handle renamed files (format: "R old -> new")
|
||||
const actualPath = filePath.includes(' -> ')
|
||||
? filePath.split(' -> ')[1].trim()
|
||||
: filePath;
|
||||
|
||||
const isConflicted =
|
||||
indexStatus === 'U' ||
|
||||
workTreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && workTreeStatus === 'D');
|
||||
|
||||
const isStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||
const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||
|
||||
// Combine diff stats from both working tree and staged
|
||||
const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 };
|
||||
const stStats = stagedStats[actualPath] || { added: 0, removed: 0 };
|
||||
|
||||
files.push({
|
||||
path: actualPath,
|
||||
indexStatus,
|
||||
workTreeStatus,
|
||||
isConflicted,
|
||||
isStaged,
|
||||
isUnstaged,
|
||||
linesAdded: wtStats.added + stStats.added,
|
||||
linesRemoved: wtStats.removed + stStats.removed,
|
||||
statusLabel: getStatusLabel(indexStatus, workTreeStatus),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
branch,
|
||||
files,
|
||||
});
|
||||
} catch (innerError) {
|
||||
logError(innerError, 'Git enhanced status failed');
|
||||
res.json({ success: true, branch: '', files: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Get enhanced status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user