rvlib-shipkit 0.14.0 → 0.16.0
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/CHANGELOG.md +129 -0
- package/README.md +6 -22
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +1 -1
- package/src/assets/panels/geometry-rects.ts +3 -17
- package/src/assets/server/studio-server.ts +1 -1
- package/src/cli/cmd_Bump.ts +52 -0
- package/src/cli/cmd_Check.ts +2 -2
- package/src/cli/cmd_Ci.ts +10 -2
- package/src/cli/cmd_Commands.ts +126 -0
- package/src/cli/cmd_Hooks.ts +66 -0
- package/src/cli/cmd_Mv.ts +29 -6
- package/src/cli/cmd_Release.ts +41 -0
- package/src/cli/cmd_Releases.ts +42 -0
- package/src/cli/cmd_Rules.ts +20 -6
- package/src/cli/cmd_Start.ts +2 -2
- package/src/cli.ts +14 -1
- package/src/collections/posts.ts +1 -1
- package/src/collections/providers.ts +1 -1
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +2 -4
- package/src/core/canonical-scripts.ts +9 -14
- package/src/core/check-suite.ts +1 -1
- package/src/core/discover-checks.ts +21 -6
- package/src/core/run-check.ts +69 -21
- package/src/core/run-ci.ts +75 -9
- package/src/core/run-fixers.ts +144 -0
- package/src/core/write-status.ts +14 -5
- package/src/hooks/commit-message.ts +42 -0
- package/src/hooks/leak-guard.ts +0 -0
- package/src/hooks/run-hooks.ts +131 -0
- package/src/init-repo.ts +10 -5
- package/src/reflect/prompt-corpus.ts +3 -4
- package/src/release/changelog.ts +53 -0
- package/src/release/git-facts.ts +133 -0
- package/src/release/run-bump.ts +419 -0
- package/src/release/run-release.ts +267 -0
- package/src/release/run-releases.ts +230 -0
- package/src/release/unit-gate.ts +67 -0
- package/src/release/units.ts +246 -0
- package/src/repo-config.ts +56 -2
- package/src/rules/SK001-package-json-shape.ts +39 -17
- package/src/rules/SK002-contracts-fulfillness.ts +1 -0
- package/src/rules/SK003-claude-md-shape.ts +2 -1
- package/src/rules/SK004-gitignore-policy.ts +1 -0
- package/src/rules/SK005-rvlib-versions.ts +22 -3
- package/src/rules/SK006-npm-publish-safety.ts +1 -0
- package/src/rules/SK007-github-visibility.ts +5 -1
- package/src/rules/SK008-reviewer-agent.ts +1 -0
- package/src/rules/SK009-commit-ai-tells.ts +66 -0
- package/src/rules/SK010-undeclared-release-unit.ts +114 -0
- package/src/rules/rule-registry.ts +26 -6
- package/src/rules/rule.ts +8 -1
- package/src/rules/run-fix.ts +15 -8
- package/src/serve/hub-app/col-card.tsx +2 -3
- package/src/serve/hub-app/hub-app.tsx +6 -8
- package/src/serve/hub-app/hub-st.ts +4 -7
- package/src/serve/hub-app/media-view.tsx +2 -2
- package/src/serve/run-serve.ts +4 -5
- package/src/serve/serve-hub.ts +2 -3
- package/src/shipkit.ts +9 -1
- package/src/tui/checks-panel.ts +1 -1
- package/src/tui/run-tui.ts +2 -3
- package/src/tui/tasks-panel.ts +10 -4
- package/src/tui/tui-store.ts +2 -2
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// what `shipkit ci` repairs before it gates.
|
|
2
|
+
//
|
|
3
|
+
// mechanical slots only (formatter, import canonicaliser): they rewrite the
|
|
4
|
+
// files the agent is already editing and their output is total. structural
|
|
5
|
+
// SK rule fixers rewrite repo config, so they stay behind --fix-rules.
|
|
6
|
+
//
|
|
7
|
+
// under CI the same fixers run in verify mode instead: a gate that silently
|
|
8
|
+
// rewrites the tree it checks can turn red green with no commit. one
|
|
9
|
+
// mechanism, two modes, rather than a formatter plus a format:check that
|
|
10
|
+
// drift apart.
|
|
11
|
+
import { existsSync, readFileSync } from 'fs'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
import { fileURLToPath } from 'url'
|
|
14
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
15
|
+
import { runFix } from '../rules/run-fix.ts'
|
|
16
|
+
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
17
|
+
|
|
18
|
+
const FIXER_TIMEOUT_MS = 120_000
|
|
19
|
+
|
|
20
|
+
export type FixerSlot = {
|
|
21
|
+
id: string
|
|
22
|
+
cmd: string[]
|
|
23
|
+
source: 'package.json script' | 'built-in (rvlib-ts-tools)'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type FixerResolution = {
|
|
27
|
+
slots: FixerSlot[]
|
|
28
|
+
/** slots nothing could fill, printed, never silent */
|
|
29
|
+
unfilled: { id: string; reason: string }[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// one fixer per mechanical concern. `fmt` accepts the two names the house
|
|
33
|
+
// vocabulary allows so a repo that calls it `format` is not silently unfixed.
|
|
34
|
+
const FMT_SCRIPTS = ['fmt', 'format'] as const
|
|
35
|
+
|
|
36
|
+
export function resolveFixers(root: string): FixerResolution {
|
|
37
|
+
const pkgPath = join(root, 'package.json')
|
|
38
|
+
let scripts: Record<string, string> = {}
|
|
39
|
+
if (existsSync(pkgPath)) {
|
|
40
|
+
try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
|
|
41
|
+
catch (e) { throw new Error(`package.json unparseable — fixers cannot register: ${String(e).slice(0, 120)}`) }
|
|
42
|
+
}
|
|
43
|
+
const slots: FixerSlot[] = []
|
|
44
|
+
const unfilled: { id: string; reason: string }[] = []
|
|
45
|
+
|
|
46
|
+
const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null)
|
|
47
|
+
if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
|
|
48
|
+
else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
|
|
49
|
+
|
|
50
|
+
if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
|
|
51
|
+
else {
|
|
52
|
+
let cli: string | null = null
|
|
53
|
+
try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) } catch { cli = null }
|
|
54
|
+
if (cli == null) unfilled.push({ id: 'imports:fix', reason: `no 'imports:fix' script and rvlib-ts-tools is unresolvable` })
|
|
55
|
+
else slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (rvlib-ts-tools)' })
|
|
56
|
+
}
|
|
57
|
+
return { slots, unfilled }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** worktree fingerprint: every dirty path mapped to `<status>:<content hash>`,
|
|
61
|
+
* or null outside a git repo. The fix pass diffs this before and after so the
|
|
62
|
+
* report names real files instead of asserting "fixed" and hoping.
|
|
63
|
+
*
|
|
64
|
+
* The hash is load-bearing, not decoration. Status alone is blind to the most
|
|
65
|
+
* common case there is: a file the agent already edited (` M`) that a fixer
|
|
66
|
+
* then reformats stays ` M`, so a status-only compare reports "nothing to
|
|
67
|
+
* rewrite" while the tree changed under it, and in verify mode that is a
|
|
68
|
+
* silent green on unformatted code. Hashing is confined to the dirty set,
|
|
69
|
+
* which is small by construction. */
|
|
70
|
+
function treeState(root: string): Map<string, string> | null {
|
|
71
|
+
const p = Bun.spawnSync(['git', 'status', '--porcelain'], { cwd: root })
|
|
72
|
+
if (p.exitCode !== 0) return null
|
|
73
|
+
const m = new Map<string, string>()
|
|
74
|
+
for (const l of p.stdout.toString().split('\n')) {
|
|
75
|
+
if (l.trim() === '') continue
|
|
76
|
+
const status = l.slice(0, 2)
|
|
77
|
+
// rename entries read `R old -> new`; the new path is what exists
|
|
78
|
+
const path = l.slice(3).trim().split(' -> ').at(-1) ?? ''
|
|
79
|
+
if (path === '') continue
|
|
80
|
+
const abs = join(root, path.replace(/^"|"$/g, ''))
|
|
81
|
+
let hash = 'absent'
|
|
82
|
+
try { if (existsSync(abs)) hash = String(Bun.hash(readFileSync(abs))) } catch { hash = 'unreadable' }
|
|
83
|
+
m.set(path, `${status}:${hash}`)
|
|
84
|
+
}
|
|
85
|
+
return m
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type FixPassOpts = {
|
|
89
|
+
/** also apply the structural SK rule fixers (default false) */
|
|
90
|
+
rules?: boolean
|
|
91
|
+
print: (line: string) => void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type FixPassResult = {
|
|
95
|
+
ran: boolean
|
|
96
|
+
/** paths whose git status changed across the pass */
|
|
97
|
+
touched: string[]
|
|
98
|
+
/** verify mode only: a fixer had to rewrite something, so the commit is not clean */
|
|
99
|
+
failed: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpts): Promise<FixPassResult> {
|
|
103
|
+
// verify mode: same fixers, but rewriting anything means the COMMIT was
|
|
104
|
+
// not clean, and the gate says so instead of quietly repairing it
|
|
105
|
+
const verify = process.env.CI != null && process.env.CI !== ''
|
|
106
|
+
const { slots, unfilled } = resolveFixers(root)
|
|
107
|
+
for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
|
|
108
|
+
const before = treeState(root)
|
|
109
|
+
|
|
110
|
+
for (const s of slots) {
|
|
111
|
+
const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
|
|
112
|
+
// a broken fixer must never be mistaken for a clean one: the gate
|
|
113
|
+
// still runs, but the failure is loud and names the command
|
|
114
|
+
if (r.code !== 0)
|
|
115
|
+
o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
|
|
116
|
+
}
|
|
117
|
+
// structural repo-config repair is never implicit, and never in verify mode
|
|
118
|
+
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print })
|
|
119
|
+
|
|
120
|
+
const after = treeState(root)
|
|
121
|
+
const touched: string[] = []
|
|
122
|
+
if (before != null && after != null) {
|
|
123
|
+
for (const [path, st] of after) if (before.get(path) !== st) touched.push(path)
|
|
124
|
+
// a file the fixer restored to its committed state disappears from
|
|
125
|
+
// porcelain entirely, that is still a change worth naming
|
|
126
|
+
for (const path of before.keys()) if (!after.has(path)) touched.push(path)
|
|
127
|
+
}
|
|
128
|
+
const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
|
|
129
|
+
if (before == null) {
|
|
130
|
+
o.print(`ok: fix pass ran, ${slots.length} fixer(s) (not a git repo — rewritten files cannot be listed)`)
|
|
131
|
+
return { ran: true, touched, failed: false }
|
|
132
|
+
}
|
|
133
|
+
if (touched.length === 0) {
|
|
134
|
+
o.print(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
|
|
135
|
+
return { ran: true, touched, failed: false }
|
|
136
|
+
}
|
|
137
|
+
if (verify) {
|
|
138
|
+
o.print(`FAIL: ${touched.length} file(s) are not fixer-clean: ${list}`)
|
|
139
|
+
o.print(` fix: run \`shipkit ci\` locally (the fix pass rewrites them) and commit the result`)
|
|
140
|
+
return { ran: true, touched, failed: true }
|
|
141
|
+
}
|
|
142
|
+
o.print(`ok: fix pass rewrote ${touched.length} file(s): ${list}`)
|
|
143
|
+
return { ran: true, touched, failed: false }
|
|
144
|
+
}
|
package/src/core/write-status.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// THE STATUS.md writer — every repo gets one
|
|
2
|
-
//
|
|
3
|
-
// shape rv-doctor pioneered. Atomic + lock-guarded via core/locked-write:
|
|
1
|
+
// THE STATUS.md writer — every repo gets one: timestamp header + verdict +
|
|
2
|
+
// fenced compact render. Atomic + lock-guarded via core/locked-write:
|
|
4
3
|
// concurrent runners never interleave — the loser skips with a WARN
|
|
5
4
|
// outcome, never corrupts the file.
|
|
6
5
|
|
|
@@ -12,6 +11,10 @@ export type StatusInput = {
|
|
|
12
11
|
name: string
|
|
13
12
|
/** the command that produced this render, e.g. 'shipkit ci' */
|
|
14
13
|
command: string
|
|
14
|
+
/** the run covered the WHOLE gate, so a green may say "clear to ship".
|
|
15
|
+
* Absent means it did not: the claim is opt-in, because a caller that
|
|
16
|
+
* forgets it costs a nag and a caller that over-claims costs a release. */
|
|
17
|
+
fullGate?: boolean
|
|
15
18
|
/** the run's printed lines (ANSI stripped here) */
|
|
16
19
|
lines: string[]
|
|
17
20
|
exitCode: number
|
|
@@ -27,11 +30,17 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
|
27
30
|
const clean = s.lines.map(stripAnsi)
|
|
28
31
|
const fails = clean.filter((l) => l.startsWith('FAIL')).length
|
|
29
32
|
const warns = clean.filter((l) => l.startsWith('WARN')).length
|
|
33
|
+
// only the full gate may say "clear to ship": a checks-only run proves
|
|
34
|
+
// the repo is sound, never that the build passes, and a narrowed gate
|
|
35
|
+
// proves only the slice it ran. the scope rides on the WARN verdict
|
|
36
|
+
// too, or a narrowed green hides behind a nag nobody reads twice
|
|
37
|
+
const full = s.fullGate === true
|
|
38
|
+
const scope = full ? 'clear to ship' : `${s.command} ran, not the whole gate`
|
|
30
39
|
const verdict = s.exitCode !== 0 || fails > 0
|
|
31
40
|
? `RED — ${Math.max(fails, 1)} FAIL line${fails === 1 ? '' : 's'}. Fixes are printed under each FAIL.`
|
|
32
41
|
: warns > 0
|
|
33
|
-
? `GREEN with nags — ${warns} WARN. Proceed.`
|
|
34
|
-
:
|
|
42
|
+
? `GREEN with nags — ${warns} WARN. ${full ? 'Proceed.' : `${scope}.`}`
|
|
43
|
+
: `GREEN — ${scope}.`
|
|
35
44
|
// local time, not UTC — an evening run must not stamp yesterday's date
|
|
36
45
|
const now = new Date()
|
|
37
46
|
const p = (n: number): string => String(n).padStart(2, '0')
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// the commit-message predicate, shared by the commit-msg hook (blocks at
|
|
2
|
+
// write time) and SK009 (audits history). one implementation, or the two
|
|
3
|
+
// drift and the drift only shows when a repo goes public.
|
|
4
|
+
//
|
|
5
|
+
// mechanical text rules only, so this stays publishable.
|
|
6
|
+
|
|
7
|
+
export type OffenseKind = 'dash' | 'attribution'
|
|
8
|
+
export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
|
|
9
|
+
|
|
10
|
+
/** trailers and footers that name an assistant, plus the robot emoji. */
|
|
11
|
+
export const AI_ATTRIBUTION =
|
|
12
|
+
/(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|🤖/i
|
|
13
|
+
|
|
14
|
+
/** everything below git's scissors line is the verbose-mode diff, which git
|
|
15
|
+
* strips before committing, diff content is not ours to police. */
|
|
16
|
+
export function stripNonMessage(message: string): string {
|
|
17
|
+
const scissors = message.search(/^# -+ >8 -+$/m)
|
|
18
|
+
return scissors === -1 ? message : message.slice(0, scissors)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** offenses in ONE commit message (or one commit body from `git log`).
|
|
22
|
+
* `#` comment lines never land in the message and are ignored; a `Revert "…"`
|
|
23
|
+
* title quotes the reverted subject verbatim and is exempt; a line-start
|
|
24
|
+
* `- ` bullet is a list marker, not punctuation. */
|
|
25
|
+
export function commitMessageOffenses(message: string): Offense[] {
|
|
26
|
+
const offenses: Offense[] = []
|
|
27
|
+
stripNonMessage(message).split('\n').forEach((raw, i) => {
|
|
28
|
+
if (raw.startsWith('#')) return
|
|
29
|
+
if (i === 0 && /^Revert "/.test(raw)) return
|
|
30
|
+
if (/[—–]/.test(raw))
|
|
31
|
+
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'em/en dash: use a comma, a period, parentheses, or a new sentence' })
|
|
32
|
+
const body = raw.replace(/^\s*[-*] /, '')
|
|
33
|
+
if (body.includes(' - '))
|
|
34
|
+
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
|
|
35
|
+
if (AI_ATTRIBUTION.test(raw))
|
|
36
|
+
offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, "Generated with" footer, or robot emoji' })
|
|
37
|
+
})
|
|
38
|
+
return offenses
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const hasDashPunctuation = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'dash')
|
|
42
|
+
export const hasAiAttribution = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'attribution')
|
|
Binary file
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// the git-hook gates, owned by the toolkit rather than by per-repo shell.
|
|
2
|
+
//
|
|
3
|
+
// hand-rolled hooks are invisible to `shipkit check`: a repo reports green
|
|
4
|
+
// while its next commit is already doomed, and nothing can enumerate what
|
|
5
|
+
// the hooks enforce. `shipkit hooks list` answers that without reading shell.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { commitMessageOffenses } from './commit-message.ts'
|
|
9
|
+
import { runLeakGuard } from './leak-guard.ts'
|
|
10
|
+
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
11
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
12
|
+
|
|
13
|
+
export type HooksConfig = {
|
|
14
|
+
/** repo-specific commands run in the pre-commit gate, after the built-ins.
|
|
15
|
+
* Non-zero exit blocks the commit; stdout/stderr are shown. */
|
|
16
|
+
preCommit?: { id: string; cmd: string[] }[]
|
|
17
|
+
/** repo-specific commands run on the commit message; the message file path
|
|
18
|
+
* is appended as the last argument. */
|
|
19
|
+
commitMsg?: { id: string; cmd: string[] }[]
|
|
20
|
+
/** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
|
|
21
|
+
keywordFile?: string
|
|
22
|
+
/** disable a built-in gate, visible in `hooks list`, never silent */
|
|
23
|
+
disable?: ('leak-guard' | 'commit-message')[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HOOK_DIR = 'hooks'
|
|
27
|
+
const HOOKS = ['pre-commit', 'commit-msg'] as const
|
|
28
|
+
|
|
29
|
+
/** wire `core.hooksPath` and write the two hook bodies. Idempotent. */
|
|
30
|
+
export function installHooks(root: string, binInvocation: string): { written: string[]; note: string } {
|
|
31
|
+
const dir = join(root, HOOK_DIR)
|
|
32
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
33
|
+
const written: string[] = []
|
|
34
|
+
for (const h of HOOKS) {
|
|
35
|
+
const body = h === 'commit-msg'
|
|
36
|
+
? `#!/bin/sh\nexec ${binInvocation} hooks commit-msg "$1"\n`
|
|
37
|
+
: `#!/bin/sh\nexec ${binInvocation} hooks pre-commit\n`
|
|
38
|
+
const path = join(dir, h)
|
|
39
|
+
const prev = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
40
|
+
if (prev !== body) { writeFileSync(path, body, { mode: 0o755 }); written.push(`${HOOK_DIR}/${h}`) }
|
|
41
|
+
}
|
|
42
|
+
const cfg = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
|
|
43
|
+
return {
|
|
44
|
+
written,
|
|
45
|
+
note: cfg.exitCode === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.exitCode}) — hooks will not fire`,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type GateOutcome = { id: string; ok: boolean; lines: string[] }
|
|
50
|
+
|
|
51
|
+
/** the pre-commit gate: built-in leak guard, then the repo's own commands. */
|
|
52
|
+
export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
|
|
53
|
+
const h = config.hooks ?? {}
|
|
54
|
+
const out: GateOutcome[] = []
|
|
55
|
+
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
56
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile })
|
|
57
|
+
if (r.status === 'skipped') out.push({ id: 'leak-guard', ok: true, lines: [`skip: leak-guard (${r.reason})`] })
|
|
58
|
+
else if (r.status === 'clean') out.push({ id: 'leak-guard', ok: true, lines: [`ok: leak-guard clean (${r.patterns} pattern(s))`] })
|
|
59
|
+
else out.push({
|
|
60
|
+
id: 'leak-guard',
|
|
61
|
+
ok: false,
|
|
62
|
+
lines: [
|
|
63
|
+
`FAIL: leak-guard matched ${r.hits.length} banned pattern(s) — this commit would leak private content`,
|
|
64
|
+
...r.hits.map((x) => ` ${x.where}: ${x.pattern}`),
|
|
65
|
+
` fix: remove the content, or edit the keyword file if the pattern is wrong`,
|
|
66
|
+
],
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
for (const c of h.preCommit ?? []) {
|
|
70
|
+
const r = await spawnWithTimeout(c.cmd, { cwd: root, timeoutMs: 120_000 })
|
|
71
|
+
out.push({
|
|
72
|
+
id: c.id,
|
|
73
|
+
ok: r.code === 0,
|
|
74
|
+
lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** the commit-msg gate: the shared message predicate, then the repo's own. */
|
|
81
|
+
export async function runCommitMsg(root: string, config: RepoConfig, messageFile: string): Promise<GateOutcome[]> {
|
|
82
|
+
const h = config.hooks ?? {}
|
|
83
|
+
const out: GateOutcome[] = []
|
|
84
|
+
const message = readFileSync(messageFile, 'utf8')
|
|
85
|
+
if (!(h.disable ?? []).includes('commit-message')) {
|
|
86
|
+
const offenses = commitMessageOffenses(message)
|
|
87
|
+
out.push(offenses.length === 0
|
|
88
|
+
? { id: 'commit-message', ok: true, lines: ['ok: commit message clean (no dash punctuation, no AI attribution)'] }
|
|
89
|
+
: {
|
|
90
|
+
id: 'commit-message',
|
|
91
|
+
ok: false,
|
|
92
|
+
lines: [
|
|
93
|
+
`FAIL: commit message has ${offenses.length} offense(s)`,
|
|
94
|
+
...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
// the leak guard also reads the MESSAGE, a private name pasted into a
|
|
99
|
+
// commit body is as public as one pasted into a file
|
|
100
|
+
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
101
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, text: [{ label: 'commit message', content: message }] })
|
|
102
|
+
if (r.status === 'hit')
|
|
103
|
+
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map((x) => ` ${x.where}: ${x.pattern}`)] })
|
|
104
|
+
}
|
|
105
|
+
for (const c of h.commitMsg ?? []) {
|
|
106
|
+
const r = await spawnWithTimeout([...c.cmd, messageFile], { cwd: root, timeoutMs: 60_000 })
|
|
107
|
+
out.push({
|
|
108
|
+
id: c.id,
|
|
109
|
+
ok: r.code === 0,
|
|
110
|
+
lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** what blocks a commit here, the enumeration hand-rolled hooks never had. */
|
|
117
|
+
export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
118
|
+
const h = config.hooks ?? {}
|
|
119
|
+
const off = new Set(h.disable ?? [])
|
|
120
|
+
const kw = h.keywordFile ?? '.rv-private/banned-keywords.txt'
|
|
121
|
+
// a gate listed as active but unable to fire is worse than no listing:
|
|
122
|
+
// this surface exists so nobody has to guess
|
|
123
|
+
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) ? '' : ` (INACTIVE, ${kw} absent)`
|
|
124
|
+
return [
|
|
125
|
+
`pre-commit leak-guard${leakState} — staged content + paths vs ${kw}`,
|
|
126
|
+
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
127
|
+
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
|
|
128
|
+
`commit-msg leak-guard${leakState} — the message text`,
|
|
129
|
+
...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
|
|
130
|
+
]
|
|
131
|
+
}
|
package/src/init-repo.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// deps — every gap is printed, never silent). Plain module behind cmd_Init.
|
|
4
4
|
// Refuses to overwrite an existing config; script merge never clobbers
|
|
5
5
|
// existing keys. `bin` switches script bodies from the npm bin to an
|
|
6
|
-
// in-repo-source CLI path
|
|
7
|
-
//
|
|
6
|
+
// in-repo-source CLI path for repos that consume shipkit by path instead of
|
|
7
|
+
// from node_modules. Only bodies shipkit cannot express as a verb are
|
|
8
|
+
// scaffolded: an alias script would be a second name for one capability.
|
|
8
9
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
9
10
|
import { join } from 'path'
|
|
10
11
|
import { canonicalBodies } from './core/canonical-scripts.ts'
|
|
12
|
+
import { hasTestFiles } from './rules/SK001-package-json-shape.ts'
|
|
11
13
|
import { reportToMotherhome } from './core/motherhome.ts'
|
|
12
14
|
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
13
15
|
import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
|
|
@@ -59,7 +61,10 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
59
61
|
// canonical script bodies — ONE source (core/canonical-scripts.ts),
|
|
60
62
|
// shared with rule SK001's autofix; vocabulary owned by
|
|
61
63
|
// meta/rv-conventions/package-json.md
|
|
64
|
+
// `test` is scaffolded only once the repo has tests: `bun test` exits
|
|
65
|
+
// non-zero with none, and a scaffold that reds the gate is a bad scaffold
|
|
62
66
|
for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
|
|
67
|
+
if (k === 'test' && !hasTestFiles(root)) continue
|
|
63
68
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
|
64
69
|
else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
|
|
65
70
|
}
|
|
@@ -76,8 +81,8 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
76
81
|
const floor = resolveFloor(defineRepo({ name }), root)
|
|
77
82
|
for (const t of floor.tasks) console.log(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
|
|
78
83
|
for (const u of floor.unfilled) console.log(`skip: ${u.id} (${u.reason})`)
|
|
79
|
-
//
|
|
80
|
-
// actually installed (
|
|
84
|
+
// every verb comes from the `shipkit` bin — dead on arrival unless the
|
|
85
|
+
// package is actually installed (bunx runs without adding the dep)
|
|
81
86
|
if (opts.bin == null) {
|
|
82
87
|
try {
|
|
83
88
|
Bun.resolveSync('rvlib-shipkit', root)
|
|
@@ -86,7 +91,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
86
91
|
console.log(' fix: bun add -d rvlib-shipkit')
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
|
-
console.log('next: fill venues/social in repo.config.ts, then `
|
|
94
|
+
console.log('next: fill venues/social in repo.config.ts, then `shipkit ci`')
|
|
90
95
|
console.log('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
|
|
91
96
|
return 0
|
|
92
97
|
}
|
|
@@ -35,8 +35,8 @@ const siblingConfigDirs = (home: string): string[] => {
|
|
|
35
35
|
* (`:` or `,` separated) PLUS ~/.claude PLUS any sibling ~/.claude-<name>
|
|
36
36
|
* holding a projects/ dir. Additive on purpose — a second local account keeps
|
|
37
37
|
* its own transcripts, and the env var only names the account running RIGHT
|
|
38
|
-
* NOW, so honoring it alone hides every session of the other one
|
|
39
|
-
*
|
|
38
|
+
* NOW, so honoring it alone hides every session of the other one — a corpus
|
|
39
|
+
* mined from a single root is silently partial. */
|
|
40
40
|
export function discoverConfigRoots(home: string = homedir()): ConfigRoot[] {
|
|
41
41
|
const fromEnv = (process.env.CLAUDE_CONFIG_DIR ?? '')
|
|
42
42
|
.split(/[:,]/)
|
|
@@ -137,8 +137,7 @@ export function promptTextOf(line: string): { text: string; entry: RawEntry } |
|
|
|
137
137
|
/** A prompt he typed while the agent was still working: the harness parks it as
|
|
138
138
|
* `{type:'queue-operation', operation:'enqueue', content}` and, if the run ends
|
|
139
139
|
* first, it may NEVER become a user message. Skipping these loses exactly the
|
|
140
|
-
* mid-flight corrections and standing conventions a retrospective is for
|
|
141
|
-
* (measured 2026-07-26: 72 such texts invisible in this repo alone). */
|
|
140
|
+
* mid-flight corrections and standing conventions a retrospective is for */
|
|
142
141
|
export function queuedTextOf(line: string): { text: string; entry: RawEntry } | null {
|
|
143
142
|
let parsed: unknown
|
|
144
143
|
try {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// reading a versioned CHANGELOG, in one place, because two readers of the
|
|
2
|
+
// same file always drift and the release then ships notes nobody wrote.
|
|
3
|
+
// House convention: newest first, one `## <version>` per released version,
|
|
4
|
+
// `## Unreleased` on top while work accumulates.
|
|
5
|
+
|
|
6
|
+
/** matches `## 1.2.3`, `## [1.2.3]`, and a dated heading in any of the dash
|
|
7
|
+
* shapes real changelogs use (`## 1.2.3 — 2026-07-28`). The dash here is
|
|
8
|
+
* DATA in someone's changelog, not prose this repo controls. */
|
|
9
|
+
export const changelogHeading = (version: string): RegExp =>
|
|
10
|
+
new RegExp(`^##\\s*\\[?${version.replace(/\./g, '\\.')}\\]?\\s*(?:[-–—:(].*)?$`)
|
|
11
|
+
|
|
12
|
+
/** the `## <version>` section of a versioned changelog, as release notes.
|
|
13
|
+
* Throws when the section is missing or empty: a release with no notes is a
|
|
14
|
+
* release nobody can read later. */
|
|
15
|
+
export function extractChangelogSection(p: { changelog: string; version: string }): string {
|
|
16
|
+
const lines = p.changelog.split('\n')
|
|
17
|
+
const head = changelogHeading(p.version)
|
|
18
|
+
const start = lines.findIndex((l) => head.test(l.trim()))
|
|
19
|
+
if (start === -1) throw new Error(`CHANGELOG has no "## ${p.version}" section — write the notes, then release`)
|
|
20
|
+
let end = lines.length
|
|
21
|
+
for (let i = start + 1; i < lines.length; i++)
|
|
22
|
+
if (lines[i]?.startsWith('## ')) { end = i; break }
|
|
23
|
+
const body = lines.slice(start + 1, end).join('\n').trim()
|
|
24
|
+
if (body === '') throw new Error(`CHANGELOG section "## ${p.version}" is empty — write the notes, then release`)
|
|
25
|
+
return body
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const hasVersionSection = (changelog: string, version: string): boolean =>
|
|
29
|
+
changelog.split('\n').some((l) => changelogHeading(version).test(l.trim()))
|
|
30
|
+
|
|
31
|
+
const UNRELEASED = /^##\s*\[?unreleased\]?\s*$/i
|
|
32
|
+
|
|
33
|
+
export const hasUnreleasedHeading = (changelog: string): boolean =>
|
|
34
|
+
changelog.split('\n').some((l) => UNRELEASED.test(l.trim()))
|
|
35
|
+
|
|
36
|
+
/** rename the Unreleased heading to the new version. Returns the new text, or
|
|
37
|
+
* an error when there is no such section or it carries no notes: a version
|
|
38
|
+
* with empty notes is a version nobody can read later. */
|
|
39
|
+
export function promoteUnreleased(changelog: string, version: string): { text: string } | { error: string } {
|
|
40
|
+
const lines = changelog.split('\n')
|
|
41
|
+
const at = lines.findIndex((l) => UNRELEASED.test(l.trim()))
|
|
42
|
+
if (at === -1) return { error: `CHANGELOG has no "## Unreleased" section — write the notes for this version under one, then bump` }
|
|
43
|
+
let end = lines.length
|
|
44
|
+
for (let i = at + 1; i < lines.length; i++) if (lines[i]?.startsWith('## ')) { end = i; break }
|
|
45
|
+
if (lines.slice(at + 1, end).join('\n').trim() === '')
|
|
46
|
+
return { error: `the "## Unreleased" section is empty — write what changed, then bump` }
|
|
47
|
+
lines[at] = `## ${version}`
|
|
48
|
+
return { text: lines.join('\n') }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** does this unit have notes waiting? `--all` selects on exactly this: having
|
|
52
|
+
* something to say IS the decision to ship. */
|
|
53
|
+
export const hasUnreleasedNotes = (changelog: string): boolean => !('error' in promoteUnreleased(changelog, '0.0.0'))
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// the git questions a release asks, in one place: which remote to push to,
|
|
2
|
+
// what the worktree is hiding, which tags exist here but nowhere else.
|
|
3
|
+
// Deliberately dependency-free (no config load, no ci) so a check script can
|
|
4
|
+
// import it without dragging the gate in.
|
|
5
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
6
|
+
|
|
7
|
+
export function git(root: string, args: string[]): { code: number; out: string } {
|
|
8
|
+
const p = Bun.spawnSync(['git', ...args], { cwd: root })
|
|
9
|
+
return { code: p.exitCode, out: `${p.stdout.toString()}${p.stderr.toString()}`.trim() }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** the push remote: the current branch's upstream when it has one, else the
|
|
13
|
+
* repo's single remote, else refuse. Never prefers a name: a repo whose
|
|
14
|
+
* remote is not called `origin` is normal, and guessing strands the tag
|
|
15
|
+
* somewhere nobody looks. */
|
|
16
|
+
export function deriveRemote(root: string): { remote: string } | { error: string } {
|
|
17
|
+
const branch = git(root, ['branch', '--show-current']).out
|
|
18
|
+
if (branch !== '') {
|
|
19
|
+
const r = git(root, ['config', '--get', `branch.${branch}.remote`])
|
|
20
|
+
if (r.code === 0 && r.out !== '') return { remote: r.out }
|
|
21
|
+
}
|
|
22
|
+
const r = git(root, ['remote'])
|
|
23
|
+
if (r.code !== 0) return { error: 'git remote failed (not a repo?)' }
|
|
24
|
+
const remotes = r.out.split('\n').map((l) => l.trim()).filter((l) => l !== '')
|
|
25
|
+
if (remotes.length === 0) return { error: 'no git remote configured' }
|
|
26
|
+
if (remotes.length === 1) return { remote: remotes[0]! }
|
|
27
|
+
return { error: `branch '${branch === '' ? '(detached)' : branch}' has no upstream and ${remotes.length} remotes exist (${remotes.join(', ')}) — set one with git push -u <remote> <branch>` }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** the worktree's dirty paths, minus the runner's own log dir. running the
|
|
31
|
+
* gate creates that dir, so counting it would make every gated release refuse
|
|
32
|
+
* in any repo that does not gitignore it. */
|
|
33
|
+
export function dirtyPaths(root: string, config: RepoConfig, pathspec?: string): string {
|
|
34
|
+
const logRoot = (config.logDir ?? 'tmp/shipkit-ci').replace(/^\.\//, '').split('/')[0]!
|
|
35
|
+
const args = pathspec != null && pathspec !== '' ? ['status', '--porcelain', '--', pathspec] : ['status', '--porcelain']
|
|
36
|
+
return git(root, args).out
|
|
37
|
+
.split('\n')
|
|
38
|
+
.filter((l) => l.trim() !== '' && !l.slice(3).trim().replace(/^"|"$/g, '').startsWith(`${logRoot}/`) && l.slice(3).trim() !== `${logRoot}/`)
|
|
39
|
+
.join('\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const tagExistsLocally = (root: string, tag: string): boolean =>
|
|
43
|
+
git(root, ['rev-parse', '--quiet', '--verify', `refs/tags/${tag}`]).code === 0
|
|
44
|
+
|
|
45
|
+
export type RemoteTags = { tags: Set<string> } | { error: string }
|
|
46
|
+
/** tag name → the COMMIT it names on the remote (the peeled oid when the tag
|
|
47
|
+
* is annotated), so two tags of the same name can be compared by what they
|
|
48
|
+
* describe rather than by their spelling */
|
|
49
|
+
export type RemoteTagRefs = { refs: Map<string, string> } | { error: string }
|
|
50
|
+
|
|
51
|
+
/** every tag the remote carries, with its commit. One `ls-remote` per repo,
|
|
52
|
+
* never one per tag: the caller compares locally. Deliberately unpatterned,
|
|
53
|
+
* because `ls-remote <pattern>` drops the peeled `^{}` line and leaves only
|
|
54
|
+
* the tag OBJECT id, which differs between two tags of the same commit. */
|
|
55
|
+
export function remoteTagRefs(root: string, remote: string): RemoteTagRefs {
|
|
56
|
+
const p = Bun.spawnSync(['git', 'ls-remote', '--tags', remote], { cwd: root, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
|
|
57
|
+
if (p.exitCode !== 0) return { error: `${p.stderr.toString()}${p.stdout.toString()}`.trim().split('\n').at(-1) ?? 'ls-remote failed' }
|
|
58
|
+
const refs = new Map<string, string>()
|
|
59
|
+
for (const line of p.stdout.toString().split('\n')) {
|
|
60
|
+
const [oid, ref] = line.split('\t')
|
|
61
|
+
const name = ref?.split('refs/tags/')[1]
|
|
62
|
+
if (oid == null || name == null || name === '') continue
|
|
63
|
+
const peeled = name.endsWith('^{}')
|
|
64
|
+
const key = peeled ? name.slice(0, -3) : name
|
|
65
|
+
// the peeled line always wins, whichever order it arrives in
|
|
66
|
+
if (peeled || !refs.has(key)) refs.set(key, oid)
|
|
67
|
+
}
|
|
68
|
+
return { refs }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function remoteTags(root: string, remote: string): RemoteTags {
|
|
72
|
+
const r = remoteTagRefs(root, remote)
|
|
73
|
+
return 'error' in r ? r : { tags: new Set(r.refs.keys()) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type RemoteTagState =
|
|
77
|
+
| { state: 'absent' }
|
|
78
|
+
| { state: 'same'; oid: string }
|
|
79
|
+
/** the name is taken on the remote by another commit — the version is burnt */
|
|
80
|
+
| { state: 'different'; remoteOid: string; localOid: string | null }
|
|
81
|
+
| { state: 'unreadable'; error: string }
|
|
82
|
+
|
|
83
|
+
/** what the remote says about ONE tag. Comparing NAMES is what let a burnt
|
|
84
|
+
* version read as "fully released": the tag was there, describing a tree
|
|
85
|
+
* nobody released. */
|
|
86
|
+
export function remoteTagState(root: string, remote: string, tag: string): RemoteTagState {
|
|
87
|
+
const r = remoteTagRefs(root, remote)
|
|
88
|
+
if ('error' in r) return { state: 'unreadable', error: r.error }
|
|
89
|
+
const remoteOid = r.refs.get(tag)
|
|
90
|
+
if (remoteOid == null) return { state: 'absent' }
|
|
91
|
+
const local = git(root, ['rev-parse', `${tag}^{}`])
|
|
92
|
+
const localOid = local.code === 0 ? local.out : null
|
|
93
|
+
return localOid === remoteOid ? { state: 'same', oid: remoteOid } : { state: 'different', remoteOid, localOid }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type MergeProbe =
|
|
97
|
+
| { state: 'clean' }
|
|
98
|
+
| { state: 'conflicts'; paths: string[] }
|
|
99
|
+
/** this git could not answer, so the caller must not claim either way */
|
|
100
|
+
| { state: 'unknown'; reason: string }
|
|
101
|
+
|
|
102
|
+
/** would `from` merge into `tip` cleanly? Answered from the object database:
|
|
103
|
+
* `git merge-tree --write-tree` (git 2.38+) writes nothing but objects, so no
|
|
104
|
+
* ref moves and no file is checked out. That is what lets a REHEARSAL answer
|
|
105
|
+
* the merge question without touching the branch a human treats as a gate.
|
|
106
|
+
*
|
|
107
|
+
* exit 1 covers both conflicts and a ref git could not resolve, so the tree
|
|
108
|
+
* oid on stdout is what separates them: no oid means no merge was attempted. */
|
|
109
|
+
export function mergeProbe(root: string, tip: string, from: string): MergeProbe {
|
|
110
|
+
// env is passed explicitly: Bun resolves argv[0] against the PATH it
|
|
111
|
+
// captured at startup unless it is, so an older git ahead on PATH would
|
|
112
|
+
// never be reached, here or in a test that stages one
|
|
113
|
+
const p = Bun.spawnSync(['git', 'merge-tree', '--write-tree', '--name-only', tip, from], { cwd: root, env: process.env })
|
|
114
|
+
const out = p.stdout.toString()
|
|
115
|
+
const [head, ...rest] = out.split('\n')
|
|
116
|
+
const wroteTree = head != null && /^[0-9a-f]{40}$/.test(head.trim())
|
|
117
|
+
if (p.exitCode === 0 && wroteTree) return { state: 'clean' }
|
|
118
|
+
if (p.exitCode === 1 && wroteTree)
|
|
119
|
+
return { state: 'conflicts', paths: (rest.join('\n').split('\n\n')[0] ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '') }
|
|
120
|
+
const reason = `${p.stderr.toString()}${out}`.trim().split('\n').filter((l) => l !== '').at(-1) ?? `git merge-tree exited ${p.exitCode}`
|
|
121
|
+
return { state: 'unknown', reason: `${reason} (needs git 2.38+)` }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** local tags matching any of the release units' tag shapes that the remote
|
|
125
|
+
* does not have. A stranded tag is invisible where he looks (GitHub), so it
|
|
126
|
+
* is a failure, not a note. */
|
|
127
|
+
export function strandedTags(p: { root: string; globs: string[]; remote: string }): { stranded: string[]; local: string[] } | { error: string } {
|
|
128
|
+
const local = p.globs.flatMap((g) => git(p.root, ['tag', '-l', g]).out.split('\n').map((l) => l.trim()).filter((l) => l !== ''))
|
|
129
|
+
const uniq = [...new Set(local)].sort()
|
|
130
|
+
const r = remoteTags(p.root, p.remote)
|
|
131
|
+
if ('error' in r) return r
|
|
132
|
+
return { stranded: uniq.filter((t) => !r.tags.has(t)), local: uniq }
|
|
133
|
+
}
|