infra-kit 0.1.105 → 0.1.108
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/.omc/state/agent-replay-36d91435-50b4-442f-9f4e-3cc68d776236.jsonl +2 -0
- package/.omc/state/agent-replay-afc6290b-40d3-4bef-b3b6-14484c034ab9.jsonl +14 -0
- package/.omc/state/agent-replay-e947a3c6-989d-4a60-91dd-6b0ddd827b2d.jsonl +3 -0
- package/.omc/state/idle-notif-cooldown.json +1 -1
- package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/subagent-tracking-state.json +17 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/mission-state.json +89 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +34 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ralph-state.json +13 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/skill-active-state.json +15 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/subagent-tracking-state.json +35 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ultrawork-state.json +11 -0
- package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/pre-tool-advisory-throttle.json +10 -0
- package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/subagent-tracking-state.json +26 -0
- package/.turbo/turbo-build.log +2 -2
- package/.turbo/turbo-test.log +43 -6
- package/dist/cli.js +61 -54
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +31 -31
- package/dist/mcp.js.map +4 -4
- package/package.json +1 -1
- package/src/commands/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +14 -0
- package/src/commands/doctor/__tests__/agent-files.test.ts +110 -0
- package/src/commands/doctor/doctor.ts +66 -1
- package/src/commands/init/__tests__/agent-files.test.ts +147 -0
- package/src/commands/init/agent-files.ts +199 -0
- package/src/commands/init/index.ts +7 -0
- package/src/commands/init/init.ts +34 -25
- package/src/commands/worktrees-add/worktrees-add.ts +13 -1
- package/src/commands/worktrees-open/__tests__/open-cmux.test.ts +96 -0
- package/src/commands/worktrees-open/worktrees-open.ts +10 -3
- package/src/entry/cli.ts +1 -1
- package/src/integrations/cmux/__tests__/canonicalize-cmux-title.test.ts +56 -0
- package/src/integrations/cmux/__tests__/close-workspace-by-title.test.ts +63 -0
- package/src/integrations/cmux/__tests__/list-workspace-titles.test.ts +65 -0
- package/src/integrations/cmux/canonicalize-cmux-title.ts +31 -0
- package/src/integrations/cmux/close-workspace-by-title.ts +12 -4
- package/src/integrations/cmux/index.ts +1 -0
- package/src/integrations/cmux/list-workspace-titles.ts +11 -6
- package/src/integrations/cmux/open-workspace-with-layout.ts +1 -1
- package/src/integrations/gh/gh-release-prs/.omc/state/sessions/c6ed6186-1aac-48e0-aa2a-edc6da8e0410/pre-tool-advisory-throttle.json +10 -0
- package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +89 -6
- package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +1 -1
- package/src/lib/managed-block/__tests__/managed-block.test.ts +121 -0
- package/src/lib/managed-block/index.ts +8 -0
- package/src/lib/managed-block/managed-block.ts +145 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -3,7 +3,9 @@ import os from 'node:os'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { logger } from 'src/lib/logger'
|
|
6
|
+
import { removeManagedBlock, upsertManagedBlock } from 'src/lib/managed-block'
|
|
6
7
|
|
|
8
|
+
import { writeAgentFiles } from './agent-files'
|
|
7
9
|
import { migrateLegacyConfig } from './migrate-config'
|
|
8
10
|
|
|
9
11
|
export const MARKER_START = '# -- infra-kit:begin --'
|
|
@@ -55,16 +57,22 @@ const USER_GLOBAL_CONFIG_EXAMPLE = `// infra-kit user-global config — ~/.infra
|
|
|
55
57
|
*/
|
|
56
58
|
export const init = async (): Promise<void> => {
|
|
57
59
|
const zshrcPath = path.join(os.homedir(), '.zshrc')
|
|
58
|
-
const shellBlock = buildShellBlock()
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Strip any prior block (current or legacy markers) anywhere in the file, then
|
|
62
|
+
// append a fresh block at end-of-file via the shared managed-block utility —
|
|
63
|
+
// the historical `removeExistingBlock` + append behavior, now centralized.
|
|
64
|
+
const existing = fs.existsSync(zshrcPath) ? removeExistingBlock(fs.readFileSync(zshrcPath, 'utf-8')) : ''
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
const updated = upsertManagedBlock({
|
|
67
|
+
content: existing,
|
|
68
|
+
body: buildShellBody(),
|
|
69
|
+
startMarker: MARKER_START,
|
|
70
|
+
endMarker: MARKER_END,
|
|
71
|
+
placement: 'append-end',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
fs.writeFileSync(zshrcPath, updated)
|
|
66
75
|
|
|
67
|
-
fs.appendFileSync(zshrcPath, `\n${shellBlock}\n`)
|
|
68
76
|
logger.info(`Added infra-kit shell functions to ${zshrcPath}`)
|
|
69
77
|
|
|
70
78
|
// Convert any legacy infra-kit.yml config layers to JSON before seeding, so a
|
|
@@ -73,6 +81,10 @@ export const init = async (): Promise<void> => {
|
|
|
73
81
|
|
|
74
82
|
seedUserGlobalConfig()
|
|
75
83
|
|
|
84
|
+
// Best-effort, non-fatal, repo-gated: keep the agent-instruction files in sync
|
|
85
|
+
// with the CLI surface. A no-op outside an infra-kit repo.
|
|
86
|
+
await writeAgentFiles()
|
|
87
|
+
|
|
76
88
|
logger.info('Run `source ~/.zshrc` or open a new terminal to activate.')
|
|
77
89
|
}
|
|
78
90
|
|
|
@@ -125,28 +137,15 @@ const isBlockLine = (line: string): boolean => {
|
|
|
125
137
|
)
|
|
126
138
|
}
|
|
127
139
|
|
|
128
|
-
const removeBetween = (content: string, start: string, end: string): string | null => {
|
|
129
|
-
const startIdx = content.indexOf(start)
|
|
130
|
-
const endIdx = content.indexOf(end)
|
|
131
|
-
|
|
132
|
-
if (startIdx === -1 || endIdx === -1) return null
|
|
133
|
-
|
|
134
|
-
// eslint-disable-next-line sonarjs/slow-regex
|
|
135
|
-
const before = content.slice(0, startIdx).replace(/\n+$/, '')
|
|
136
|
-
const after = content.slice(endIdx + end.length).replace(/^\n+/, '')
|
|
137
|
-
|
|
138
|
-
return before + (after ? `\n${after}` : '')
|
|
139
|
-
}
|
|
140
|
-
|
|
141
140
|
const removeExistingBlock = (content: string): string => {
|
|
142
141
|
// 1. Current markers
|
|
143
|
-
const result =
|
|
142
|
+
const result = removeManagedBlock(content, MARKER_START, MARKER_END)
|
|
144
143
|
|
|
145
144
|
if (result !== null) return result
|
|
146
145
|
|
|
147
146
|
// 2. Legacy paired markers (# region / # endregion)
|
|
148
147
|
for (const [start, end] of LEGACY_PAIRED) {
|
|
149
|
-
const legacyResult =
|
|
148
|
+
const legacyResult = removeManagedBlock(content, start, end)
|
|
150
149
|
|
|
151
150
|
if (legacyResult !== null) return legacyResult
|
|
152
151
|
}
|
|
@@ -171,11 +170,14 @@ const removeExistingBlock = (content: string): string => {
|
|
|
171
170
|
return before + (remaining ? `\n${remaining}` : '')
|
|
172
171
|
}
|
|
173
172
|
|
|
174
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The inner shell-function lines (no markers). Composed into the full marked
|
|
175
|
+
* block by {@link buildShellBlock} and fed to `upsertManagedBlock` by `init()`.
|
|
176
|
+
*/
|
|
177
|
+
export const buildShellBody = (): string => {
|
|
175
178
|
const runCmd = 'pnpm exec infra-kit'
|
|
176
179
|
|
|
177
180
|
return [
|
|
178
|
-
MARKER_START,
|
|
179
181
|
'zmodload zsh/stat 2>/dev/null',
|
|
180
182
|
'zmodload zsh/datetime 2>/dev/null',
|
|
181
183
|
// eslint-disable-next-line no-template-curly-in-string
|
|
@@ -223,6 +225,13 @@ export const buildShellBlock = (): string => {
|
|
|
223
225
|
'if (( _INFRA_KIT_SHELL_STARTED > 0 )); then',
|
|
224
226
|
' add-zsh-hook precmd _infra_kit_autoload',
|
|
225
227
|
'fi',
|
|
226
|
-
MARKER_END,
|
|
227
228
|
].join('\n')
|
|
228
229
|
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The full marker-delimited shell block (`MARKER_START … MARKER_END`). Kept as
|
|
233
|
+
* a single composed string so `doctor`'s exact-match freshness check stays valid.
|
|
234
|
+
*/
|
|
235
|
+
export const buildShellBlock = (): string => {
|
|
236
|
+
return `${MARKER_START}\n${buildShellBody()}\n${MARKER_END}`
|
|
237
|
+
}
|
|
@@ -6,7 +6,12 @@ import process from 'node:process'
|
|
|
6
6
|
import { z } from 'zod'
|
|
7
7
|
import { $ } from 'zx'
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildCmuxWorkspaceTitle,
|
|
11
|
+
canonicalizeCmuxTitle,
|
|
12
|
+
listCmuxWorkspaceTitles,
|
|
13
|
+
openCmuxWorkspaceWithLayout,
|
|
14
|
+
} from 'src/integrations/cmux'
|
|
10
15
|
import { addFoldersToCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
|
|
11
16
|
import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
12
17
|
import { commandEcho } from 'src/lib/command-echo'
|
|
@@ -227,10 +232,17 @@ export const worktreesAdd = async (options: WorktreeManagementArgs) => {
|
|
|
227
232
|
|
|
228
233
|
if (openInCmux) {
|
|
229
234
|
const repoName = await getRepoName()
|
|
235
|
+
const existingTitles = await listCmuxWorkspaceTitles()
|
|
230
236
|
|
|
231
237
|
for (const branch of createdWorktrees) {
|
|
232
238
|
const title = buildCmuxWorkspaceTitle({ repoName, branch })
|
|
233
239
|
|
|
240
|
+
// Skip branches whose cmux workspace is already open (canonical match),
|
|
241
|
+
// so re-running worktrees-add never duplicates an existing workspace.
|
|
242
|
+
if (existingTitles.has(canonicalizeCmuxTitle(title))) {
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
|
|
234
246
|
await openCmuxWorkspaceWithLayout({
|
|
235
247
|
cwd: `${worktreeDir}/${branch}`,
|
|
236
248
|
title,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { listCmuxWorkspaceTitles, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
|
|
4
|
+
import { getRepoName } from 'src/lib/git-utils'
|
|
5
|
+
|
|
6
|
+
import { openCmux } from '../worktrees-open'
|
|
7
|
+
|
|
8
|
+
vi.mock('src/integrations/cmux', async (importActual) => {
|
|
9
|
+
const actual = await importActual<typeof import('src/integrations/cmux')>()
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
listCmuxWorkspaceTitles: vi.fn(),
|
|
14
|
+
openCmuxWorkspaceWithLayout: vi.fn(),
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
vi.mock('src/lib/git-utils', () => {
|
|
19
|
+
return {
|
|
20
|
+
getRepoName: vi.fn(),
|
|
21
|
+
getProjectRoot: vi.fn(),
|
|
22
|
+
getCurrentWorktrees: vi.fn(),
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const REPO = 'hulyo-monorepo'
|
|
27
|
+
const WORKTREE_DIR = '/repos/project-worktrees'
|
|
28
|
+
const BRANCHES = ['release/v1.48.0', 'release/n/checkout-redesign']
|
|
29
|
+
|
|
30
|
+
const titleFor = {
|
|
31
|
+
versioned: `${REPO} 1.48.0`,
|
|
32
|
+
named: `${REPO} checkout-redesign`,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('openCmux dedup', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks()
|
|
38
|
+
vi.mocked(getRepoName).mockResolvedValue(REPO)
|
|
39
|
+
vi.mocked(openCmuxWorkspaceWithLayout).mockResolvedValue(undefined)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('opens a workspace for every branch when none are open', async () => {
|
|
43
|
+
vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set())
|
|
44
|
+
|
|
45
|
+
const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: BRANCHES })
|
|
46
|
+
|
|
47
|
+
expect(result.opened).toEqual([titleFor.versioned, titleFor.named])
|
|
48
|
+
expect(result.skipped).toEqual([])
|
|
49
|
+
expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledTimes(2)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('skips every branch when all are already open (no duplicates)', async () => {
|
|
53
|
+
vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set([titleFor.versioned, titleFor.named]))
|
|
54
|
+
|
|
55
|
+
const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: BRANCHES })
|
|
56
|
+
|
|
57
|
+
expect(result.opened).toEqual([])
|
|
58
|
+
expect(result.skipped).toEqual([titleFor.versioned, titleFor.named])
|
|
59
|
+
expect(openCmuxWorkspaceWithLayout).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('opens only the missing branch in a mixed state', async () => {
|
|
63
|
+
vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set([titleFor.versioned]))
|
|
64
|
+
|
|
65
|
+
const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: BRANCHES })
|
|
66
|
+
|
|
67
|
+
expect(result.skipped).toEqual([titleFor.versioned])
|
|
68
|
+
expect(result.opened).toEqual([titleFor.named])
|
|
69
|
+
expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledTimes(1)
|
|
70
|
+
expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledWith({
|
|
71
|
+
cwd: `${WORKTREE_DIR}/release/n/checkout-redesign`,
|
|
72
|
+
title: titleFor.named,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('skips a versioned branch whose workspace was stored under an old v-prefixed title', async () => {
|
|
77
|
+
// listCmuxWorkspaceTitles already returns canonical keys, so an old
|
|
78
|
+
// "hulyo-monorepo v1.48.0" workspace is surfaced as the canonical key below.
|
|
79
|
+
vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set([titleFor.versioned]))
|
|
80
|
+
|
|
81
|
+
const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: ['release/v1.48.0'] })
|
|
82
|
+
|
|
83
|
+
expect(result.opened).toEqual([])
|
|
84
|
+
expect(result.skipped).toEqual([titleFor.versioned])
|
|
85
|
+
expect(openCmuxWorkspaceWithLayout).not.toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns empty outcome when there are no worktrees', async () => {
|
|
89
|
+
vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set())
|
|
90
|
+
|
|
91
|
+
const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: [] })
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({ ran: true, opened: [], skipped: [] })
|
|
94
|
+
expect(listCmuxWorkspaceTitles).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { $ } from 'zx'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildCmuxWorkspaceTitle,
|
|
6
|
+
canonicalizeCmuxTitle,
|
|
7
|
+
listCmuxWorkspaceTitles,
|
|
8
|
+
openCmuxWorkspaceWithLayout,
|
|
9
|
+
} from 'src/integrations/cmux'
|
|
5
10
|
import { reconcileCursorWorkspaceFolders, resolveCursorWorkspacePath } from 'src/integrations/cursor'
|
|
6
11
|
import { commandEcho } from 'src/lib/command-echo'
|
|
7
12
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
@@ -113,7 +118,7 @@ interface OpenCmuxOutcome {
|
|
|
113
118
|
skipped: string[]
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> => {
|
|
121
|
+
export const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> => {
|
|
117
122
|
const { worktreeDir, currentBranches } = args
|
|
118
123
|
|
|
119
124
|
if (currentBranches.length === 0) {
|
|
@@ -129,7 +134,9 @@ const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> => {
|
|
|
129
134
|
for (const branch of currentBranches) {
|
|
130
135
|
const title = buildCmuxWorkspaceTitle({ repoName, branch })
|
|
131
136
|
|
|
132
|
-
|
|
137
|
+
// existingTitles holds canonical keys; match the built title the same way so
|
|
138
|
+
// dedup survives whitespace / cross-CLI-version title drift (no duplicates).
|
|
139
|
+
if (existingTitles.has(canonicalizeCmuxTitle(title))) {
|
|
133
140
|
skipped.push(title)
|
|
134
141
|
continue
|
|
135
142
|
}
|
package/src/entry/cli.ts
CHANGED
|
@@ -286,7 +286,7 @@ program
|
|
|
286
286
|
|
|
287
287
|
program
|
|
288
288
|
.command('init')
|
|
289
|
-
.description('Inject shell integration into
|
|
289
|
+
.description('Inject shell integration into .zshrc and sync repo agent-instruction files')
|
|
290
290
|
.action(async () => {
|
|
291
291
|
await init()
|
|
292
292
|
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { buildCmuxWorkspaceTitle } from 'src/integrations/cmux'
|
|
4
|
+
|
|
5
|
+
import { canonicalizeCmuxTitle } from '../canonicalize-cmux-title'
|
|
6
|
+
|
|
7
|
+
describe('canonicalizeCmuxTitle', () => {
|
|
8
|
+
it('trims and collapses internal whitespace', () => {
|
|
9
|
+
expect(canonicalizeCmuxTitle(' hulyo-monorepo 1.48.0 ')).toBe('hulyo-monorepo 1.48.0')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('normalizes a v-prefixed semver token to its bare form', () => {
|
|
13
|
+
expect(canonicalizeCmuxTitle('hulyo-monorepo v1.48.0')).toBe('hulyo-monorepo 1.48.0')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('treats the v-prefixed and bare semver titles as the same key (cross-version drift)', () => {
|
|
17
|
+
expect(canonicalizeCmuxTitle('hulyo-monorepo v1.48.0')).toBe(canonicalizeCmuxTitle('hulyo-monorepo 1.48.0'))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('does NOT strip a leading v from a named release', () => {
|
|
21
|
+
expect(canonicalizeCmuxTitle('hulyo-monorepo vega-redesign')).toBe('hulyo-monorepo vega-redesign')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('leaves a non-release fallback title containing a slash untouched', () => {
|
|
25
|
+
expect(canonicalizeCmuxTitle('hulyo-monorepo feature/foo')).toBe('hulyo-monorepo feature/foo')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('does not corrupt a non-semver token that merely starts with v', () => {
|
|
29
|
+
expect(canonicalizeCmuxTitle('v8-engine 1.2.3')).toBe('v8-engine 1.2.3')
|
|
30
|
+
expect(canonicalizeCmuxTitle('appv1.2.3 main')).toBe('appv1.2.3 main')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('is idempotent', () => {
|
|
34
|
+
const once = canonicalizeCmuxTitle('hulyo-monorepo v1.48.0 ')
|
|
35
|
+
|
|
36
|
+
expect(canonicalizeCmuxTitle(once)).toBe(once)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// The invariant that generalizes the whole fix: the title a workspace is
|
|
40
|
+
// created/renamed with must round-trip through canonicalization to the same
|
|
41
|
+
// key the dedup/close check builds — regardless of which CLI version stored it.
|
|
42
|
+
describe('round-trip invariant', () => {
|
|
43
|
+
it('built title canonicalizes to a stable key for a versioned release', () => {
|
|
44
|
+
const built = buildCmuxWorkspaceTitle({ repoName: 'hulyo-monorepo', branch: 'release/v1.48.0' })
|
|
45
|
+
const storedByOldCli = 'hulyo-monorepo v1.48.0'
|
|
46
|
+
|
|
47
|
+
expect(canonicalizeCmuxTitle(built)).toBe(canonicalizeCmuxTitle(storedByOldCli))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('built title canonicalizes to a stable key for a named release', () => {
|
|
51
|
+
const built = buildCmuxWorkspaceTitle({ repoName: 'hulyo-monorepo', branch: 'release/n/checkout-redesign' })
|
|
52
|
+
|
|
53
|
+
expect(canonicalizeCmuxTitle(built)).toBe('hulyo-monorepo checkout-redesign')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { $ } from 'zx'
|
|
3
|
+
|
|
4
|
+
import { closeCmuxWorkspaceByTitle } from '../close-workspace-by-title'
|
|
5
|
+
|
|
6
|
+
const listOutput = vi.hoisted(() => {
|
|
7
|
+
return { value: '' }
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
vi.mock('zx', () => {
|
|
11
|
+
function makeResult(stdout: string) {
|
|
12
|
+
return Object.assign(Promise.resolve({ stdout }), {
|
|
13
|
+
quiet: () => {
|
|
14
|
+
return Promise.resolve({ stdout })
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
$: vi.fn((strings: TemplateStringsArray) => {
|
|
21
|
+
return strings.join('').includes('list-workspaces') ? makeResult(listOutput.value) : makeResult('')
|
|
22
|
+
}),
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
type CmuxCall = [TemplateStringsArray, ...string[]]
|
|
27
|
+
|
|
28
|
+
function isCloseCall(call: CmuxCall): boolean {
|
|
29
|
+
return call[0].join('').includes('close-workspace')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const closeCallRef = (): string | undefined => {
|
|
33
|
+
const calls = vi.mocked($).mock.calls as unknown as CmuxCall[]
|
|
34
|
+
|
|
35
|
+
return calls.find(isCloseCall)?.[1]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('closeCmuxWorkspaceByTitle', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks()
|
|
41
|
+
listOutput.value = ''
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('closes a workspace stored under an old v-prefixed title when asked with the bare-semver title', async () => {
|
|
45
|
+
listOutput.value = [
|
|
46
|
+
' workspace:8 hulyo-monorepo v1.48.0',
|
|
47
|
+
'* workspace:6 obsidian-workspace [selected]',
|
|
48
|
+
'',
|
|
49
|
+
].join('\n')
|
|
50
|
+
|
|
51
|
+
await closeCmuxWorkspaceByTitle('hulyo-monorepo 1.48.0')
|
|
52
|
+
|
|
53
|
+
expect(closeCallRef()).toBe('workspace:8')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not close anything when no title matches', async () => {
|
|
57
|
+
listOutput.value = ' workspace:8 hulyo-monorepo 2.0.0\n'
|
|
58
|
+
|
|
59
|
+
await closeCmuxWorkspaceByTitle('hulyo-monorepo 1.48.0')
|
|
60
|
+
|
|
61
|
+
expect(closeCallRef()).toBeUndefined()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { listCmuxWorkspaceTitles } from '../list-workspace-titles'
|
|
4
|
+
|
|
5
|
+
const listOutput = vi.hoisted(() => {
|
|
6
|
+
return { value: '' }
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
vi.mock('zx', () => {
|
|
10
|
+
function makeResult() {
|
|
11
|
+
return Object.assign(Promise.resolve({ stdout: listOutput.value }), {
|
|
12
|
+
quiet: () => {
|
|
13
|
+
return Promise.resolve({ stdout: listOutput.value })
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
$: vi.fn(makeResult),
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('listCmuxWorkspaceTitles', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
listOutput.value = ''
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('parses titles, stripping the [selected] suffix and leading * marker', async () => {
|
|
29
|
+
listOutput.value = [
|
|
30
|
+
' workspace:8 hulyo-monorepo 1.48.0',
|
|
31
|
+
'* workspace:6 obsidian-workspace [selected]',
|
|
32
|
+
'',
|
|
33
|
+
].join('\n')
|
|
34
|
+
|
|
35
|
+
const titles = await listCmuxWorkspaceTitles()
|
|
36
|
+
|
|
37
|
+
expect(titles.has('hulyo-monorepo 1.48.0')).toBe(true)
|
|
38
|
+
expect(titles.has('obsidian-workspace')).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('canonicalizes a v-prefixed (old CLI) stored title to the bare-semver key', async () => {
|
|
42
|
+
listOutput.value = ' workspace:8 hulyo-monorepo v1.48.0\n'
|
|
43
|
+
|
|
44
|
+
const titles = await listCmuxWorkspaceTitles()
|
|
45
|
+
|
|
46
|
+
expect(titles.has('hulyo-monorepo 1.48.0')).toBe(true)
|
|
47
|
+
expect(titles.has('hulyo-monorepo v1.48.0')).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('collapses extra internal whitespace into the canonical key', async () => {
|
|
51
|
+
listOutput.value = ' workspace:3 hulyo-monorepo 1.48.0\n'
|
|
52
|
+
|
|
53
|
+
const titles = await listCmuxWorkspaceTitles()
|
|
54
|
+
|
|
55
|
+
expect(titles.has('hulyo-monorepo 1.48.0')).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns an empty set when cmux output is empty', async () => {
|
|
59
|
+
listOutput.value = ''
|
|
60
|
+
|
|
61
|
+
const titles = await listCmuxWorkspaceTitles()
|
|
62
|
+
|
|
63
|
+
expect(titles.size).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Matches a `v`-prefixed semver token (e.g. `v1.48.0`) anchored on shape. */
|
|
2
|
+
const V_SEMVER_TOKEN_RE = /\bv(\d+\.\d+\.\d+)\b/g
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonicalizes a cmux workspace title into a stable dedup/close key.
|
|
6
|
+
*
|
|
7
|
+
* cmux workspace titles are human display strings built by
|
|
8
|
+
* `buildCmuxWorkspaceTitle`, so the value stored when a workspace is created can
|
|
9
|
+
* drift from the value rebuilt later — across whitespace and across CLI versions
|
|
10
|
+
* (an older build titled version releases `v1.48.0`; the current build titles
|
|
11
|
+
* them `1.48.0`). Keying dedup or close on the raw title silently creates
|
|
12
|
+
* duplicate / unclosable workspaces whenever that drift occurs.
|
|
13
|
+
*
|
|
14
|
+
* Canonicalization collapses the known drift axes so both sides round-trip to an
|
|
15
|
+
* equal key:
|
|
16
|
+
* - trims and collapses internal whitespace to single spaces;
|
|
17
|
+
* - normalizes a `v`-prefixed semver token to its bare form
|
|
18
|
+
* (`v1.48.0` → `1.48.0`), anchored on semver shape so named releases that
|
|
19
|
+
* merely start with `v` (e.g. `vega-redesign`) are left untouched.
|
|
20
|
+
*
|
|
21
|
+
* Non-release fallback titles (which may contain `/`, e.g. `feature/foo`) are
|
|
22
|
+
* preserved as-is apart from whitespace normalization.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* canonicalizeCmuxTitle('hulyo-monorepo v1.48.0') // => 'hulyo-monorepo 1.48.0'
|
|
26
|
+
* canonicalizeCmuxTitle('hulyo-monorepo 1.48.0') // => 'hulyo-monorepo 1.48.0'
|
|
27
|
+
* canonicalizeCmuxTitle('hulyo-monorepo vega-redesign') // => 'hulyo-monorepo vega-redesign'
|
|
28
|
+
*/
|
|
29
|
+
export const canonicalizeCmuxTitle = (raw: string): string => {
|
|
30
|
+
return raw.trim().replace(/\s+/g, ' ').replace(V_SEMVER_TOKEN_RE, '$1')
|
|
31
|
+
}
|
|
@@ -2,8 +2,11 @@ import { $ } from 'zx'
|
|
|
2
2
|
|
|
3
3
|
import { logger } from 'src/lib/logger'
|
|
4
4
|
|
|
5
|
+
import { canonicalizeCmuxTitle } from './canonicalize-cmux-title'
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
|
-
* Best-effort close of the cmux workspace whose title
|
|
8
|
+
* Best-effort close of the cmux workspace whose title matches `title` (compared
|
|
9
|
+
* via {@link canonicalizeCmuxTitle}, so a drifted stored title still resolves).
|
|
7
10
|
* Silently no-ops if cmux isn't running, the workspace isn't found, or close fails.
|
|
8
11
|
*/
|
|
9
12
|
export const closeCmuxWorkspaceByTitle = async (title: string): Promise<void> => {
|
|
@@ -24,13 +27,18 @@ export const closeCmuxWorkspaceByTitle = async (title: string): Promise<void> =>
|
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* Parses `cmux list-workspaces` output and returns the workspace ref whose
|
|
27
|
-
* title
|
|
30
|
+
* title matches `title`, or undefined if no match. Both sides are compared via
|
|
31
|
+
* {@link canonicalizeCmuxTitle} so a workspace stored under a drifted title
|
|
32
|
+
* (whitespace, or an older CLI's `v`-prefixed semver) is still found — keeping
|
|
33
|
+
* close symmetric with the dedup in `worktrees-open`.
|
|
28
34
|
*
|
|
29
35
|
* Each line looks like:
|
|
30
|
-
* " workspace:8 hulyo-monorepo
|
|
36
|
+
* " workspace:8 hulyo-monorepo 1.48.0"
|
|
31
37
|
* "* workspace:6 obsidian-workspace [selected]"
|
|
32
38
|
*/
|
|
33
39
|
const findWorkspaceRefByTitle = (output: string, title: string): string | undefined => {
|
|
40
|
+
const target = canonicalizeCmuxTitle(title)
|
|
41
|
+
|
|
34
42
|
for (const rawLine of output.split('\n')) {
|
|
35
43
|
// eslint-disable-next-line sonarjs/slow-regex, regexp/no-super-linear-backtracking
|
|
36
44
|
const match = rawLine.match(/^[* ]\s*(workspace:\d+)\s+(.+?)(?:\s+\[selected\])?\s*$/)
|
|
@@ -42,7 +50,7 @@ const findWorkspaceRefByTitle = (output: string, title: string): string | undefi
|
|
|
42
50
|
const ref = match[1]
|
|
43
51
|
const lineTitle = match[2]?.trim() ?? ''
|
|
44
52
|
|
|
45
|
-
if (lineTitle ===
|
|
53
|
+
if (canonicalizeCmuxTitle(lineTitle) === target) {
|
|
46
54
|
return ref
|
|
47
55
|
}
|
|
48
56
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { canonicalizeCmuxTitle } from './canonicalize-cmux-title'
|
|
1
2
|
export { closeCmuxWorkspaceByTitle } from './close-workspace-by-title'
|
|
2
3
|
export { listCmuxWorkspaceTitles } from './list-workspace-titles'
|
|
3
4
|
export { openCmuxWorkspaceWithLayout } from './open-workspace-with-layout'
|
|
@@ -2,14 +2,19 @@ import { $ } from 'zx'
|
|
|
2
2
|
|
|
3
3
|
import { logger } from 'src/lib/logger'
|
|
4
4
|
|
|
5
|
+
import { canonicalizeCmuxTitle } from './canonicalize-cmux-title'
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
|
-
* Returns the set of titles for all currently-open cmux
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Returns the set of **canonical** titles for all currently-open cmux
|
|
9
|
+
* workspaces (see {@link canonicalizeCmuxTitle}). Keying on the canonical form
|
|
10
|
+
* lets callers match a workspace even when its stored title drifted from the
|
|
11
|
+
* title they rebuild (whitespace, or an older CLI's `v`-prefixed semver).
|
|
12
|
+
* Returns an empty set if cmux isn't running, the call fails, or the output
|
|
13
|
+
* can't be parsed — callers should treat "empty" as "unknown, proceed as if
|
|
14
|
+
* nothing is open".
|
|
10
15
|
*
|
|
11
16
|
* Each line of `cmux list-workspaces` looks like:
|
|
12
|
-
* " workspace:8 hulyo-monorepo
|
|
17
|
+
* " workspace:8 hulyo-monorepo 1.48.0"
|
|
13
18
|
* "* workspace:6 obsidian-workspace [selected]"
|
|
14
19
|
*/
|
|
15
20
|
export const listCmuxWorkspaceTitles = async (): Promise<Set<string>> => {
|
|
@@ -29,7 +34,7 @@ export const listCmuxWorkspaceTitles = async (): Promise<Set<string>> => {
|
|
|
29
34
|
const title = match[1]?.trim()
|
|
30
35
|
|
|
31
36
|
if (title) {
|
|
32
|
-
titles.add(title)
|
|
37
|
+
titles.add(canonicalizeCmuxTitle(title))
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -26,7 +26,7 @@ export const openCmuxWorkspaceWithLayout = async (args: OpenCmuxWorkspaceArgs):
|
|
|
26
26
|
await $`cmux new-split down --workspace ${workspaceRef} --surface ${leftTopRef}`
|
|
27
27
|
|
|
28
28
|
if (title) {
|
|
29
|
-
await $`cmux workspace rename --workspace ${workspaceRef} ${title}`
|
|
29
|
+
await $`cmux workspace rename --workspace ${workspaceRef} --title ${title}`
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"entries": {
|
|
4
|
+
"79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
|
|
5
|
+
"last_emitted_at_ms": 1781518823336,
|
|
6
|
+
"message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"updated_at": "2026-06-15T10:20:23.336Z"
|
|
10
|
+
}
|