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.
- 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/.omc/state/sessions/3a844fbd-ace8-4af1-8516-33d611839886/pre-tool-advisory-throttle.json +18 -0
- package/.turbo/turbo-build.log +6 -7
- package/.turbo/turbo-eslint-check.log +1 -2
- package/.turbo/turbo-prettier-check.log +1 -2
- package/.turbo/turbo-test.log +70 -35
- package/.turbo/turbo-ts-check.log +1 -2
- package/dist/cli.js +50 -50
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +36 -36
- package/dist/mcp.js.map +4 -4
- package/package.json +1 -1
- package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -0
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +5 -0
- package/src/commands/release-create/__tests__/release-create.test.ts +38 -1
- package/src/commands/release-create/release-create.ts +36 -4
- package/src/commands/worktrees-add/worktrees-add.ts +16 -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/commands/worktrees-remove/worktrees-remove.ts +3 -0
- package/src/commands/worktrees-sync/worktrees-sync.ts +3 -0
- 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/src/lib/git-guard/__tests__/git-guard.test.ts +92 -0
- package/src/lib/git-guard/git-guard.ts +55 -0
- package/src/lib/git-guard/index.ts +2 -0
- package/src/lib/git-utils/__tests__/git-primitives.test.ts +96 -0
- package/src/lib/git-utils/git-utils.ts +41 -0
- package/src/lib/git-utils/index.ts +8 -1
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
|
288
|
-
*
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
|
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'
|