@siteboon/claude-code-ui 1.25.0 → 1.25.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-BmWWsL1A.js → index-DF_FFT3b.js} +255 -239
- package/dist/assets/index-WNTmA_ug.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/cursor-cli.js +189 -115
- package/server/index.js +12 -2
- package/server/routes/commands.js +4 -4
- package/server/routes/git.js +325 -89
- package/server/utils/commandParser.js +2 -2
- package/server/utils/frontmatter.js +18 -0
- package/dist/assets/index-CO53aUoS.css +0 -32
package/server/routes/git.js
CHANGED
|
@@ -7,6 +7,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
|
|
7
7
|
import { spawnCursor } from '../cursor-cli.js';
|
|
8
8
|
|
|
9
9
|
const router = express.Router();
|
|
10
|
+
const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
|
|
10
11
|
|
|
11
12
|
function spawnAsync(command, args, options = {}) {
|
|
12
13
|
return new Promise((resolve, reject) => {
|
|
@@ -61,10 +62,19 @@ function validateBranchName(branch) {
|
|
|
61
62
|
return branch;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
function validateFilePath(file) {
|
|
65
|
+
function validateFilePath(file, projectPath) {
|
|
65
66
|
if (!file || file.includes('\0')) {
|
|
66
67
|
throw new Error('Invalid file path');
|
|
67
68
|
}
|
|
69
|
+
// Prevent path traversal: resolve the file relative to the project root
|
|
70
|
+
// and ensure the result stays within the project directory
|
|
71
|
+
if (projectPath) {
|
|
72
|
+
const resolved = path.resolve(projectPath, file);
|
|
73
|
+
const normalizedRoot = path.resolve(projectPath) + path.sep;
|
|
74
|
+
if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
|
|
75
|
+
throw new Error('Invalid file path: path traversal detected');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
68
78
|
return file;
|
|
69
79
|
}
|
|
70
80
|
|
|
@@ -75,15 +85,32 @@ function validateRemoteName(remote) {
|
|
|
75
85
|
return remote;
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
function validateProjectPath(projectPath) {
|
|
89
|
+
if (!projectPath || projectPath.includes('\0')) {
|
|
90
|
+
throw new Error('Invalid project path');
|
|
91
|
+
}
|
|
92
|
+
const resolved = path.resolve(projectPath);
|
|
93
|
+
// Must be an absolute path after resolution
|
|
94
|
+
if (!path.isAbsolute(resolved)) {
|
|
95
|
+
throw new Error('Invalid project path: must be absolute');
|
|
96
|
+
}
|
|
97
|
+
// Block obviously dangerous paths
|
|
98
|
+
if (resolved === '/' || resolved === path.sep) {
|
|
99
|
+
throw new Error('Invalid project path: root directory not allowed');
|
|
100
|
+
}
|
|
101
|
+
return resolved;
|
|
102
|
+
}
|
|
103
|
+
|
|
78
104
|
// Helper function to get the actual project path from the encoded project name
|
|
79
105
|
async function getActualProjectPath(projectName) {
|
|
106
|
+
let projectPath;
|
|
80
107
|
try {
|
|
81
|
-
|
|
108
|
+
projectPath = await extractProjectDirectory(projectName);
|
|
82
109
|
} catch (error) {
|
|
83
110
|
console.error(`Error extracting project directory for ${projectName}:`, error);
|
|
84
|
-
|
|
85
|
-
return projectName.replace(/-/g, '/');
|
|
111
|
+
throw new Error(`Unable to resolve project path for "${projectName}"`);
|
|
86
112
|
}
|
|
113
|
+
return validateProjectPath(projectPath);
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
// Helper function to strip git diff headers
|
|
@@ -139,6 +166,127 @@ async function validateGitRepository(projectPath) {
|
|
|
139
166
|
}
|
|
140
167
|
}
|
|
141
168
|
|
|
169
|
+
function getGitErrorDetails(error) {
|
|
170
|
+
return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isMissingHeadRevisionError(error) {
|
|
174
|
+
const errorDetails = getGitErrorDetails(error).toLowerCase();
|
|
175
|
+
return errorDetails.includes('unknown revision')
|
|
176
|
+
|| errorDetails.includes('ambiguous argument')
|
|
177
|
+
|| errorDetails.includes('needed a single revision')
|
|
178
|
+
|| errorDetails.includes('bad revision');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function getCurrentBranchName(projectPath) {
|
|
182
|
+
try {
|
|
183
|
+
// symbolic-ref works even when the repository has no commits.
|
|
184
|
+
const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
|
|
185
|
+
const branchName = stdout.trim();
|
|
186
|
+
if (branchName) {
|
|
187
|
+
return branchName;
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// Fall back to rev-parse for detached HEAD and older git edge cases.
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
|
194
|
+
return stdout.trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function repositoryHasCommits(projectPath) {
|
|
198
|
+
try {
|
|
199
|
+
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
|
200
|
+
return true;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (isMissingHeadRevisionError(error)) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function getRepositoryRootPath(projectPath) {
|
|
210
|
+
const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
|
|
211
|
+
return stdout.trim();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeRepositoryRelativeFilePath(filePath) {
|
|
215
|
+
return String(filePath)
|
|
216
|
+
.replace(/\\/g, '/')
|
|
217
|
+
.replace(/^\.\/+/, '')
|
|
218
|
+
.replace(/^\/+/, '')
|
|
219
|
+
.trim();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseStatusFilePaths(statusOutput) {
|
|
223
|
+
return statusOutput
|
|
224
|
+
.split('\n')
|
|
225
|
+
.map((line) => line.trimEnd())
|
|
226
|
+
.filter((line) => line.trim())
|
|
227
|
+
.map((line) => {
|
|
228
|
+
const statusPath = line.substring(3);
|
|
229
|
+
const renamedFilePath = statusPath.split(' -> ')[1];
|
|
230
|
+
return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
|
|
231
|
+
})
|
|
232
|
+
.filter(Boolean);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
|
|
236
|
+
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
|
237
|
+
const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
|
|
238
|
+
const candidates = [normalizedFilePath];
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
projectRelativePath
|
|
242
|
+
&& projectRelativePath !== '.'
|
|
243
|
+
&& !normalizedFilePath.startsWith(`${projectRelativePath}/`)
|
|
244
|
+
) {
|
|
245
|
+
candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return Array.from(new Set(candidates.filter(Boolean)));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function resolveRepositoryFilePath(projectPath, filePath) {
|
|
252
|
+
validateFilePath(filePath);
|
|
253
|
+
|
|
254
|
+
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
|
255
|
+
const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
|
|
256
|
+
|
|
257
|
+
for (const candidateFilePath of candidateFilePaths) {
|
|
258
|
+
const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
|
|
259
|
+
if (stdout.trim()) {
|
|
260
|
+
return {
|
|
261
|
+
repositoryRootPath,
|
|
262
|
+
repositoryRelativeFilePath: candidateFilePath,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
|
|
268
|
+
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
|
269
|
+
if (!normalizedFilePath.includes('/')) {
|
|
270
|
+
const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
|
|
271
|
+
const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
|
|
272
|
+
const suffixMatches = changedFilePaths.filter(
|
|
273
|
+
(changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
if (suffixMatches.length === 1) {
|
|
277
|
+
return {
|
|
278
|
+
repositoryRootPath,
|
|
279
|
+
repositoryRelativeFilePath: suffixMatches[0],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
repositoryRootPath,
|
|
286
|
+
repositoryRelativeFilePath: candidateFilePaths[0],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
142
290
|
// Get git status for a project
|
|
143
291
|
router.get('/status', async (req, res) => {
|
|
144
292
|
const { project } = req.query;
|
|
@@ -153,21 +301,8 @@ router.get('/status', async (req, res) => {
|
|
|
153
301
|
// Validate git repository
|
|
154
302
|
await validateGitRepository(projectPath);
|
|
155
303
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
let hasCommits = true;
|
|
159
|
-
try {
|
|
160
|
-
const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
|
161
|
-
branch = branchOutput.trim();
|
|
162
|
-
} catch (error) {
|
|
163
|
-
// No HEAD exists - repository has no commits yet
|
|
164
|
-
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
|
|
165
|
-
hasCommits = false;
|
|
166
|
-
branch = 'main';
|
|
167
|
-
} else {
|
|
168
|
-
throw error;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
304
|
+
const branch = await getCurrentBranchName(projectPath);
|
|
305
|
+
const hasCommits = await repositoryHasCommits(projectPath);
|
|
171
306
|
|
|
172
307
|
// Get git status
|
|
173
308
|
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
|
|
@@ -228,47 +363,65 @@ router.get('/diff', async (req, res) => {
|
|
|
228
363
|
|
|
229
364
|
// Validate git repository
|
|
230
365
|
await validateGitRepository(projectPath);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
366
|
+
|
|
367
|
+
const {
|
|
368
|
+
repositoryRootPath,
|
|
369
|
+
repositoryRelativeFilePath,
|
|
370
|
+
} = await resolveRepositoryFilePath(projectPath, file);
|
|
234
371
|
|
|
235
372
|
// Check if file is untracked or deleted
|
|
236
|
-
const { stdout: statusOutput } = await spawnAsync(
|
|
373
|
+
const { stdout: statusOutput } = await spawnAsync(
|
|
374
|
+
'git',
|
|
375
|
+
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
|
376
|
+
{ cwd: repositoryRootPath },
|
|
377
|
+
);
|
|
237
378
|
const isUntracked = statusOutput.startsWith('??');
|
|
238
379
|
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
|
239
380
|
|
|
240
381
|
let diff;
|
|
241
382
|
if (isUntracked) {
|
|
242
383
|
// For untracked files, show the entire file content as additions
|
|
243
|
-
const filePath = path.join(
|
|
384
|
+
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
244
385
|
const stats = await fs.stat(filePath);
|
|
245
386
|
|
|
246
387
|
if (stats.isDirectory()) {
|
|
247
388
|
// For directories, show a simple message
|
|
248
|
-
diff = `Directory: ${
|
|
389
|
+
diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
|
|
249
390
|
} else {
|
|
250
391
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
|
251
392
|
const lines = fileContent.split('\n');
|
|
252
|
-
diff = `--- /dev/null\n+++ b/${
|
|
393
|
+
diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
|
|
253
394
|
lines.map(line => `+${line}`).join('\n');
|
|
254
395
|
}
|
|
255
396
|
} else if (isDeleted) {
|
|
256
397
|
// For deleted files, show the entire file content from HEAD as deletions
|
|
257
|
-
const { stdout: fileContent } = await spawnAsync(
|
|
398
|
+
const { stdout: fileContent } = await spawnAsync(
|
|
399
|
+
'git',
|
|
400
|
+
['show', `HEAD:${repositoryRelativeFilePath}`],
|
|
401
|
+
{ cwd: repositoryRootPath },
|
|
402
|
+
);
|
|
258
403
|
const lines = fileContent.split('\n');
|
|
259
|
-
diff = `--- a/${
|
|
404
|
+
diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
|
260
405
|
lines.map(line => `-${line}`).join('\n');
|
|
261
406
|
} else {
|
|
262
407
|
// Get diff for tracked files
|
|
263
408
|
// First check for unstaged changes (working tree vs index)
|
|
264
|
-
const { stdout: unstagedDiff } = await spawnAsync(
|
|
409
|
+
const { stdout: unstagedDiff } = await spawnAsync(
|
|
410
|
+
'git',
|
|
411
|
+
['diff', '--', repositoryRelativeFilePath],
|
|
412
|
+
{ cwd: repositoryRootPath },
|
|
413
|
+
);
|
|
265
414
|
|
|
266
415
|
if (unstagedDiff) {
|
|
267
416
|
// Show unstaged changes if they exist
|
|
268
417
|
diff = stripDiffHeaders(unstagedDiff);
|
|
269
418
|
} else {
|
|
270
419
|
// If no unstaged changes, check for staged changes (index vs HEAD)
|
|
271
|
-
const { stdout: stagedDiff } = await spawnAsync(
|
|
420
|
+
const { stdout: stagedDiff } = await spawnAsync(
|
|
421
|
+
'git',
|
|
422
|
+
['diff', '--cached', '--', repositoryRelativeFilePath],
|
|
423
|
+
{ cwd: repositoryRootPath },
|
|
424
|
+
);
|
|
272
425
|
diff = stripDiffHeaders(stagedDiff) || '';
|
|
273
426
|
}
|
|
274
427
|
}
|
|
@@ -294,11 +447,17 @@ router.get('/file-with-diff', async (req, res) => {
|
|
|
294
447
|
// Validate git repository
|
|
295
448
|
await validateGitRepository(projectPath);
|
|
296
449
|
|
|
297
|
-
|
|
298
|
-
|
|
450
|
+
const {
|
|
451
|
+
repositoryRootPath,
|
|
452
|
+
repositoryRelativeFilePath,
|
|
453
|
+
} = await resolveRepositoryFilePath(projectPath, file);
|
|
299
454
|
|
|
300
455
|
// Check file status
|
|
301
|
-
const { stdout: statusOutput } = await spawnAsync(
|
|
456
|
+
const { stdout: statusOutput } = await spawnAsync(
|
|
457
|
+
'git',
|
|
458
|
+
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
|
459
|
+
{ cwd: repositoryRootPath },
|
|
460
|
+
);
|
|
302
461
|
const isUntracked = statusOutput.startsWith('??');
|
|
303
462
|
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
|
304
463
|
|
|
@@ -307,12 +466,16 @@ router.get('/file-with-diff', async (req, res) => {
|
|
|
307
466
|
|
|
308
467
|
if (isDeleted) {
|
|
309
468
|
// For deleted files, get content from HEAD
|
|
310
|
-
const { stdout: headContent } = await spawnAsync(
|
|
469
|
+
const { stdout: headContent } = await spawnAsync(
|
|
470
|
+
'git',
|
|
471
|
+
['show', `HEAD:${repositoryRelativeFilePath}`],
|
|
472
|
+
{ cwd: repositoryRootPath },
|
|
473
|
+
);
|
|
311
474
|
oldContent = headContent;
|
|
312
475
|
currentContent = headContent; // Show the deleted content in editor
|
|
313
476
|
} else {
|
|
314
477
|
// Get current file content
|
|
315
|
-
const filePath = path.join(
|
|
478
|
+
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
316
479
|
const stats = await fs.stat(filePath);
|
|
317
480
|
|
|
318
481
|
if (stats.isDirectory()) {
|
|
@@ -325,7 +488,11 @@ router.get('/file-with-diff', async (req, res) => {
|
|
|
325
488
|
if (!isUntracked) {
|
|
326
489
|
// Get the old content from HEAD for tracked files
|
|
327
490
|
try {
|
|
328
|
-
const { stdout: headContent } = await spawnAsync(
|
|
491
|
+
const { stdout: headContent } = await spawnAsync(
|
|
492
|
+
'git',
|
|
493
|
+
['show', `HEAD:${repositoryRelativeFilePath}`],
|
|
494
|
+
{ cwd: repositoryRootPath },
|
|
495
|
+
);
|
|
329
496
|
oldContent = headContent;
|
|
330
497
|
} catch (error) {
|
|
331
498
|
// File might be newly added to git (staged but not committed)
|
|
@@ -403,15 +570,16 @@ router.post('/commit', async (req, res) => {
|
|
|
403
570
|
|
|
404
571
|
// Validate git repository
|
|
405
572
|
await validateGitRepository(projectPath);
|
|
573
|
+
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
|
406
574
|
|
|
407
575
|
// Stage selected files
|
|
408
576
|
for (const file of files) {
|
|
409
|
-
|
|
410
|
-
await spawnAsync('git', ['add',
|
|
577
|
+
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
|
578
|
+
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
|
411
579
|
}
|
|
412
580
|
|
|
413
581
|
// Commit with message
|
|
414
|
-
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd:
|
|
582
|
+
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
|
|
415
583
|
|
|
416
584
|
res.json({ success: true, output: stdout });
|
|
417
585
|
} catch (error) {
|
|
@@ -420,6 +588,53 @@ router.post('/commit', async (req, res) => {
|
|
|
420
588
|
}
|
|
421
589
|
});
|
|
422
590
|
|
|
591
|
+
// Revert latest local commit (keeps changes staged)
|
|
592
|
+
router.post('/revert-local-commit', async (req, res) => {
|
|
593
|
+
const { project } = req.body;
|
|
594
|
+
|
|
595
|
+
if (!project) {
|
|
596
|
+
return res.status(400).json({ error: 'Project name is required' });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
const projectPath = await getActualProjectPath(project);
|
|
601
|
+
await validateGitRepository(projectPath);
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
|
605
|
+
} catch (error) {
|
|
606
|
+
return res.status(400).json({
|
|
607
|
+
error: 'No local commit to revert',
|
|
608
|
+
details: 'This repository has no commit yet.',
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
// Soft reset rewinds one commit while preserving all file changes in the index.
|
|
614
|
+
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
|
|
615
|
+
} catch (error) {
|
|
616
|
+
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
|
|
617
|
+
const isInitialCommit = errorDetails.includes('HEAD~1') &&
|
|
618
|
+
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
|
|
619
|
+
|
|
620
|
+
if (!isInitialCommit) {
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
|
|
625
|
+
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
res.json({
|
|
629
|
+
success: true,
|
|
630
|
+
output: 'Latest local commit reverted successfully. Changes were kept staged.',
|
|
631
|
+
});
|
|
632
|
+
} catch (error) {
|
|
633
|
+
console.error('Git revert local commit error:', error);
|
|
634
|
+
res.status(500).json({ error: error.message });
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
423
638
|
// Get list of branches
|
|
424
639
|
router.get('/branches', async (req, res) => {
|
|
425
640
|
const { project } = req.query;
|
|
@@ -582,8 +797,13 @@ router.get('/commit-diff', async (req, res) => {
|
|
|
582
797
|
'git', ['show', commit],
|
|
583
798
|
{ cwd: projectPath }
|
|
584
799
|
);
|
|
585
|
-
|
|
586
|
-
|
|
800
|
+
|
|
801
|
+
const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
|
|
802
|
+
const diff = isTruncated
|
|
803
|
+
? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
|
|
804
|
+
: stdout;
|
|
805
|
+
|
|
806
|
+
res.json({ diff, isTruncated });
|
|
587
807
|
} catch (error) {
|
|
588
808
|
console.error('Git commit diff error:', error);
|
|
589
809
|
res.json({ error: error.message });
|
|
@@ -605,18 +825,20 @@ router.post('/generate-commit-message', async (req, res) => {
|
|
|
605
825
|
|
|
606
826
|
try {
|
|
607
827
|
const projectPath = await getActualProjectPath(project);
|
|
828
|
+
await validateGitRepository(projectPath);
|
|
829
|
+
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
|
608
830
|
|
|
609
831
|
// Get diff for selected files
|
|
610
832
|
let diffContext = '';
|
|
611
833
|
for (const file of files) {
|
|
612
834
|
try {
|
|
613
|
-
|
|
835
|
+
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
|
614
836
|
const { stdout } = await spawnAsync(
|
|
615
|
-
'git', ['diff', 'HEAD', '--',
|
|
616
|
-
{ cwd:
|
|
837
|
+
'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
|
|
838
|
+
{ cwd: repositoryRootPath }
|
|
617
839
|
);
|
|
618
840
|
if (stdout) {
|
|
619
|
-
diffContext += `\n--- ${
|
|
841
|
+
diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
|
|
620
842
|
}
|
|
621
843
|
} catch (error) {
|
|
622
844
|
console.error(`Error getting diff for ${file}:`, error);
|
|
@@ -628,14 +850,15 @@ router.post('/generate-commit-message', async (req, res) => {
|
|
|
628
850
|
// Try to get content of untracked files
|
|
629
851
|
for (const file of files) {
|
|
630
852
|
try {
|
|
631
|
-
const
|
|
853
|
+
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
|
854
|
+
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
632
855
|
const stats = await fs.stat(filePath);
|
|
633
856
|
|
|
634
857
|
if (!stats.isDirectory()) {
|
|
635
858
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
636
|
-
diffContext += `\n--- ${
|
|
859
|
+
diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
|
|
637
860
|
} else {
|
|
638
|
-
diffContext += `\n--- ${
|
|
861
|
+
diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
|
|
639
862
|
}
|
|
640
863
|
} catch (error) {
|
|
641
864
|
console.error(`Error reading file ${file}:`, error);
|
|
@@ -804,9 +1027,30 @@ router.get('/remote-status', async (req, res) => {
|
|
|
804
1027
|
const projectPath = await getActualProjectPath(project);
|
|
805
1028
|
await validateGitRepository(projectPath);
|
|
806
1029
|
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
|
|
1030
|
+
const branch = await getCurrentBranchName(projectPath);
|
|
1031
|
+
const hasCommits = await repositoryHasCommits(projectPath);
|
|
1032
|
+
|
|
1033
|
+
const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
|
1034
|
+
const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
|
|
1035
|
+
const hasRemote = remotes.length > 0;
|
|
1036
|
+
const fallbackRemoteName = hasRemote
|
|
1037
|
+
? (remotes.includes('origin') ? 'origin' : remotes[0])
|
|
1038
|
+
: null;
|
|
1039
|
+
|
|
1040
|
+
// Repositories initialized with `git init` can have a branch but no commits.
|
|
1041
|
+
// Return a non-error state so the UI can show the initial-commit workflow.
|
|
1042
|
+
if (!hasCommits) {
|
|
1043
|
+
return res.json({
|
|
1044
|
+
hasRemote,
|
|
1045
|
+
hasUpstream: false,
|
|
1046
|
+
branch,
|
|
1047
|
+
remoteName: fallbackRemoteName,
|
|
1048
|
+
ahead: 0,
|
|
1049
|
+
behind: 0,
|
|
1050
|
+
isUpToDate: false,
|
|
1051
|
+
message: 'Repository has no commits yet'
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
810
1054
|
|
|
811
1055
|
// Check if there's a remote tracking branch (smart detection)
|
|
812
1056
|
let trackingBranch;
|
|
@@ -816,25 +1060,11 @@ router.get('/remote-status', async (req, res) => {
|
|
|
816
1060
|
trackingBranch = stdout.trim();
|
|
817
1061
|
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
|
818
1062
|
} catch (error) {
|
|
819
|
-
// No upstream branch configured - but check if we have remotes
|
|
820
|
-
let hasRemote = false;
|
|
821
|
-
let remoteName = null;
|
|
822
|
-
try {
|
|
823
|
-
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
|
824
|
-
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
|
825
|
-
if (remotes.length > 0) {
|
|
826
|
-
hasRemote = true;
|
|
827
|
-
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
828
|
-
}
|
|
829
|
-
} catch (remoteError) {
|
|
830
|
-
// No remotes configured
|
|
831
|
-
}
|
|
832
|
-
|
|
833
1063
|
return res.json({
|
|
834
1064
|
hasRemote,
|
|
835
1065
|
hasUpstream: false,
|
|
836
1066
|
branch,
|
|
837
|
-
remoteName,
|
|
1067
|
+
remoteName: fallbackRemoteName,
|
|
838
1068
|
message: 'No remote tracking branch configured'
|
|
839
1069
|
});
|
|
840
1070
|
}
|
|
@@ -876,8 +1106,7 @@ router.post('/fetch', async (req, res) => {
|
|
|
876
1106
|
await validateGitRepository(projectPath);
|
|
877
1107
|
|
|
878
1108
|
// Get current branch and its upstream remote
|
|
879
|
-
const
|
|
880
|
-
const branch = currentBranch.trim();
|
|
1109
|
+
const branch = await getCurrentBranchName(projectPath);
|
|
881
1110
|
|
|
882
1111
|
let remoteName = 'origin'; // fallback
|
|
883
1112
|
try {
|
|
@@ -918,8 +1147,7 @@ router.post('/pull', async (req, res) => {
|
|
|
918
1147
|
await validateGitRepository(projectPath);
|
|
919
1148
|
|
|
920
1149
|
// Get current branch and its upstream remote
|
|
921
|
-
const
|
|
922
|
-
const branch = currentBranch.trim();
|
|
1150
|
+
const branch = await getCurrentBranchName(projectPath);
|
|
923
1151
|
|
|
924
1152
|
let remoteName = 'origin'; // fallback
|
|
925
1153
|
let remoteBranch = branch; // fallback
|
|
@@ -987,8 +1215,7 @@ router.post('/push', async (req, res) => {
|
|
|
987
1215
|
await validateGitRepository(projectPath);
|
|
988
1216
|
|
|
989
1217
|
// Get current branch and its upstream remote
|
|
990
|
-
const
|
|
991
|
-
const branch = currentBranch.trim();
|
|
1218
|
+
const branch = await getCurrentBranchName(projectPath);
|
|
992
1219
|
|
|
993
1220
|
let remoteName = 'origin'; // fallback
|
|
994
1221
|
let remoteBranch = branch; // fallback
|
|
@@ -1062,8 +1289,7 @@ router.post('/publish', async (req, res) => {
|
|
|
1062
1289
|
validateBranchName(branch);
|
|
1063
1290
|
|
|
1064
1291
|
// Get current branch to verify it matches the requested branch
|
|
1065
|
-
const
|
|
1066
|
-
const currentBranchName = currentBranch.trim();
|
|
1292
|
+
const currentBranchName = await getCurrentBranchName(projectPath);
|
|
1067
1293
|
|
|
1068
1294
|
if (currentBranchName !== branch) {
|
|
1069
1295
|
return res.status(400).json({
|
|
@@ -1137,12 +1363,17 @@ router.post('/discard', async (req, res) => {
|
|
|
1137
1363
|
try {
|
|
1138
1364
|
const projectPath = await getActualProjectPath(project);
|
|
1139
1365
|
await validateGitRepository(projectPath);
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1366
|
+
const {
|
|
1367
|
+
repositoryRootPath,
|
|
1368
|
+
repositoryRelativeFilePath,
|
|
1369
|
+
} = await resolveRepositoryFilePath(projectPath, file);
|
|
1143
1370
|
|
|
1144
1371
|
// Check file status to determine correct discard command
|
|
1145
|
-
const { stdout: statusOutput } = await spawnAsync(
|
|
1372
|
+
const { stdout: statusOutput } = await spawnAsync(
|
|
1373
|
+
'git',
|
|
1374
|
+
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
|
1375
|
+
{ cwd: repositoryRootPath },
|
|
1376
|
+
);
|
|
1146
1377
|
|
|
1147
1378
|
if (!statusOutput.trim()) {
|
|
1148
1379
|
return res.status(400).json({ error: 'No changes to discard for this file' });
|
|
@@ -1152,7 +1383,7 @@ router.post('/discard', async (req, res) => {
|
|
|
1152
1383
|
|
|
1153
1384
|
if (status === '??') {
|
|
1154
1385
|
// Untracked file or directory - delete it
|
|
1155
|
-
const filePath = path.join(
|
|
1386
|
+
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
1156
1387
|
const stats = await fs.stat(filePath);
|
|
1157
1388
|
|
|
1158
1389
|
if (stats.isDirectory()) {
|
|
@@ -1162,13 +1393,13 @@ router.post('/discard', async (req, res) => {
|
|
|
1162
1393
|
}
|
|
1163
1394
|
} else if (status.includes('M') || status.includes('D')) {
|
|
1164
1395
|
// Modified or deleted file - restore from HEAD
|
|
1165
|
-
await spawnAsync('git', ['restore',
|
|
1396
|
+
await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
|
1166
1397
|
} else if (status.includes('A')) {
|
|
1167
1398
|
// Added file - unstage it
|
|
1168
|
-
await spawnAsync('git', ['reset', 'HEAD',
|
|
1399
|
+
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
|
1169
1400
|
}
|
|
1170
1401
|
|
|
1171
|
-
res.json({ success: true, message: `Changes discarded for ${
|
|
1402
|
+
res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
|
|
1172
1403
|
} catch (error) {
|
|
1173
1404
|
console.error('Git discard error:', error);
|
|
1174
1405
|
res.status(500).json({ error: error.message });
|
|
@@ -1186,12 +1417,17 @@ router.post('/delete-untracked', async (req, res) => {
|
|
|
1186
1417
|
try {
|
|
1187
1418
|
const projectPath = await getActualProjectPath(project);
|
|
1188
1419
|
await validateGitRepository(projectPath);
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1420
|
+
const {
|
|
1421
|
+
repositoryRootPath,
|
|
1422
|
+
repositoryRelativeFilePath,
|
|
1423
|
+
} = await resolveRepositoryFilePath(projectPath, file);
|
|
1192
1424
|
|
|
1193
1425
|
// Check if file is actually untracked
|
|
1194
|
-
const { stdout: statusOutput } = await spawnAsync(
|
|
1426
|
+
const { stdout: statusOutput } = await spawnAsync(
|
|
1427
|
+
'git',
|
|
1428
|
+
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
|
1429
|
+
{ cwd: repositoryRootPath },
|
|
1430
|
+
);
|
|
1195
1431
|
|
|
1196
1432
|
if (!statusOutput.trim()) {
|
|
1197
1433
|
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
|
@@ -1204,16 +1440,16 @@ router.post('/delete-untracked', async (req, res) => {
|
|
|
1204
1440
|
}
|
|
1205
1441
|
|
|
1206
1442
|
// Delete the untracked file or directory
|
|
1207
|
-
const filePath = path.join(
|
|
1443
|
+
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
|
1208
1444
|
const stats = await fs.stat(filePath);
|
|
1209
1445
|
|
|
1210
1446
|
if (stats.isDirectory()) {
|
|
1211
1447
|
// Use rm with recursive option for directories
|
|
1212
1448
|
await fs.rm(filePath, { recursive: true, force: true });
|
|
1213
|
-
res.json({ success: true, message: `Untracked directory ${
|
|
1449
|
+
res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
|
|
1214
1450
|
} else {
|
|
1215
1451
|
await fs.unlink(filePath);
|
|
1216
|
-
res.json({ success: true, message: `Untracked file ${
|
|
1452
|
+
res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
|
|
1217
1453
|
}
|
|
1218
1454
|
} catch (error) {
|
|
1219
1455
|
console.error('Git delete untracked error:', error);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import matter from 'gray-matter';
|
|
2
1
|
import { promises as fs } from 'fs';
|
|
3
2
|
import path from 'path';
|
|
4
3
|
import { execFile } from 'child_process';
|
|
5
4
|
import { promisify } from 'util';
|
|
6
5
|
import { parse as parseShellCommand } from 'shell-quote';
|
|
6
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
7
7
|
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
9
9
|
|
|
@@ -32,7 +32,7 @@ const BASH_COMMAND_ALLOWLIST = [
|
|
|
32
32
|
*/
|
|
33
33
|
export function parseCommand(content) {
|
|
34
34
|
try {
|
|
35
|
-
const parsed =
|
|
35
|
+
const parsed = parseFrontmatter(content);
|
|
36
36
|
return {
|
|
37
37
|
data: parsed.data || {},
|
|
38
38
|
content: parsed.content || '',
|