@ts47andres/exeggutor 1.1.3 → 1.1.5

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,320 +1,320 @@
1
- import { exec, spawn } from 'child_process';
2
- import * as path from 'path';
3
- import * as fs from 'fs';
4
- import * as os from 'os';
5
-
6
- // Validates git branch names to prevent command injection and ensure compliance with git rules.
7
- export function validateBranchName(branch: string): void {
8
- const cleanPattern = /^[a-zA-Z0-9-_./@]+$/; // Pattern matching safe git branch characters.
9
- if (!branch || !cleanPattern.test(branch)) {
10
- throw new Error('Invalid branch name. Only alphanumeric, dashes, underscores, dots, slashes, and @ are permitted.');
11
- }
12
- }
13
-
14
- // Helper function that executes a terminal command asynchronously and returns its stdout.
15
- export function execAsync(command: string, cwd: string): Promise<string> {
16
- console.log(`[GIT] execAsync(command="${command}", cwd="${cwd}")`);
17
- const p = new Promise<string>((resolve, reject) => {
18
- const child = exec(command, { cwd, windowsHide: true }, (error, stdout, stderr) => {
19
- if (error) {
20
- const errMsg = stderr || error.message; // Resolves the exact error details from stderr or standard node error.
21
- reject(new Error(errMsg));
22
- } else {
23
- const cleanedOutput = stdout.trim(); // The stripped stdout returned from the execution.
24
- resolve(cleanedOutput);
25
- }
26
- }); // The child process handle spawned for this command run.
27
- }); // The promise mapping the command execution flow.
28
- return p;
29
- }
30
-
31
- // Verifies if the target directory is a valid git repository.
32
- export async function isGitRepository(folderPath: string): Promise<boolean> {
33
- try {
34
- const resolvedPath = path.resolve(folderPath); // Resolved absolute target path string.
35
- await execAsync('git rev-parse --is-inside-work-tree', resolvedPath);
36
- const successResult = true; // Flag denoting a valid git workspace.
37
- return successResult;
38
- } catch (err) {
39
- const failResult = false; // Flag denoting an invalid git workspace.
40
- return failResult;
41
- }
42
- }
43
-
44
- // Retrieves the list of all local git branches available in the repository.
45
- export async function getBranches(folderPath: string): Promise<string[]> {
46
- const resolvedPath = path.resolve(folderPath); // Resolved absolute target path string.
47
- const isGit = await isGitRepository(resolvedPath); // Flag verifying if the directory has git initialized.
48
- if (!isGit) {
49
- const emptyList: string[] = []; // Initialized empty branches array.
50
- return emptyList;
51
- }
52
- const rawBranches = await execAsync('git branch --format="%(refname:short)"', resolvedPath); // Raw branch string output from command line.
53
- const branchesList = rawBranches.split('\n').map(b => b.trim()).filter(b => b.length > 0); // Parsed and filtered array of branch names.
54
- return branchesList;
55
- }
56
-
57
- // Sets up a git worktree for a specific branch inside a hidden sub-folder.
58
- export async function setupGitWorktree(repoPath: string, branch: string): Promise<string> {
59
- console.log(`[GIT] setupGitWorktree(repoPath="${repoPath}", branch="${branch}")`);
60
- validateBranchName(branch);
61
- const resolvedRepo = path.resolve(repoPath); // Resolved absolute parent repository path.
62
- const isGit = await isGitRepository(resolvedRepo); // Check flag verifying if the path is a git repo.
63
- if (!isGit) {
64
- throw new Error('Target folder is not a valid Git repository');
65
- }
66
-
67
- console.log(`[GIT] setupGitWorktree: checking existing worktrees for branch="${branch}"`);
68
- // Check if the branch is already checked out in any worktree (including the main repository).
69
- const worktreeListOutput = await execAsync('git worktree list --porcelain', resolvedRepo); // Porcelain worktree list output.
70
- const worktreeLines = worktreeListOutput.split('\n'); // Split by lines.
71
- let currentWorktreePath = resolvedRepo; // Holds the path of the current worktree being processed.
72
- for (const line of worktreeLines) {
73
- if (line.startsWith('worktree ')) {
74
- currentWorktreePath = line.substring(9).trim(); // Extract path.
75
- } else if (line.startsWith('branch ')) {
76
- const ref = line.substring(7).trim(); // Extract branch ref.
77
- if (ref === `refs/heads/${branch}`) {
78
- const foundWorktreePath = path.resolve(currentWorktreePath); // Found matching worktree path.
79
- return foundWorktreePath;
80
- }
81
- }
82
- }
83
-
84
- const sanitizedBranch = branch.replace(/[^a-zA-Z0-9-_]/g, '_'); // Sanitized branch name to avoid unsafe folder characters.
85
- const worktreePath = path.join(resolvedRepo, '.exeggutor-worktrees', sanitizedBranch); // Path to host the worktree outside the hidden git directory.
86
- const worktreeParent = path.dirname(worktreePath); // Parent directory of the target worktree path.
87
-
88
- if (!fs.existsSync(worktreeParent)) {
89
- fs.mkdirSync(worktreeParent, { recursive: true });
90
- }
91
-
92
- // Ensure .exeggutor-worktrees is ignored in git.
93
- const gitignorePath = path.join(resolvedRepo, '.gitignore'); // Path to workspace gitignore file.
94
- const ignorePattern = '.exeggutor-worktrees/'; // Pattern to ignore.
95
- try {
96
- if (fs.existsSync(gitignorePath)) {
97
- const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8'); // Loaded gitignore content.
98
- if (!gitignoreContent.includes(ignorePattern)) {
99
- const finalContent = gitignoreContent.endsWith('\n') || gitignoreContent.length === 0
100
- ? gitignoreContent + ignorePattern + '\n'
101
- : gitignoreContent + '\n' + ignorePattern + '\n'; // Structured appended content.
102
- fs.writeFileSync(gitignorePath, finalContent, 'utf8');
103
- }
104
- } else {
105
- fs.writeFileSync(gitignorePath, ignorePattern + '\n', 'utf8');
106
- }
107
- } catch (_) {
108
- // Safe ignore ignore-write errors.
109
- }
110
-
111
- if (fs.existsSync(worktreePath)) {
112
- console.log(`[GIT] setupGitWorktree: worktree path already exists at "${worktreePath}"`);
113
- const pathExistsResult = worktreePath; // Returns the existing path directly if the worktree directory is already present.
114
- return pathExistsResult;
115
- }
116
-
117
- const branches = await getBranches(resolvedRepo); // Fetch the list of local branches.
118
- const branchExists = branches.includes(branch); // Flag indicating if the requested branch exists locally.
119
-
120
- if (branchExists) {
121
- console.log(`[GIT] setupGitWorktree: adding existing branch "${branch}" to worktree`);
122
- await execAsync(`git worktree add "${worktreePath}" "${branch}"`, resolvedRepo);
123
- } else {
124
- console.log(`[GIT] setupGitWorktree: creating new branch "${branch}" with worktree`);
125
- await execAsync(`git worktree add -b "${branch}" "${worktreePath}"`, resolvedRepo);
126
- }
127
- console.log(`[GIT] setupGitWorktree: done, path="${worktreePath}"`);
128
-
129
- const finalPath = worktreePath; // Path of the newly created worktree.
130
- return finalPath;
131
- }
132
-
133
- // Removes a git worktree and prunes the worktree directory reference.
134
- export async function removeGitWorktree(repoPath: string, worktreePath: string): Promise<void> {
135
- console.log(`[GIT] removeGitWorktree(repoPath="${repoPath}", worktreePath="${worktreePath}")`);
136
- const resolvedRepo = path.resolve(repoPath); // Resolved absolute repository path.
137
- const isGit = await isGitRepository(resolvedRepo); // Verification flag.
138
- if (!isGit) {
139
- console.log(`[GIT] removeGitWorktree: "${resolvedRepo}" is not a git repo, skipping`);
140
- return;
141
- }
142
- const normalizedWorktreePath = path.resolve(worktreePath); // Normalized path of the target worktree.
143
- let retries = 5; // Maximum retries count for lock back-off.
144
- while (retries > 0) {
145
- try {
146
- console.log(`[GIT] removeGitWorktree: running "git worktree remove --force" (attempt ${6 - retries})`);
147
- await execAsync(`git worktree remove --force "${normalizedWorktreePath}"`, resolvedRepo);
148
- console.log(`[GIT] removeGitWorktree: remove OK`);
149
- break;
150
- } catch (err) {
151
- console.log(`[GIT] removeGitWorktree: remove attempt ${6 - retries} failed: ${err}`);
152
- retries--;
153
- if (retries === 0) {
154
- // Final fallback skip.
155
- } else {
156
- await new Promise(resolve => setTimeout(resolve, 100));
157
- }
158
- }
159
- }
160
- console.log(`[GIT] removeGitWorktree: pruning worktrees`);
161
- await execAsync('git worktree prune', resolvedRepo);
162
- console.log(`[GIT] removeGitWorktree: done`);
163
- }
164
-
165
- // Creates a new Git branch in the specified repository.
166
- export async function createBranch(repoPath: string, branchName: string): Promise<void> {
167
- console.log(`[GIT] createBranch(repoPath="${repoPath}", branchName="${branchName}")`);
168
- validateBranchName(branchName);
169
- const resolvedRepo = path.resolve(repoPath); // Resolved absolute repository path.
170
- const isGit = await isGitRepository(resolvedRepo); // Verification flag.
171
- if (!isGit) {
172
- throw new Error('Target folder is not a valid Git repository');
173
- }
174
- await execAsync(`git branch "${branchName}"`, resolvedRepo);
175
- console.log(`[GIT] createBranch: branch "${branchName}" created`);
176
- }
177
-
178
- // Mutex flag preventing more than one concurrent folder picker dialog.
179
- let pickerInFlight = false;
180
-
181
- // Opens a native folder picker dialog using platform-specific tools.
182
- // Windows: compiled FolderPicker.exe (C#/.NET) using FolderBrowserDialog.
183
- // macOS: osascript with choose folder AppleScript command.
184
- // Linux: zenity --file-selection, with kdialog as a fallback.
185
- // Returns the selected path, or an empty string if the user cancelled.
186
- export async function showFolderPicker(): Promise<string> {
187
- // Guard: reject concurrent requests — only one picker dialog at a time.
188
- if (pickerInFlight) {
189
- return ''; // A dialog is already open; silently return empty.
190
- }
191
- pickerInFlight = true;
192
-
193
- try {
194
- const platform = os.platform();
195
-
196
- if (platform === 'win32') {
197
- const binaryPath = path.join(__dirname, '..', 'bin', 'FolderPicker.exe');
198
- const resolvedPath = path.resolve(binaryPath);
199
-
200
- if (!fs.existsSync(resolvedPath)) {
201
- throw new Error(
202
- `Native folder picker not available (not found at ${resolvedPath}). ` +
203
- 'Type the workspace path manually.'
204
- );
205
- }
206
-
207
- return await new Promise<string>((resolve, reject) => {
208
- const child = spawn(resolvedPath, [], {
209
- stdio: ['ignore', 'pipe', 'pipe'],
210
- windowsHide: false, // Show the dialog window.
211
- });
212
-
213
- let stdout = '';
214
- let stderr = '';
215
-
216
- child.stdout.on('data', (data: Buffer) => {
217
- stdout += data.toString();
218
- });
219
-
220
- child.stderr.on('data', (data: Buffer) => {
221
- stderr += data.toString();
222
- });
223
-
224
- child.on('close', (code: number | null) => {
225
- if (code === 0) {
226
- resolve(stdout.trim()); // Path selected.
227
- } else if (code === 2) {
228
- resolve(''); // User cancelled — not an error.
229
- } else {
230
- const errMsg = stderr.trim() || `Folder picker exited with code ${code}`;
231
- reject(new Error(errMsg));
232
- }
233
- });
234
-
235
- child.on('error', (err: Error) => {
236
- reject(new Error(`Failed to launch folder picker: ${err.message}`));
237
- });
238
- });
239
- }
240
-
241
- if (platform === 'darwin') {
242
- // osascript exits with code 1 and stderr containing 'User canceled.' on cancel.
243
- return await new Promise<string>((resolve, reject) => {
244
- const child = spawn('osascript', [
245
- '-e',
246
- 'POSIX path of (choose folder with prompt "Select workspace folder")'
247
- ], { stdio: ['ignore', 'pipe', 'pipe'] });
248
-
249
- let stdout = '';
250
- let stderr = '';
251
- child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
252
- child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
253
- child.on('close', (code: number | null) => {
254
- if (code === 0) {
255
- resolve(stdout.trim());
256
- } else if (stderr.toLowerCase().includes('user canceled') || stderr.toLowerCase().includes('cancelled')) {
257
- resolve(''); // User dismissed the dialog.
258
- } else {
259
- reject(new Error('Failed to open macOS folder picker. Type the workspace path manually.'));
260
- }
261
- });
262
- child.on('error', () => {
263
- reject(new Error('osascript not found. Type the workspace path manually.'));
264
- });
265
- });
266
- }
267
-
268
- // Linux: zenity exits 0 on OK, 1 on cancel. kdialog exits 0 on OK, 1 on cancel.
269
- try {
270
- return await new Promise<string>((resolve, reject) => {
271
- const child = spawn('zenity', [
272
- '--file-selection', '--directory',
273
- '--title=Select workspace folder'
274
- ], { stdio: ['ignore', 'pipe', 'pipe'] });
275
- let stdout = '';
276
- child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
277
- child.on('close', (code: number | null) => {
278
- if (code === 0) {
279
- resolve(stdout.trim());
280
- } else {
281
- resolve(''); // code 1 = cancel (or dismiss), not an error.
282
- }
283
- });
284
- child.on('error', (_err: Error) => {
285
- reject(new Error('zenity_not_found'));
286
- });
287
- });
288
- } catch (err1: any) {
289
- // zenity not installed — try kdialog (KDE).
290
- try {
291
- return await new Promise<string>((resolve, reject) => {
292
- const child = spawn('kdialog', [
293
- '--getexistingdirectory',
294
- '--title', 'Select workspace folder'
295
- ], { stdio: ['ignore', 'pipe', 'pipe'] });
296
- let stdout = '';
297
- child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
298
- child.on('close', (code: number | null) => {
299
- if (code === 0) {
300
- resolve(stdout.trim());
301
- } else {
302
- resolve(''); // code 1 = cancel.
303
- }
304
- });
305
- child.on('error', (_err: Error) => {
306
- reject(new Error('kdialog_not_found'));
307
- });
308
- });
309
- } catch (err2: any) {
310
- throw new Error(
311
- 'Folder picker not available. Install zenity (GNOME) or kdialog (KDE), ' +
312
- 'or type the workspace path manually.'
313
- );
314
- }
315
- }
316
- } finally {
317
- pickerInFlight = false; // Always release the lock.
318
- }
319
- }
320
-
1
+ import { exec, spawn } from 'child_process';
2
+ import * as path from 'path';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+
6
+ // Validates git branch names to prevent command injection and ensure compliance with git rules.
7
+ export function validateBranchName(branch: string): void {
8
+ const cleanPattern = /^[a-zA-Z0-9-_./@]+$/; // Pattern matching safe git branch characters.
9
+ if (!branch || !cleanPattern.test(branch)) {
10
+ throw new Error('Invalid branch name. Only alphanumeric, dashes, underscores, dots, slashes, and @ are permitted.');
11
+ }
12
+ }
13
+
14
+ // Helper function that executes a terminal command asynchronously and returns its stdout.
15
+ export function execAsync(command: string, cwd: string): Promise<string> {
16
+ console.log(`[GIT] execAsync(command="${command}", cwd="${cwd}")`);
17
+ const p = new Promise<string>((resolve, reject) => {
18
+ const child = exec(command, { cwd, windowsHide: true }, (error, stdout, stderr) => {
19
+ if (error) {
20
+ const errMsg = stderr || error.message; // Resolves the exact error details from stderr or standard node error.
21
+ reject(new Error(errMsg));
22
+ } else {
23
+ const cleanedOutput = stdout.trim(); // The stripped stdout returned from the execution.
24
+ resolve(cleanedOutput);
25
+ }
26
+ }); // The child process handle spawned for this command run.
27
+ }); // The promise mapping the command execution flow.
28
+ return p;
29
+ }
30
+
31
+ // Verifies if the target directory is a valid git repository.
32
+ export async function isGitRepository(folderPath: string): Promise<boolean> {
33
+ try {
34
+ const resolvedPath = path.resolve(folderPath); // Resolved absolute target path string.
35
+ await execAsync('git rev-parse --is-inside-work-tree', resolvedPath);
36
+ const successResult = true; // Flag denoting a valid git workspace.
37
+ return successResult;
38
+ } catch (err) {
39
+ const failResult = false; // Flag denoting an invalid git workspace.
40
+ return failResult;
41
+ }
42
+ }
43
+
44
+ // Retrieves the list of all local git branches available in the repository.
45
+ export async function getBranches(folderPath: string): Promise<string[]> {
46
+ const resolvedPath = path.resolve(folderPath); // Resolved absolute target path string.
47
+ const isGit = await isGitRepository(resolvedPath); // Flag verifying if the directory has git initialized.
48
+ if (!isGit) {
49
+ const emptyList: string[] = []; // Initialized empty branches array.
50
+ return emptyList;
51
+ }
52
+ const rawBranches = await execAsync('git branch --format="%(refname:short)"', resolvedPath); // Raw branch string output from command line.
53
+ const branchesList = rawBranches.split('\n').map(b => b.trim()).filter(b => b.length > 0); // Parsed and filtered array of branch names.
54
+ return branchesList;
55
+ }
56
+
57
+ // Sets up a git worktree for a specific branch inside a hidden sub-folder.
58
+ export async function setupGitWorktree(repoPath: string, branch: string): Promise<string> {
59
+ console.log(`[GIT] setupGitWorktree(repoPath="${repoPath}", branch="${branch}")`);
60
+ validateBranchName(branch);
61
+ const resolvedRepo = path.resolve(repoPath); // Resolved absolute parent repository path.
62
+ const isGit = await isGitRepository(resolvedRepo); // Check flag verifying if the path is a git repo.
63
+ if (!isGit) {
64
+ throw new Error('Target folder is not a valid Git repository');
65
+ }
66
+
67
+ console.log(`[GIT] setupGitWorktree: checking existing worktrees for branch="${branch}"`);
68
+ // Check if the branch is already checked out in any worktree (including the main repository).
69
+ const worktreeListOutput = await execAsync('git worktree list --porcelain', resolvedRepo); // Porcelain worktree list output.
70
+ const worktreeLines = worktreeListOutput.split('\n'); // Split by lines.
71
+ let currentWorktreePath = resolvedRepo; // Holds the path of the current worktree being processed.
72
+ for (const line of worktreeLines) {
73
+ if (line.startsWith('worktree ')) {
74
+ currentWorktreePath = line.substring(9).trim(); // Extract path.
75
+ } else if (line.startsWith('branch ')) {
76
+ const ref = line.substring(7).trim(); // Extract branch ref.
77
+ if (ref === `refs/heads/${branch}`) {
78
+ const foundWorktreePath = path.resolve(currentWorktreePath); // Found matching worktree path.
79
+ return foundWorktreePath;
80
+ }
81
+ }
82
+ }
83
+
84
+ const sanitizedBranch = branch.replace(/[^a-zA-Z0-9-_]/g, '_'); // Sanitized branch name to avoid unsafe folder characters.
85
+ const worktreePath = path.join(resolvedRepo, '.exeggutor-worktrees', sanitizedBranch); // Path to host the worktree outside the hidden git directory.
86
+ const worktreeParent = path.dirname(worktreePath); // Parent directory of the target worktree path.
87
+
88
+ if (!fs.existsSync(worktreeParent)) {
89
+ fs.mkdirSync(worktreeParent, { recursive: true });
90
+ }
91
+
92
+ // Ensure .exeggutor-worktrees is ignored in git.
93
+ const gitignorePath = path.join(resolvedRepo, '.gitignore'); // Path to workspace gitignore file.
94
+ const ignorePattern = '.exeggutor-worktrees/'; // Pattern to ignore.
95
+ try {
96
+ if (fs.existsSync(gitignorePath)) {
97
+ const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8'); // Loaded gitignore content.
98
+ if (!gitignoreContent.includes(ignorePattern)) {
99
+ const finalContent = gitignoreContent.endsWith('\n') || gitignoreContent.length === 0
100
+ ? gitignoreContent + ignorePattern + '\n'
101
+ : gitignoreContent + '\n' + ignorePattern + '\n'; // Structured appended content.
102
+ fs.writeFileSync(gitignorePath, finalContent, 'utf8');
103
+ }
104
+ } else {
105
+ fs.writeFileSync(gitignorePath, ignorePattern + '\n', 'utf8');
106
+ }
107
+ } catch (_) {
108
+ // Safe ignore ignore-write errors.
109
+ }
110
+
111
+ if (fs.existsSync(worktreePath)) {
112
+ console.log(`[GIT] setupGitWorktree: worktree path already exists at "${worktreePath}"`);
113
+ const pathExistsResult = worktreePath; // Returns the existing path directly if the worktree directory is already present.
114
+ return pathExistsResult;
115
+ }
116
+
117
+ const branches = await getBranches(resolvedRepo); // Fetch the list of local branches.
118
+ const branchExists = branches.includes(branch); // Flag indicating if the requested branch exists locally.
119
+
120
+ if (branchExists) {
121
+ console.log(`[GIT] setupGitWorktree: adding existing branch "${branch}" to worktree`);
122
+ await execAsync(`git worktree add "${worktreePath}" "${branch}"`, resolvedRepo);
123
+ } else {
124
+ console.log(`[GIT] setupGitWorktree: creating new branch "${branch}" with worktree`);
125
+ await execAsync(`git worktree add -b "${branch}" "${worktreePath}"`, resolvedRepo);
126
+ }
127
+ console.log(`[GIT] setupGitWorktree: done, path="${worktreePath}"`);
128
+
129
+ const finalPath = worktreePath; // Path of the newly created worktree.
130
+ return finalPath;
131
+ }
132
+
133
+ // Removes a git worktree and prunes the worktree directory reference.
134
+ export async function removeGitWorktree(repoPath: string, worktreePath: string): Promise<void> {
135
+ console.log(`[GIT] removeGitWorktree(repoPath="${repoPath}", worktreePath="${worktreePath}")`);
136
+ const resolvedRepo = path.resolve(repoPath); // Resolved absolute repository path.
137
+ const isGit = await isGitRepository(resolvedRepo); // Verification flag.
138
+ if (!isGit) {
139
+ console.log(`[GIT] removeGitWorktree: "${resolvedRepo}" is not a git repo, skipping`);
140
+ return;
141
+ }
142
+ const normalizedWorktreePath = path.resolve(worktreePath); // Normalized path of the target worktree.
143
+ let retries = 5; // Maximum retries count for lock back-off.
144
+ while (retries > 0) {
145
+ try {
146
+ console.log(`[GIT] removeGitWorktree: running "git worktree remove --force" (attempt ${6 - retries})`);
147
+ await execAsync(`git worktree remove --force "${normalizedWorktreePath}"`, resolvedRepo);
148
+ console.log(`[GIT] removeGitWorktree: remove OK`);
149
+ break;
150
+ } catch (err) {
151
+ console.log(`[GIT] removeGitWorktree: remove attempt ${6 - retries} failed: ${err}`);
152
+ retries--;
153
+ if (retries === 0) {
154
+ // Final fallback skip.
155
+ } else {
156
+ await new Promise(resolve => setTimeout(resolve, 100));
157
+ }
158
+ }
159
+ }
160
+ console.log(`[GIT] removeGitWorktree: pruning worktrees`);
161
+ await execAsync('git worktree prune', resolvedRepo);
162
+ console.log(`[GIT] removeGitWorktree: done`);
163
+ }
164
+
165
+ // Creates a new Git branch in the specified repository.
166
+ export async function createBranch(repoPath: string, branchName: string): Promise<void> {
167
+ console.log(`[GIT] createBranch(repoPath="${repoPath}", branchName="${branchName}")`);
168
+ validateBranchName(branchName);
169
+ const resolvedRepo = path.resolve(repoPath); // Resolved absolute repository path.
170
+ const isGit = await isGitRepository(resolvedRepo); // Verification flag.
171
+ if (!isGit) {
172
+ throw new Error('Target folder is not a valid Git repository');
173
+ }
174
+ await execAsync(`git branch "${branchName}"`, resolvedRepo);
175
+ console.log(`[GIT] createBranch: branch "${branchName}" created`);
176
+ }
177
+
178
+ // Mutex flag preventing more than one concurrent folder picker dialog.
179
+ let pickerInFlight = false;
180
+
181
+ // Opens a native folder picker dialog using platform-specific tools.
182
+ // Windows: compiled FolderPicker.exe (C#/.NET) using FolderBrowserDialog.
183
+ // macOS: osascript with choose folder AppleScript command.
184
+ // Linux: zenity --file-selection, with kdialog as a fallback.
185
+ // Returns the selected path, or an empty string if the user cancelled.
186
+ export async function showFolderPicker(): Promise<string> {
187
+ // Guard: reject concurrent requests — only one picker dialog at a time.
188
+ if (pickerInFlight) {
189
+ return ''; // A dialog is already open; silently return empty.
190
+ }
191
+ pickerInFlight = true;
192
+
193
+ try {
194
+ const platform = os.platform();
195
+
196
+ if (platform === 'win32') {
197
+ const binaryPath = path.join(__dirname, '..', 'bin', 'FolderPicker.exe');
198
+ const resolvedPath = path.resolve(binaryPath);
199
+
200
+ if (!fs.existsSync(resolvedPath)) {
201
+ throw new Error(
202
+ `Native folder picker not available (not found at ${resolvedPath}). ` +
203
+ 'Type the workspace path manually.'
204
+ );
205
+ }
206
+
207
+ return await new Promise<string>((resolve, reject) => {
208
+ const child = spawn(resolvedPath, [], {
209
+ stdio: ['ignore', 'pipe', 'pipe'],
210
+ windowsHide: false, // Show the dialog window.
211
+ });
212
+
213
+ let stdout = '';
214
+ let stderr = '';
215
+
216
+ child.stdout.on('data', (data: Buffer) => {
217
+ stdout += data.toString();
218
+ });
219
+
220
+ child.stderr.on('data', (data: Buffer) => {
221
+ stderr += data.toString();
222
+ });
223
+
224
+ child.on('close', (code: number | null) => {
225
+ if (code === 0) {
226
+ resolve(stdout.trim()); // Path selected.
227
+ } else if (code === 2) {
228
+ resolve(''); // User cancelled — not an error.
229
+ } else {
230
+ const errMsg = stderr.trim() || `Folder picker exited with code ${code}`;
231
+ reject(new Error(errMsg));
232
+ }
233
+ });
234
+
235
+ child.on('error', (err: Error) => {
236
+ reject(new Error(`Failed to launch folder picker: ${err.message}`));
237
+ });
238
+ });
239
+ }
240
+
241
+ if (platform === 'darwin') {
242
+ // osascript exits with code 1 and stderr containing 'User canceled.' on cancel.
243
+ return await new Promise<string>((resolve, reject) => {
244
+ const child = spawn('osascript', [
245
+ '-e',
246
+ 'POSIX path of (choose folder with prompt "Select workspace folder")'
247
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
248
+
249
+ let stdout = '';
250
+ let stderr = '';
251
+ child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
252
+ child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
253
+ child.on('close', (code: number | null) => {
254
+ if (code === 0) {
255
+ resolve(stdout.trim());
256
+ } else if (stderr.toLowerCase().includes('user canceled') || stderr.toLowerCase().includes('cancelled')) {
257
+ resolve(''); // User dismissed the dialog.
258
+ } else {
259
+ reject(new Error('Failed to open macOS folder picker. Type the workspace path manually.'));
260
+ }
261
+ });
262
+ child.on('error', () => {
263
+ reject(new Error('osascript not found. Type the workspace path manually.'));
264
+ });
265
+ });
266
+ }
267
+
268
+ // Linux: zenity exits 0 on OK, 1 on cancel. kdialog exits 0 on OK, 1 on cancel.
269
+ try {
270
+ return await new Promise<string>((resolve, reject) => {
271
+ const child = spawn('zenity', [
272
+ '--file-selection', '--directory',
273
+ '--title=Select workspace folder'
274
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
275
+ let stdout = '';
276
+ child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
277
+ child.on('close', (code: number | null) => {
278
+ if (code === 0) {
279
+ resolve(stdout.trim());
280
+ } else {
281
+ resolve(''); // code 1 = cancel (or dismiss), not an error.
282
+ }
283
+ });
284
+ child.on('error', (_err: Error) => {
285
+ reject(new Error('zenity_not_found'));
286
+ });
287
+ });
288
+ } catch (err1: any) {
289
+ // zenity not installed — try kdialog (KDE).
290
+ try {
291
+ return await new Promise<string>((resolve, reject) => {
292
+ const child = spawn('kdialog', [
293
+ '--getexistingdirectory',
294
+ '--title', 'Select workspace folder'
295
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
296
+ let stdout = '';
297
+ child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
298
+ child.on('close', (code: number | null) => {
299
+ if (code === 0) {
300
+ resolve(stdout.trim());
301
+ } else {
302
+ resolve(''); // code 1 = cancel.
303
+ }
304
+ });
305
+ child.on('error', (_err: Error) => {
306
+ reject(new Error('kdialog_not_found'));
307
+ });
308
+ });
309
+ } catch (err2: any) {
310
+ throw new Error(
311
+ 'Folder picker not available. Install zenity (GNOME) or kdialog (KDE), ' +
312
+ 'or type the workspace path manually.'
313
+ );
314
+ }
315
+ }
316
+ } finally {
317
+ pickerInFlight = false; // Always release the lock.
318
+ }
319
+ }
320
+