trackfw 7.0.0 → 7.2.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/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/commands/branch.js +47 -0
- package/src/commands/commit.js +7 -3
- package/src/commands/discover.js +6 -0
- package/src/commands/doctor.js +33 -0
- package/src/commands/index.js +3 -0
- package/src/commands/integrations.js +79 -2
- package/src/commands/push.js +51 -0
- package/src/commands/release.js +47 -0
- package/src/commands/serve.js +52 -4
- package/src/commands/ship.js +10 -2
- package/src/commands/update-harness.js +319 -12
- package/src/commands/update.js +1 -1
- package/src/config/index.js +17 -2
- package/src/generators/hooks.js +650 -45
- package/src/generators/init.js +12 -0
- package/src/i18n/locales/en-US.json +0 -3
- package/src/i18n/locales/es-ES.json +0 -3
- package/src/i18n/locales/pt-BR.json +0 -3
- package/src/integrations/assets/agents/architect.md +12 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +1 -1
- package/src/integrations/manager.js +118 -17
- package/src/integrations/render.js +116 -2
- package/src/lib/fatal-error.js +64 -0
- package/src/push/runner.js +223 -0
- package/src/release/runner.js +474 -0
- package/src/ship/runner.js +226 -93
- package/src/validator/index.js +686 -95
package/src/ship/runner.js
CHANGED
|
@@ -8,16 +8,78 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const { spawnSync } = require('child_process')
|
|
11
|
-
const fs = require('fs')
|
|
12
|
-
const path = require('path')
|
|
13
11
|
const { load: loadConfig, reset: resetConfig } = require('../config')
|
|
14
12
|
const { resolve: forgeResolve } = require('../forge/resolve')
|
|
15
13
|
const { forgeAdapter } = require('../forge/adapter')
|
|
14
|
+
const validator = require('../validator')
|
|
15
|
+
const { evaluateBranchIntegration, DECISION: BRANCH_PRUNE_DECISION } = require('../branch/prune')
|
|
16
16
|
|
|
17
17
|
// Git subcommands that modify local or remote state.
|
|
18
18
|
// In --dry-run mode these are printed but not executed.
|
|
19
19
|
const GIT_WRITE_COMMANDS = new Set(['commit', 'push', 'fetch'])
|
|
20
20
|
|
|
21
|
+
// force-with-lease refusal messages. Named constants so the ML-1B parity gate has a single
|
|
22
|
+
// place to compare byte-for-byte across the 3 CLIs. Byte-identical to Go's
|
|
23
|
+
// forceLease*Msg/Fmt constants (internal/commands/ship.go) and Python's FORCE_LEASE_* below.
|
|
24
|
+
// Never expose "--force" (raw) as a flag — see
|
|
25
|
+
// ADR-2026-08-19-caminho-governado-para-push-forcado-e-tag-de-release.md.
|
|
26
|
+
const FORCE_LEASE_NO_FORGE_CLI_MSG =
|
|
27
|
+
'trackfw ship --force-with-lease requires a forge CLI (gh, glab, or az) to confirm an open pull/merge request before rewriting remote history. No forge CLI is available for this repository — install and authenticate it, or push without --force-with-lease.'
|
|
28
|
+
|
|
29
|
+
function forceLeaseNoPROpenMsg(branch) {
|
|
30
|
+
return `trackfw ship --force-with-lease refuses to run: branch "${branch}" has no open pull/merge request. Open the PR/MR first (trackfw ship without --force-with-lease, or your forge's web UI), then retry.`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function forceLeaseCannotVerifyMsg(branch, cliName, errMessage) {
|
|
34
|
+
return `trackfw ship --force-with-lease could not verify whether branch "${branch}" has an open pull/merge request (${cliName} CLI error: ${errMessage}). Refusing rather than risking a force push without a verified PR — check your ${cliName} CLI authentication and retry.`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* defaultCheckPROpen queries the resolved forge CLI for an open PR/MR whose source branch is
|
|
39
|
+
* `branch`, using the same list-based query shape for every forge: empty result means "no PR"
|
|
40
|
+
* (exit 0), any non-zero exit or unparseable output means "cannot verify" (thrown — never
|
|
41
|
+
* conflated with "no PR"). bitbucket and "manual" never reach here: runShip only calls
|
|
42
|
+
* checkPROpen when adapter.available is true, and bitbucket's adapter is always available=false.
|
|
43
|
+
* @param {object} adapter
|
|
44
|
+
* @param {string} branch
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
function defaultCheckPROpen(adapter, branch) {
|
|
48
|
+
let args
|
|
49
|
+
switch (adapter.forge) {
|
|
50
|
+
case 'github':
|
|
51
|
+
args = ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number']
|
|
52
|
+
break
|
|
53
|
+
case 'gitlab':
|
|
54
|
+
// glab mr list: --source-branch filters by source branch, --state opened matches
|
|
55
|
+
// gh's "open" state, -F json requests machine-readable output (glab's own flag,
|
|
56
|
+
// not an external jq/GNU dependency).
|
|
57
|
+
args = ['mr', 'list', '--source-branch', branch, '--state', 'opened', '-F', 'json']
|
|
58
|
+
break
|
|
59
|
+
case 'azure':
|
|
60
|
+
// az defaults to --output json; passed explicitly here for clarity, not reliance
|
|
61
|
+
// on the ambient default.
|
|
62
|
+
args = ['repos', 'pr', 'list', '--source-branch', branch, '--status', 'active', '--output', 'json']
|
|
63
|
+
break
|
|
64
|
+
default:
|
|
65
|
+
throw new Error(`no PR/MR query defined for forge "${adapter.forge}"`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = spawnSync(adapter.cliName, args, { encoding: 'utf8' })
|
|
69
|
+
if (result.status !== 0) {
|
|
70
|
+
const msg = (result.stderr || '').trim() || `${adapter.cliName} exited with ${result.status}`
|
|
71
|
+
throw new Error(msg)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let parsed
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(result.stdout || '[]')
|
|
77
|
+
} catch (e) {
|
|
78
|
+
throw new Error(`could not parse ${adapter.cliName} output: ${e.message}`)
|
|
79
|
+
}
|
|
80
|
+
return Array.isArray(parsed) && parsed.length > 0
|
|
81
|
+
}
|
|
82
|
+
|
|
21
83
|
/**
|
|
22
84
|
* isGitWriteCmd returns true when the first arg is a write-mode git subcommand.
|
|
23
85
|
* @param {string[]} args
|
|
@@ -77,66 +139,25 @@ function defaultCheckGovernance() {
|
|
|
77
139
|
|
|
78
140
|
/**
|
|
79
141
|
* checkShipGovernance — hard gate (bypasses config/baseline/lenient).
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
142
|
+
*
|
|
143
|
+
* Delegates entirely to the shared validator functions already used by `trackfw validate`,
|
|
144
|
+
* `trackfw branch new` and `trackfw commit` — never reimplement this logic locally. Byte-
|
|
145
|
+
* identical to Go's CheckShipGovernance (internal/validator/validator.go), which has the same
|
|
146
|
+
* no-args shape and the same two checks:
|
|
147
|
+
* 1. validateBranchHasWIPRoadmap — current branch (feat/fix/refactor only; re-derived
|
|
148
|
+
* internally from TRACKFW_BRANCH/git, same as Go) has a matching roadmap in wip/ OR done/
|
|
149
|
+
* 2. validateWIPHasREQ — every roadmap in wip/ has a linked REQ
|
|
150
|
+
* Before this ML, this function reimplemented both checks locally with its own wording (no
|
|
151
|
+
* "nor done/" clause, no done/ directory scan at all) — see
|
|
152
|
+
* vault/notes/ship-checkgovernance-error-stream-wording-divergence-2026-08-16.md. Duplicating
|
|
153
|
+
* the check was the actual root cause of the drift, not just the wording.
|
|
83
154
|
* @returns {string[]} violation messages
|
|
84
155
|
*/
|
|
85
156
|
function checkShipGovernance() {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const wipDir = path.join(roadmapDir, 'wip')
|
|
91
|
-
|
|
92
|
-
// Get branch name
|
|
93
|
-
const branchResult = defaultExecGit(['symbolic-ref', '--short', 'HEAD'])
|
|
94
|
-
const branch = branchResult.error ? '' : branchResult.stdout.trim()
|
|
95
|
-
|
|
96
|
-
// Check branch has matching roadmap in wip/ — gated types only (feat/fix/refactor); chore/docs
|
|
97
|
-
// are exempted by the outer runShip Step 2 skip and never reach this function in practice, but
|
|
98
|
-
// isGatedShipBranch keeps this check correct in isolation too.
|
|
99
|
-
if (branch && isGatedShipBranch(branch)) {
|
|
100
|
-
const slug = normalizeBranchSlug(branch.split('/').slice(1).join('/'))
|
|
101
|
-
let hasMatch = false
|
|
102
|
-
let wipFiles = []
|
|
103
|
-
|
|
104
|
-
if (fs.existsSync(wipDir)) {
|
|
105
|
-
wipFiles = fs.readdirSync(wipDir).filter(f => f.endsWith('.md'))
|
|
106
|
-
for (const f of wipFiles) {
|
|
107
|
-
if (normalizeBranchSlug(f).includes(slug)) {
|
|
108
|
-
hasMatch = true
|
|
109
|
-
break
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (wipFiles.length === 0) {
|
|
115
|
-
violations.push(
|
|
116
|
-
`branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ — create governance artifacts first:\n` +
|
|
117
|
-
' trackfw req new "<title>"\n' +
|
|
118
|
-
' trackfw roadmap new "<title>"\n' +
|
|
119
|
-
' trackfw roadmap move <name> wip'
|
|
120
|
-
)
|
|
121
|
-
} else if (!hasMatch) {
|
|
122
|
-
violations.push(
|
|
123
|
-
`branch "${branch}" has no matching roadmap in wip/ (found: ${wipFiles.join(', ')}) — ` +
|
|
124
|
-
'include the branch slug in the roadmap filename'
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Check WIP roadmaps have a linked REQ
|
|
129
|
-
if (hasMatch && fs.existsSync(wipDir)) {
|
|
130
|
-
for (const f of wipFiles) {
|
|
131
|
-
const content = fs.readFileSync(path.join(wipDir, f), 'utf8')
|
|
132
|
-
if (!content.includes('REQ:') && !content.includes('req:')) {
|
|
133
|
-
violations.push(`roadmap "${f}" is in wip but has no linked REQ`)
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return violations
|
|
157
|
+
return [
|
|
158
|
+
...validator.validateBranchHasWIPRoadmap(),
|
|
159
|
+
...validator.validateWIPHasREQ(),
|
|
160
|
+
]
|
|
140
161
|
}
|
|
141
162
|
|
|
142
163
|
/**
|
|
@@ -172,8 +193,17 @@ function normalizeBranchSlug(value) {
|
|
|
172
193
|
}
|
|
173
194
|
|
|
174
195
|
/**
|
|
175
|
-
* detectPendingSquashMerges warns about remote branches with
|
|
176
|
-
* Non-blocking.
|
|
196
|
+
* detectPendingSquashMerges warns about remote branches with genuinely pending work vs
|
|
197
|
+
* origin/main. Non-blocking.
|
|
198
|
+
*
|
|
199
|
+
* Reuses evaluateBranchIntegration (branch/prune.js) — the same touched-files heuristic
|
|
200
|
+
* `trackfw branch prune` uses — instead of a naive bidirectional `git diff origin/main <branch>
|
|
201
|
+
* --stat`. The naive check false-positives on a branch that was squash-merged and is now merely
|
|
202
|
+
* stale (main advanced further afterwards): it always shows a non-empty diff even though nothing
|
|
203
|
+
* from the branch is actually missing from main. Only DECISION.PENDING_WORK — genuine,
|
|
204
|
+
* unintegrated work — surfaces this warning; every other decision (no_own_work,
|
|
205
|
+
* content_identical, review_doc_config, no_merge_base, eval_error) stays silent, the same
|
|
206
|
+
* posture the naive check had on error (skip, no warning).
|
|
177
207
|
* @param {string} currentBranch
|
|
178
208
|
* @param {function} execGit
|
|
179
209
|
* @param {function} writeln
|
|
@@ -188,9 +218,8 @@ function detectPendingSquashMerges(currentBranch, execGit, writeln) {
|
|
|
188
218
|
const shortName = candidate.replace(/^origin\//, '')
|
|
189
219
|
if (shortName === currentBranch) continue
|
|
190
220
|
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
if (diff.trim()) {
|
|
221
|
+
const evalResult = evaluateBranchIntegration(candidate, execGit)
|
|
222
|
+
if (evalResult.decision === BRANCH_PRUNE_DECISION.PENDING_WORK) {
|
|
194
223
|
writeln(`Warning: branch "${shortName}" appears to have unmerged changes vs origin/main.`)
|
|
195
224
|
}
|
|
196
225
|
}
|
|
@@ -273,10 +302,17 @@ function allDocOnly(files) {
|
|
|
273
302
|
*/
|
|
274
303
|
const COMMIT_MESSAGE_SEP = '\x1e'
|
|
275
304
|
|
|
305
|
+
// GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX is the fixed prefix `git symbolic-ref
|
|
306
|
+
// refs/remotes/origin/HEAD` always returns before the branch name, because "origin" is the
|
|
307
|
+
// literal ref namespace queried — never derived from the output itself. Stripping this exact
|
|
308
|
+
// prefix (instead of cutting at the last '/') is what makes defaultBaseBranch correct for a
|
|
309
|
+
// default branch that itself contains a slash (e.g. "release/7.2"): lastIndexOf('/') used to
|
|
310
|
+
// cut at "7.2", discarding "release/". Mirrors Go's ship.go gitSymbolicRefOriginHeadPrefix.
|
|
311
|
+
const GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX = 'refs/remotes/origin/'
|
|
312
|
+
|
|
276
313
|
/**
|
|
277
314
|
* defaultBaseBranch resolves the repository's default branch for `git log <base>..HEAD`.
|
|
278
|
-
* Tries `git symbolic-ref refs/remotes/origin/HEAD`
|
|
279
|
-
* only the name after the last slash is kept) and falls back to "main" when that fails or
|
|
315
|
+
* Tries `git symbolic-ref refs/remotes/origin/HEAD` and falls back to "main" when that fails or
|
|
280
316
|
* yields nothing.
|
|
281
317
|
* @param {function} execGit
|
|
282
318
|
* @returns {string}
|
|
@@ -285,9 +321,9 @@ function defaultBaseBranch(execGit) {
|
|
|
285
321
|
const { stdout, error } = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'])
|
|
286
322
|
if (error) return 'main'
|
|
287
323
|
const out = (stdout || '').trim()
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return
|
|
324
|
+
if (!out.startsWith(GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX)) return 'main'
|
|
325
|
+
const name = out.slice(GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX.length)
|
|
326
|
+
return name === '' ? 'main' : name
|
|
291
327
|
}
|
|
292
328
|
|
|
293
329
|
/**
|
|
@@ -396,6 +432,15 @@ function runShip(opts, deps = {}) {
|
|
|
396
432
|
const execGit = deps.execGit || defaultExecGit
|
|
397
433
|
const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
|
|
398
434
|
const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
|
|
435
|
+
// writeErr — the terminal error line for every abort path below, written to STDERR with the
|
|
436
|
+
// "Error: " prefix. Mirrors Go exactly: ship.go returns these same messages as a bare
|
|
437
|
+
// fmt.Errorf(...), and internal/commands/root.go's Execute() wrapper prints them to stderr as
|
|
438
|
+
// `fmt.Fprintln(os.Stderr, cmd.ErrPrefix(), err.Error())` — cmd.ErrPrefix() is "Error:", and
|
|
439
|
+
// Fprintln inserts exactly one space between operands, so the wire format is
|
|
440
|
+
// "Error: <message>\n". Every multi-line detail printed BEFORE the abort (violation lists,
|
|
441
|
+
// remediation hints, "Note: ..." blocks) stays on stdout via writeln, same as Go's deps.out —
|
|
442
|
+
// only this final one-line (or one-block) summary moves to stderr.
|
|
443
|
+
const writeErr = deps.writeErr || ((s) => process.stderr.write(`Error: ${s}\n`))
|
|
399
444
|
|
|
400
445
|
// Inner git wrapper: skips write commands in dry-run mode.
|
|
401
446
|
function git(args) {
|
|
@@ -416,20 +461,20 @@ function runShip(opts, deps = {}) {
|
|
|
416
461
|
// ─── Step 1: Branch validation ─────────────────────────────────────────────
|
|
417
462
|
const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
|
|
418
463
|
if (branchResult.error) {
|
|
419
|
-
|
|
464
|
+
writeErr(`could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
|
|
420
465
|
return 1
|
|
421
466
|
}
|
|
422
467
|
const branch = branchResult.stdout.trim()
|
|
423
468
|
|
|
424
469
|
// main/master is blocked unconditionally — the doc-only exception never applies here.
|
|
425
470
|
if (branch === 'main' || branch === 'master') {
|
|
426
|
-
|
|
471
|
+
writeErr(`trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
|
|
427
472
|
return 1
|
|
428
473
|
}
|
|
429
474
|
|
|
430
475
|
if (!docOnly && !isShipBranch(branch)) {
|
|
431
|
-
|
|
432
|
-
`
|
|
476
|
+
writeErr(
|
|
477
|
+
`branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
|
|
433
478
|
'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
|
|
434
479
|
)
|
|
435
480
|
return 1
|
|
@@ -461,13 +506,66 @@ function runShip(opts, deps = {}) {
|
|
|
461
506
|
writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
|
|
462
507
|
writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
|
|
463
508
|
writeln("configured — ship always requires REQ + roadmap in wip/.")
|
|
464
|
-
|
|
509
|
+
writeErr(`governance check failed: ${violations.length} violation(s)`)
|
|
465
510
|
return 1
|
|
466
511
|
}
|
|
467
512
|
|
|
468
513
|
writeln('Governance: OK')
|
|
469
514
|
}
|
|
470
515
|
|
|
516
|
+
// ─── Step 2.5: force-with-lease gate ───────────────────────────────────────
|
|
517
|
+
// Runs before any write (commit/push) — a refusal here must never leave a local
|
|
518
|
+
// commit the caller cannot push. Read-only, so it runs in --dry-run too, same
|
|
519
|
+
// posture as the read-only calls in Step 0 / Step 7.
|
|
520
|
+
//
|
|
521
|
+
// forceLeaseAdapter/forceLeaseResolution are reused by Step 7 below to avoid a
|
|
522
|
+
// second forge resolution and a duplicate "Forge: ..." line, and because Step 7
|
|
523
|
+
// must skip PR/MR creation entirely once this gate has confirmed one is already
|
|
524
|
+
// open — creating a second one would be a spurious failure on every successful
|
|
525
|
+
// force push.
|
|
526
|
+
let forceLeaseAdapter = null
|
|
527
|
+
let forceLeaseResolution = null
|
|
528
|
+
if (opts.forceWithLease) {
|
|
529
|
+
const { stdout: gateRemoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
|
|
530
|
+
const gateRemoteURL = (gateRemoteURLRaw || '').trim()
|
|
531
|
+
|
|
532
|
+
let resolution
|
|
533
|
+
try {
|
|
534
|
+
resolution = forgeResolve({
|
|
535
|
+
flagForge: opts.forge || '',
|
|
536
|
+
configForge: deps.configForge || '',
|
|
537
|
+
remoteURL: gateRemoteURL,
|
|
538
|
+
repoDir: deps.repoDir || '',
|
|
539
|
+
})
|
|
540
|
+
} catch (resErr) {
|
|
541
|
+
writeErr(resErr.message)
|
|
542
|
+
return 1
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const adapter = forgeAdapter(resolution.forge, deps.availFn || undefined)
|
|
546
|
+
if (resolution.forge === 'manual' || !adapter.available) {
|
|
547
|
+
writeErr(FORCE_LEASE_NO_FORGE_CLI_MSG)
|
|
548
|
+
return 1
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const checkPROpen = deps.checkPROpen || defaultCheckPROpen
|
|
552
|
+
let open
|
|
553
|
+
try {
|
|
554
|
+
open = checkPROpen(adapter, branch)
|
|
555
|
+
} catch (prErr) {
|
|
556
|
+
writeErr(forceLeaseCannotVerifyMsg(branch, adapter.cliName, prErr.message))
|
|
557
|
+
return 1
|
|
558
|
+
}
|
|
559
|
+
if (!open) {
|
|
560
|
+
writeErr(forceLeaseNoPROpenMsg(branch))
|
|
561
|
+
return 1
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
writeln(`force-with-lease: open ${adapter.noun} confirmed for "${branch}" (${resolution.forge}).`)
|
|
565
|
+
forceLeaseAdapter = adapter
|
|
566
|
+
forceLeaseResolution = resolution
|
|
567
|
+
}
|
|
568
|
+
|
|
471
569
|
// ─── Step 3: Squash-merge detection ────────────────────────────────────────
|
|
472
570
|
if (opts.dryRun) {
|
|
473
571
|
writeln('[dry-run] git fetch origin --prune')
|
|
@@ -490,9 +588,16 @@ function runShip(opts, deps = {}) {
|
|
|
490
588
|
writeln('────────────────────────────────────────────────────────\n')
|
|
491
589
|
|
|
492
590
|
// Reuses stagedFiles read at the top of the function (Step 0) — never re-query git here.
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
591
|
+
//
|
|
592
|
+
// --force-with-lease push-only mode: a rebase that resolved conflicts already
|
|
593
|
+
// committed the result (the index is clean afterwards) — there is nothing left to
|
|
594
|
+
// stage or commit, only to push. Only --force-with-lease grants this exception;
|
|
595
|
+
// without it, "nothing staged" still aborts exactly as before (non-regression).
|
|
596
|
+
const pushOnly = opts.forceWithLease && stagedFiles.length === 0
|
|
597
|
+
|
|
598
|
+
if (stagedFiles.length === 0 && !opts.forceWithLease) {
|
|
599
|
+
writeErr(
|
|
600
|
+
'nothing is staged — stage your files explicitly before running ship:\n' +
|
|
496
601
|
' git add <file1> <file2> ...\n' +
|
|
497
602
|
"Never use 'git add .' or 'git add -A'"
|
|
498
603
|
)
|
|
@@ -500,29 +605,38 @@ function runShip(opts, deps = {}) {
|
|
|
500
605
|
}
|
|
501
606
|
|
|
502
607
|
// ─── Step 5: Commit ────────────────────────────────────────────────────────
|
|
503
|
-
if (
|
|
504
|
-
writeln(
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
608
|
+
if (pushOnly) {
|
|
609
|
+
writeln('Nothing staged — --force-with-lease pushes existing commits only, no new commit.')
|
|
610
|
+
} else {
|
|
611
|
+
if (!opts.message) {
|
|
612
|
+
writeErr(
|
|
613
|
+
'commit message is required — use -m:\n' +
|
|
614
|
+
' trackfw ship -m "feat(<scope>): <description>"'
|
|
615
|
+
)
|
|
616
|
+
return 1
|
|
617
|
+
}
|
|
510
618
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
619
|
+
const { error: commitErr } = git(['commit', '-m', opts.message])
|
|
620
|
+
if (commitErr) {
|
|
621
|
+
writeErr(`git commit failed: ${commitErr.message}`)
|
|
622
|
+
return 1
|
|
623
|
+
}
|
|
516
624
|
|
|
517
|
-
|
|
518
|
-
|
|
625
|
+
if (!opts.dryRun) {
|
|
626
|
+
writeln(`Committed: ${opts.message}`)
|
|
627
|
+
}
|
|
519
628
|
}
|
|
520
629
|
|
|
521
630
|
// ─── Step 6: Push ──────────────────────────────────────────────────────────
|
|
522
|
-
|
|
631
|
+
let pushArgs = buildPushArgs(branch, execGit)
|
|
632
|
+
if (opts.forceWithLease) {
|
|
633
|
+
// Fixed position: push --force-with-lease [-u] origin <branch> — identical
|
|
634
|
+
// across the 3 CLIs (ML-1B's parity gate compares this literally).
|
|
635
|
+
pushArgs = [pushArgs[0], '--force-with-lease', ...pushArgs.slice(1)]
|
|
636
|
+
}
|
|
523
637
|
const { error: pushErr } = git(pushArgs)
|
|
524
638
|
if (pushErr) {
|
|
525
|
-
|
|
639
|
+
writeErr(`git push failed: ${pushErr.message}`)
|
|
526
640
|
return 1
|
|
527
641
|
}
|
|
528
642
|
|
|
@@ -531,6 +645,17 @@ function runShip(opts, deps = {}) {
|
|
|
531
645
|
}
|
|
532
646
|
|
|
533
647
|
// ─── Step 7: Open PR/MR ────────────────────────────────────────────────────
|
|
648
|
+
// --force-with-lease only ever reaches here after Step 2.5 confirmed a PR/MR is
|
|
649
|
+
// already open on this branch — creating another one would be a spurious failure
|
|
650
|
+
// on every successful force push. Reuses the adapter/resolution Step 2.5 already
|
|
651
|
+
// computed instead of resolving the forge a second time.
|
|
652
|
+
if (opts.forceWithLease) {
|
|
653
|
+
writeln(`Forge: ${forceLeaseResolution.forge} (source: ${forceLeaseResolution.source})`)
|
|
654
|
+
writeln(`${forceLeaseAdapter.noun} already open — skipping creation (--force-with-lease).`)
|
|
655
|
+
writeln('\nship complete.')
|
|
656
|
+
return 0
|
|
657
|
+
}
|
|
658
|
+
|
|
534
659
|
const { stdout: remoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
|
|
535
660
|
const remoteURL = (remoteURLRaw || '').trim()
|
|
536
661
|
|
|
@@ -638,4 +763,12 @@ module.exports = {
|
|
|
638
763
|
gitCommitsSince,
|
|
639
764
|
buildPRBody,
|
|
640
765
|
COMMIT_MESSAGE_SEP,
|
|
766
|
+
detectPendingSquashMerges,
|
|
767
|
+
defaultCheckPROpen,
|
|
768
|
+
FORCE_LEASE_NO_FORGE_CLI_MSG,
|
|
769
|
+
forceLeaseNoPROpenMsg,
|
|
770
|
+
forceLeaseCannotVerifyMsg,
|
|
771
|
+
// Exported for reuse by push/runner.js (AC2 of REQ-2026-08-22-trackfw-push).
|
|
772
|
+
buildPushArgs,
|
|
773
|
+
defaultExecGit,
|
|
641
774
|
}
|