infra-kit 0.1.95 → 0.1.98
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/.eslintcache +1 -1
- package/.turbo/turbo-eslint-check.log +1 -1
- package/.turbo/turbo-prettier-check.log +1 -4
- package/.turbo/turbo-test.log +172 -63
- package/.turbo/turbo-ts-check.log +5 -6
- package/dist/cli.js +57 -36
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +24 -22
- package/dist/mcp.js.map +4 -4
- package/package.json +2 -2
- package/src/commands/config/config.ts +125 -0
- package/src/commands/config/index.ts +1 -0
- package/src/commands/doctor/doctor.ts +27 -18
- package/src/commands/init/init.ts +54 -1
- package/src/commands/release-create/release-create.ts +123 -72
- package/src/commands/release-create-batch/release-create-batch.ts +45 -21
- package/src/commands/worktrees-open/index.ts +1 -0
- package/src/commands/worktrees-open/worktrees-open.ts +197 -0
- package/src/commands/worktrees-remove/worktrees-remove.ts +4 -2
- package/src/entry/cli.ts +35 -6
- package/src/integrations/cmux/index.ts +1 -0
- package/src/integrations/cmux/list-workspace-titles.ts +42 -0
- package/src/integrations/cursor/index.ts +1 -0
- package/src/integrations/cursor/reconcile-workspace-folders.ts +90 -0
- package/src/lib/__tests__/infra-kit-config.test.ts +3 -1
- package/src/lib/git-utils/git-utils.ts +27 -8
- package/src/lib/infra-kit-config/index.ts +2 -2
- package/src/lib/infra-kit-config/infra-kit-config.ts +183 -37
- package/src/lib/version-utils/__tests__/next-version.test.ts +112 -0
- package/src/lib/version-utils/index.ts +11 -0
- package/src/lib/version-utils/load-existing-versions.ts +67 -0
- package/src/lib/version-utils/next-version.ts +148 -0
- package/src/mcp/tools/index.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { z } from 'zod/v4'
|
|
2
|
+
import { $ } from 'zx'
|
|
3
|
+
|
|
4
|
+
import { buildCmuxWorkspaceTitle, listCmuxWorkspaceTitles, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
|
|
5
|
+
import { reconcileCursorWorkspaceFolders, resolveCursorWorkspacePath } from 'src/integrations/cursor'
|
|
6
|
+
import { commandEcho } from 'src/lib/command-echo'
|
|
7
|
+
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
8
|
+
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
9
|
+
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
10
|
+
import { logger } from 'src/lib/logger'
|
|
11
|
+
import type { ToolsExecutionResult } from 'src/types'
|
|
12
|
+
|
|
13
|
+
interface WorktreesOpenResult {
|
|
14
|
+
openedCmux: string[]
|
|
15
|
+
skippedCmux: string[]
|
|
16
|
+
cursorFoldersAdded: number
|
|
17
|
+
cursorFoldersRemoved: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Cold-start restore command: reconciles `Main.code-workspace` against the set
|
|
22
|
+
* of release worktrees on disk, opens Cursor against it, and ensures one cmux
|
|
23
|
+
* workspace exists per worktree. Idempotent and additive — never removes
|
|
24
|
+
* worktrees, never recreates running cmux workspaces.
|
|
25
|
+
*/
|
|
26
|
+
export const worktreesOpen = async (): Promise<ToolsExecutionResult> => {
|
|
27
|
+
commandEcho.start('worktrees-open')
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const projectRoot = await getProjectRoot()
|
|
31
|
+
const worktreeDir = `${projectRoot}${WORKTREES_DIR_SUFFIX}`
|
|
32
|
+
const currentBranches = await getCurrentWorktrees('release')
|
|
33
|
+
|
|
34
|
+
const cursorOutcome = await openCursor({ projectRoot, worktreeDir, currentBranches })
|
|
35
|
+
const cmuxOutcome = await openCmux({ worktreeDir, currentBranches })
|
|
36
|
+
|
|
37
|
+
const result: WorktreesOpenResult = {
|
|
38
|
+
openedCmux: cmuxOutcome.opened,
|
|
39
|
+
skippedCmux: cmuxOutcome.skipped,
|
|
40
|
+
cursorFoldersAdded: cursorOutcome.added,
|
|
41
|
+
cursorFoldersRemoved: cursorOutcome.removed,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
logResults(result, { cursorRan: cursorOutcome.ran, cmuxRan: cmuxOutcome.ran })
|
|
45
|
+
|
|
46
|
+
commandEcho.print()
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
50
|
+
structuredContent: { ...result },
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logger.error({ error }, '❌ Error opening worktrees')
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface OpenCursorArgs {
|
|
59
|
+
projectRoot: string
|
|
60
|
+
worktreeDir: string
|
|
61
|
+
currentBranches: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface OpenCursorOutcome {
|
|
65
|
+
ran: boolean
|
|
66
|
+
added: number
|
|
67
|
+
removed: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const openCursor = async (args: OpenCursorArgs): Promise<OpenCursorOutcome> => {
|
|
71
|
+
const { projectRoot, worktreeDir, currentBranches } = args
|
|
72
|
+
|
|
73
|
+
const config = await getInfraKitConfig()
|
|
74
|
+
const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
|
|
75
|
+
|
|
76
|
+
if (!cursorConfig || cursorConfig.mode !== 'workspace' || !cursorConfig.workspaceConfigPath) {
|
|
77
|
+
logger.warn('⚠️ Skipping Cursor: ide.provider must be "cursor", mode "workspace", and workspaceConfigPath set.')
|
|
78
|
+
|
|
79
|
+
return { ran: false, added: 0, removed: 0 }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const { added, removed } = await reconcileCursorWorkspaceFolders({
|
|
86
|
+
workspacePath,
|
|
87
|
+
worktreeDir,
|
|
88
|
+
currentBranches,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
await $`cursor ${workspacePath}`
|
|
92
|
+
|
|
93
|
+
return { ran: true, added: added.length, removed: removed.length }
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.warn({ error }, `⚠️ Failed to reconcile/open Cursor workspace at ${workspacePath}`)
|
|
96
|
+
|
|
97
|
+
return { ran: false, added: 0, removed: 0 }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface OpenCmuxArgs {
|
|
102
|
+
worktreeDir: string
|
|
103
|
+
currentBranches: string[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface OpenCmuxOutcome {
|
|
107
|
+
ran: boolean
|
|
108
|
+
opened: string[]
|
|
109
|
+
skipped: string[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> => {
|
|
113
|
+
const { worktreeDir, currentBranches } = args
|
|
114
|
+
|
|
115
|
+
if (currentBranches.length === 0) {
|
|
116
|
+
return { ran: true, opened: [], skipped: [] }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const repoName = await getRepoName()
|
|
120
|
+
const existingTitles = await listCmuxWorkspaceTitles()
|
|
121
|
+
|
|
122
|
+
const opened: string[] = []
|
|
123
|
+
const skipped: string[] = []
|
|
124
|
+
|
|
125
|
+
for (const branch of currentBranches) {
|
|
126
|
+
const title = buildCmuxWorkspaceTitle({ repoName, branch })
|
|
127
|
+
|
|
128
|
+
if (existingTitles.has(title)) {
|
|
129
|
+
skipped.push(title)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await openCmuxWorkspaceWithLayout({ cwd: `${worktreeDir}/${branch}`, title })
|
|
135
|
+
opened.push(title)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
logger.warn({ error, title }, `⚠️ Failed to open cmux workspace for ${branch}`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { ran: true, opened, skipped }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface LogResultsContext {
|
|
145
|
+
cursorRan: boolean
|
|
146
|
+
cmuxRan: boolean
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const logResults = (result: WorktreesOpenResult, context: LogResultsContext): void => {
|
|
150
|
+
if (context.cursorRan) {
|
|
151
|
+
if (result.cursorFoldersAdded > 0) {
|
|
152
|
+
logger.info(`✅ Added ${result.cursorFoldersAdded} folder(s) to Cursor workspace`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (result.cursorFoldersRemoved > 0) {
|
|
156
|
+
logger.info(`🧹 Removed ${result.cursorFoldersRemoved} dangling folder(s) from Cursor workspace`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (result.openedCmux.length > 0) {
|
|
161
|
+
logger.info('✅ Opened cmux workspaces:')
|
|
162
|
+
for (const title of result.openedCmux) {
|
|
163
|
+
logger.info(title)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (result.skippedCmux.length > 0) {
|
|
168
|
+
logger.info(`ℹ️ Skipped ${result.skippedCmux.length} cmux workspace(s) already open`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
!context.cursorRan &&
|
|
173
|
+
result.openedCmux.length === 0 &&
|
|
174
|
+
result.skippedCmux.length === 0 &&
|
|
175
|
+
result.cursorFoldersAdded === 0 &&
|
|
176
|
+
result.cursorFoldersRemoved === 0
|
|
177
|
+
) {
|
|
178
|
+
logger.info('ℹ️ Nothing to open')
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// MCP Tool Registration
|
|
183
|
+
export const worktreesOpenMcpTool = {
|
|
184
|
+
name: 'worktrees-open',
|
|
185
|
+
description:
|
|
186
|
+
'Open Cursor against the configured workspace file and ensure a cmux workspace exists for each existing release worktree. Idempotent and additive — never removes worktrees, never recreates running cmux workspaces. Use after a cold start (Cursor + cmux closed). For stale-worktree cleanup, use worktrees-sync.',
|
|
187
|
+
inputSchema: {},
|
|
188
|
+
outputSchema: {
|
|
189
|
+
openedCmux: z.array(z.string()).describe('Titles of cmux workspaces opened during this run'),
|
|
190
|
+
skippedCmux: z.array(z.string()).describe('Titles of cmux workspaces that were already open'),
|
|
191
|
+
cursorFoldersAdded: z.number().describe('Number of worktree folders added to the Cursor workspace file'),
|
|
192
|
+
cursorFoldersRemoved: z
|
|
193
|
+
.number()
|
|
194
|
+
.describe('Number of dangling worktree folders removed from the Cursor workspace file'),
|
|
195
|
+
},
|
|
196
|
+
handler: worktreesOpen,
|
|
197
|
+
}
|
|
@@ -116,6 +116,7 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
|
|
|
116
116
|
branches: selectedReleaseBranches,
|
|
117
117
|
worktreeDir,
|
|
118
118
|
repoName,
|
|
119
|
+
allSelected,
|
|
119
120
|
})
|
|
120
121
|
|
|
121
122
|
await syncCursorWorkspaceOnRemove({ removedWorktrees, worktreeDir, projectRoot })
|
|
@@ -148,13 +149,14 @@ interface RemoveWorktreesArgs {
|
|
|
148
149
|
branches: string[]
|
|
149
150
|
worktreeDir: string
|
|
150
151
|
repoName: string
|
|
152
|
+
allSelected: boolean
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
/**
|
|
154
156
|
* Remove worktrees for the specified branches and whole folder
|
|
155
157
|
*/
|
|
156
158
|
const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> => {
|
|
157
|
-
const { branches, worktreeDir, repoName } = args
|
|
159
|
+
const { branches, worktreeDir, repoName, allSelected } = args
|
|
158
160
|
|
|
159
161
|
const results = await Promise.allSettled(
|
|
160
162
|
branches.map(async (branch) => {
|
|
@@ -182,7 +184,7 @@ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> =>
|
|
|
182
184
|
}
|
|
183
185
|
}
|
|
184
186
|
|
|
185
|
-
if (removed.length === branches.length) {
|
|
187
|
+
if (allSelected && removed.length === branches.length) {
|
|
186
188
|
await $`git worktree prune`
|
|
187
189
|
await $`rm -rf ${worktreeDir}`
|
|
188
190
|
|
package/src/entry/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@ import select, { Separator } from '@inquirer/select'
|
|
|
2
2
|
import { Command, Option } from 'commander'
|
|
3
3
|
import process from 'node:process'
|
|
4
4
|
|
|
5
|
+
import { configEdit, configPath } from 'src/commands/config'
|
|
5
6
|
import { doctor } from 'src/commands/doctor'
|
|
6
7
|
import { envClear } from 'src/commands/env-clear'
|
|
7
8
|
import { envList } from 'src/commands/env-list'
|
|
@@ -19,6 +20,7 @@ import { version } from 'src/commands/version'
|
|
|
19
20
|
import { CURSOR_MODES, worktreesAdd } from 'src/commands/worktrees-add'
|
|
20
21
|
import type { CursorMode } from 'src/commands/worktrees-add'
|
|
21
22
|
import { worktreesList } from 'src/commands/worktrees-list'
|
|
23
|
+
import { worktreesOpen } from 'src/commands/worktrees-open'
|
|
22
24
|
import { worktreesRemove } from 'src/commands/worktrees-remove'
|
|
23
25
|
import { worktreesSync } from 'src/commands/worktrees-sync'
|
|
24
26
|
import { logger } from 'src/lib/logger'
|
|
@@ -79,25 +81,29 @@ program
|
|
|
79
81
|
program
|
|
80
82
|
.command('release-create')
|
|
81
83
|
.description('Create a single release branch')
|
|
82
|
-
.option(
|
|
84
|
+
.option(
|
|
85
|
+
'-v, --version <version>',
|
|
86
|
+
'Version to create, e.g. "1.2.5", "next", or "next,next,1.2.7" (multi-value routes to batch)',
|
|
87
|
+
)
|
|
83
88
|
.option('-d, --description <description>', 'Optional description for the Jira version')
|
|
84
89
|
.addOption(new Option('-t, --type <type>', 'Release type (default: regular)').choices(['regular', 'hotfix']))
|
|
85
90
|
.option('-y, --yes', 'Skip confirmation prompt')
|
|
86
|
-
.option('--no-checkout', 'Do not checkout the created branch after creation (checkout is default)')
|
|
87
91
|
.action(async (options) => {
|
|
88
92
|
await releaseCreate({
|
|
89
93
|
version: options.version,
|
|
90
94
|
description: options.description,
|
|
91
95
|
type: options.type,
|
|
92
96
|
confirmedCommand: options.yes,
|
|
93
|
-
checkout: options.checkout,
|
|
94
97
|
})
|
|
95
98
|
})
|
|
96
99
|
|
|
97
100
|
program
|
|
98
101
|
.command('release-create-batch')
|
|
99
102
|
.description('Create multiple release branches (batch operation)')
|
|
100
|
-
.option(
|
|
103
|
+
.option(
|
|
104
|
+
'-v, --versions <versions>',
|
|
105
|
+
'Comma-separated versions, e.g. "1.2.5, 1.2.6", "next,next", or "next,next,1.2.7"',
|
|
106
|
+
)
|
|
101
107
|
.addOption(new Option('-t, --type <type>', 'Release type (default: regular)').choices(['regular', 'hotfix']))
|
|
102
108
|
.option('-y, --yes', 'Skip confirmation prompt')
|
|
103
109
|
.action(async (options) => {
|
|
@@ -191,6 +197,29 @@ program
|
|
|
191
197
|
await worktreesRemove({ confirmedCommand: options.yes, all: options.all, versions: options.versions })
|
|
192
198
|
})
|
|
193
199
|
|
|
200
|
+
program
|
|
201
|
+
.command('worktrees-open')
|
|
202
|
+
.description('Open Cursor + cmux for existing release worktrees (cold-start restore)')
|
|
203
|
+
.action(async () => {
|
|
204
|
+
await worktreesOpen()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const configCmd = program.command('config').description('Manage infra-kit configuration files')
|
|
208
|
+
|
|
209
|
+
configCmd
|
|
210
|
+
.command('path')
|
|
211
|
+
.description('Show the resolved config merge chain and file paths')
|
|
212
|
+
.action(async () => {
|
|
213
|
+
await configPath()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
configCmd
|
|
217
|
+
.command('edit')
|
|
218
|
+
.description('Open the user-scope per-project override file in $EDITOR')
|
|
219
|
+
.action(async () => {
|
|
220
|
+
await configEdit()
|
|
221
|
+
})
|
|
222
|
+
|
|
194
223
|
program
|
|
195
224
|
.command('doctor')
|
|
196
225
|
.description('Check installation and authentication status of gh and doppler CLIs')
|
|
@@ -251,8 +280,8 @@ if (process.argv.length <= 2) {
|
|
|
251
280
|
'release-deploy-selected',
|
|
252
281
|
'release-deliver',
|
|
253
282
|
]
|
|
254
|
-
const worktreeCommands = ['worktrees-add', 'worktrees-list', 'worktrees-remove', 'worktrees-sync']
|
|
255
|
-
const envCommands = ['doctor', 'init', 'version', 'env-status', 'env-list', 'env-load', 'env-clear']
|
|
283
|
+
const worktreeCommands = ['worktrees-add', 'worktrees-list', 'worktrees-open', 'worktrees-remove', 'worktrees-sync']
|
|
284
|
+
const envCommands = ['doctor', 'init', 'version', 'config', 'env-status', 'env-list', 'env-load', 'env-clear']
|
|
256
285
|
|
|
257
286
|
const commandMap = new Map(
|
|
258
287
|
program.commands.map((cmd) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { closeCmuxWorkspaceByTitle } from './close-workspace-by-title'
|
|
2
|
+
export { listCmuxWorkspaceTitles } from './list-workspace-titles'
|
|
2
3
|
export { openCmuxWorkspaceWithLayout } from './open-workspace-with-layout'
|
|
3
4
|
export { buildCmuxWorkspaceTitle } from './workspace-title'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { $ } from 'zx'
|
|
2
|
+
|
|
3
|
+
import { logger } from 'src/lib/logger'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the set of titles for all currently-open cmux workspaces.
|
|
7
|
+
* Returns an empty set if cmux isn't running, the call fails, or the
|
|
8
|
+
* output can't be parsed — callers should treat "empty" as "unknown,
|
|
9
|
+
* proceed as if nothing is open".
|
|
10
|
+
*
|
|
11
|
+
* Each line of `cmux list-workspaces` looks like:
|
|
12
|
+
* " workspace:8 hulyo-monorepo v1.48.0"
|
|
13
|
+
* "* workspace:6 obsidian-workspace [selected]"
|
|
14
|
+
*/
|
|
15
|
+
export const listCmuxWorkspaceTitles = async (): Promise<Set<string>> => {
|
|
16
|
+
try {
|
|
17
|
+
const output = (await $`cmux list-workspaces`.quiet()).stdout
|
|
18
|
+
|
|
19
|
+
const titles = new Set<string>()
|
|
20
|
+
|
|
21
|
+
for (const rawLine of output.split('\n')) {
|
|
22
|
+
// eslint-disable-next-line sonarjs/slow-regex, regexp/no-super-linear-backtracking
|
|
23
|
+
const match = rawLine.match(/^[* ]\s*workspace:\d+\s+(.+?)(?:\s+\[selected\])?\s*$/)
|
|
24
|
+
|
|
25
|
+
if (!match) {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const title = match[1]?.trim()
|
|
30
|
+
|
|
31
|
+
if (title) {
|
|
32
|
+
titles.add(title)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return titles
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger.debug({ error }, 'cmux: skipped listing workspace titles')
|
|
39
|
+
|
|
40
|
+
return new Set()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { addFoldersToCursorWorkspace } from './add-folders-to-workspace'
|
|
2
|
+
export { reconcileCursorWorkspaceFolders } from './reconcile-workspace-folders'
|
|
2
3
|
export { removeFoldersFromCursorWorkspace } from './remove-folders-from-workspace'
|
|
3
4
|
export { resolveCursorWorkspacePath } from './resolve-workspace-path'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { addFoldersToCursorWorkspace } from './add-folders-to-workspace'
|
|
5
|
+
import { removeFoldersFromCursorWorkspace } from './remove-folders-from-workspace'
|
|
6
|
+
|
|
7
|
+
interface ReconcileCursorWorkspaceFoldersArgs {
|
|
8
|
+
workspacePath: string
|
|
9
|
+
worktreeDir: string
|
|
10
|
+
currentBranches: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ReconcileCursorWorkspaceFoldersResult {
|
|
14
|
+
added: string[]
|
|
15
|
+
removed: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface WorkspaceFolderEntry {
|
|
19
|
+
path: string
|
|
20
|
+
name?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface WorkspaceFile {
|
|
24
|
+
folders?: WorkspaceFolderEntry[]
|
|
25
|
+
[key: string]: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reconciles the configured Cursor workspace's `folders` array against the
|
|
30
|
+
* actual set of release worktrees on disk:
|
|
31
|
+
* - Adds any worktree folders that aren't already listed.
|
|
32
|
+
* - Removes entries whose absolute path lives under `${worktreeDir}/release/`
|
|
33
|
+
* but no longer corresponds to a current branch (drift from manual
|
|
34
|
+
* `git worktree remove`, deleted branches, etc.).
|
|
35
|
+
*
|
|
36
|
+
* Non-worktree folder entries are left untouched.
|
|
37
|
+
*/
|
|
38
|
+
export const reconcileCursorWorkspaceFolders = async (
|
|
39
|
+
args: ReconcileCursorWorkspaceFoldersArgs,
|
|
40
|
+
): Promise<ReconcileCursorWorkspaceFoldersResult> => {
|
|
41
|
+
const { workspacePath, worktreeDir, currentBranches } = args
|
|
42
|
+
|
|
43
|
+
const workspaceDir = path.dirname(workspacePath)
|
|
44
|
+
const releaseRoot = path.resolve(`${worktreeDir}/release`)
|
|
45
|
+
|
|
46
|
+
const raw = await fs.readFile(workspacePath, 'utf-8')
|
|
47
|
+
const parsed = JSON.parse(raw) as WorkspaceFile
|
|
48
|
+
|
|
49
|
+
const existingFolders = parsed.folders ?? []
|
|
50
|
+
|
|
51
|
+
const desiredAbsolutePaths = new Set(
|
|
52
|
+
currentBranches.map((branch) => {
|
|
53
|
+
return path.resolve(`${worktreeDir}/${branch}`)
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const danglingFolderPaths: string[] = []
|
|
58
|
+
|
|
59
|
+
for (const entry of existingFolders) {
|
|
60
|
+
const entryAbsolutePath = path.resolve(workspaceDir, entry.path)
|
|
61
|
+
|
|
62
|
+
const isReleaseShaped = entryAbsolutePath === releaseRoot || entryAbsolutePath.startsWith(`${releaseRoot}/`)
|
|
63
|
+
|
|
64
|
+
if (isReleaseShaped && !desiredAbsolutePaths.has(entryAbsolutePath)) {
|
|
65
|
+
danglingFolderPaths.push(entryAbsolutePath)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let removed: string[] = []
|
|
70
|
+
|
|
71
|
+
if (danglingFolderPaths.length > 0) {
|
|
72
|
+
const result = await removeFoldersFromCursorWorkspace({
|
|
73
|
+
workspacePath,
|
|
74
|
+
folderPaths: danglingFolderPaths,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
removed = result.removed
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const desiredFolderPaths = currentBranches.map((branch) => {
|
|
81
|
+
return `${worktreeDir}/${branch}`
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const { added } =
|
|
85
|
+
desiredFolderPaths.length > 0
|
|
86
|
+
? await addFoldersToCursorWorkspace({ workspacePath, folderPaths: desiredFolderPaths })
|
|
87
|
+
: { added: [] as string[] }
|
|
88
|
+
|
|
89
|
+
return { added, removed }
|
|
90
|
+
}
|
|
@@ -4,13 +4,14 @@ import path from 'node:path'
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
|
|
6
6
|
// Import AFTER the mock is declared so the module picks up the mocked dep.
|
|
7
|
-
import { getProjectRoot } from 'src/lib/git-utils'
|
|
7
|
+
import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
8
8
|
|
|
9
9
|
import { getInfraKitConfig, resetInfraKitConfigCache } from '../infra-kit-config'
|
|
10
10
|
|
|
11
11
|
vi.mock('src/lib/git-utils', () => {
|
|
12
12
|
return {
|
|
13
13
|
getProjectRoot: vi.fn(),
|
|
14
|
+
getRepoName: vi.fn(),
|
|
14
15
|
}
|
|
15
16
|
})
|
|
16
17
|
|
|
@@ -35,6 +36,7 @@ const withTmpRepo = async (fn: (tmp: string) => Promise<void>): Promise<void> =>
|
|
|
35
36
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'infra-kit-config-test-'))
|
|
36
37
|
|
|
37
38
|
vi.mocked(getProjectRoot).mockResolvedValue(tmp)
|
|
39
|
+
vi.mocked(getRepoName).mockResolvedValue(path.basename(tmp))
|
|
38
40
|
resetInfraKitConfigCache()
|
|
39
41
|
|
|
40
42
|
try {
|
|
@@ -21,6 +21,29 @@ export const getCurrentWorktrees = async (type: 'release' | 'feature'): Promise<
|
|
|
21
21
|
})
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Extract the branch name from a `git worktree list` output line.
|
|
26
|
+
*
|
|
27
|
+
* `git worktree list` formats each line as:
|
|
28
|
+
* <path> <hash> [<branch>]
|
|
29
|
+
*
|
|
30
|
+
* Reads the branch from the trailing `[branch]` token so it works for the
|
|
31
|
+
* main checkout too (whose path does not encode the branch name).
|
|
32
|
+
*/
|
|
33
|
+
const parseWorktreeBranch = (line: string): string | null => {
|
|
34
|
+
const trimmed = line.trimEnd()
|
|
35
|
+
|
|
36
|
+
if (!trimmed.endsWith(']')) return null
|
|
37
|
+
|
|
38
|
+
const open = trimmed.lastIndexOf('[')
|
|
39
|
+
|
|
40
|
+
if (open === -1) return null
|
|
41
|
+
|
|
42
|
+
const branch = trimmed.slice(open + 1, -1)
|
|
43
|
+
|
|
44
|
+
return branch.length > 0 ? branch : null
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
/**
|
|
25
48
|
* Extract a release branch name from a `git worktree list` output line.
|
|
26
49
|
*
|
|
@@ -35,11 +58,9 @@ export const getCurrentWorktrees = async (type: 'release' | 'feature'): Promise<
|
|
|
35
58
|
* // => null
|
|
36
59
|
*/
|
|
37
60
|
const releaseWorktreePredicate = (line: string): string | null => {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (parts.length < 3 || !parts[0]?.includes('release/v')) return null
|
|
61
|
+
const branch = parseWorktreeBranch(line)
|
|
41
62
|
|
|
42
|
-
return
|
|
63
|
+
return branch?.startsWith('release/v') ? branch : null
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
/**
|
|
@@ -56,11 +77,9 @@ const releaseWorktreePredicate = (line: string): string | null => {
|
|
|
56
77
|
* // => null
|
|
57
78
|
*/
|
|
58
79
|
const featureWorktreePredicate = (line: string): string | null => {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
if (parts.length < 3 || !parts[0]?.includes('feature/')) return null
|
|
80
|
+
const branch = parseWorktreeBranch(line)
|
|
62
81
|
|
|
63
|
-
return
|
|
82
|
+
return branch?.startsWith('feature/') ? branch : null
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
/**
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { getInfraKitConfig, resetInfraKitConfigCache } from './infra-kit-config'
|
|
2
|
-
export type { InfraKitConfig } from './infra-kit-config'
|
|
1
|
+
export { getInfraKitConfig, getInfraKitConfigPaths, resetInfraKitConfigCache } from './infra-kit-config'
|
|
2
|
+
export type { InfraKitConfig, InfraKitConfigPaths } from './infra-kit-config'
|