infra-kit 0.1.107 → 0.1.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-36d91435-50b4-442f-9f4e-3cc68d776236.jsonl +2 -0
  3. package/.omc/state/idle-notif-cooldown.json +1 -1
  4. package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/pre-tool-advisory-throttle.json +18 -0
  5. package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/subagent-tracking-state.json +17 -0
  6. package/.omc/state/sessions/3a844fbd-ace8-4af1-8516-33d611839886/pre-tool-advisory-throttle.json +18 -0
  7. package/.turbo/turbo-build.log +6 -7
  8. package/.turbo/turbo-eslint-check.log +1 -2
  9. package/.turbo/turbo-prettier-check.log +1 -2
  10. package/.turbo/turbo-test.log +70 -35
  11. package/.turbo/turbo-ts-check.log +1 -2
  12. package/dist/cli.js +50 -50
  13. package/dist/cli.js.map +4 -4
  14. package/dist/mcp.js +36 -36
  15. package/dist/mcp.js.map +4 -4
  16. package/package.json +1 -1
  17. package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -0
  18. package/src/commands/gh-release-deliver/gh-release-deliver.ts +5 -0
  19. package/src/commands/release-create/__tests__/release-create.test.ts +38 -1
  20. package/src/commands/release-create/release-create.ts +36 -4
  21. package/src/commands/worktrees-add/worktrees-add.ts +16 -1
  22. package/src/commands/worktrees-open/__tests__/open-cmux.test.ts +96 -0
  23. package/src/commands/worktrees-open/worktrees-open.ts +10 -3
  24. package/src/commands/worktrees-remove/worktrees-remove.ts +3 -0
  25. package/src/commands/worktrees-sync/worktrees-sync.ts +3 -0
  26. package/src/integrations/cmux/__tests__/canonicalize-cmux-title.test.ts +56 -0
  27. package/src/integrations/cmux/__tests__/close-workspace-by-title.test.ts +63 -0
  28. package/src/integrations/cmux/__tests__/list-workspace-titles.test.ts +65 -0
  29. package/src/integrations/cmux/canonicalize-cmux-title.ts +31 -0
  30. package/src/integrations/cmux/close-workspace-by-title.ts +12 -4
  31. package/src/integrations/cmux/index.ts +1 -0
  32. package/src/integrations/cmux/list-workspace-titles.ts +11 -6
  33. package/src/integrations/gh/gh-release-prs/.omc/state/sessions/c6ed6186-1aac-48e0-aa2a-edc6da8e0410/pre-tool-advisory-throttle.json +10 -0
  34. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +89 -6
  35. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +1 -1
  36. package/src/lib/git-guard/__tests__/git-guard.test.ts +92 -0
  37. package/src/lib/git-guard/git-guard.ts +55 -0
  38. package/src/lib/git-guard/index.ts +2 -0
  39. package/src/lib/git-utils/__tests__/git-primitives.test.ts +96 -0
  40. package/src/lib/git-utils/git-utils.ts +41 -0
  41. package/src/lib/git-utils/index.ts +8 -1
  42. package/tsconfig.tsbuildinfo +1 -1
@@ -2,14 +2,19 @@ import { $ } from 'zx'
2
2
 
3
3
  import { logger } from 'src/lib/logger'
4
4
 
5
+ import { canonicalizeCmuxTitle } from './canonicalize-cmux-title'
6
+
5
7
  /**
6
- * Returns the set of titles for all currently-open cmux workspaces.
7
- * Returns an empty set if cmux isn't running, the call fails, or the
8
- * output can't be parsed callers should treat "empty" as "unknown,
9
- * proceed as if nothing is open".
8
+ * Returns the set of **canonical** titles for all currently-open cmux
9
+ * workspaces (see {@link canonicalizeCmuxTitle}). Keying on the canonical form
10
+ * lets callers match a workspace even when its stored title drifted from the
11
+ * title they rebuild (whitespace, or an older CLI's `v`-prefixed semver).
12
+ * Returns an empty set if cmux isn't running, the call fails, or the output
13
+ * can't be parsed — callers should treat "empty" as "unknown, proceed as if
14
+ * nothing is open".
10
15
  *
11
16
  * Each line of `cmux list-workspaces` looks like:
12
- * " workspace:8 hulyo-monorepo v1.48.0"
17
+ * " workspace:8 hulyo-monorepo 1.48.0"
13
18
  * "* workspace:6 obsidian-workspace [selected]"
14
19
  */
15
20
  export const listCmuxWorkspaceTitles = async (): Promise<Set<string>> => {
@@ -29,7 +34,7 @@ export const listCmuxWorkspaceTitles = async (): Promise<Set<string>> => {
29
34
  const title = match[1]?.trim()
30
35
 
31
36
  if (title) {
32
- titles.add(title)
37
+ titles.add(canonicalizeCmuxTitle(title))
33
38
  }
34
39
  }
35
40
 
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "entries": {
4
+ "79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
5
+ "last_emitted_at_ms": 1781518823336,
6
+ "message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
7
+ }
8
+ },
9
+ "updated_at": "2026-06-15T10:20:23.336Z"
10
+ }
@@ -1,6 +1,8 @@
1
+ import { execFileSync } from 'node:child_process'
1
2
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { quote } from 'zx'
2
4
 
3
- import { getReleasePRs, getReleasePRsWithInfo } from '../gh-release-prs'
5
+ import { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo } from '../gh-release-prs'
4
6
 
5
7
  interface FakePR {
6
8
  number: number
@@ -12,17 +14,32 @@ interface FakePR {
12
14
  }
13
15
 
14
16
  const responses = vi.hoisted(() => {
15
- return { release: [] as FakePR[], hotfix: [] as FakePR[] }
17
+ return {
18
+ release: [] as FakePR[],
19
+ hotfix: [] as FakePR[],
20
+ calls: [] as { strings: string[]; values: unknown[] }[],
21
+ }
16
22
  })
17
23
 
18
24
  // Mock zx's tagged-template `$`: the gh pr list call for `--base dev` returns
19
- // the "release" set, `--base main` returns the "hotfix" set. The command is
20
- // reconstructed from the template strings so we can branch on the base flag.
21
- vi.mock('zx', () => {
25
+ // the "release" set, `--base main` returns the "hotfix" set, and `gh pr create`
26
+ // returns a fake PR URL. Every invocation is captured (template strings +
27
+ // interpolated values) so tests can reconstruct the exact command zx would run.
28
+ // Spreading `...actual` keeps zx's real `quote` available for that reconstruction.
29
+ vi.mock('zx', async (importOriginal) => {
30
+ const actual = await importOriginal<typeof import('zx')>()
31
+
22
32
  return {
23
- $: vi.fn((strings: TemplateStringsArray) => {
33
+ ...actual,
34
+ $: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
24
35
  const command = strings.join('')
25
36
 
37
+ responses.calls.push({ strings: [...strings], values })
38
+
39
+ if (command.includes('gh pr create')) {
40
+ return Promise.resolve({ stdout: 'https://github.com/acme/repo/pull/123' })
41
+ }
42
+
26
43
  if (command.includes('--base main')) {
27
44
  return Promise.resolve({ stdout: JSON.stringify(responses.hotfix) })
28
45
  }
@@ -113,3 +130,69 @@ describe('getReleasePRsWithInfo (discovery + sort)', () => {
113
130
  ])
114
131
  })
115
132
  })
133
+
134
+ describe('createReleaseBranch (gh pr create title quoting)', () => {
135
+ beforeEach(() => {
136
+ responses.calls = []
137
+ })
138
+
139
+ // Rebuild the exact command zx would have run: interleave each template
140
+ // string with the zx-quoted form of its interpolated value. This reflects
141
+ // what the shell actually receives, including zx's ANSI-C `$'...'` escaping
142
+ // for values containing spaces.
143
+ const reconstruct = (call: { strings: string[]; values: unknown[] }): string => {
144
+ return call.strings
145
+ .map((part, i) => {
146
+ return i < call.values.length ? part + quote(String(call.values[i])) : part
147
+ })
148
+ .join('')
149
+ }
150
+
151
+ const findCreateCommand = (): string => {
152
+ const call = responses.calls.find((c) => {
153
+ return c.strings.join('').includes('gh pr create')
154
+ })
155
+
156
+ if (!call) throw new Error('gh pr create was not invoked')
157
+
158
+ return reconstruct(call)
159
+ }
160
+
161
+ // Run the reconstructed command through a real shell with `gh pr create`
162
+ // replaced by a function that prints back the value the shell parsed for
163
+ // `--title`. This is the behavioral proof of what gh would receive.
164
+ const shellTitleArg = (command: string): string => {
165
+ const probe = command.replace(
166
+ 'gh pr create',
167
+ 'f(){ while [ "$#" -gt 0 ]; do if [ "$1" = "--title" ]; then printf %s "$2"; return 0; fi; shift; done; }; f',
168
+ )
169
+
170
+ // Absolute path (not PATH-resolved) keeps the lint's command-injection guard happy.
171
+ return execFileSync('/bin/sh', ['-c', probe]).toString()
172
+ }
173
+
174
+ it("delivers a spaced title to gh as the literal string, with no $'...' corruption", async () => {
175
+ const id = { kind: 'version', semver: { major: 1, minor: 89, patch: 1 }, raw: '1.89.1' } as const
176
+
177
+ await createReleaseBranch({ id, jiraVersionUrl: 'https://jira.example/v1.89.1', type: 'hotfix' })
178
+
179
+ const command = findCreateCommand()
180
+
181
+ // Behavioral: the shell parses --title to exactly the intended title.
182
+ expect(shellTitleArg(command)).toBe('Hotfix v1.89.1')
183
+ // The pre-fix bug wrapped zx's `$'...'` token in literal double quotes, so
184
+ // the shell passed the literal `$'Hotfix v1.89.1'` to gh. That marker must be absent.
185
+ expect(command).not.toContain('"$\'')
186
+ })
187
+
188
+ it('delivers a named release title to gh as the literal string', async () => {
189
+ const id = { kind: 'name', name: 'checkout-redesign', raw: 'checkout-redesign' } as const
190
+
191
+ await createReleaseBranch({ id, jiraVersionUrl: 'https://jira.example/checkout-redesign', type: 'regular' })
192
+
193
+ const command = findCreateCommand()
194
+
195
+ expect(shellTitleArg(command)).toBe('Release checkout-redesign')
196
+ expect(command).not.toContain('"$\'')
197
+ })
198
+ })
@@ -178,7 +178,7 @@ export const createReleaseBranch = async (
178
178
  await $`git push origin ${branchName}`
179
179
 
180
180
  // Create PR and capture URL
181
- const prResult = await $`gh pr create --title "${prTitle}" --body ${body} --base ${baseBranch} --head ${branchName}`
181
+ const prResult = await $`gh pr create --title ${prTitle} --body ${body} --base ${baseBranch} --head ${branchName}`
182
182
 
183
183
  const prLink = prResult.stdout.trim()
184
184
 
@@ -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
+ })
@@ -93,6 +93,47 @@ export const getProjectRoot = async (): Promise<string> => {
93
93
  return result.stdout.trim()
94
94
  }
95
95
 
96
+ /**
97
+ * Get the current git branch name (e.g. `dev`, `main`, `release/v1.2.3`).
98
+ */
99
+ export const getCurrentBranch = async (): Promise<string> => {
100
+ const result = await $`git rev-parse --abbrev-ref HEAD`
101
+
102
+ return result.stdout.trim()
103
+ }
104
+
105
+ /**
106
+ * Whether the working tree has no staged, unstaged, or untracked changes.
107
+ */
108
+ export const isWorkingTreeClean = async (): Promise<boolean> => {
109
+ const result = await $`git status --porcelain`
110
+
111
+ return result.stdout.trim().length === 0
112
+ }
113
+
114
+ /**
115
+ * Whether the current checkout is a linked git worktree rather than the main
116
+ * repository checkout.
117
+ *
118
+ * A linked worktree's git dir lives under `<main>/.git/worktrees/<name>`, so it
119
+ * differs from the shared common dir; in the main checkout the two resolve to
120
+ * the same path. Both are anchored to the toplevel so `--git-common-dir` (which
121
+ * git may report relative to cwd) resolves consistently.
122
+ */
123
+ export const isInsideLinkedWorktree = async (): Promise<boolean> => {
124
+ const cwd = await getProjectRoot()
125
+
126
+ const [gitDirResult, commonDirResult] = await Promise.all([
127
+ $({ cwd })`git rev-parse --absolute-git-dir`,
128
+ $({ cwd })`git rev-parse --git-common-dir`,
129
+ ])
130
+
131
+ const gitDir = gitDirResult.stdout.trim()
132
+ const commonDir = path.resolve(cwd, commonDirResult.stdout.trim())
133
+
134
+ return gitDir !== commonDir
135
+ }
136
+
96
137
  /**
97
138
  * Get the current repository name (basename of the project root)
98
139
  */
@@ -1 +1,8 @@
1
- export { getCurrentWorktrees, getProjectRoot, getRepoName } from './git-utils'
1
+ export {
2
+ getCurrentBranch,
3
+ getCurrentWorktrees,
4
+ getProjectRoot,
5
+ getRepoName,
6
+ isInsideLinkedWorktree,
7
+ isWorkingTreeClean,
8
+ } from './git-utils'