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.
Files changed (43) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/sessions/3a844fbd-ace8-4af1-8516-33d611839886/pre-tool-advisory-throttle.json +18 -0
  3. package/.turbo/turbo-build.log +6 -7
  4. package/.turbo/turbo-eslint-check.log +1 -2
  5. package/.turbo/turbo-prettier-check.log +1 -2
  6. package/.turbo/turbo-test.log +70 -72
  7. package/.turbo/turbo-ts-check.log +1 -2
  8. package/dist/cli.js +50 -50
  9. package/dist/cli.js.map +4 -4
  10. package/dist/mcp.js +37 -37
  11. package/dist/mcp.js.map +4 -4
  12. package/package.json +1 -1
  13. package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -0
  14. package/src/commands/gh-release-deliver/gh-release-deliver.ts +5 -0
  15. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +2 -2
  16. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +1 -1
  17. package/src/commands/release-create/__tests__/release-create.test.ts +38 -1
  18. package/src/commands/release-create/release-create.ts +36 -4
  19. package/src/commands/release-desc-edit/release-desc-edit.ts +2 -2
  20. package/src/commands/worktrees-add/worktrees-add.ts +3 -0
  21. package/src/commands/worktrees-open/__tests__/open-cmux.test.ts +2 -2
  22. package/src/commands/worktrees-remove/worktrees-remove.ts +3 -0
  23. package/src/commands/worktrees-sync/worktrees-sync.ts +3 -0
  24. package/src/integrations/cmux/__tests__/canonicalize-cmux-title.test.ts +1 -1
  25. package/src/integrations/cmux/workspace-title.ts +1 -1
  26. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +10 -10
  27. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +1 -1
  28. package/src/lib/git-guard/__tests__/git-guard.test.ts +92 -0
  29. package/src/lib/git-guard/git-guard.ts +55 -0
  30. package/src/lib/git-guard/index.ts +2 -0
  31. package/src/lib/git-utils/__tests__/git-primitives.test.ts +96 -0
  32. package/src/lib/git-utils/__tests__/git-utils.test.ts +4 -4
  33. package/src/lib/git-utils/git-utils.ts +41 -0
  34. package/src/lib/git-utils/index.ts +8 -1
  35. package/src/lib/release-id/__tests__/release-id.test.ts +27 -19
  36. package/src/lib/release-id/release-id.ts +19 -18
  37. package/src/lib/release-utils/__tests__/release-utils.test.ts +8 -8
  38. package/src/lib/release-utils/release-utils.ts +1 -1
  39. package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +4 -4
  40. package/src/lib/version-utils/__tests__/next-version.test.ts +1 -1
  41. package/src/lib/version-utils/load-existing-versions.ts +1 -1
  42. package/src/lib/version-utils/version-utils.ts +1 -1
  43. 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.108",
4
+ "version": "0.1.110",
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()
@@ -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/n/checkout-redesign" | "dev"
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/n/<name> branch. Pass "dev" to deploy from the dev branch instead. Required for MCP calls.',
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/n/<name> branch. Pass "dev" to deploy from the dev branch instead. Required for MCP calls.',
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 { 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/<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/n/<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.",
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/n/checkout-redesign")'),
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/n/checkout-redesign']
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/n/checkout-redesign`,
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/n/checkout-redesign' })
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/n/checkout-redesign"`. Non-release branches (cmux titles them too)
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/n/zeta-feature', createdAt: '2026-01-10T00:00:00Z', title: 'Release zeta-feature' }),
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/n/alpha-feature', createdAt: '2026-01-05T00:00:00Z', title: 'Release alpha-feature' }),
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/n/alpha-feature',
80
- 'release/n/zeta-feature',
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/garbage', createdAt: '2026-01-01T00:00:00Z' }),
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/n/checkout-redesign',
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/n/checkout-redesign'])
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/n/beta-feature', createdAt: '2026-02-02T00:00:00Z', title: 'Release beta-feature' }),
123
- pr({ headRefName: 'release/garbage', createdAt: '2026-02-03T00:00:00Z', title: 'Release garbage' }),
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/n/beta-feature', title: 'Release beta-feature', createdAt: '2026-02-02T00:00:00Z' },
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/n/checkout-redesign]
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,2 @@
1
+ export { assertManagementContext } from './git-guard'
2
+ export type { AssertManagementContextArgs } from './git-guard'
@@ -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
+ })