infra-kit 0.1.107 → 0.1.109

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 (42) 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/.omc/state/sessions/3a844fbd-ace8-4af1-8516-33d611839886/pre-tool-advisory-throttle.json +18 -0
  7. package/.turbo/turbo-build.log +6 -7
  8. package/.turbo/turbo-eslint-check.log +1 -2
  9. package/.turbo/turbo-prettier-check.log +1 -2
  10. package/.turbo/turbo-test.log +70 -35
  11. package/.turbo/turbo-ts-check.log +1 -2
  12. package/dist/cli.js +50 -50
  13. package/dist/cli.js.map +4 -4
  14. package/dist/mcp.js +36 -36
  15. package/dist/mcp.js.map +4 -4
  16. package/package.json +1 -1
  17. package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -0
  18. package/src/commands/gh-release-deliver/gh-release-deliver.ts +5 -0
  19. package/src/commands/release-create/__tests__/release-create.test.ts +38 -1
  20. package/src/commands/release-create/release-create.ts +36 -4
  21. package/src/commands/worktrees-add/worktrees-add.ts +16 -1
  22. package/src/commands/worktrees-open/__tests__/open-cmux.test.ts +96 -0
  23. package/src/commands/worktrees-open/worktrees-open.ts +10 -3
  24. package/src/commands/worktrees-remove/worktrees-remove.ts +3 -0
  25. package/src/commands/worktrees-sync/worktrees-sync.ts +3 -0
  26. package/src/integrations/cmux/__tests__/canonicalize-cmux-title.test.ts +56 -0
  27. package/src/integrations/cmux/__tests__/close-workspace-by-title.test.ts +63 -0
  28. package/src/integrations/cmux/__tests__/list-workspace-titles.test.ts +65 -0
  29. package/src/integrations/cmux/canonicalize-cmux-title.ts +31 -0
  30. package/src/integrations/cmux/close-workspace-by-title.ts +12 -4
  31. package/src/integrations/cmux/index.ts +1 -0
  32. package/src/integrations/cmux/list-workspace-titles.ts +11 -6
  33. package/src/integrations/gh/gh-release-prs/.omc/state/sessions/c6ed6186-1aac-48e0-aa2a-edc6da8e0410/pre-tool-advisory-throttle.json +10 -0
  34. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +89 -6
  35. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +1 -1
  36. package/src/lib/git-guard/__tests__/git-guard.test.ts +92 -0
  37. package/src/lib/git-guard/git-guard.ts +55 -0
  38. package/src/lib/git-guard/index.ts +2 -0
  39. package/src/lib/git-utils/__tests__/git-primitives.test.ts +96 -0
  40. package/src/lib/git-utils/git-utils.ts +41 -0
  41. package/src/lib/git-utils/index.ts +8 -1
  42. 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.109",
5
5
  "description": "infra-kit",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
@@ -8,6 +8,7 @@ import { $ } from 'zx'
8
8
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
9
9
  import { commandEcho } from 'src/lib/command-echo'
10
10
  import { OperationError } from 'src/lib/errors/operation-error'
11
+ import { assertManagementContext } from 'src/lib/git-guard'
11
12
  import { logger } from 'src/lib/logger'
12
13
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions, releaseBranchLabels } from 'src/lib/release-utils'
13
14
  import { defineMcpTool, textContent } from 'src/types'
@@ -25,6 +26,8 @@ export const ghMergeDev = async (args: GhMergeDevArgs) => {
25
26
 
26
27
  commandEcho.start('merge-dev')
27
28
 
29
+ await assertManagementContext({ operation: 'merge dev into release branches', requiredBranch: 'dev' })
30
+
28
31
  // Only merge dev into regular releases (not hotfixes, which target main)
29
32
  const allPRs = await getReleasePRsWithInfo()
30
33
  const releasePRsList = allPRs
@@ -10,6 +10,7 @@ import { commandEcho } from 'src/lib/command-echo'
10
10
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
11
11
  import { formatZxError } from 'src/lib/errors/format-zx-error'
12
12
  import { OperationError } from 'src/lib/errors/operation-error'
13
+ import { assertManagementContext } from 'src/lib/git-guard'
13
14
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
14
15
  import { logger } from 'src/lib/logger'
15
16
  import { displayLabel, formatJiraName, formatRcTitle, parseBranchName } from 'src/lib/release-id'
@@ -339,6 +340,10 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
339
340
 
340
341
  commandEcho.start('release-deliver')
341
342
 
343
+ // Branch-agnostic (operates on release/RC PRs via gh and self-switches), so
344
+ // only the worktree + clean-tree legs apply.
345
+ await assertManagementContext({ operation: 'deliver release' })
346
+
342
347
  const { selectedReleaseBranch, releasePrTitle } = version
343
348
  ? await resolveTargetFromVersion(version)
344
349
  : await resolveTargetInteractively()
@@ -1,7 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { z } from 'zod'
3
3
 
4
- import { releaseCreateMcpTool } from '../release-create'
4
+ import { OperationError } from 'src/lib/errors/operation-error'
5
+ import type { ReleaseType } from 'src/lib/release-utils'
6
+ import type { ReleaseEntry } from 'src/lib/version-utils'
7
+
8
+ import { assertHomogeneousReleaseType, releaseCreateMcpTool } from '../release-create'
5
9
 
6
10
  /**
7
11
  * The MCP `releases[]` entry schema enforces that exactly one of `version` or
@@ -53,3 +57,36 @@ describe('release-create MCP releases[] entry schema', () => {
53
57
  }).toThrow(/exactly one of/)
54
58
  })
55
59
  })
60
+
61
+ const entryOfType = (type: ReleaseType): ReleaseEntry => {
62
+ return {
63
+ id: { kind: 'version', semver: { major: 1, minor: 2, patch: 3 }, raw: '1.2.3' },
64
+ type,
65
+ } as ReleaseEntry
66
+ }
67
+
68
+ describe('assertHomogeneousReleaseType', () => {
69
+ it('accepts an all-regular batch', () => {
70
+ expect(() => {
71
+ return assertHomogeneousReleaseType([entryOfType('regular'), entryOfType('regular')])
72
+ }).not.toThrow()
73
+ })
74
+
75
+ it('accepts an all-hotfix batch', () => {
76
+ expect(() => {
77
+ return assertHomogeneousReleaseType([entryOfType('hotfix')])
78
+ }).not.toThrow()
79
+ })
80
+
81
+ it('rejects a mixed regular+hotfix batch with an OperationError', () => {
82
+ expect(() => {
83
+ return assertHomogeneousReleaseType([entryOfType('regular'), entryOfType('hotfix')])
84
+ }).toThrow(OperationError)
85
+ })
86
+
87
+ it('mixed-batch error mentions separate invocations', () => {
88
+ expect(() => {
89
+ return assertHomogeneousReleaseType([entryOfType('regular'), entryOfType('hotfix')])
90
+ }).toThrow(/separate invocations/)
91
+ })
92
+ })
@@ -7,9 +7,10 @@ import { question } from 'zx'
7
7
  import { loadJiraConfig } from 'src/integrations/jira'
8
8
  import { commandEcho } from 'src/lib/command-echo'
9
9
  import { OperationError } from 'src/lib/errors/operation-error'
10
+ import { assertManagementContext } from 'src/lib/git-guard'
10
11
  import { logger } from 'src/lib/logger'
11
12
  import { InvalidReleaseNameError, displayLabel, validateName } from 'src/lib/release-id'
12
- import { createSingleRelease, prepareGitForRelease } from 'src/lib/release-utils'
13
+ import { createSingleRelease, getBaseBranch, prepareGitForRelease } from 'src/lib/release-utils'
13
14
  import type { ReleaseCreationResult, ReleaseType } from 'src/lib/release-utils'
14
15
  import {
15
16
  NoPriorVersionsError,
@@ -212,6 +213,28 @@ const collectEntries = async (
212
213
  return interactive
213
214
  }
214
215
 
216
+ /**
217
+ * Reject a batch that mixes regular and hotfix releases. They branch off
218
+ * different bases (dev vs main), so the batch has no single required branch for
219
+ * the management guard — they must be created in separate invocations.
220
+ * Exported for unit testing without running the side-effecting handler.
221
+ */
222
+ export const assertHomogeneousReleaseType = (entries: ReleaseEntry[]): void => {
223
+ const types = new Set(
224
+ entries.map((entry) => {
225
+ return entry.type
226
+ }),
227
+ )
228
+
229
+ if (types.size > 1) {
230
+ throw new OperationError(undefined, {
231
+ operation: 'create release',
232
+ remediation: 'create regular and hotfix releases in separate invocations',
233
+ stderrExcerpt: 'mixed regular and hotfix releases in one batch are not supported',
234
+ })
235
+ }
236
+ }
237
+
215
238
  const confirmReleases = async (entries: ReleaseEntry[], confirmedCommand: boolean): Promise<void> => {
216
239
  const summary = entries.map(formatReleaseSummary).join('\n - ')
217
240
  const answer = confirmedCommand
@@ -284,8 +307,10 @@ const logFinalSummary = (total: number, successCount: number, failureCount: numb
284
307
 
285
308
  /**
286
309
  * Create one or more release branches. Each release carries its own type
287
- * (regular/hotfix) and optional Jira description, so a single invocation
288
- * may mix regular and hotfix releases off their respective base branches.
310
+ * (regular/hotfix) and optional Jira description. All entries in a single
311
+ * invocation must share the same type — regular and hotfix releases branch off
312
+ * different bases (dev vs main), so mixed batches are rejected and must be
313
+ * created separately.
289
314
  */
290
315
  export const releaseCreate = async (args: ReleaseCreateArgs) => {
291
316
  const { releases: inputReleases, confirmedCommand } = args
@@ -311,6 +336,13 @@ export const releaseCreate = async (args: ReleaseCreateArgs) => {
311
336
  })
312
337
  }
313
338
 
339
+ assertHomogeneousReleaseType(entries)
340
+
341
+ await assertManagementContext({
342
+ operation: 'create release',
343
+ requiredBranch: getBaseBranch(entries[0]!.type),
344
+ })
345
+
314
346
  await confirmReleases(entries, Boolean(confirmedCommand))
315
347
 
316
348
  const created: ReleaseCreationResult[] = []
@@ -347,7 +379,7 @@ export const releaseCreate = async (args: ReleaseCreateArgs) => {
347
379
  export const releaseCreateMcpTool = defineMcpTool({
348
380
  name: 'release-create',
349
381
  description:
350
- 'Create one or more releases in a single call. Each entry in "releases" carries EITHER a "version" (semver or the literal token "next") OR a "name" (free-form kebab-case identifier) — exactly one is required and they are mutually exclusive. Each entry also has its own type (regular|hotfix, default regular) and optional description, so regular and hotfix releases can be mixed in the same invocation. For each release this tool switches to the appropriate base branch (dev for regular, main for hotfix), cuts the release branch (release/v<semver> for versions, release/n/<name> for names), opens a GitHub release PR, and creates the matching Jira fix version (v<semver> for versions, <name> for names). The literal token "next" auto-increments from the union of remote release branches and Jira fix versions (regular bumps minor + resets patch; hotfix bumps patch on the highest minor); multiple "next" tokens advance sequentially across mixed types. Named releases never auto-bump and "next" is version-only. Confirmation is auto-skipped for MCP calls, so the caller is responsible for gating. Continues on per-release failure and reports successes/failures.',
382
+ 'Create one or more releases in a single call. Each entry in "releases" carries EITHER a "version" (semver or the literal token "next") OR a "name" (free-form kebab-case identifier) — exactly one is required and they are mutually exclusive. Each entry also has its own type (regular|hotfix, default regular) and optional description; all entries in one call must share the same type — mixed regular+hotfix batches are rejected (create them in separate invocations). For each release this tool switches to the appropriate base branch (dev for regular, main for hotfix), cuts the release branch (release/v<semver> for versions, release/n/<name> for names), opens a GitHub release PR, and creates the matching Jira fix version (v<semver> for versions, <name> for names). The literal token "next" auto-increments from the union of remote release branches and Jira fix versions (regular bumps minor + resets patch; hotfix bumps patch on the highest minor); multiple "next" tokens advance sequentially. Named releases never auto-bump and "next" is version-only. Must be run from the main repository checkout (not a linked worktree) on the matching base branch with a clean working tree. Confirmation is auto-skipped for MCP calls, so the caller is responsible for gating. Continues on per-release failure and reports successes/failures.',
351
383
  inputSchema: {
352
384
  releases: z
353
385
  .array(
@@ -6,12 +6,18 @@ 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'
13
18
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
14
19
  import { OperationError } from 'src/lib/errors/operation-error'
20
+ import { assertManagementContext } from 'src/lib/git-guard'
15
21
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
16
22
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
17
23
  import { logger } from 'src/lib/logger'
@@ -45,6 +51,8 @@ export const worktreesAdd = async (options: WorktreeManagementArgs) => {
45
51
 
46
52
  commandEcho.start('worktrees-add')
47
53
 
54
+ await assertManagementContext({ operation: 'create worktrees', requiredBranch: 'dev' })
55
+
48
56
  try {
49
57
  const currentWorktrees = await getCurrentWorktrees('release')
50
58
  const projectRoot = await getProjectRoot()
@@ -227,10 +235,17 @@ export const worktreesAdd = async (options: WorktreeManagementArgs) => {
227
235
 
228
236
  if (openInCmux) {
229
237
  const repoName = await getRepoName()
238
+ const existingTitles = await listCmuxWorkspaceTitles()
230
239
 
231
240
  for (const branch of createdWorktrees) {
232
241
  const title = buildCmuxWorkspaceTitle({ repoName, branch })
233
242
 
243
+ // Skip branches whose cmux workspace is already open (canonical match),
244
+ // so re-running worktrees-add never duplicates an existing workspace.
245
+ if (existingTitles.has(canonicalizeCmuxTitle(title))) {
246
+ continue
247
+ }
248
+
234
249
  await openCmuxWorkspaceWithLayout({
235
250
  cwd: `${worktreeDir}/${branch}`,
236
251
  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
  }
@@ -8,6 +8,7 @@ import { getReleasePRsWithInfo } from 'src/integrations/gh'
8
8
  import { commandEcho } from 'src/lib/command-echo'
9
9
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
10
10
  import { OperationError } from 'src/lib/errors/operation-error'
11
+ import { assertManagementContext } from 'src/lib/git-guard'
11
12
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
12
13
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
13
14
  import { logger } from 'src/lib/logger'
@@ -33,6 +34,8 @@ export const worktreesRemove = async (options: WorktreeManagementArgs) => {
33
34
 
34
35
  commandEcho.start('worktrees-remove')
35
36
 
37
+ await assertManagementContext({ operation: 'remove worktrees', requiredBranch: 'dev' })
38
+
36
39
  try {
37
40
  const currentWorktrees = await getCurrentWorktrees('release')
38
41
 
@@ -9,6 +9,7 @@ import { getReleasePRs } from 'src/integrations/gh'
9
9
  import { commandEcho } from 'src/lib/command-echo'
10
10
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
11
11
  import { OperationError } from 'src/lib/errors/operation-error'
12
+ import { assertManagementContext } from 'src/lib/git-guard'
12
13
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
13
14
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
14
15
  import { logger } from 'src/lib/logger'
@@ -28,6 +29,8 @@ export const worktreesSync = async (options: WorktreeSyncArgs) => {
28
29
 
29
30
  commandEcho.start('worktrees-sync')
30
31
 
32
+ await assertManagementContext({ operation: 'sync worktrees', requiredBranch: 'dev' })
33
+
31
34
  try {
32
35
  const currentWorktrees = await getCurrentWorktrees('release')
33
36
  const projectRoot = await getProjectRoot()
@@ -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'