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.
Files changed (26) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-36d91435-50b4-442f-9f4e-3cc68d776236.jsonl +2 -0
  3. package/.omc/state/idle-notif-cooldown.json +1 -1
  4. package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/pre-tool-advisory-throttle.json +18 -0
  5. package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/subagent-tracking-state.json +17 -0
  6. package/.turbo/turbo-build.log +2 -2
  7. package/.turbo/turbo-test.log +43 -6
  8. package/dist/cli.js +48 -48
  9. package/dist/cli.js.map +4 -4
  10. package/dist/mcp.js +31 -31
  11. package/dist/mcp.js.map +4 -4
  12. package/package.json +1 -1
  13. package/src/commands/worktrees-add/worktrees-add.ts +13 -1
  14. package/src/commands/worktrees-open/__tests__/open-cmux.test.ts +96 -0
  15. package/src/commands/worktrees-open/worktrees-open.ts +10 -3
  16. package/src/integrations/cmux/__tests__/canonicalize-cmux-title.test.ts +56 -0
  17. package/src/integrations/cmux/__tests__/close-workspace-by-title.test.ts +63 -0
  18. package/src/integrations/cmux/__tests__/list-workspace-titles.test.ts +65 -0
  19. package/src/integrations/cmux/canonicalize-cmux-title.ts +31 -0
  20. package/src/integrations/cmux/close-workspace-by-title.ts +12 -4
  21. package/src/integrations/cmux/index.ts +1 -0
  22. package/src/integrations/cmux/list-workspace-titles.ts +11 -6
  23. package/src/integrations/gh/gh-release-prs/.omc/state/sessions/c6ed6186-1aac-48e0-aa2a-edc6da8e0410/pre-tool-advisory-throttle.json +10 -0
  24. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +89 -6
  25. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +1 -1
  26. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infra-kit",
3
3
  "type": "module",
4
- "version": "0.1.107",
4
+ "version": "0.1.108",
5
5
  "description": "infra-kit",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
@@ -6,7 +6,12 @@ import process from 'node:process'
6
6
  import { z } from 'zod'
7
7
  import { $ } from 'zx'
8
8
 
9
- import { buildCmuxWorkspaceTitle, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
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 { buildCmuxWorkspaceTitle, listCmuxWorkspaceTitles, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
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
- if (existingTitles.has(title)) {
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 exactly matches `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 exactly matches `title`, or undefined if no match.
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 v1.48.0"
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 === title) {
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 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".
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 v1.48.0"
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 { release: [] as FakePR[], hotfix: [] as FakePR[] }
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. The command is
20
- // reconstructed from the template strings so we can branch on the base flag.
21
- vi.mock('zx', () => {
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
- $: vi.fn((strings: TemplateStringsArray) => {
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 "${prTitle}" --body ${body} --base ${baseBranch} --head ${branchName}`
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