gitspace 0.2.0-rc.5 → 0.2.0-rc.6

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/README.md CHANGED
@@ -208,7 +208,7 @@ fi
208
208
 
209
209
  Bundles can be loaded from:
210
210
 
211
- 1. **In-repo** (automatic): `.gitspace/`, `.gitspace-config/`, `.spaces-config/`, or `.spaces/` in the cloned repository
211
+ 1. **In-repo** (automatic): `.gitspace/` directory in the cloned repository
212
212
  2. **Local path**: `gssh add project --bundle-path /path/to/bundle/`
213
213
  3. **Remote URL**: `gssh add project --bundle-url https://example.com/bundle.zip`
214
214
 
package/bun.lock CHANGED
@@ -33,6 +33,12 @@
33
33
  "eslint": "^8.57.0",
34
34
  "typescript": "^5.3.3",
35
35
  },
36
+ "optionalDependencies": {
37
+ "@gitspace/darwin-arm64": "0.2.0-rc.5",
38
+ "@gitspace/darwin-x64": "0.2.0-rc.5",
39
+ "@gitspace/linux-arm64": "0.2.0-rc.5",
40
+ "@gitspace/linux-x64": "0.2.0-rc.5",
41
+ },
36
42
  },
37
43
  },
38
44
  "packages": {
@@ -46,6 +52,8 @@
46
52
 
47
53
  "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="],
48
54
 
55
+ "@gitspace/darwin-arm64": ["@gitspace/darwin-arm64@0.2.0-rc.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "gssh-darwin-arm64": "bin/gssh" } }, "sha512-jeFMG2y/Ztvlfc9BHm1M4yyTR1P5bDtdpA40DVNSSdhGv4NpsGPifVBo5GUwEQOAO9QTHFuxkdAl+LK5dYS9yQ=="],
56
+
49
57
  "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
50
58
 
51
59
  "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
@@ -311,18 +311,18 @@ git status
311
311
 
312
312
  ### Repo Config Bundles
313
313
 
314
- Bundles allow repository owners to share onboarding configurations. Place in `.gitspace-config/` in your repo:
314
+ Bundles allow repository owners to share onboarding configurations. Place in `.gitspace/` in your repo:
315
315
 
316
316
  ```
317
- .gitspace-config/
318
- ├── gitspace-bundle.json # Bundle manifest with onboarding steps
317
+ .gitspace/
318
+ ├── bundle.json # Bundle manifest with onboarding steps
319
319
  ├── pre/ # Scripts to run before setup
320
320
  ├── setup/ # Scripts to run on first workspace creation
321
321
  ├── select/ # Scripts to run every time workspace is opened
322
322
  └── remove/ # Scripts to run before workspace deletion
323
323
  ```
324
324
 
325
- Bundle manifest example (`gitspace-bundle.json`):
325
+ Bundle manifest example (`bundle.json`):
326
326
  ```json
327
327
  {
328
328
  "version": "1.0",
@@ -388,7 +388,7 @@ fi
388
388
  ```
389
389
 
390
390
  Bundle sources:
391
- - **In-repo** (automatic): `.gitspace-config/`, `gitspace-config/`, or `.gitspace/` in the cloned repository
391
+ - **In-repo** (automatic): `.gitspace/` directory in the cloned repository
392
392
  - **Local path**: `gssh add project --bundle-path /path/to/bundle/`
393
393
  - **Remote URL**: `gssh add project --bundle-url https://example.com/bundle.zip`
394
394
 
@@ -920,11 +920,11 @@ git status
920
920
 
921
921
  SECTION: Local Workflow - Repo Config Bundles
922
922
 
923
- Bundles allow teams to share onboarding configurations. Place in `.gitspace-config/`:
923
+ Bundles allow teams to share onboarding configurations. Place in `.gitspace/`:
924
924
 
925
925
  ```
926
- .gitspace-config/
927
- ├── gitspace-bundle.json # Manifest
926
+ .gitspace/
927
+ ├── bundle.json # Manifest
928
928
  ├── pre/ # Pre-setup scripts
929
929
  ├── setup/ # Setup scripts
930
930
  └── select/ # Select scripts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitspace",
3
- "version": "0.2.0-rc.5",
3
+ "version": "0.2.0-rc.6",
4
4
  "description": "CLI for managing GitHub workspaces with git worktrees and secure remote terminal access",
5
5
  "bin": {
6
6
  "gssh": "./bin/gssh"
@@ -17,10 +17,10 @@
17
17
  "relay": "bun src/relay/index.ts"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@gitspace/darwin-arm64": "0.2.0-rc.5",
21
- "@gitspace/darwin-x64": "0.2.0-rc.5",
22
- "@gitspace/linux-x64": "0.2.0-rc.5",
23
- "@gitspace/linux-arm64": "0.2.0-rc.5"
20
+ "@gitspace/darwin-arm64": "0.2.0-rc.6",
21
+ "@gitspace/darwin-x64": "0.2.0-rc.6",
22
+ "@gitspace/linux-x64": "0.2.0-rc.6",
23
+ "@gitspace/linux-arm64": "0.2.0-rc.6"
24
24
  },
25
25
  "keywords": [
26
26
  "cli",
@@ -3,31 +3,27 @@
3
3
  * Handles 'gssh remove workspace' and 'gssh remove project'
4
4
  */
5
5
 
6
- import { existsSync, rmSync, readdirSync } from 'fs'
6
+ import { existsSync, readdirSync } from 'fs'
7
7
  import { join } from 'path'
8
8
  import {
9
9
  getCurrentProject,
10
10
  readProjectConfig,
11
11
  getProjectWorkspacesDir,
12
- getProjectBaseDir,
13
12
  getProjectDir,
14
- readGlobalConfig,
15
- updateGlobalConfig,
16
13
  getAllProjectNames,
17
- getScriptsPhaseDir,
18
14
  } from '../core/config.js'
15
+ import { getWorktreeInfo } from '../core/git.js'
19
16
  import {
20
- removeWorktree,
21
- deleteLocalBranch,
22
- getWorktreeInfo,
23
- } from '../core/git.js'
17
+ deleteWorkspaceCore,
18
+ deleteProjectCore,
19
+ } from '../core/workspace.js'
24
20
  import { logger } from '../utils/logger.js'
25
21
  import { selectItem, promptConfirm, promptInput } from '../utils/prompts.js'
26
22
  import { SpacesError, NoProjectError } from '../types/errors.js'
27
- import { runScriptsInTerminal } from '../utils/run-scripts.js'
28
23
 
29
24
  /**
30
- * Remove a workspace
25
+ * Remove a workspace (CLI command)
26
+ * Handles interactive prompts and delegates to core deletion logic
31
27
  */
32
28
  export async function removeWorkspace(
33
29
  workspaceNameArg?: string,
@@ -42,7 +38,6 @@ export async function removeWorkspace(
42
38
  }
43
39
 
44
40
  const workspacesDir = getProjectWorkspacesDir(currentProject)
45
- const baseDir = getProjectBaseDir(currentProject)
46
41
 
47
42
  if (!existsSync(workspacesDir)) {
48
43
  throw new SpacesError('No workspaces found', 'USER_ERROR', 1)
@@ -82,7 +77,7 @@ export async function removeWorkspace(
82
77
 
83
78
  const workspacePath = join(workspacesDir, workspaceName)
84
79
 
85
- // Get workspace info
80
+ // Get workspace info for display
86
81
  const info = await getWorktreeInfo(workspacePath)
87
82
 
88
83
  if (!info) {
@@ -117,44 +112,37 @@ export async function removeWorkspace(
117
112
  }
118
113
  }
119
114
 
120
- // Run remove scripts (cleanup before deletion)
121
- const projectConfig = readProjectConfig(currentProject)
122
- const removeScriptsDir = getScriptsPhaseDir(currentProject, 'remove')
123
- await runScriptsInTerminal(
124
- removeScriptsDir,
125
- workspacePath,
126
- workspaceName,
127
- projectConfig.repository
128
- )
115
+ // Delegate to core deletion logic (interactive mode for CLI)
116
+ logger.info('Removing workspace...')
117
+ const result = await deleteWorkspaceCore(currentProject, workspaceName, {
118
+ nonInteractive: false, // CLI is interactive
119
+ keepBranch: options.keepBranch,
120
+ })
129
121
 
130
- // Remove worktree
131
- logger.info('Removing worktree...')
132
- await removeWorktree(baseDir, workspacePath, true)
122
+ if (!result.success) {
123
+ throw new SpacesError(
124
+ result.error || 'Failed to remove workspace',
125
+ 'SYSTEM_ERROR',
126
+ 2
127
+ )
128
+ }
133
129
 
134
130
  logger.success(`Removed worktree: ${workspaceName}`)
135
131
 
136
- // Ask about deleting local branch unless --keep-branch
137
- if (!options.keepBranch) {
138
- // const deleteBranch = await promptConfirm(`Delete local branch "${info.branch}"?`, false);
139
- const deleteBranch = true
140
-
141
- if (deleteBranch) {
142
- try {
143
- await deleteLocalBranch(baseDir, info.branch, true)
144
- logger.success(`Deleted branch: ${info.branch}`)
145
- } catch (error) {
146
- logger.warning(
147
- `Could not delete branch: ${
148
- error instanceof Error ? error.message : 'Unknown error'
149
- }`
150
- )
151
- }
152
- }
132
+ if (result.sessionsKilled > 0) {
133
+ logger.info(`Killed ${result.sessionsKilled} active session(s)`)
134
+ }
135
+
136
+ if (result.branchDeleted) {
137
+ logger.success(`Deleted branch: ${result.branch}`)
138
+ } else if (result.branch && !options.keepBranch) {
139
+ logger.warning(`Could not delete branch: ${result.branch}`)
153
140
  }
154
141
  }
155
142
 
156
143
  /**
157
- * Remove a project
144
+ * Remove a project (CLI command)
145
+ * Handles interactive prompts and delegates to core deletion logic
158
146
  */
159
147
  export async function removeProject(
160
148
  projectNameArg?: string,
@@ -226,16 +214,44 @@ export async function removeProject(
226
214
  }
227
215
  }
228
216
 
229
- // Remove entire project directory
230
- logger.info('Removing project directory...')
231
- rmSync(projectDir, { recursive: true, force: true })
217
+ // Delegate to core deletion logic (interactive mode for CLI)
218
+ logger.info('Removing project...')
219
+ const result = await deleteProjectCore(projectName, {
220
+ nonInteractive: false, // CLI is interactive
221
+ })
222
+
223
+ if (!result.success) {
224
+ if (result.errors.length > 0) {
225
+ for (const error of result.errors) {
226
+ logger.warning(` ${error}`)
227
+ }
228
+ }
229
+ throw new SpacesError(
230
+ 'Failed to remove project completely',
231
+ 'SYSTEM_ERROR',
232
+ 2
233
+ )
234
+ }
235
+
236
+ // Log any partial errors that occurred during cleanup (even on success)
237
+ if (result.errors.length > 0) {
238
+ logger.warning('Some cleanup operations had issues:')
239
+ for (const error of result.errors) {
240
+ logger.warning(` ${error}`)
241
+ }
242
+ }
232
243
 
233
244
  logger.success(`Removed project: ${projectName}`)
234
245
 
235
- // Update global config if this was the current project
236
- const globalConfig = readGlobalConfig()
237
- if (globalConfig.currentProject === projectName) {
238
- updateGlobalConfig({ currentProject: null })
246
+ if (result.sessionsKilled > 0) {
247
+ logger.info(`Killed ${result.sessionsKilled} active session(s)`)
248
+ }
249
+
250
+ if (result.workspacesDeleted > 0) {
251
+ logger.info(`Cleaned up ${result.workspacesDeleted} workspace(s)`)
252
+ }
253
+
254
+ if (result.wasCurrentProject) {
239
255
  logger.info('Cleared current project (was this project)')
240
256
  }
241
257
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Tests for workspace.ts core deletion functions
3
+ *
4
+ * These tests mock dependencies to test the orchestration logic
5
+ * without requiring a running tmux-lite server or actual git repos.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
9
+ import { mkdirSync, rmSync, existsSync, writeFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { tmpdir } from 'os';
12
+
13
+ // We'll test the module by creating a real file structure
14
+ // but mocking the tmux-lite and git operations
15
+
16
+ describe('deleteWorkspaceCore', () => {
17
+ let testDir: string;
18
+ let projectDir: string;
19
+ let workspacesDir: string;
20
+ let baseDir: string;
21
+
22
+ // Track mock state
23
+ let mockSessions: Array<{ id: string; name: string; cwd: string }>;
24
+ let killedSessions: string[];
25
+ let removedWorktrees: string[];
26
+ let deletedBranches: string[];
27
+ let serverRunning: boolean;
28
+
29
+ beforeEach(() => {
30
+ // Create test directory structure
31
+ testDir = join(tmpdir(), `workspace-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
32
+ projectDir = join(testDir, 'gitspace', 'test-project');
33
+ workspacesDir = join(projectDir, 'workspaces');
34
+ baseDir = join(projectDir, 'base');
35
+
36
+ mkdirSync(workspacesDir, { recursive: true });
37
+ mkdirSync(baseDir, { recursive: true });
38
+
39
+ // Create a fake workspace
40
+ mkdirSync(join(workspacesDir, 'my-workspace'));
41
+
42
+ // Create project config
43
+ writeFileSync(join(projectDir, '.config.json'), JSON.stringify({
44
+ repository: 'owner/test-repo',
45
+ baseBranch: 'main',
46
+ }));
47
+
48
+ // Reset mock state
49
+ mockSessions = [];
50
+ killedSessions = [];
51
+ removedWorktrees = [];
52
+ deletedBranches = [];
53
+ serverRunning = true;
54
+ });
55
+
56
+ afterEach(() => {
57
+ if (existsSync(testDir)) {
58
+ rmSync(testDir, { recursive: true, force: true });
59
+ }
60
+ mock.restore();
61
+ });
62
+
63
+ it('should export deleteWorkspaceCore function', async () => {
64
+ const { deleteWorkspaceCore } = await import('../workspace');
65
+ expect(typeof deleteWorkspaceCore).toBe('function');
66
+ });
67
+
68
+ it('should export deleteProjectCore function', async () => {
69
+ const { deleteProjectCore } = await import('../workspace');
70
+ expect(typeof deleteProjectCore).toBe('function');
71
+ });
72
+
73
+ it('should export DeleteWorkspaceOptions type', async () => {
74
+ // Type check - if this compiles, the type exists
75
+ const { deleteWorkspaceCore } = await import('../workspace');
76
+ const options: Parameters<typeof deleteWorkspaceCore>[2] = {
77
+ nonInteractive: true,
78
+ keepBranch: false,
79
+ };
80
+ expect(options.nonInteractive).toBe(true);
81
+ });
82
+
83
+ it('should export DeleteWorkspaceResult type', async () => {
84
+ const { deleteWorkspaceCore } = await import('../workspace');
85
+ // The return type should have these properties
86
+ type Result = Awaited<ReturnType<typeof deleteWorkspaceCore>>;
87
+ const checkType = (r: Result) => {
88
+ r.success;
89
+ r.workspaceName;
90
+ r.branch;
91
+ r.branchDeleted;
92
+ r.sessionsKilled;
93
+ r.error;
94
+ };
95
+ expect(typeof checkType).toBe('function');
96
+ });
97
+ });
98
+
99
+ describe('deleteProjectCore', () => {
100
+ it('should export DeleteProjectOptions type', async () => {
101
+ const { deleteProjectCore } = await import('../workspace');
102
+ const options: Parameters<typeof deleteProjectCore>[1] = {
103
+ nonInteractive: true,
104
+ };
105
+ expect(options.nonInteractive).toBe(true);
106
+ });
107
+
108
+ it('should export DeleteProjectResult type', async () => {
109
+ const { deleteProjectCore } = await import('../workspace');
110
+ type Result = Awaited<ReturnType<typeof deleteProjectCore>>;
111
+ const checkType = (r: Result) => {
112
+ r.success;
113
+ r.projectName;
114
+ r.workspacesDeleted;
115
+ r.sessionsKilled;
116
+ r.wasCurrentProject;
117
+ r.errors;
118
+ };
119
+ expect(typeof checkType).toBe('function');
120
+ });
121
+ });
122
+
123
+ describe('integration behavior', () => {
124
+ it('deleteWorkspaceCore should accept nonInteractive option', async () => {
125
+ // Verify the function signature accepts nonInteractive option
126
+ const { deleteWorkspaceCore } = await import('../workspace');
127
+ expect(typeof deleteWorkspaceCore).toBe('function');
128
+
129
+ // Verify the function's third parameter accepts the expected options
130
+ // This is a compile-time check - if it compiles, the type is correct
131
+ type Options = Parameters<typeof deleteWorkspaceCore>[2];
132
+ const options: Options = { nonInteractive: true, keepBranch: false };
133
+ expect(options.nonInteractive).toBe(true);
134
+ expect(options.keepBranch).toBe(false);
135
+
136
+ // Note: Full integration testing would require mocking many modules
137
+ // For now, we verify the API shape is correct
138
+ });
139
+
140
+ it('deleteProjectCore should accept nonInteractive option', async () => {
141
+ const { deleteProjectCore } = await import('../workspace');
142
+ expect(typeof deleteProjectCore).toBe('function');
143
+
144
+ // Verify the function's second parameter accepts the expected options
145
+ type Options = Parameters<typeof deleteProjectCore>[1];
146
+ const options: Options = { nonInteractive: true };
147
+ expect(options.nonInteractive).toBe(true);
148
+ });
149
+ });
@@ -23,7 +23,7 @@ import type { SpacesBundle, LoadedBundle } from '../types/bundle.js';
23
23
  import { getScriptsPhaseDir } from './config.js';
24
24
 
25
25
  const BUNDLE_FILENAME = 'bundle.json';
26
- const BUNDLE_SUBDIRS = ['.gitspace', '.gitspace-config', 'gitspace-config', '.spaces-config', 'spaces-config', '.spaces'];
26
+ const BUNDLE_SUBDIRS = ['.gitspace'];
27
27
  const SCRIPT_PHASES = ['pre', 'setup', 'select', 'remove'] as const;
28
28
 
29
29
  function assertSafeExtractedPaths(rootDir: string): void {
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Core workspace and project deletion operations
3
+ * These functions contain the shared logic used by CLI, TUI, and remote handlers
4
+ */
5
+
6
+ import { existsSync, readdirSync, rmSync } from 'fs';
7
+ import { join } from 'path';
8
+ import {
9
+ readProjectConfig,
10
+ getProjectWorkspacesDir,
11
+ getProjectBaseDir,
12
+ getProjectDir,
13
+ getScriptsPhaseDir,
14
+ readGlobalConfig,
15
+ updateGlobalConfig,
16
+ } from './config.js';
17
+ import {
18
+ removeWorktree,
19
+ deleteLocalBranch,
20
+ getWorktreeInfo,
21
+ } from './git.js';
22
+ import { runScriptsInTerminal } from '../utils/run-scripts.js';
23
+ import { logger } from '../utils/logger.js';
24
+ import {
25
+ listSessions,
26
+ killSession,
27
+ isServerRunning,
28
+ } from '../lib/tmux-lite/cli.js';
29
+
30
+ /**
31
+ * Options for workspace deletion
32
+ */
33
+ export interface DeleteWorkspaceOptions {
34
+ /**
35
+ * Run in non-interactive mode (for TUI/daemon/remote contexts).
36
+ * When true, remove scripts run with stdin closed.
37
+ */
38
+ nonInteractive?: boolean;
39
+ /** Keep the local branch after removing worktree */
40
+ keepBranch?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Result of workspace deletion
45
+ */
46
+ export interface DeleteWorkspaceResult {
47
+ success: boolean;
48
+ workspaceName: string;
49
+ branch?: string;
50
+ branchDeleted: boolean;
51
+ sessionsKilled: number;
52
+ error?: string;
53
+ }
54
+
55
+ /**
56
+ * Core workspace deletion logic
57
+ * Used by CLI, TUI, and remote session handlers
58
+ *
59
+ * @param projectName - Name of the project containing the workspace
60
+ * @param workspaceName - Name of the workspace to delete
61
+ * @param options - Deletion options
62
+ */
63
+ export async function deleteWorkspaceCore(
64
+ projectName: string,
65
+ workspaceName: string,
66
+ options: DeleteWorkspaceOptions = {}
67
+ ): Promise<DeleteWorkspaceResult> {
68
+ const workspacesDir = getProjectWorkspacesDir(projectName);
69
+ const baseDir = getProjectBaseDir(projectName);
70
+ const workspacePath = join(workspacesDir, workspaceName);
71
+
72
+ const result: DeleteWorkspaceResult = {
73
+ success: false,
74
+ workspaceName,
75
+ branchDeleted: false,
76
+ sessionsKilled: 0,
77
+ };
78
+
79
+ // Validate workspace exists before attempting deletion
80
+ if (!existsSync(workspacePath)) {
81
+ result.error = `Workspace "${workspaceName}" does not exist`;
82
+ return result;
83
+ }
84
+
85
+ // Get workspace info before deletion
86
+ const info = await getWorktreeInfo(workspacePath);
87
+ if (info) {
88
+ result.branch = info.branch;
89
+ }
90
+
91
+ // Kill any sessions running in this workspace
92
+ try {
93
+ if (await isServerRunning()) {
94
+ const sessions = await listSessions();
95
+ const workspaceSessions = sessions.filter(s => s.cwd === workspacePath);
96
+ for (const session of workspaceSessions) {
97
+ try {
98
+ await killSession(session.id);
99
+ result.sessionsKilled++;
100
+ logger.debug(`Killed session ${session.name} (${session.id})`);
101
+ } catch (e) {
102
+ logger.debug(`Failed to kill session ${session.id}: ${e}`);
103
+ }
104
+ }
105
+ }
106
+ } catch (e) {
107
+ logger.debug(`Error checking/killing sessions: ${e}`);
108
+ }
109
+
110
+ // Run remove scripts (cleanup before deletion)
111
+ try {
112
+ const projectConfig = readProjectConfig(projectName);
113
+ const removeScriptsDir = getScriptsPhaseDir(projectName, 'remove');
114
+ await runScriptsInTerminal(
115
+ removeScriptsDir,
116
+ workspacePath,
117
+ workspaceName,
118
+ projectConfig.repository,
119
+ { nonInteractive: options.nonInteractive }
120
+ );
121
+ } catch (e) {
122
+ // Scripts are best-effort, log but continue
123
+ logger.debug(`Remove scripts failed for ${workspaceName}: ${e}`);
124
+ }
125
+
126
+ // Remove worktree
127
+ try {
128
+ await removeWorktree(baseDir, workspacePath, true);
129
+ } catch (e) {
130
+ result.error = e instanceof Error ? e.message : 'Failed to remove worktree';
131
+ return result;
132
+ }
133
+
134
+ // Try to delete the local branch
135
+ if (!options.keepBranch && info?.branch) {
136
+ try {
137
+ await deleteLocalBranch(baseDir, info.branch, true);
138
+ result.branchDeleted = true;
139
+ } catch (e) {
140
+ // Branch deletion is best-effort
141
+ logger.debug(`Could not delete branch ${info.branch}: ${e}`);
142
+ }
143
+ }
144
+
145
+ result.success = true;
146
+ return result;
147
+ }
148
+
149
+ /**
150
+ * Options for project deletion
151
+ */
152
+ export interface DeleteProjectOptions {
153
+ /**
154
+ * Run in non-interactive mode (for TUI/daemon/remote contexts).
155
+ * When true, remove scripts run with stdin closed.
156
+ */
157
+ nonInteractive?: boolean;
158
+ }
159
+
160
+ /**
161
+ * Result of project deletion
162
+ */
163
+ export interface DeleteProjectResult {
164
+ success: boolean;
165
+ projectName: string;
166
+ workspacesDeleted: number;
167
+ sessionsKilled: number;
168
+ wasCurrentProject: boolean;
169
+ errors: string[];
170
+ }
171
+
172
+ /**
173
+ * Core project deletion logic
174
+ * Tears down sessions, runs remove scripts for all workspaces, then deletes project
175
+ *
176
+ * @param projectName - Name of the project to delete
177
+ * @param options - Deletion options
178
+ */
179
+ export async function deleteProjectCore(
180
+ projectName: string,
181
+ options: DeleteProjectOptions = {}
182
+ ): Promise<DeleteProjectResult> {
183
+ const projectDir = getProjectDir(projectName);
184
+ const workspacesDir = getProjectWorkspacesDir(projectName);
185
+
186
+ const result: DeleteProjectResult = {
187
+ success: false,
188
+ projectName,
189
+ workspacesDeleted: 0,
190
+ sessionsKilled: 0,
191
+ wasCurrentProject: false,
192
+ errors: [],
193
+ };
194
+
195
+ // Validate project directory exists before attempting deletion
196
+ if (!existsSync(projectDir)) {
197
+ result.errors.push(`Project "${projectName}" does not exist`);
198
+ return result;
199
+ }
200
+
201
+ // Get list of workspaces
202
+ let workspaceNames: string[] = [];
203
+ if (existsSync(workspacesDir)) {
204
+ try {
205
+ workspaceNames = readdirSync(workspacesDir);
206
+ } catch (e) {
207
+ logger.debug(`Could not read workspaces dir: ${e}`);
208
+ }
209
+ }
210
+
211
+ // Delete each workspace (this handles session teardown and remove scripts)
212
+ for (const workspaceName of workspaceNames) {
213
+ try {
214
+ const wsResult = await deleteWorkspaceCore(projectName, workspaceName, {
215
+ nonInteractive: options.nonInteractive,
216
+ keepBranch: true, // Don't try to delete branches, we're removing the whole repo
217
+ });
218
+ if (wsResult.success) {
219
+ result.workspacesDeleted++;
220
+ result.sessionsKilled += wsResult.sessionsKilled;
221
+ } else if (wsResult.error) {
222
+ result.errors.push(`${workspaceName}: ${wsResult.error}`);
223
+ }
224
+ } catch (e) {
225
+ const msg = e instanceof Error ? e.message : String(e);
226
+ result.errors.push(`${workspaceName}: ${msg}`);
227
+ logger.debug(`Failed to delete workspace ${workspaceName}: ${e}`);
228
+ }
229
+ }
230
+
231
+ // Remove entire project directory
232
+ try {
233
+ rmSync(projectDir, { recursive: true, force: true });
234
+ } catch (e) {
235
+ const msg = e instanceof Error ? e.message : 'Failed to remove project directory';
236
+ result.errors.push(msg);
237
+ return result;
238
+ }
239
+
240
+ // Update global config if this was the current project
241
+ const globalConfig = readGlobalConfig();
242
+ if (globalConfig.currentProject === projectName) {
243
+ updateGlobalConfig({ currentProject: null });
244
+ result.wasCurrentProject = true;
245
+ }
246
+
247
+ result.success = true;
248
+ return result;
249
+ }