infra-kit 0.1.108 → 0.1.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintcache +1 -1
- package/.omc/state/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 +36 -36
- package/dist/mcp.js.map +4 -4
- package/package.json +1 -1
- package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -0
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +5 -0
- package/src/commands/release-create/__tests__/release-create.test.ts +38 -1
- package/src/commands/release-create/release-create.ts +36 -4
- package/src/commands/worktrees-add/worktrees-add.ts +3 -0
- package/src/commands/worktrees-remove/worktrees-remove.ts +3 -0
- package/src/commands/worktrees-sync/worktrees-sync.ts +3 -0
- package/src/lib/git-guard/__tests__/git-guard.test.ts +92 -0
- package/src/lib/git-guard/git-guard.ts +55 -0
- package/src/lib/git-guard/index.ts +2 -0
- package/src/lib/git-utils/__tests__/git-primitives.test.ts +96 -0
- package/src/lib/git-utils/git-utils.ts +41 -0
- package/src/lib/git-utils/index.ts +8 -1
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ import { $ } from 'zx'
|
|
|
8
8
|
import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
9
9
|
import { commandEcho } from 'src/lib/command-echo'
|
|
10
10
|
import { OperationError } from 'src/lib/errors/operation-error'
|
|
11
|
+
import { assertManagementContext } from 'src/lib/git-guard'
|
|
11
12
|
import { logger } from 'src/lib/logger'
|
|
12
13
|
import { detectReleaseType, formatBranchChoices, getJiraDescriptions, releaseBranchLabels } from 'src/lib/release-utils'
|
|
13
14
|
import { defineMcpTool, textContent } from 'src/types'
|
|
@@ -25,6 +26,8 @@ export const ghMergeDev = async (args: GhMergeDevArgs) => {
|
|
|
25
26
|
|
|
26
27
|
commandEcho.start('merge-dev')
|
|
27
28
|
|
|
29
|
+
await assertManagementContext({ operation: 'merge dev into release branches', requiredBranch: 'dev' })
|
|
30
|
+
|
|
28
31
|
// Only merge dev into regular releases (not hotfixes, which target main)
|
|
29
32
|
const allPRs = await getReleasePRsWithInfo()
|
|
30
33
|
const releasePRsList = allPRs
|
|
@@ -10,6 +10,7 @@ import { commandEcho } from 'src/lib/command-echo'
|
|
|
10
10
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
11
11
|
import { formatZxError } from 'src/lib/errors/format-zx-error'
|
|
12
12
|
import { OperationError } from 'src/lib/errors/operation-error'
|
|
13
|
+
import { assertManagementContext } from 'src/lib/git-guard'
|
|
13
14
|
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
14
15
|
import { logger } from 'src/lib/logger'
|
|
15
16
|
import { displayLabel, formatJiraName, formatRcTitle, parseBranchName } from 'src/lib/release-id'
|
|
@@ -339,6 +340,10 @@ export const ghReleaseDeliver = async (args: GhReleaseDeliverArgs) => {
|
|
|
339
340
|
|
|
340
341
|
commandEcho.start('release-deliver')
|
|
341
342
|
|
|
343
|
+
// Branch-agnostic (operates on release/RC PRs via gh and self-switches), so
|
|
344
|
+
// only the worktree + clean-tree legs apply.
|
|
345
|
+
await assertManagementContext({ operation: 'deliver release' })
|
|
346
|
+
|
|
342
347
|
const { selectedReleaseBranch, releasePrTitle } = version
|
|
343
348
|
? await resolveTargetFromVersion(version)
|
|
344
349
|
: await resolveTargetInteractively()
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import type { z } from 'zod'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { OperationError } from 'src/lib/errors/operation-error'
|
|
5
|
+
import type { ReleaseType } from 'src/lib/release-utils'
|
|
6
|
+
import type { ReleaseEntry } from 'src/lib/version-utils'
|
|
7
|
+
|
|
8
|
+
import { assertHomogeneousReleaseType, releaseCreateMcpTool } from '../release-create'
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* The MCP `releases[]` entry schema enforces that exactly one of `version` or
|
|
@@ -53,3 +57,36 @@ describe('release-create MCP releases[] entry schema', () => {
|
|
|
53
57
|
}).toThrow(/exactly one of/)
|
|
54
58
|
})
|
|
55
59
|
})
|
|
60
|
+
|
|
61
|
+
const entryOfType = (type: ReleaseType): ReleaseEntry => {
|
|
62
|
+
return {
|
|
63
|
+
id: { kind: 'version', semver: { major: 1, minor: 2, patch: 3 }, raw: '1.2.3' },
|
|
64
|
+
type,
|
|
65
|
+
} as ReleaseEntry
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('assertHomogeneousReleaseType', () => {
|
|
69
|
+
it('accepts an all-regular batch', () => {
|
|
70
|
+
expect(() => {
|
|
71
|
+
return assertHomogeneousReleaseType([entryOfType('regular'), entryOfType('regular')])
|
|
72
|
+
}).not.toThrow()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('accepts an all-hotfix batch', () => {
|
|
76
|
+
expect(() => {
|
|
77
|
+
return assertHomogeneousReleaseType([entryOfType('hotfix')])
|
|
78
|
+
}).not.toThrow()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('rejects a mixed regular+hotfix batch with an OperationError', () => {
|
|
82
|
+
expect(() => {
|
|
83
|
+
return assertHomogeneousReleaseType([entryOfType('regular'), entryOfType('hotfix')])
|
|
84
|
+
}).toThrow(OperationError)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('mixed-batch error mentions separate invocations', () => {
|
|
88
|
+
expect(() => {
|
|
89
|
+
return assertHomogeneousReleaseType([entryOfType('regular'), entryOfType('hotfix')])
|
|
90
|
+
}).toThrow(/separate invocations/)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -7,9 +7,10 @@ import { question } from 'zx'
|
|
|
7
7
|
import { loadJiraConfig } from 'src/integrations/jira'
|
|
8
8
|
import { commandEcho } from 'src/lib/command-echo'
|
|
9
9
|
import { OperationError } from 'src/lib/errors/operation-error'
|
|
10
|
+
import { assertManagementContext } from 'src/lib/git-guard'
|
|
10
11
|
import { logger } from 'src/lib/logger'
|
|
11
12
|
import { InvalidReleaseNameError, displayLabel, validateName } from 'src/lib/release-id'
|
|
12
|
-
import { createSingleRelease, prepareGitForRelease } from 'src/lib/release-utils'
|
|
13
|
+
import { createSingleRelease, getBaseBranch, prepareGitForRelease } from 'src/lib/release-utils'
|
|
13
14
|
import type { ReleaseCreationResult, ReleaseType } from 'src/lib/release-utils'
|
|
14
15
|
import {
|
|
15
16
|
NoPriorVersionsError,
|
|
@@ -212,6 +213,28 @@ const collectEntries = async (
|
|
|
212
213
|
return interactive
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Reject a batch that mixes regular and hotfix releases. They branch off
|
|
218
|
+
* different bases (dev vs main), so the batch has no single required branch for
|
|
219
|
+
* the management guard — they must be created in separate invocations.
|
|
220
|
+
* Exported for unit testing without running the side-effecting handler.
|
|
221
|
+
*/
|
|
222
|
+
export const assertHomogeneousReleaseType = (entries: ReleaseEntry[]): void => {
|
|
223
|
+
const types = new Set(
|
|
224
|
+
entries.map((entry) => {
|
|
225
|
+
return entry.type
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if (types.size > 1) {
|
|
230
|
+
throw new OperationError(undefined, {
|
|
231
|
+
operation: 'create release',
|
|
232
|
+
remediation: 'create regular and hotfix releases in separate invocations',
|
|
233
|
+
stderrExcerpt: 'mixed regular and hotfix releases in one batch are not supported',
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
215
238
|
const confirmReleases = async (entries: ReleaseEntry[], confirmedCommand: boolean): Promise<void> => {
|
|
216
239
|
const summary = entries.map(formatReleaseSummary).join('\n - ')
|
|
217
240
|
const answer = confirmedCommand
|
|
@@ -284,8 +307,10 @@ const logFinalSummary = (total: number, successCount: number, failureCount: numb
|
|
|
284
307
|
|
|
285
308
|
/**
|
|
286
309
|
* Create one or more release branches. Each release carries its own type
|
|
287
|
-
* (regular/hotfix) and optional Jira description
|
|
288
|
-
*
|
|
310
|
+
* (regular/hotfix) and optional Jira description. All entries in a single
|
|
311
|
+
* invocation must share the same type — regular and hotfix releases branch off
|
|
312
|
+
* different bases (dev vs main), so mixed batches are rejected and must be
|
|
313
|
+
* created separately.
|
|
289
314
|
*/
|
|
290
315
|
export const releaseCreate = async (args: ReleaseCreateArgs) => {
|
|
291
316
|
const { releases: inputReleases, confirmedCommand } = args
|
|
@@ -311,6 +336,13 @@ export const releaseCreate = async (args: ReleaseCreateArgs) => {
|
|
|
311
336
|
})
|
|
312
337
|
}
|
|
313
338
|
|
|
339
|
+
assertHomogeneousReleaseType(entries)
|
|
340
|
+
|
|
341
|
+
await assertManagementContext({
|
|
342
|
+
operation: 'create release',
|
|
343
|
+
requiredBranch: getBaseBranch(entries[0]!.type),
|
|
344
|
+
})
|
|
345
|
+
|
|
314
346
|
await confirmReleases(entries, Boolean(confirmedCommand))
|
|
315
347
|
|
|
316
348
|
const created: ReleaseCreationResult[] = []
|
|
@@ -347,7 +379,7 @@ export const releaseCreate = async (args: ReleaseCreateArgs) => {
|
|
|
347
379
|
export const releaseCreateMcpTool = defineMcpTool({
|
|
348
380
|
name: 'release-create',
|
|
349
381
|
description:
|
|
350
|
-
'Create one or more releases in a single call. Each entry in "releases" carries EITHER a "version" (semver or the literal token "next") OR a "name" (free-form kebab-case identifier) — exactly one is required and they are mutually exclusive. Each entry also has its own type (regular|hotfix, default regular) and optional description
|
|
382
|
+
'Create one or more releases in a single call. Each entry in "releases" carries EITHER a "version" (semver or the literal token "next") OR a "name" (free-form kebab-case identifier) — exactly one is required and they are mutually exclusive. Each entry also has its own type (regular|hotfix, default regular) and optional description; all entries in one call must share the same type — mixed regular+hotfix batches are rejected (create them in separate invocations). For each release this tool switches to the appropriate base branch (dev for regular, main for hotfix), cuts the release branch (release/v<semver> for versions, release/n/<name> for names), opens a GitHub release PR, and creates the matching Jira fix version (v<semver> for versions, <name> for names). The literal token "next" auto-increments from the union of remote release branches and Jira fix versions (regular bumps minor + resets patch; hotfix bumps patch on the highest minor); multiple "next" tokens advance sequentially. Named releases never auto-bump and "next" is version-only. Must be run from the main repository checkout (not a linked worktree) on the matching base branch with a clean working tree. Confirmation is auto-skipped for MCP calls, so the caller is responsible for gating. Continues on per-release failure and reports successes/failures.',
|
|
351
383
|
inputSchema: {
|
|
352
384
|
releases: z
|
|
353
385
|
.array(
|
|
@@ -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()
|
|
@@ -8,6 +8,7 @@ import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
|
8
8
|
import { commandEcho } from 'src/lib/command-echo'
|
|
9
9
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
10
10
|
import { OperationError } from 'src/lib/errors/operation-error'
|
|
11
|
+
import { assertManagementContext } from 'src/lib/git-guard'
|
|
11
12
|
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
12
13
|
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
13
14
|
import { logger } from 'src/lib/logger'
|
|
@@ -33,6 +34,8 @@ export const worktreesRemove = async (options: WorktreeManagementArgs) => {
|
|
|
33
34
|
|
|
34
35
|
commandEcho.start('worktrees-remove')
|
|
35
36
|
|
|
37
|
+
await assertManagementContext({ operation: 'remove worktrees', requiredBranch: 'dev' })
|
|
38
|
+
|
|
36
39
|
try {
|
|
37
40
|
const currentWorktrees = await getCurrentWorktrees('release')
|
|
38
41
|
|
|
@@ -9,6 +9,7 @@ import { getReleasePRs } from 'src/integrations/gh'
|
|
|
9
9
|
import { commandEcho } from 'src/lib/command-echo'
|
|
10
10
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
11
11
|
import { OperationError } from 'src/lib/errors/operation-error'
|
|
12
|
+
import { assertManagementContext } from 'src/lib/git-guard'
|
|
12
13
|
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
13
14
|
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
14
15
|
import { logger } from 'src/lib/logger'
|
|
@@ -28,6 +29,8 @@ export const worktreesSync = async (options: WorktreeSyncArgs) => {
|
|
|
28
29
|
|
|
29
30
|
commandEcho.start('worktrees-sync')
|
|
30
31
|
|
|
32
|
+
await assertManagementContext({ operation: 'sync worktrees', requiredBranch: 'dev' })
|
|
33
|
+
|
|
31
34
|
try {
|
|
32
35
|
const currentWorktrees = await getCurrentWorktrees('release')
|
|
33
36
|
const projectRoot = await getProjectRoot()
|
|
@@ -0,0 +1,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
|
+
})
|
|
@@ -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
|
*/
|