@nemus-cli/nemus 0.2.9 → 0.2.11

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.
@@ -1,7 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import * as fs from 'fs/promises';
3
- import { WORKSPACES_DIR } from '../utils/config';
4
- import * as path from 'path';
3
+ import { safeWorkspacePath } from '../utils/validation';
5
4
  import { listWorkspaces } from '../utils/workspace-meta';
6
5
  import { promptMultiWorkspaceSelection } from '../utils/prompts';
7
6
  import { logInfo, logSuccess, logError, logWarning } from '../utils/logger';
@@ -35,10 +34,35 @@ async function handleDelete(opts: {
35
34
  if (opts.workspace) {
36
35
  const selectedNames = parseList(opts.workspace);
37
36
  const workspaces = await listWorkspaces();
37
+ const known = new Map(workspaces.map(ws => [ws.name, ws]));
38
38
 
39
+ // Resolve to validated, existing targets. safeWorkspacePath() both
40
+ // enforces the name allowlist and pins the path inside WORKSPACES_DIR, so
41
+ // a name like "../../etc" can never reach fs.rm; unknown names are skipped
42
+ // rather than deleted at a guessed path.
43
+ const targets: { name: string; path: string; workspace: typeof workspaces[number] }[] = [];
39
44
  for (const name of selectedNames) {
40
- const workspace = workspaces.find(ws => ws.name === name);
41
- const workspacePath = path.join(WORKSPACES_DIR, name);
45
+ const workspace = known.get(name);
46
+ if (!workspace) {
47
+ logError(`Workspace "${name}" not found — skipping`);
48
+ continue;
49
+ }
50
+ let workspacePath: string;
51
+ try {
52
+ workspacePath = safeWorkspacePath(name);
53
+ } catch (error) {
54
+ logError(error instanceof Error ? error.message : `Invalid workspace name "${name}"`);
55
+ continue;
56
+ }
57
+ targets.push({ name, path: workspacePath, workspace });
58
+ }
59
+
60
+ if (targets.length === 0) {
61
+ logInfo('Nothing to delete');
62
+ return;
63
+ }
64
+
65
+ for (const { name, path: workspacePath, workspace } of targets) {
42
66
  if (workspace?.metadata) {
43
67
  console.log(`${colorize(name, 'cyan')}`);
44
68
  console.log(` Repositories: ${workspace.metadata.repositories.length}`);
@@ -56,9 +80,9 @@ async function handleDelete(opts: {
56
80
  {
57
81
  type: 'confirm',
58
82
  name: 'confirmed',
59
- message: selectedNames.length === 1
60
- ? `Delete workspace ${selectedNames[0]}?`
61
- : `Delete these ${selectedNames.length} workspaces?`,
83
+ message: targets.length === 1
84
+ ? `Delete workspace ${targets[0].name}?`
85
+ : `Delete these ${targets.length} workspaces?`,
62
86
  default: true,
63
87
  },
64
88
  ]);
@@ -68,8 +92,7 @@ async function handleDelete(opts: {
68
92
  }
69
93
  }
70
94
 
71
- for (const name of selectedNames) {
72
- const workspacePath = path.join(WORKSPACES_DIR, name);
95
+ for (const { name, path: workspacePath } of targets) {
73
96
  try {
74
97
  await fs.rm(workspacePath, { recursive: true, force: true });
75
98
  logSuccess(`Deleted "${colorize(name, 'cyan')}"`);
@@ -92,45 +115,56 @@ async function handleDelete(opts: {
92
115
 
93
116
  const selectedNames = await promptMultiWorkspaceSelection(workspaces);
94
117
 
118
+ // Resolve + validate paths once. Names come from disk, but safeWorkspacePath
119
+ // must not throw mid-flow and crash the interactive session, so skip any
120
+ // name that fails the allowlist rather than aborting.
121
+ const resolved: { name: string; path: string; workspace: typeof workspaces[number] | undefined }[] = [];
95
122
  for (const name of selectedNames) {
96
- const workspace = workspaces.find(ws => ws.name === name);
97
- const workspacePath = path.join(WORKSPACES_DIR, name);
98
- if (workspace?.metadata) {
99
- console.log(`${colorize(name, 'cyan')}`);
100
- console.log(` Repositories: ${workspace.metadata.repositories.length}`);
101
- console.log(` Created: ${new Date(workspace.metadata.createdAt).toLocaleString()}`);
102
- console.log(` Path: ${workspacePath}`);
103
- } else {
104
- console.log(`${colorize(name, 'cyan')}`);
105
- console.log(` Path: ${workspacePath}`);
123
+ try {
124
+ resolved.push({ name, path: safeWorkspacePath(name), workspace: workspaces.find(ws => ws.name === name) });
125
+ } catch (error) {
126
+ logError(error instanceof Error ? error.message : `Invalid workspace name "${name}"`);
106
127
  }
107
128
  }
108
- console.log('');
109
129
 
110
- logWarning('This will permanently delete all cloned repositories in the selected workspaces!');
130
+ if (resolved.length > 0) {
131
+ for (const { name, path: workspacePath, workspace } of resolved) {
132
+ if (workspace?.metadata) {
133
+ console.log(`${colorize(name, 'cyan')}`);
134
+ console.log(` Repositories: ${workspace.metadata.repositories.length}`);
135
+ console.log(` Created: ${new Date(workspace.metadata.createdAt).toLocaleString()}`);
136
+ console.log(` Path: ${workspacePath}`);
137
+ } else {
138
+ console.log(`${colorize(name, 'cyan')}`);
139
+ console.log(` Path: ${workspacePath}`);
140
+ }
141
+ }
142
+ console.log('');
111
143
 
112
- const confirmMessage = selectedNames.length === 1
113
- ? `Delete workspace ${selectedNames[0]}?`
114
- : `Delete these ${selectedNames.length} workspaces?`;
144
+ logWarning('This will permanently delete all cloned repositories in the selected workspaces!');
115
145
 
116
- const { confirmed } = await inquirer.prompt([
117
- {
118
- type: 'confirm',
119
- name: 'confirmed',
120
- message: confirmMessage,
121
- default: true,
122
- },
123
- ]);
146
+ const confirmMessage = resolved.length === 1
147
+ ? `Delete workspace ${resolved[0].name}?`
148
+ : `Delete these ${resolved.length} workspaces?`;
149
+
150
+ const { confirmed } = await inquirer.prompt([
151
+ {
152
+ type: 'confirm',
153
+ name: 'confirmed',
154
+ message: confirmMessage,
155
+ default: true,
156
+ },
157
+ ]);
124
158
 
125
- if (confirmed) {
126
- for (const name of selectedNames) {
127
- const workspacePath = path.join(WORKSPACES_DIR, name);
128
- try {
129
- await fs.rm(workspacePath, { recursive: true, force: true });
130
- logSuccess(`Deleted "${colorize(name, 'cyan')}"`);
131
- } catch (error) {
132
- logError(`Failed to delete "${name}"`);
133
- if (error instanceof Error) logError(error.message);
159
+ if (confirmed) {
160
+ for (const { name, path: workspacePath } of resolved) {
161
+ try {
162
+ await fs.rm(workspacePath, { recursive: true, force: true });
163
+ logSuccess(`Deleted "${colorize(name, 'cyan')}"`);
164
+ } catch (error) {
165
+ logError(`Failed to delete "${name}"`);
166
+ if (error instanceof Error) logError(error.message);
167
+ }
134
168
  }
135
169
  }
136
170
  }
@@ -4,6 +4,7 @@ import { WORKSPACES_DIR } from '../utils/config';
4
4
  import { loadMetadata } from '../utils/workspace-meta';
5
5
  import { runAllHealthChecks, calculateHealthScore } from '../utils/health-checks';
6
6
  import { logError, logInfo, logStep, logSuccess, logWarning } from '../utils/logger';
7
+ import { outputJson, outputJsonError } from '../utils/output';
7
8
  import { colorize } from '../utils/colors';
8
9
  import { HealthCheckResult } from '../types';
9
10
  import { resolveWorkspace } from '../utils/command-helpers';
@@ -59,22 +60,44 @@ export function registerDoctorCommand(parent: Command) {
59
60
  .alias('doc')
60
61
  .description('Run comprehensive health checks')
61
62
  .argument('[workspace]', 'Workspace name')
62
- .action(async (workspace) => {
63
- await handleDoctor(workspace);
63
+ .option('--json', 'Output as JSON')
64
+ .action(async (workspace, opts) => {
65
+ await handleDoctor(workspace, opts);
64
66
  });
65
67
  }
66
68
 
67
- async function handleDoctor(workspaceArg?: string) {
69
+ async function handleDoctor(workspaceArg?: string, opts: { json?: boolean } = {}) {
70
+ // In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
71
+ // human log on stderr. `process.exit(1)` stays the last statement so TS still
72
+ // narrows `metadata` to non-null below.
68
73
  try {
74
+ // JSON mode is non-interactive: require an explicit workspace rather than prompt.
75
+ if (opts.json && !workspaceArg) {
76
+ outputJsonError('doctor --json requires a workspace name');
77
+ process.exit(1);
78
+ }
69
79
  const selectedWorkspace = await resolveWorkspace(workspaceArg);
70
80
  const workspacePath = path.join(WORKSPACES_DIR, selectedWorkspace);
71
81
  const metadata = await loadMetadata(workspacePath);
72
82
 
73
83
  if (!metadata) {
74
- logError(`Workspace metadata not found for: ${selectedWorkspace}`);
84
+ if (opts.json) outputJsonError(`Workspace metadata not found for: ${selectedWorkspace}`);
85
+ else logError(`Workspace metadata not found for: ${selectedWorkspace}`);
75
86
  process.exit(1);
76
87
  }
77
88
 
89
+ if (opts.json) {
90
+ const results = await runAllHealthChecks(workspacePath, metadata);
91
+ const score = calculateHealthScore(results);
92
+ const overall = results.some(r => r.status === 'error')
93
+ ? 'error'
94
+ : results.some(r => r.status === 'warning')
95
+ ? 'warning'
96
+ : 'healthy';
97
+ outputJson({ workspace: selectedWorkspace, score, status: overall, checks: results });
98
+ return;
99
+ }
100
+
78
101
  logStep(`Running health checks for workspace: ${colorize(selectedWorkspace, 'cyan')}`);
79
102
  logInfo('This may take a moment...');
80
103
 
@@ -101,9 +124,11 @@ async function handleDoctor(workspaceArg?: string) {
101
124
  logSuccess('All health checks passed! Your workspace is in good shape.');
102
125
  }
103
126
  } catch (error) {
104
- logError('Failed to run health checks');
105
- if (error instanceof Error) {
106
- logError(error.message);
127
+ if (opts.json) {
128
+ outputJsonError(error instanceof Error ? error.message : 'Failed to run health checks');
129
+ } else {
130
+ logError('Failed to run health checks');
131
+ if (error instanceof Error) logError(error.message);
107
132
  }
108
133
  process.exit(1);
109
134
  }
@@ -196,6 +196,36 @@ describe('list-workspaces main', () => {
196
196
  expect(messages).toContainEqual(expect.stringContaining('ws-a'));
197
197
  });
198
198
 
199
+ it('--json: writes one valid JSON document to stdout and does not prompt', async () => {
200
+ const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
201
+ process.argv = ['node', 'list-workspaces.js', '--json'];
202
+ mockListWorkspaces.mockResolvedValueOnce(makeWorkspaceList('ws-a', 'ws-b'));
203
+
204
+ await main();
205
+
206
+ expect(mockPrompt).not.toHaveBeenCalled();
207
+ expect(writeSpy).toHaveBeenCalledTimes(1);
208
+ const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
209
+ expect(payload.count).toBe(2);
210
+ expect(payload.workspaces.map((w: any) => w.name).sort()).toEqual(['ws-a', 'ws-b']);
211
+ expect(payload.workspaces[0]).toMatchObject({ repoCount: 1, hasSession: false });
212
+ writeSpy.mockRestore();
213
+ });
214
+
215
+ it('--json: empty list emits count 0, no prompt, no log noise', async () => {
216
+ const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
217
+ process.argv = ['node', 'list-workspaces.js', '--json'];
218
+ mockListWorkspaces.mockResolvedValueOnce([]);
219
+
220
+ await main();
221
+
222
+ expect(logInfo).not.toHaveBeenCalled();
223
+ expect(mockPrompt).not.toHaveBeenCalled();
224
+ const payload = JSON.parse(writeSpy.mock.calls[0][0] as string);
225
+ expect(payload).toEqual({ archived: false, count: 0, workspaces: [] });
226
+ writeSpy.mockRestore();
227
+ });
228
+
199
229
  it('sorts workspaces with sessions before those without', async () => {
200
230
  mockListWorkspaces.mockResolvedValueOnce(makeWorkspaceList('no-session', 'has-session'));
201
231
  mockGetWorkspaceSessions.mockResolvedValueOnce([{
@@ -5,6 +5,7 @@ import * as fsPromises from 'fs/promises';
5
5
  import { listWorkspaces } from '../utils/workspace-meta';
6
6
  import { getWorkspaceSessions } from '../utils/claude-sessions';
7
7
  import { logInfo, logError } from '../utils/logger';
8
+ import { outputJson } from '../utils/output';
8
9
  import { colorize } from '../utils/colors';
9
10
  import inquirer from 'inquirer';
10
11
  import autocompletePrompt from 'inquirer-autocomplete-prompt';
@@ -31,12 +32,13 @@ export function registerListCommand(parent: Command) {
31
32
  .alias('l')
32
33
  .description('List workspaces and navigate to one')
33
34
  .option('-a, --archived', 'Show archived workspaces')
35
+ .option('--json', 'Output as JSON (no interactive selection)')
34
36
  .action(async (opts) => {
35
37
  await handleList(opts);
36
38
  });
37
39
  }
38
40
 
39
- async function handleList(opts: { archived?: boolean }) {
41
+ async function handleList(opts: { archived?: boolean; json?: boolean }) {
40
42
  const showArchived = opts.archived ?? false;
41
43
  const title = showArchived ? 'Archived Workspaces' : 'Existing Workspaces';
42
44
 
@@ -49,6 +51,10 @@ async function handleList(opts: { archived?: boolean }) {
49
51
  ]);
50
52
 
51
53
  if (workspaces.length === 0) {
54
+ if (opts.json) {
55
+ outputJson({ archived: showArchived, count: 0, workspaces: [] });
56
+ return;
57
+ }
52
58
  console.log('\n' + '='.repeat(60));
53
59
  console.log(colorize(title, 'bright'));
54
60
  console.log('='.repeat(60) + '\n');
@@ -85,6 +91,23 @@ async function handleList(opts: { archived?: boolean }) {
85
91
  return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
86
92
  });
87
93
 
94
+ // JSON mode: one document to stdout, no table, no interactive selection.
95
+ if (opts.json) {
96
+ outputJson({
97
+ archived: showArchived,
98
+ count: items.length,
99
+ workspaces: items.map(i => ({
100
+ name: i.name,
101
+ path: i.wsPath,
102
+ repoCount: i.repoCount,
103
+ createdAt: i.createdAt || null,
104
+ lastActive: i.lastActiveLabel,
105
+ hasSession: i.hasSession,
106
+ })),
107
+ });
108
+ return;
109
+ }
110
+
88
111
  console.log('');
89
112
  console.log(colorize(' ' + title, 'bright') + colorize(' (sorted by last active)', 'dim'));
90
113
  console.log(colorize(' ' + '─'.repeat(54), 'dim'));
@@ -172,5 +195,6 @@ async function handleList(opts: { archived?: boolean }) {
172
195
  export async function main() {
173
196
  const args = process.argv.slice(2);
174
197
  const archived = args.includes('--archived') || args.includes('-a');
175
- await handleList({ archived });
198
+ const json = args.includes('--json');
199
+ await handleList({ archived, json });
176
200
  }
@@ -4,6 +4,7 @@ import { WORKSPACES_DIR } from '../utils/config';
4
4
  import { loadMetadata } from '../utils/workspace-meta';
5
5
  import { getAllReposStatus } from '../utils/git-status';
6
6
  import { logError, logInfo, logStep } from '../utils/logger';
7
+ import { outputJson, outputJsonError } from '../utils/output';
7
8
  import { colorize } from '../utils/colors';
8
9
  import { resolveWorkspace } from '../utils/command-helpers';
9
10
 
@@ -78,33 +79,59 @@ export function registerStatusCommand(parent: Command) {
78
79
  .alias('st')
79
80
  .description('Show git status across all repos')
80
81
  .argument('[workspace]', 'Workspace name')
81
- .action(async (workspace) => {
82
- await handleStatus(workspace);
82
+ .option('--json', 'Output as JSON')
83
+ .action(async (workspace, opts) => {
84
+ await handleStatus(workspace, opts);
83
85
  });
84
86
  }
85
87
 
86
- async function handleStatus(workspaceArg?: string) {
88
+ async function handleStatus(workspaceArg?: string, opts: { json?: boolean } = {}) {
89
+ // In --json mode, failures are parseable JSON on stdout + exit 1; otherwise a
90
+ // human log on stderr. `process.exit(1)` stays the last statement so TS still
91
+ // narrows `metadata` to non-null below.
87
92
  try {
93
+ // JSON mode is non-interactive: require an explicit workspace rather than prompt.
94
+ if (opts.json && !workspaceArg) {
95
+ outputJsonError('status --json requires a workspace name');
96
+ process.exit(1);
97
+ }
88
98
  const selectedWorkspace = await resolveWorkspace(workspaceArg);
89
99
  const workspacePath = path.join(WORKSPACES_DIR, selectedWorkspace);
90
100
  const metadata = await loadMetadata(workspacePath);
91
101
 
92
102
  if (!metadata) {
93
- logError(`Workspace metadata not found for: ${selectedWorkspace}`);
103
+ if (opts.json) outputJsonError(`Workspace metadata not found for: ${selectedWorkspace}`);
104
+ else logError(`Workspace metadata not found for: ${selectedWorkspace}`);
94
105
  process.exit(1);
95
106
  }
96
107
 
108
+ const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
109
+
110
+ if (opts.json) {
111
+ const statuses = await getAllReposStatus(workspacePath, repoDirectoryNames, 3);
112
+ outputJson({
113
+ workspace: selectedWorkspace,
114
+ path: workspacePath,
115
+ repoCount: statuses.length,
116
+ clean: statuses.every(s => s.clean),
117
+ repositories: statuses,
118
+ });
119
+ return;
120
+ }
121
+
97
122
  logStep(`Checking status for workspace: ${colorize(selectedWorkspace, 'cyan')}`);
98
123
  logInfo(`Found ${metadata.repositories.length} repositories`);
99
124
 
100
- const repoDirectoryNames = metadata.repositories.map(r => r.directoryName);
101
125
  const statuses = await getAllReposStatus(workspacePath, repoDirectoryNames, 3);
102
126
 
103
127
  displayStatusTable(statuses);
104
128
  } catch (error) {
105
- logError('Failed to check workspace status');
106
- if (error instanceof Error) {
107
- logError(error.message);
129
+ const msg = error instanceof Error ? error.message : 'Failed to check workspace status';
130
+ if (opts.json) {
131
+ outputJsonError(msg);
132
+ } else {
133
+ logError('Failed to check workspace status');
134
+ if (error instanceof Error) logError(error.message);
108
135
  }
109
136
  process.exit(1);
110
137
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ts-node
2
2
 
3
- import { execSync } from 'child_process';
3
+ import { execSync, execFileSync } from 'child_process';
4
4
  import * as path from 'path';
5
5
  import * as fs from 'fs';
6
6
  import { logSuccess, logError, logInfo, logWarning } from '../utils/logger';
@@ -37,7 +37,7 @@ function installShellIntegration(): void {
37
37
  }
38
38
 
39
39
  try {
40
- execSync(`bash "${scriptPath}" ${shellType}`, { stdio: 'inherit' });
40
+ execFileSync('bash', [scriptPath, shellType], { stdio: 'inherit' });
41
41
  logSuccess('Shell integration installed (auto-CD for w/workspace commands)');
42
42
  } catch {
43
43
  logInfo('Shell integration install skipped (non-critical)');
@@ -264,8 +264,9 @@ async function install() {
264
264
  logInfo(`MCP server path: ${colorize(serverPath, 'cyan')}`);
265
265
 
266
266
  try {
267
- execSync(
268
- `claude mcp add nemus -s user -- node "${serverPath}"`,
267
+ execFileSync(
268
+ 'claude',
269
+ ['mcp', 'add', 'nemus', '-s', 'user', '--', 'node', serverPath],
269
270
  { stdio: 'pipe' }
270
271
  );
271
272
  logSuccess('MCP server registered globally with Claude Code');
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Record execFile invocations; every git call must pass args as an argv ARRAY
4
+ // (no shell), so a branch name with shell metacharacters is inert.
5
+ const mockExecFile = vi.fn();
6
+ vi.mock('child_process', () => ({
7
+ execFile: (...args: unknown[]) => {
8
+ const cb = args[args.length - 1] as (e: Error | null, r: { stdout: string; stderr: string }) => void;
9
+ mockExecFile(args[0], args[1], args[2]);
10
+ cb(null, { stdout: '', stderr: '' });
11
+ },
12
+ }));
13
+ vi.mock('./git-status', () => ({ hasUncommittedChanges: vi.fn().mockResolvedValue(false) }));
14
+
15
+ import { createBranch } from './branch-operations';
16
+
17
+ describe('branch-operations argv safety (no shell injection)', () => {
18
+ beforeEach(() => vi.clearAllMocks());
19
+
20
+ it('passes a malicious branch name as a single argv element, never a shell string', async () => {
21
+ const evil = 'foo$(touch /tmp/pwned)';
22
+ const res = await createBranch('/repo', 'api', evil);
23
+ expect(res.success).toBe(true);
24
+
25
+ // Every call is execFile('git', [...args]) — args is an array, and the evil
26
+ // name appears verbatim as one element (so the shell never sees it).
27
+ for (const [bin, args] of mockExecFile.mock.calls) {
28
+ expect(bin).toBe('git');
29
+ expect(Array.isArray(args)).toBe(true);
30
+ }
31
+ const checkout = mockExecFile.mock.calls.find(([, a]) => (a as string[])[0] === 'checkout');
32
+ expect(checkout![1]).toEqual(['checkout', '-b', evil]);
33
+ });
34
+
35
+ it('checks out a base branch as its own argv element too', async () => {
36
+ await createBranch('/repo', 'api', 'feature', 'release/1.0');
37
+ const calls = mockExecFile.mock.calls.map(([, a]) => a as string[]);
38
+ expect(calls).toContainEqual(['checkout', 'release/1.0']);
39
+ expect(calls).toContainEqual(['checkout', '-b', 'feature']);
40
+ });
41
+ });
@@ -1,10 +1,13 @@
1
- import { exec } from 'child_process';
1
+ import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import * as fs from 'fs/promises';
4
- import * as path from 'path';
5
4
  import { hasUncommittedChanges } from './git-status';
6
5
 
7
- const execAsync = promisify(exec);
6
+ // execFile (no shell): git args are passed as an argv array, so a branch name
7
+ // containing shell metacharacters can never be interpreted as a command.
8
+ const execFileAsync = promisify(execFile);
9
+ const git = (args: string[], opts: { cwd: string; timeout?: number }) =>
10
+ execFileAsync('git', args, opts);
8
11
  const GIT_TIMEOUT = 30000;
9
12
 
10
13
  export interface BranchSwitchResult {
@@ -34,7 +37,7 @@ export const switchBranch = async (
34
37
 
35
38
  // Check if it's a git repository
36
39
  try {
37
- await execAsync('git rev-parse --git-dir', { cwd: repoPath });
40
+ await git(['rev-parse', '--git-dir'], { cwd: repoPath });
38
41
  } catch {
39
42
  return {
40
43
  repo: repoName,
@@ -44,7 +47,7 @@ export const switchBranch = async (
44
47
  }
45
48
 
46
49
  // Get current branch
47
- const { stdout: currentBranchOutput } = await execAsync('git branch --show-current', { cwd: repoPath });
50
+ const { stdout: currentBranchOutput } = await git(['branch', '--show-current'], { cwd: repoPath });
48
51
  const currentBranch = currentBranchOutput.trim();
49
52
 
50
53
  if (currentBranch === targetBranch) {
@@ -57,7 +60,7 @@ export const switchBranch = async (
57
60
  }
58
61
 
59
62
  // Check for uncommitted changes
60
- const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: repoPath });
63
+ const { stdout: statusOutput } = await git(['status', '--porcelain'], { cwd: repoPath });
61
64
  if (statusOutput.trim().length > 0) {
62
65
  return {
63
66
  repo: repoName,
@@ -68,17 +71,17 @@ export const switchBranch = async (
68
71
  }
69
72
 
70
73
  // Fetch to ensure we have latest branches
71
- await execAsync('git fetch', { cwd: repoPath, timeout: GIT_TIMEOUT });
74
+ await git(['fetch'], { cwd: repoPath, timeout: GIT_TIMEOUT });
72
75
 
73
76
  // Check if branch exists locally
74
77
  try {
75
- await execAsync(`git rev-parse --verify ${targetBranch}`, { cwd: repoPath });
78
+ await git(['rev-parse', '--verify', targetBranch], { cwd: repoPath });
76
79
  } catch {
77
80
  // Branch doesn't exist locally, check remote
78
81
  try {
79
- await execAsync(`git rev-parse --verify origin/${targetBranch}`, { cwd: repoPath });
82
+ await git(['rev-parse', '--verify', `origin/${targetBranch}`], { cwd: repoPath });
80
83
  // Branch exists on remote, create local tracking branch
81
- await execAsync(`git checkout -b ${targetBranch} origin/${targetBranch}`, { cwd: repoPath });
84
+ await git(['checkout', '-b', targetBranch, `origin/${targetBranch}`], { cwd: repoPath });
82
85
  return {
83
86
  repo: repoName,
84
87
  status: 'success',
@@ -96,7 +99,7 @@ export const switchBranch = async (
96
99
  }
97
100
 
98
101
  // Switch to existing local branch
99
- await execAsync(`git checkout ${targetBranch}`, { cwd: repoPath });
102
+ await git(['checkout', targetBranch], { cwd: repoPath });
100
103
 
101
104
  return {
102
105
  repo: repoName,
@@ -143,17 +146,11 @@ export const createBranch = async (
143
146
 
144
147
  // Checkout base branch if specified
145
148
  if (baseBranch) {
146
- await execAsync(`git checkout ${baseBranch}`, {
147
- cwd: repoPath,
148
- timeout: GIT_TIMEOUT,
149
- });
149
+ await git(['checkout', baseBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
150
150
  }
151
151
 
152
152
  // Create and checkout new branch
153
- await execAsync(`git checkout -b ${branchName}`, {
154
- cwd: repoPath,
155
- timeout: GIT_TIMEOUT,
156
- });
153
+ await git(['checkout', '-b', branchName], { cwd: repoPath, timeout: GIT_TIMEOUT });
157
154
 
158
155
  return {
159
156
  repo: repoName,
@@ -179,21 +176,15 @@ export const mergeBranch = async (
179
176
  ): Promise<BranchOperationResult> => {
180
177
  try {
181
178
  // Checkout target branch
182
- await execAsync(`git checkout ${targetBranch}`, {
183
- cwd: repoPath,
184
- timeout: GIT_TIMEOUT,
185
- });
186
-
187
- // Build merge command
188
- let mergeCmd = `git merge ${sourceBranch}`;
189
- if (options?.noFf) mergeCmd += ' --no-ff';
190
- if (options?.ffOnly) mergeCmd += ' --ff-only';
191
- if (options?.squash) mergeCmd += ' --squash';
192
-
193
- await execAsync(mergeCmd, {
194
- cwd: repoPath,
195
- timeout: GIT_TIMEOUT,
196
- });
179
+ await git(['checkout', targetBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
180
+
181
+ // Build merge argv
182
+ const mergeArgs = ['merge', sourceBranch];
183
+ if (options?.noFf) mergeArgs.push('--no-ff');
184
+ if (options?.ffOnly) mergeArgs.push('--ff-only');
185
+ if (options?.squash) mergeArgs.push('--squash');
186
+
187
+ await git(mergeArgs, { cwd: repoPath, timeout: GIT_TIMEOUT });
197
188
 
198
189
  return {
199
190
  repo: repoName,
@@ -216,10 +207,7 @@ export const rebaseBranch = async (
216
207
  targetBranch: string
217
208
  ): Promise<BranchOperationResult> => {
218
209
  try {
219
- await execAsync(`git rebase ${targetBranch}`, {
220
- cwd: repoPath,
221
- timeout: GIT_TIMEOUT,
222
- });
210
+ await git(['rebase', targetBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
223
211
 
224
212
  return {
225
213
  repo: repoName,
@@ -1,9 +1,9 @@
1
- import { exec } from 'child_process';
1
+ import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import * as fs from 'fs/promises';
4
4
  import * as path from 'path';
5
5
 
6
- const execAsync = promisify(exec);
6
+ const execFileAsync = promisify(execFile);
7
7
 
8
8
  export interface CleanupResult {
9
9
  operation: string;
@@ -14,7 +14,7 @@ export interface CleanupResult {
14
14
 
15
15
  export const calculateDirSize = async (dirPath: string): Promise<number> => {
16
16
  try {
17
- const { stdout } = await execAsync(`du -sk "${dirPath}"`);
17
+ const { stdout } = await execFileAsync('du', ['-sk', dirPath]);
18
18
  const sizeInKB = parseInt(stdout.split('\t')[0], 10);
19
19
  return sizeInKB;
20
20
  } catch {
@@ -72,10 +72,10 @@ export const removeBuildArtifacts = async (repoPath: string): Promise<CleanupRes
72
72
 
73
73
  export const gitClean = async (repoPath: string, dryRun: boolean = false): Promise<CleanupResult> => {
74
74
  try {
75
- const cmd = dryRun ? 'git clean -fdxn' : 'git clean -fdx';
76
- const { stdout } = await execAsync(cmd, { cwd: repoPath });
75
+ const args = dryRun ? ['clean', '-fdxn'] : ['clean', '-fdx'];
76
+ const { stdout } = await execFileAsync('git', args, { cwd: repoPath });
77
77
 
78
- const lines = stdout.split('\n').filter(l => l.trim());
78
+ const lines = stdout.split('\n').filter((l: string) => l.trim());
79
79
 
80
80
  return {
81
81
  operation: 'Git clean',