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
@@ -23,8 +23,8 @@ describe('getCurrentWorktrees', () => {
23
23
  worktreeList.stdout = [
24
24
  asWorktreeLine('main'),
25
25
  asWorktreeLine('release/v1.18.22'),
26
- asWorktreeLine('release/n/checkout-redesign'),
27
- asWorktreeLine('release/garbage'),
26
+ asWorktreeLine('release/checkout-redesign'),
27
+ asWorktreeLine('release/Bad_Name'),
28
28
  asWorktreeLine('feature/login-page'),
29
29
  '/repos/project abc1234 (bare)',
30
30
  '',
@@ -32,13 +32,13 @@ describe('getCurrentWorktrees', () => {
32
32
  })
33
33
 
34
34
  it('returns versioned AND named release worktrees for type release', async () => {
35
- await expect(getCurrentWorktrees('release')).resolves.toEqual(['release/v1.18.22', 'release/n/checkout-redesign'])
35
+ await expect(getCurrentWorktrees('release')).resolves.toEqual(['release/v1.18.22', 'release/checkout-redesign'])
36
36
  })
37
37
 
38
38
  it('excludes junk release branches and non-release branches', async () => {
39
39
  const branches = await getCurrentWorktrees('release')
40
40
 
41
- expect(branches).not.toContain('release/garbage')
41
+ expect(branches).not.toContain('release/Bad_Name')
42
42
  expect(branches).not.toContain('feature/login-page')
43
43
  expect(branches).not.toContain('main')
44
44
  })
@@ -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'
@@ -29,13 +29,13 @@ describe('parseBranchName', () => {
29
29
  expect(parseBranchName('release/v1.2.3')).toEqual(version(1, 2, 3))
30
30
  })
31
31
 
32
- it('parses release/n/<name> as a name', () => {
33
- expect(parseBranchName('release/n/checkout-redesign')).toEqual(name('checkout-redesign'))
32
+ it('parses release/<name> as a name', () => {
33
+ expect(parseBranchName('release/checkout-redesign')).toEqual(name('checkout-redesign'))
34
34
  })
35
35
 
36
36
  it('strips a leading refs/heads/ prefix for versions and names', () => {
37
37
  expect(parseBranchName('refs/heads/release/v2.0.1')).toEqual(version(2, 0, 1))
38
- expect(parseBranchName('refs/heads/release/n/my-feature')).toEqual(name('my-feature'))
38
+ expect(parseBranchName('refs/heads/release/my-feature')).toEqual(name('my-feature'))
39
39
  })
40
40
 
41
41
  it('trims surrounding whitespace', () => {
@@ -48,23 +48,31 @@ describe('parseBranchName', () => {
48
48
  expect(parseBranchName('dev')).toBeNull()
49
49
  })
50
50
 
51
- it('returns null for junk under the release/ prefix', () => {
52
- expect(parseBranchName('release/foo')).toBeNull()
53
- expect(parseBranchName('release/garbage')).toBeNull()
51
+ it('parses bare release/<kebab> as a name', () => {
52
+ expect(parseBranchName('release/foo')).toEqual(name('foo'))
53
+ expect(parseBranchName('release/garbage')).toEqual(name('garbage'))
54
54
  })
55
55
 
56
- it('returns null for incomplete version branches', () => {
56
+ it('returns null for malformed version branches that are also not valid names', () => {
57
57
  expect(parseBranchName('release/v1.2')).toBeNull()
58
- expect(parseBranchName('release/v1')).toBeNull()
59
58
  expect(parseBranchName('release/v1.2.3.4')).toBeNull()
60
- expect(parseBranchName('release/vfoo')).toBeNull()
59
+ })
60
+
61
+ it('falls through to a name when the release/v segment is not valid semver', () => {
62
+ expect(parseBranchName('release/v1.2.3')).toEqual(version(1, 2, 3))
63
+ expect(parseBranchName('release/v1')).toEqual(name('v1'))
64
+ expect(parseBranchName('release/vfoo')).toEqual(name('vfoo'))
65
+ expect(parseBranchName('release/vnext')).toEqual(name('vnext'))
61
66
  })
62
67
 
63
68
  it('returns null for invalid names', () => {
64
- expect(parseBranchName('release/n/Bad_Name')).toBeNull()
65
- expect(parseBranchName('release/n/UPPER')).toBeNull()
66
- expect(parseBranchName('release/n/main')).toBeNull()
67
- expect(parseBranchName('release/n/')).toBeNull()
69
+ expect(parseBranchName('release/Bad_Name')).toBeNull()
70
+ expect(parseBranchName('release/UPPER')).toBeNull()
71
+ expect(parseBranchName('release/main')).toBeNull()
72
+ expect(parseBranchName('release/')).toBeNull()
73
+ // The retired release/n/<name> form is no longer valid: 'n/checkout-redesign'
74
+ // contains a slash and fails the kebab-case rule.
75
+ expect(parseBranchName('release/n/checkout-redesign')).toBeNull()
68
76
  })
69
77
 
70
78
  it('never throws on arbitrary input', () => {
@@ -81,19 +89,19 @@ describe('parseBranchName', () => {
81
89
  describe('parseReleaseRef', () => {
82
90
  it('delegates a release branch ref to parseBranchName', () => {
83
91
  expect(parseReleaseRef('release/v1.2.3')).toEqual(version(1, 2, 3))
84
- expect(parseReleaseRef('release/n/checkout-redesign')).toEqual(name('checkout-redesign'))
92
+ expect(parseReleaseRef('release/checkout-redesign')).toEqual(name('checkout-redesign'))
85
93
  expect(parseReleaseRef('refs/heads/release/v3.4.5')).toEqual(version(3, 4, 5))
86
94
  })
87
95
 
88
96
  it('throws when a release branch ref is invalid', () => {
89
97
  expect(() => {
90
- return parseReleaseRef('release/garbage')
98
+ return parseReleaseRef('release/Bad_Name')
91
99
  }).toThrow(InvalidReleaseRefError)
92
100
  expect(() => {
93
101
  return parseReleaseRef('release/v1.2')
94
102
  }).toThrow(InvalidReleaseRefError)
95
103
  expect(() => {
96
- return parseReleaseRef('release/n/Bad_Name')
104
+ return parseReleaseRef('release/main')
97
105
  }).toThrow(InvalidReleaseRefError)
98
106
  })
99
107
 
@@ -204,7 +212,7 @@ describe('validateName', () => {
204
212
  describe('formatBranchName', () => {
205
213
  it('formats versions and names', () => {
206
214
  expect(formatBranchName(version(1, 2, 3))).toBe('release/v1.2.3')
207
- expect(formatBranchName(name('checkout-redesign'))).toBe('release/n/checkout-redesign')
215
+ expect(formatBranchName(name('checkout-redesign'))).toBe('release/checkout-redesign')
208
216
  })
209
217
  })
210
218
 
@@ -244,13 +252,13 @@ describe('displayLabel', () => {
244
252
  describe('isReleaseBranch', () => {
245
253
  it('returns true for both branch schemes', () => {
246
254
  expect(isReleaseBranch('release/v1.2.3')).toBe(true)
247
- expect(isReleaseBranch('release/n/checkout-redesign')).toBe(true)
255
+ expect(isReleaseBranch('release/checkout-redesign')).toBe(true)
248
256
  expect(isReleaseBranch('refs/heads/release/v1.2.3')).toBe(true)
249
257
  })
250
258
 
251
259
  it('returns false for junk', () => {
252
260
  expect(isReleaseBranch('feature/x')).toBe(false)
253
- expect(isReleaseBranch('release/foo')).toBe(false)
261
+ expect(isReleaseBranch('release/Bad_Name')).toBe(false)
254
262
  expect(isReleaseBranch('main')).toBe(false)
255
263
  })
256
264
 
@@ -18,7 +18,6 @@ const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
18
18
 
19
19
  const RELEASE_BRANCH_PREFIX = 'release/'
20
20
  const VERSION_BRANCH_PREFIX = 'release/v'
21
- const NAME_BRANCH_PREFIX = 'release/n/'
22
21
  const REFS_HEADS_PREFIX = 'refs/heads/'
23
22
 
24
23
  const NEXT_TOKEN = 'next'
@@ -91,33 +90,35 @@ export const validateName = (name: string): void => {
91
90
  /**
92
91
  * Lenient parse of a git branch name into a {@link ReleaseId}. Tolerates a
93
92
  * leading `refs/heads/`. Returns `null` for anything that is not a valid
94
- * `release/v<semver>` or `release/n/<name>` branch. Never throws.
93
+ * `release/v<semver>` or `release/<name>` branch. Never throws.
94
+ *
95
+ * Precedence is version-first: `release/v<semver>` is a version, but if the
96
+ * segment after `release/v` is not a valid semver (e.g. `release/vnext`), the
97
+ * whole token after `release/` is treated as a candidate name instead.
95
98
  */
96
99
  export const parseBranchName = (branch: string): ReleaseId | null => {
97
100
  const stripped = stripRefsHeads(branch.trim())
98
101
 
102
+ if (!stripped.startsWith(RELEASE_BRANCH_PREFIX)) return null
103
+
99
104
  if (stripped.startsWith(VERSION_BRANCH_PREFIX)) {
100
105
  const semverPart = stripped.slice(VERSION_BRANCH_PREFIX.length)
101
106
  const match = BRANCH_SEMVER_RE.exec(semverPart)
102
107
 
103
- if (!match) return null
104
-
105
- return makeVersion(Number(match[1]), Number(match[2]), Number(match[3]))
108
+ if (match) {
109
+ return makeVersion(Number(match[1]), Number(match[2]), Number(match[3]))
110
+ }
106
111
  }
107
112
 
108
- if (stripped.startsWith(NAME_BRANCH_PREFIX)) {
109
- const namePart = stripped.slice(NAME_BRANCH_PREFIX.length)
113
+ const namePart = stripped.slice(RELEASE_BRANCH_PREFIX.length)
110
114
 
111
- try {
112
- validateName(namePart)
113
- } catch {
114
- return null
115
- }
116
-
117
- return { kind: 'name', name: namePart, raw: namePart }
115
+ try {
116
+ validateName(namePart)
117
+ } catch {
118
+ return null
118
119
  }
119
120
 
120
- return null
121
+ return { kind: 'name', name: namePart, raw: namePart }
121
122
  }
122
123
 
123
124
  /**
@@ -138,7 +139,7 @@ export const parseReleaseRef = (input: string): ReleaseId => {
138
139
 
139
140
  if (!parsed) {
140
141
  throw new InvalidReleaseRefError(
141
- `"${input}" looks like a release branch but is not a valid release/v<semver> or release/n/<name> ref.`,
142
+ `"${input}" looks like a release branch but is not a valid release/v<semver> or release/<name> ref.`,
142
143
  )
143
144
  }
144
145
 
@@ -168,11 +169,11 @@ export const parseReleaseRef = (input: string): ReleaseId => {
168
169
  return { kind: 'name', name: trimmed, raw: trimmed }
169
170
  }
170
171
 
171
- /** Render the branch name for a release id: `release/v1.2.3` | `release/n/<name>`. */
172
+ /** Render the branch name for a release id: `release/v1.2.3` | `release/<name>`. */
172
173
  export const formatBranchName = (id: ReleaseId): string => {
173
174
  if (id.kind === 'version') return `${VERSION_BRANCH_PREFIX}${id.raw}`
174
175
 
175
- return `${NAME_BRANCH_PREFIX}${id.name}`
176
+ return `${RELEASE_BRANCH_PREFIX}${id.name}`
176
177
  }
177
178
 
178
179
  /**
@@ -14,7 +14,7 @@ describe('parseBranchChoices', () => {
14
14
  it('parses version and name branches and drops junk', () => {
15
15
  const result = parseBranchChoices([
16
16
  'release/v1.2.3',
17
- 'release/n/checkout-redesign',
17
+ 'release/checkout-redesign',
18
18
  'feature/not-a-release',
19
19
  'release/v9.9', // malformed semver
20
20
  ])
@@ -31,7 +31,7 @@ describe('parseBranchChoices', () => {
31
31
 
32
32
  expect(labels).toEqual(['1.2.3', 'checkout-redesign'])
33
33
  expect(kinds).toEqual(['version', 'name'])
34
- expect(branches).toEqual(['release/v1.2.3', 'release/n/checkout-redesign'])
34
+ expect(branches).toEqual(['release/v1.2.3', 'release/checkout-redesign'])
35
35
  })
36
36
 
37
37
  it('returns an empty array when nothing parses', () => {
@@ -42,7 +42,7 @@ describe('parseBranchChoices', () => {
42
42
  describe('formatBranchChoices', () => {
43
43
  it('labels versions and names, keying Jira descriptions by the Jira version name', () => {
44
44
  const choices = formatBranchChoices({
45
- branches: ['release/v1.2.3', 'release/n/checkout-redesign'],
45
+ branches: ['release/v1.2.3', 'release/checkout-redesign'],
46
46
  // Jira descriptions are keyed by the Jira version NAME: `v1.2.3` | `<name>`.
47
47
  descriptions: new Map([
48
48
  ['v1.2.3', 'version desc'],
@@ -54,16 +54,16 @@ describe('formatBranchChoices', () => {
54
54
  expect(choices[0]?.value).toBe('release/v1.2.3')
55
55
  expect(choices[0]?.name).toContain('1.2.3')
56
56
  expect(choices[0]?.name).toContain('version desc')
57
- expect(choices[1]?.value).toBe('release/n/checkout-redesign')
57
+ expect(choices[1]?.value).toBe('release/checkout-redesign')
58
58
  expect(choices[1]?.name).toContain('checkout-redesign')
59
59
  expect(choices[1]?.name).toContain('name desc')
60
60
  })
61
61
 
62
62
  it('includes the type tag when types are provided', () => {
63
63
  const choices = formatBranchChoices({
64
- branches: ['release/n/checkout-redesign'],
64
+ branches: ['release/checkout-redesign'],
65
65
  descriptions: new Map(),
66
- types: new Map([['release/n/checkout-redesign', 'hotfix']]),
66
+ types: new Map([['release/checkout-redesign', 'hotfix']]),
67
67
  })
68
68
 
69
69
  expect(choices[0]?.name).toContain('[hotfix]')
@@ -90,7 +90,7 @@ describe('resolveReleaseBranch', () => {
90
90
  })
91
91
 
92
92
  it('builds a name branch from a release name', () => {
93
- expect(resolveReleaseBranch('checkout-redesign')).toBe('release/n/checkout-redesign')
93
+ expect(resolveReleaseBranch('checkout-redesign')).toBe('release/checkout-redesign')
94
94
  })
95
95
 
96
96
  it('throws an OperationError for junk input', () => {
@@ -113,7 +113,7 @@ describe('releaseLabelFromBranch', () => {
113
113
 
114
114
  it('labels version and name branches', () => {
115
115
  expect(releaseLabelFromBranch('release/v1.2.3')).toBe('1.2.3')
116
- expect(releaseLabelFromBranch('release/n/checkout-redesign')).toBe('checkout-redesign')
116
+ expect(releaseLabelFromBranch('release/checkout-redesign')).toBe('checkout-redesign')
117
117
  })
118
118
 
119
119
  it('falls back to the raw branch when it does not parse', () => {
@@ -160,7 +160,7 @@ export const parseBranchChoices = (branches: string[]): ParsedBranchChoice[] =>
160
160
 
161
161
  /**
162
162
  * Resolve an operator-supplied release ref (version `1.2.3` / `v1.2.3` or name
163
- * `checkout-redesign`) to its branch name (`release/v1.2.3` | `release/n/<name>`).
163
+ * `checkout-redesign`) to its branch name (`release/v1.2.3` | `release/<name>`).
164
164
  * Strict: surfaces a parse failure as an OperationError with remediation text.
165
165
  */
166
166
  export const resolveReleaseBranch = (versionArg: string): string => {
@@ -13,11 +13,11 @@ describe('extractVersionBranches', () => {
13
13
  expect(extractVersionBranches(stdout)).toEqual(['1.62.0', '1.64.5'])
14
14
  })
15
15
 
16
- it('ignores named release/n/* branches (irrelevant to next-bump math)', () => {
16
+ it('ignores named release/<name> branches (irrelevant to next-bump math)', () => {
17
17
  const stdout = [
18
18
  lsRemoteLine('release/v1.0.0'),
19
- lsRemoteLine('release/n/checkout-redesign'),
20
- lsRemoteLine('release/n/zeta-feature'),
19
+ lsRemoteLine('release/checkout-redesign'),
20
+ lsRemoteLine('release/zeta-feature'),
21
21
  ].join('\n')
22
22
 
23
23
  expect(extractVersionBranches(stdout)).toEqual(['1.0.0'])
@@ -25,7 +25,7 @@ describe('extractVersionBranches', () => {
25
25
 
26
26
  it('ignores junk and non-release lines without throwing', () => {
27
27
  const stdout = [
28
- lsRemoteLine('release/garbage'),
28
+ lsRemoteLine('release/Bad_Name'),
29
29
  lsRemoteLine('release/v2.3.4'),
30
30
  'malformed-line-without-tab',
31
31
  lsRemoteLine('feature/login'),
@@ -295,7 +295,7 @@ describe('resolveReleaseEntries', () => {
295
295
  it('named entry produces the named branch/PR/Jira output', () => {
296
296
  const { id } = resolve({ name: 'checkout-redesign', type: 'regular' })
297
297
 
298
- expect(formatBranchName(id)).toBe('release/n/checkout-redesign')
298
+ expect(formatBranchName(id)).toBe('release/checkout-redesign')
299
299
  expect(formatPrTitle(id, 'regular')).toBe('Release checkout-redesign')
300
300
  expect(formatJiraName(id)).toBe('checkout-redesign')
301
301
  })
@@ -11,7 +11,7 @@ import type { SemVer } from './next-version'
11
11
  * Extract version-branch tokens from raw `git ls-remote` stdout. Each line is
12
12
  * `<sha>\t<ref>`; refs are routed through release-id's lenient
13
13
  * {@link parseBranchName} and only `kind: 'version'` ids are kept (named
14
- * `release/n/*` branches are irrelevant to `next`-bump math and are dropped).
14
+ * `release/<name>` branches are irrelevant to `next`-bump math and are dropped).
15
15
  * Returns the no-`v` semver tokens (e.g. `1.2.3`) that
16
16
  * {@link collectKnownVersions} parses as versions. Pure — no I/O — so it is
17
17
  * unit-testable without the network.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * SemVer-ONLY: this assumes a `v<semver>` token and will produce `NaN`s for
5
5
  * anything else. Callers that may see branch names or named releases
6
- * (`release/n/<name>`) MUST use the `release-id` module (`parseBranchName` /
6
+ * (`release/<name>`) MUST use the `release-id` module (`parseBranchName` /
7
7
  * `parseReleaseRef` + `compareReleaseIds`) instead. Only provably names-free
8
8
  * callers (the `collectKnownVersions` / `next`-bump math path) may use this.
9
9
  */