infra-kit 0.1.107 → 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/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/.turbo/turbo-build.log +2 -2
- package/.turbo/turbo-test.log +43 -6
- package/dist/cli.js +48 -48
- 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/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/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/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/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
1
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { quote } from 'zx'
|
|
2
4
|
|
|
3
|
-
import { getReleasePRs, getReleasePRsWithInfo } from '../gh-release-prs'
|
|
5
|
+
import { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo } from '../gh-release-prs'
|
|
4
6
|
|
|
5
7
|
interface FakePR {
|
|
6
8
|
number: number
|
|
@@ -12,17 +14,32 @@ interface FakePR {
|
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const responses = vi.hoisted(() => {
|
|
15
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
release: [] as FakePR[],
|
|
19
|
+
hotfix: [] as FakePR[],
|
|
20
|
+
calls: [] as { strings: string[]; values: unknown[] }[],
|
|
21
|
+
}
|
|
16
22
|
})
|
|
17
23
|
|
|
18
24
|
// Mock zx's tagged-template `$`: the gh pr list call for `--base dev` returns
|
|
19
|
-
// the "release" set, `--base main` returns the "hotfix" set
|
|
20
|
-
//
|
|
21
|
-
|
|
25
|
+
// the "release" set, `--base main` returns the "hotfix" set, and `gh pr create`
|
|
26
|
+
// returns a fake PR URL. Every invocation is captured (template strings +
|
|
27
|
+
// interpolated values) so tests can reconstruct the exact command zx would run.
|
|
28
|
+
// Spreading `...actual` keeps zx's real `quote` available for that reconstruction.
|
|
29
|
+
vi.mock('zx', async (importOriginal) => {
|
|
30
|
+
const actual = await importOriginal<typeof import('zx')>()
|
|
31
|
+
|
|
22
32
|
return {
|
|
23
|
-
|
|
33
|
+
...actual,
|
|
34
|
+
$: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
24
35
|
const command = strings.join('')
|
|
25
36
|
|
|
37
|
+
responses.calls.push({ strings: [...strings], values })
|
|
38
|
+
|
|
39
|
+
if (command.includes('gh pr create')) {
|
|
40
|
+
return Promise.resolve({ stdout: 'https://github.com/acme/repo/pull/123' })
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
if (command.includes('--base main')) {
|
|
27
44
|
return Promise.resolve({ stdout: JSON.stringify(responses.hotfix) })
|
|
28
45
|
}
|
|
@@ -113,3 +130,69 @@ describe('getReleasePRsWithInfo (discovery + sort)', () => {
|
|
|
113
130
|
])
|
|
114
131
|
})
|
|
115
132
|
})
|
|
133
|
+
|
|
134
|
+
describe('createReleaseBranch (gh pr create title quoting)', () => {
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
responses.calls = []
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Rebuild the exact command zx would have run: interleave each template
|
|
140
|
+
// string with the zx-quoted form of its interpolated value. This reflects
|
|
141
|
+
// what the shell actually receives, including zx's ANSI-C `$'...'` escaping
|
|
142
|
+
// for values containing spaces.
|
|
143
|
+
const reconstruct = (call: { strings: string[]; values: unknown[] }): string => {
|
|
144
|
+
return call.strings
|
|
145
|
+
.map((part, i) => {
|
|
146
|
+
return i < call.values.length ? part + quote(String(call.values[i])) : part
|
|
147
|
+
})
|
|
148
|
+
.join('')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const findCreateCommand = (): string => {
|
|
152
|
+
const call = responses.calls.find((c) => {
|
|
153
|
+
return c.strings.join('').includes('gh pr create')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if (!call) throw new Error('gh pr create was not invoked')
|
|
157
|
+
|
|
158
|
+
return reconstruct(call)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Run the reconstructed command through a real shell with `gh pr create`
|
|
162
|
+
// replaced by a function that prints back the value the shell parsed for
|
|
163
|
+
// `--title`. This is the behavioral proof of what gh would receive.
|
|
164
|
+
const shellTitleArg = (command: string): string => {
|
|
165
|
+
const probe = command.replace(
|
|
166
|
+
'gh pr create',
|
|
167
|
+
'f(){ while [ "$#" -gt 0 ]; do if [ "$1" = "--title" ]; then printf %s "$2"; return 0; fi; shift; done; }; f',
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// Absolute path (not PATH-resolved) keeps the lint's command-injection guard happy.
|
|
171
|
+
return execFileSync('/bin/sh', ['-c', probe]).toString()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
it("delivers a spaced title to gh as the literal string, with no $'...' corruption", async () => {
|
|
175
|
+
const id = { kind: 'version', semver: { major: 1, minor: 89, patch: 1 }, raw: '1.89.1' } as const
|
|
176
|
+
|
|
177
|
+
await createReleaseBranch({ id, jiraVersionUrl: 'https://jira.example/v1.89.1', type: 'hotfix' })
|
|
178
|
+
|
|
179
|
+
const command = findCreateCommand()
|
|
180
|
+
|
|
181
|
+
// Behavioral: the shell parses --title to exactly the intended title.
|
|
182
|
+
expect(shellTitleArg(command)).toBe('Hotfix v1.89.1')
|
|
183
|
+
// The pre-fix bug wrapped zx's `$'...'` token in literal double quotes, so
|
|
184
|
+
// the shell passed the literal `$'Hotfix v1.89.1'` to gh. That marker must be absent.
|
|
185
|
+
expect(command).not.toContain('"$\'')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('delivers a named release title to gh as the literal string', async () => {
|
|
189
|
+
const id = { kind: 'name', name: 'checkout-redesign', raw: 'checkout-redesign' } as const
|
|
190
|
+
|
|
191
|
+
await createReleaseBranch({ id, jiraVersionUrl: 'https://jira.example/checkout-redesign', type: 'regular' })
|
|
192
|
+
|
|
193
|
+
const command = findCreateCommand()
|
|
194
|
+
|
|
195
|
+
expect(shellTitleArg(command)).toBe('Release checkout-redesign')
|
|
196
|
+
expect(command).not.toContain('"$\'')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -178,7 +178,7 @@ export const createReleaseBranch = async (
|
|
|
178
178
|
await $`git push origin ${branchName}`
|
|
179
179
|
|
|
180
180
|
// Create PR and capture URL
|
|
181
|
-
const prResult = await $`gh pr create --title
|
|
181
|
+
const prResult = await $`gh pr create --title ${prTitle} --body ${body} --base ${baseBranch} --head ${branchName}`
|
|
182
182
|
|
|
183
183
|
const prLink = prResult.stdout.trim()
|
|
184
184
|
|