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.
Files changed (34) hide show
  1. package/.eslintcache +1 -1
  2. package/.turbo/turbo-eslint-check.log +1 -1
  3. package/.turbo/turbo-prettier-check.log +1 -4
  4. package/.turbo/turbo-test.log +172 -63
  5. package/.turbo/turbo-ts-check.log +5 -6
  6. package/dist/cli.js +57 -36
  7. package/dist/cli.js.map +4 -4
  8. package/dist/mcp.js +24 -22
  9. package/dist/mcp.js.map +4 -4
  10. package/package.json +2 -2
  11. package/src/commands/config/config.ts +125 -0
  12. package/src/commands/config/index.ts +1 -0
  13. package/src/commands/doctor/doctor.ts +27 -18
  14. package/src/commands/init/init.ts +54 -1
  15. package/src/commands/release-create/release-create.ts +123 -72
  16. package/src/commands/release-create-batch/release-create-batch.ts +45 -21
  17. package/src/commands/worktrees-open/index.ts +1 -0
  18. package/src/commands/worktrees-open/worktrees-open.ts +197 -0
  19. package/src/commands/worktrees-remove/worktrees-remove.ts +4 -2
  20. package/src/entry/cli.ts +35 -6
  21. package/src/integrations/cmux/index.ts +1 -0
  22. package/src/integrations/cmux/list-workspace-titles.ts +42 -0
  23. package/src/integrations/cursor/index.ts +1 -0
  24. package/src/integrations/cursor/reconcile-workspace-folders.ts +90 -0
  25. package/src/lib/__tests__/infra-kit-config.test.ts +3 -1
  26. package/src/lib/git-utils/git-utils.ts +27 -8
  27. package/src/lib/infra-kit-config/index.ts +2 -2
  28. package/src/lib/infra-kit-config/infra-kit-config.ts +183 -37
  29. package/src/lib/version-utils/__tests__/next-version.test.ts +112 -0
  30. package/src/lib/version-utils/index.ts +11 -0
  31. package/src/lib/version-utils/load-existing-versions.ts +67 -0
  32. package/src/lib/version-utils/next-version.ts +148 -0
  33. package/src/mcp/tools/index.ts +2 -0
  34. 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('-v, --version <version>', 'Specify the version to create, e.g. 1.2.5')
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('-v, --versions <versions>', 'Specify the versions to create by comma, e.g. 1.2.5, 1.2.6')
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 parts = line.split(' ').filter(Boolean)
39
-
40
- if (parts.length < 3 || !parts[0]?.includes('release/v')) return null
61
+ const branch = parseWorktreeBranch(line)
41
62
 
42
- return `release/${parts[0]?.split('/').pop() || ''}`
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 parts = line.split(' ').filter(Boolean)
60
-
61
- if (parts.length < 3 || !parts[0]?.includes('feature/')) return null
80
+ const branch = parseWorktreeBranch(line)
62
81
 
63
- return `feature/${parts[0]?.split('/').pop() || ''}`
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'