@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.
@@ -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
- return await extractProjectDirectory(projectName);
108
+ projectPath = await extractProjectDirectory(projectName);
82
109
  } catch (error) {
83
110
  console.error(`Error extracting project directory for ${projectName}:`, error);
84
- // Fallback to the old method
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
- // Get current branch - handle case where there are no commits yet
157
- let branch = 'main';
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
- // Validate file path
233
- validateFilePath(file);
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('git', ['status', '--porcelain', file], { cwd: projectPath });
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(projectPath, file);
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: ${file}\n(Cannot show diff for directories)`;
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/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
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('git', ['show', `HEAD:${file}`], { cwd: projectPath });
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/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
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('git', ['diff', '--', file], { cwd: projectPath });
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('git', ['diff', '--cached', '--', file], { cwd: projectPath });
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
- // Validate file path
298
- validateFilePath(file);
450
+ const {
451
+ repositoryRootPath,
452
+ repositoryRelativeFilePath,
453
+ } = await resolveRepositoryFilePath(projectPath, file);
299
454
 
300
455
  // Check file status
301
- const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
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('git', ['show', `HEAD:${file}`], { cwd: projectPath });
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(projectPath, file);
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('git', ['show', `HEAD:${file}`], { cwd: projectPath });
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
- validateFilePath(file);
410
- await spawnAsync('git', ['add', file], { cwd: projectPath });
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: projectPath });
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
- res.json({ diff: stdout });
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
- validateFilePath(file);
835
+ const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
614
836
  const { stdout } = await spawnAsync(
615
- 'git', ['diff', 'HEAD', '--', file],
616
- { cwd: projectPath }
837
+ 'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
838
+ { cwd: repositoryRootPath }
617
839
  );
618
840
  if (stdout) {
619
- diffContext += `\n--- ${file} ---\n${stdout}`;
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 filePath = path.join(projectPath, file);
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--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
859
+ diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
637
860
  } else {
638
- diffContext += `\n--- ${file} (new directory) ---\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
- // Get current branch
808
- const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
809
- const branch = currentBranch.trim();
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 { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
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 { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
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 { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
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 { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
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
- // Validate file path
1142
- validateFilePath(file);
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('git', ['status', '--porcelain', file], { cwd: projectPath });
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(projectPath, file);
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', file], { cwd: projectPath });
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', file], { cwd: projectPath });
1399
+ await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
1169
1400
  }
1170
1401
 
1171
- res.json({ success: true, message: `Changes discarded for ${file}` });
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
- // Validate file path
1191
- validateFilePath(file);
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('git', ['status', '--porcelain', file], { cwd: projectPath });
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(projectPath, file);
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 ${file} deleted successfully` });
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 ${file} deleted successfully` });
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 = matter(content);
35
+ const parsed = parseFrontmatter(content);
36
36
  return {
37
37
  data: parsed.data || {},
38
38
  content: parsed.content || '',