infra-kit 0.1.108 → 0.1.110
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/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 -72
- 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 +37 -37
- 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/gh-release-deploy-all/gh-release-deploy-all.ts +2 -2
- package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +1 -1
- 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/release-desc-edit/release-desc-edit.ts +2 -2
- package/src/commands/worktrees-add/worktrees-add.ts +3 -0
- package/src/commands/worktrees-open/__tests__/open-cmux.test.ts +2 -2
- 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 +1 -1
- package/src/integrations/cmux/workspace-title.ts +1 -1
- package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +10 -10
- 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/__tests__/git-utils.test.ts +4 -4
- package/src/lib/git-utils/git-utils.ts +41 -0
- package/src/lib/git-utils/index.ts +8 -1
- package/src/lib/release-id/__tests__/release-id.test.ts +27 -19
- package/src/lib/release-id/release-id.ts +19 -18
- package/src/lib/release-utils/__tests__/release-utils.test.ts +8 -8
- package/src/lib/release-utils/release-utils.ts +1 -1
- package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +4 -4
- package/src/lib/version-utils/__tests__/next-version.test.ts +1 -1
- package/src/lib/version-utils/load-existing-versions.ts +1 -1
- package/src/lib/version-utils/version-utils.ts +1 -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()
|
|
@@ -31,7 +31,7 @@ export const ghReleaseDeployAll = async (args: GhReleaseDeployAllArgs) => {
|
|
|
31
31
|
|
|
32
32
|
commandEcho.start('release-deploy-all')
|
|
33
33
|
|
|
34
|
-
let selectedReleaseBranch = '' // "release/v1.8.0" | "release/
|
|
34
|
+
let selectedReleaseBranch = '' // "release/v1.8.0" | "release/checkout-redesign" | "dev"
|
|
35
35
|
|
|
36
36
|
if (version) {
|
|
37
37
|
selectedReleaseBranch = version === 'dev' ? 'dev' : resolveReleaseBranch(version)
|
|
@@ -143,7 +143,7 @@ export const ghReleaseDeployAllMcpTool = defineMcpTool({
|
|
|
143
143
|
version: z
|
|
144
144
|
.string()
|
|
145
145
|
.describe(
|
|
146
|
-
'Accepts a release version (e.g. "1.2.5") OR a release name (e.g. "checkout-redesign") — resolves to the release/vX.Y.Z or release
|
|
146
|
+
'Accepts a release version (e.g. "1.2.5") OR a release name (e.g. "checkout-redesign") — resolves to the release/vX.Y.Z or release/<name> branch. Pass "dev" to deploy from the dev branch instead. Required for MCP calls.',
|
|
147
147
|
),
|
|
148
148
|
env: z
|
|
149
149
|
.string()
|
|
@@ -232,7 +232,7 @@ export const ghReleaseDeploySelectedMcpTool = defineMcpTool({
|
|
|
232
232
|
version: z
|
|
233
233
|
.string()
|
|
234
234
|
.describe(
|
|
235
|
-
'Accepts a release version (e.g. "1.2.5") OR a release name (e.g. "checkout-redesign") — resolves to the release/vX.Y.Z or release
|
|
235
|
+
'Accepts a release version (e.g. "1.2.5") OR a release name (e.g. "checkout-redesign") — resolves to the release/vX.Y.Z or release/<name> branch. Pass "dev" to deploy from the dev branch instead. Required for MCP calls.',
|
|
236
236
|
),
|
|
237
237
|
env: z
|
|
238
238
|
.string()
|
|
@@ -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/<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(
|
|
@@ -208,7 +208,7 @@ export const releaseDescEdit = async (args: ReleaseDescEditArgs) => {
|
|
|
208
208
|
export const releaseDescEditMcpTool = defineMcpTool({
|
|
209
209
|
name: 'release-desc-edit',
|
|
210
210
|
description:
|
|
211
|
-
"Edit a release's description in Jira and in the matching GitHub release PR body. Accepts a release version or a release name: targets the Jira fix version named `v<version>` (versioned) or `<name>` (named) and the open PR on branch `release/v<version>` or `release
|
|
211
|
+
"Edit a release's description in Jira and in the matching GitHub release PR body. Accepts a release version or a release name: targets the Jira fix version named `v<version>` (versioned) or `<name>` (named) and the open PR on branch `release/v<version>` or `release/<name>`. The PR body is rewritten canonically to `<jiraVersionUrl>\\n\\n<description>` — any prior manual edits to the body are overwritten. Both `version` and `description` are required for MCP calls (the picker/prompt are unreachable without a TTY). Empty `description` clears the description on both sides. Confirmation is auto-skipped for MCP, so the caller is responsible for gating.",
|
|
212
212
|
inputSchema: {
|
|
213
213
|
version: z
|
|
214
214
|
.string()
|
|
@@ -217,7 +217,7 @@ export const releaseDescEditMcpTool = defineMcpTool({
|
|
|
217
217
|
},
|
|
218
218
|
outputSchema: {
|
|
219
219
|
version: z.string().describe('Release version'),
|
|
220
|
-
branch: z.string().describe('Release branch name (e.g. "release/v1.2.5" or "release/
|
|
220
|
+
branch: z.string().describe('Release branch name (e.g. "release/v1.2.5" or "release/checkout-redesign")'),
|
|
221
221
|
jiraVersionUrl: z.string().describe('Jira fix version URL'),
|
|
222
222
|
previousDescription: z.string().describe('The description before the update'),
|
|
223
223
|
newDescription: z.string().describe('The description after the update'),
|
|
@@ -17,6 +17,7 @@ import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
|
17
17
|
import { commandEcho } from 'src/lib/command-echo'
|
|
18
18
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
19
19
|
import { OperationError } from 'src/lib/errors/operation-error'
|
|
20
|
+
import { assertManagementContext } from 'src/lib/git-guard'
|
|
20
21
|
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
21
22
|
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
22
23
|
import { logger } from 'src/lib/logger'
|
|
@@ -50,6 +51,8 @@ export const worktreesAdd = async (options: WorktreeManagementArgs) => {
|
|
|
50
51
|
|
|
51
52
|
commandEcho.start('worktrees-add')
|
|
52
53
|
|
|
54
|
+
await assertManagementContext({ operation: 'create worktrees', requiredBranch: 'dev' })
|
|
55
|
+
|
|
53
56
|
try {
|
|
54
57
|
const currentWorktrees = await getCurrentWorktrees('release')
|
|
55
58
|
const projectRoot = await getProjectRoot()
|
|
@@ -25,7 +25,7 @@ vi.mock('src/lib/git-utils', () => {
|
|
|
25
25
|
|
|
26
26
|
const REPO = 'hulyo-monorepo'
|
|
27
27
|
const WORKTREE_DIR = '/repos/project-worktrees'
|
|
28
|
-
const BRANCHES = ['release/v1.48.0', 'release/
|
|
28
|
+
const BRANCHES = ['release/v1.48.0', 'release/checkout-redesign']
|
|
29
29
|
|
|
30
30
|
const titleFor = {
|
|
31
31
|
versioned: `${REPO} 1.48.0`,
|
|
@@ -68,7 +68,7 @@ describe('openCmux dedup', () => {
|
|
|
68
68
|
expect(result.opened).toEqual([titleFor.named])
|
|
69
69
|
expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledTimes(1)
|
|
70
70
|
expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledWith({
|
|
71
|
-
cwd: `${WORKTREE_DIR}/release/
|
|
71
|
+
cwd: `${WORKTREE_DIR}/release/checkout-redesign`,
|
|
72
72
|
title: titleFor.named,
|
|
73
73
|
})
|
|
74
74
|
})
|
|
@@ -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()
|
|
@@ -48,7 +48,7 @@ describe('canonicalizeCmuxTitle', () => {
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
it('built title canonicalizes to a stable key for a named release', () => {
|
|
51
|
-
const built = buildCmuxWorkspaceTitle({ repoName: 'hulyo-monorepo', branch: 'release/
|
|
51
|
+
const built = buildCmuxWorkspaceTitle({ repoName: 'hulyo-monorepo', branch: 'release/checkout-redesign' })
|
|
52
52
|
|
|
53
53
|
expect(canonicalizeCmuxTitle(built)).toBe('hulyo-monorepo checkout-redesign')
|
|
54
54
|
})
|
|
@@ -10,7 +10,7 @@ interface BuildCmuxWorkspaceTitleArgs {
|
|
|
10
10
|
* `worktrees-remove`. Release branches are rendered via their release-id
|
|
11
11
|
* display label so the title reads e.g. `"hulyo-monorepo 1.48.0"` for
|
|
12
12
|
* `"release/v1.48.0"` and `"hulyo-monorepo checkout-redesign"` for
|
|
13
|
-
* `"release/
|
|
13
|
+
* `"release/checkout-redesign"`. Non-release branches (cmux titles them too)
|
|
14
14
|
* fall back to the raw branch string.
|
|
15
15
|
*/
|
|
16
16
|
export const buildCmuxWorkspaceTitle = (args: BuildCmuxWorkspaceTitleArgs): string => {
|
|
@@ -67,33 +67,33 @@ describe('getReleasePRs (discovery + sort)', () => {
|
|
|
67
67
|
|
|
68
68
|
it('sorts version branches first by semver ascending (numeric, 1.9.0 < 1.10.0), then names by createdAt', async () => {
|
|
69
69
|
responses.release = [
|
|
70
|
-
pr({ headRefName: 'release/
|
|
70
|
+
pr({ headRefName: 'release/zeta-feature', createdAt: '2026-01-10T00:00:00Z', title: 'Release zeta-feature' }),
|
|
71
71
|
pr({ headRefName: 'release/v1.10.0', createdAt: '2026-01-02T00:00:00Z' }),
|
|
72
|
-
pr({ headRefName: 'release/
|
|
72
|
+
pr({ headRefName: 'release/alpha-feature', createdAt: '2026-01-05T00:00:00Z', title: 'Release alpha-feature' }),
|
|
73
73
|
pr({ headRefName: 'release/v1.9.0', createdAt: '2026-01-01T00:00:00Z' }),
|
|
74
74
|
]
|
|
75
75
|
|
|
76
76
|
await expect(getReleasePRs()).resolves.toEqual([
|
|
77
77
|
'release/v1.9.0',
|
|
78
78
|
'release/v1.10.0',
|
|
79
|
-
'release/
|
|
80
|
-
'release/
|
|
79
|
+
'release/alpha-feature',
|
|
80
|
+
'release/zeta-feature',
|
|
81
81
|
])
|
|
82
82
|
})
|
|
83
83
|
|
|
84
84
|
it('filters out unparseable junk branches instead of throwing or NaN-sorting', async () => {
|
|
85
85
|
responses.release = [
|
|
86
|
-
pr({ headRefName: 'release/
|
|
86
|
+
pr({ headRefName: 'release/Bad_Name', createdAt: '2026-01-01T00:00:00Z' }),
|
|
87
87
|
pr({ headRefName: 'release/v1.2.3', createdAt: '2026-01-02T00:00:00Z' }),
|
|
88
88
|
pr({ headRefName: 'totally-not-a-release', createdAt: '2026-01-03T00:00:00Z' }),
|
|
89
89
|
pr({
|
|
90
|
-
headRefName: 'release/
|
|
90
|
+
headRefName: 'release/checkout-redesign',
|
|
91
91
|
createdAt: '2026-01-04T00:00:00Z',
|
|
92
92
|
title: 'Release checkout-redesign',
|
|
93
93
|
}),
|
|
94
94
|
]
|
|
95
95
|
|
|
96
|
-
await expect(getReleasePRs()).resolves.toEqual(['release/v1.2.3', 'release/
|
|
96
|
+
await expect(getReleasePRs()).resolves.toEqual(['release/v1.2.3', 'release/checkout-redesign'])
|
|
97
97
|
})
|
|
98
98
|
|
|
99
99
|
it('merges hotfix (base main) and release (base dev) sets', async () => {
|
|
@@ -119,14 +119,14 @@ describe('getReleasePRsWithInfo (discovery + sort)', () => {
|
|
|
119
119
|
|
|
120
120
|
it('returns branch/title/createdAt in the locked order with junk filtered', async () => {
|
|
121
121
|
responses.release = [
|
|
122
|
-
pr({ headRefName: 'release/
|
|
123
|
-
pr({ headRefName: 'release/
|
|
122
|
+
pr({ headRefName: 'release/beta-feature', createdAt: '2026-02-02T00:00:00Z', title: 'Release beta-feature' }),
|
|
123
|
+
pr({ headRefName: 'release/Bad_Name', createdAt: '2026-02-03T00:00:00Z', title: 'Release Bad_Name' }),
|
|
124
124
|
pr({ headRefName: 'release/v3.1.0', createdAt: '2026-02-01T00:00:00Z', title: 'Release v3.1.0' }),
|
|
125
125
|
]
|
|
126
126
|
|
|
127
127
|
await expect(getReleasePRsWithInfo()).resolves.toEqual([
|
|
128
128
|
{ branch: 'release/v3.1.0', title: 'Release v3.1.0', createdAt: '2026-02-01T00:00:00Z' },
|
|
129
|
-
{ branch: 'release/
|
|
129
|
+
{ branch: 'release/beta-feature', title: 'Release beta-feature', createdAt: '2026-02-02T00:00:00Z' },
|
|
130
130
|
])
|
|
131
131
|
})
|
|
132
132
|
})
|
|
@@ -77,7 +77,7 @@ const fetchAllReleasePRs = async (): Promise<ReleasePR[]> => {
|
|
|
77
77
|
* (version branches first by semver ascending, then named branches by PR
|
|
78
78
|
* creation date). Unparseable head refs are filtered out.
|
|
79
79
|
*
|
|
80
|
-
* @returns [release/v1.18.22, release/v1.18.23, release/
|
|
80
|
+
* @returns [release/v1.18.22, release/v1.18.23, release/checkout-redesign]
|
|
81
81
|
*/
|
|
82
82
|
export const getReleasePRs = async (): Promise<string[]> => {
|
|
83
83
|
try {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { OperationError } from 'src/lib/errors/operation-error'
|
|
4
|
+
import { assertManagementContext } from 'src/lib/git-guard'
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => {
|
|
7
|
+
return {
|
|
8
|
+
isInsideLinkedWorktree: vi.fn(),
|
|
9
|
+
getCurrentBranch: vi.fn(),
|
|
10
|
+
isWorkingTreeClean: vi.fn(),
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
vi.mock('src/lib/git-utils', () => {
|
|
15
|
+
return {
|
|
16
|
+
isInsideLinkedWorktree: mocks.isInsideLinkedWorktree,
|
|
17
|
+
getCurrentBranch: mocks.getCurrentBranch,
|
|
18
|
+
isWorkingTreeClean: mocks.isWorkingTreeClean,
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('assertManagementContext', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks()
|
|
25
|
+
|
|
26
|
+
// Default: main checkout, on dev, clean tree (the all-pass baseline).
|
|
27
|
+
mocks.isInsideLinkedWorktree.mockResolvedValue(false)
|
|
28
|
+
mocks.getCurrentBranch.mockResolvedValue('dev')
|
|
29
|
+
mocks.isWorkingTreeClean.mockResolvedValue(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('resolves when in main checkout, on the required branch, with a clean tree', async () => {
|
|
33
|
+
await expect(
|
|
34
|
+
assertManagementContext({ operation: 'create release', requiredBranch: 'dev' }),
|
|
35
|
+
).resolves.toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('throws when inside a linked worktree', async () => {
|
|
39
|
+
mocks.isInsideLinkedWorktree.mockResolvedValue(true)
|
|
40
|
+
|
|
41
|
+
await expect(assertManagementContext({ operation: 'create release', requiredBranch: 'dev' })).rejects.toMatchObject(
|
|
42
|
+
{
|
|
43
|
+
message: expect.stringContaining('worktree'),
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('checks the worktree before the branch', async () => {
|
|
49
|
+
mocks.isInsideLinkedWorktree.mockResolvedValue(true)
|
|
50
|
+
mocks.getCurrentBranch.mockResolvedValue('feature/x')
|
|
51
|
+
|
|
52
|
+
await expect(assertManagementContext({ operation: 'create release', requiredBranch: 'dev' })).rejects.toThrow(
|
|
53
|
+
/worktree/,
|
|
54
|
+
)
|
|
55
|
+
expect(mocks.getCurrentBranch).not.toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('throws on the wrong branch when requiredBranch is set', async () => {
|
|
59
|
+
mocks.getCurrentBranch.mockResolvedValue('main')
|
|
60
|
+
|
|
61
|
+
await expect(assertManagementContext({ operation: 'create release', requiredBranch: 'dev' })).rejects.toMatchObject(
|
|
62
|
+
{
|
|
63
|
+
message: expect.stringContaining('switch to dev'),
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('skips the branch check when requiredBranch is omitted', async () => {
|
|
69
|
+
mocks.getCurrentBranch.mockResolvedValue('main')
|
|
70
|
+
|
|
71
|
+
await expect(assertManagementContext({ operation: 'deliver release' })).resolves.toBeUndefined()
|
|
72
|
+
expect(mocks.getCurrentBranch).not.toHaveBeenCalled()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('throws when the working tree is dirty', async () => {
|
|
76
|
+
mocks.isWorkingTreeClean.mockResolvedValue(false)
|
|
77
|
+
|
|
78
|
+
await expect(assertManagementContext({ operation: 'create release', requiredBranch: 'dev' })).rejects.toMatchObject(
|
|
79
|
+
{
|
|
80
|
+
message: expect.stringContaining('commit or stash'),
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('throws OperationError (not a plain Error) on violation', async () => {
|
|
86
|
+
mocks.isWorkingTreeClean.mockResolvedValue(false)
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
assertManagementContext({ operation: 'sync worktrees', requiredBranch: 'dev' }),
|
|
90
|
+
).rejects.toBeInstanceOf(OperationError)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { OperationError } from 'src/lib/errors/operation-error'
|
|
2
|
+
import { getCurrentBranch, isInsideLinkedWorktree, isWorkingTreeClean } from 'src/lib/git-utils'
|
|
3
|
+
|
|
4
|
+
export interface AssertManagementContextArgs {
|
|
5
|
+
/** Operation name surfaced in the failure message, e.g. 'create release'. */
|
|
6
|
+
operation: string
|
|
7
|
+
/**
|
|
8
|
+
* Canonical branch the command must run from (e.g. 'dev' / 'main'). Omit to
|
|
9
|
+
* skip the branch check for commands that are branch-agnostic or self-switch.
|
|
10
|
+
*/
|
|
11
|
+
requiredBranch?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Guard release- and worktree-management commands so they run only from the
|
|
16
|
+
* main repository checkout, with a clean working tree, and (where applicable)
|
|
17
|
+
* on the canonical branch.
|
|
18
|
+
*
|
|
19
|
+
* Checks run most-structural-first — worktree, then branch, then clean — so the
|
|
20
|
+
* operator fixes the most fundamental problem first. The worktree and clean-tree
|
|
21
|
+
* checks always run; the branch check runs only when `requiredBranch` is set.
|
|
22
|
+
* Throws {@link OperationError} on the first violation, which surfaces uniformly
|
|
23
|
+
* to both CLI users and MCP-connected agents.
|
|
24
|
+
*/
|
|
25
|
+
export const assertManagementContext = async (args: AssertManagementContextArgs): Promise<void> => {
|
|
26
|
+
const { operation, requiredBranch } = args
|
|
27
|
+
|
|
28
|
+
if (await isInsideLinkedWorktree()) {
|
|
29
|
+
throw new OperationError(undefined, {
|
|
30
|
+
operation,
|
|
31
|
+
remediation: 'run this from the main repository checkout, not a linked git worktree',
|
|
32
|
+
stderrExcerpt: 'command run from inside a linked worktree',
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (requiredBranch) {
|
|
37
|
+
const currentBranch = await getCurrentBranch()
|
|
38
|
+
|
|
39
|
+
if (currentBranch !== requiredBranch) {
|
|
40
|
+
throw new OperationError(undefined, {
|
|
41
|
+
operation,
|
|
42
|
+
remediation: `switch to ${requiredBranch} first (git switch ${requiredBranch})`,
|
|
43
|
+
stderrExcerpt: `current branch is "${currentBranch}", expected "${requiredBranch}"`,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!(await isWorkingTreeClean())) {
|
|
49
|
+
throw new OperationError(undefined, {
|
|
50
|
+
operation,
|
|
51
|
+
remediation: 'commit or stash your changes, then retry',
|
|
52
|
+
stderrExcerpt: 'working tree has uncommitted changes',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { getCurrentBranch, isInsideLinkedWorktree, isWorkingTreeClean } from 'src/lib/git-utils'
|
|
4
|
+
|
|
5
|
+
// Controllable per-command git output. The mocked `$` dispatches on the command
|
|
6
|
+
// text so a single test can make `--absolute-git-dir` and `--git-common-dir`
|
|
7
|
+
// return DIFFERENT values — which the shared single-stdout mock cannot express.
|
|
8
|
+
const git = vi.hoisted(() => {
|
|
9
|
+
return {
|
|
10
|
+
toplevel: '/repo',
|
|
11
|
+
branch: 'dev',
|
|
12
|
+
status: '',
|
|
13
|
+
absoluteGitDir: '/repo/.git',
|
|
14
|
+
commonDir: '.git',
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
vi.mock('zx', () => {
|
|
19
|
+
const run = (strings: TemplateStringsArray): Promise<{ stdout: string }> => {
|
|
20
|
+
const command = strings.join('')
|
|
21
|
+
|
|
22
|
+
if (command.includes('--show-toplevel')) return Promise.resolve({ stdout: `${git.toplevel}\n` })
|
|
23
|
+
if (command.includes('--abbrev-ref HEAD')) return Promise.resolve({ stdout: `${git.branch}\n` })
|
|
24
|
+
if (command.includes('status --porcelain')) return Promise.resolve({ stdout: git.status })
|
|
25
|
+
if (command.includes('--absolute-git-dir')) return Promise.resolve({ stdout: `${git.absoluteGitDir}\n` })
|
|
26
|
+
if (command.includes('--git-common-dir')) return Promise.resolve({ stdout: `${git.commonDir}\n` })
|
|
27
|
+
|
|
28
|
+
return Promise.resolve({ stdout: '' })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// zx's `$` supports both tagged-template form (`$`...``) and an options form
|
|
32
|
+
// (`$({ cwd })`...``) that returns a fresh tagged-template function.
|
|
33
|
+
const $ = vi.fn((first: unknown) => {
|
|
34
|
+
if (!Array.isArray(first)) {
|
|
35
|
+
return (strings: TemplateStringsArray) => {
|
|
36
|
+
return run(strings)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return run(first as unknown as TemplateStringsArray)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return { $ }
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('getCurrentBranch', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
git.branch = 'dev'
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns the trimmed branch name', async () => {
|
|
52
|
+
git.branch = 'release/v1.2.3'
|
|
53
|
+
|
|
54
|
+
await expect(getCurrentBranch()).resolves.toBe('release/v1.2.3')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('isWorkingTreeClean', () => {
|
|
59
|
+
it('returns true when porcelain output is empty', async () => {
|
|
60
|
+
git.status = ''
|
|
61
|
+
|
|
62
|
+
await expect(isWorkingTreeClean()).resolves.toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns true when porcelain output is whitespace only', async () => {
|
|
66
|
+
git.status = '\n'
|
|
67
|
+
|
|
68
|
+
await expect(isWorkingTreeClean()).resolves.toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns false when there are changes', async () => {
|
|
72
|
+
git.status = ' M src/foo.ts\n?? new.ts\n'
|
|
73
|
+
|
|
74
|
+
await expect(isWorkingTreeClean()).resolves.toBe(false)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('isInsideLinkedWorktree', () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
git.toplevel = '/repo'
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('returns false in the main checkout (git dir resolves to the common dir)', async () => {
|
|
84
|
+
git.absoluteGitDir = '/repo/.git'
|
|
85
|
+
git.commonDir = '.git'
|
|
86
|
+
|
|
87
|
+
await expect(isInsideLinkedWorktree()).resolves.toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('returns true in a linked worktree (git dir under .git/worktrees, differs from common dir)', async () => {
|
|
91
|
+
git.absoluteGitDir = '/repo/.git/worktrees/release-v1.2.3'
|
|
92
|
+
git.commonDir = '/repo/.git'
|
|
93
|
+
|
|
94
|
+
await expect(isInsideLinkedWorktree()).resolves.toBe(true)
|
|
95
|
+
})
|
|
96
|
+
})
|