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 +1 -1
- package/bun.lock +8 -0
- package/docs/SITE_DOCS_FIGMA_MAKE.md +8 -8
- package/package.json +5 -5
- package/src/commands/remove.ts +66 -50
- package/src/core/__tests__/workspace.test.ts +149 -0
- package/src/core/bundle.ts +1 -1
- package/src/core/workspace.ts +249 -0
- package/src/index.ts +7 -1
- package/src/lib/remote-session/session-handler.ts +31 -21
- package/src/tui/app.tsx +178 -25
- package/src/utils/__tests__/run-scripts.test.ts +306 -0
- package/src/utils/__tests__/run-workspace-scripts.test.ts +252 -0
- package/src/utils/run-scripts.ts +27 -3
- package/src/utils/run-workspace-scripts.ts +89 -0
- package/src/utils/sanitize.ts +64 -0
- package/src/utils/workspace-state.ts +17 -1
- package/src/version.generated.d.ts +2 -0
- package/.claude/settings.local.json +0 -25
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
|
|
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
|
|
314
|
+
Bundles allow repository owners to share onboarding configurations. Place in `.gitspace/` in your repo:
|
|
315
315
|
|
|
316
316
|
```
|
|
317
|
-
.gitspace
|
|
318
|
-
├──
|
|
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 (`
|
|
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
|
|
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
|
|
923
|
+
Bundles allow teams to share onboarding configurations. Place in `.gitspace/`:
|
|
924
924
|
|
|
925
925
|
```
|
|
926
|
-
.gitspace
|
|
927
|
-
├──
|
|
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.
|
|
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.
|
|
21
|
-
"@gitspace/darwin-x64": "0.2.0-rc.
|
|
22
|
-
"@gitspace/linux-x64": "0.2.0-rc.
|
|
23
|
-
"@gitspace/linux-arm64": "0.2.0-rc.
|
|
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",
|
package/src/commands/remove.ts
CHANGED
|
@@ -3,31 +3,27 @@
|
|
|
3
3
|
* Handles 'gssh remove workspace' and 'gssh remove project'
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync,
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
//
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
//
|
|
230
|
-
logger.info('Removing project
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
});
|
package/src/core/bundle.ts
CHANGED
|
@@ -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'
|
|
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
|
+
}
|