trackfw 7.1.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/package.json +1 -1
- package/src/commands/commit.js +7 -3
- package/src/commands/doctor.js +2 -1
- package/src/commands/index.js +2 -0
- package/src/commands/integrations.js +63 -1
- package/src/commands/push.js +51 -0
- package/src/commands/release.js +47 -0
- package/src/commands/ship.js +10 -2
- package/src/commands/update-harness.js +2 -1
- package/src/commands/update.js +1 -1
- package/src/config/index.js +17 -2
- package/src/generators/hooks.js +176 -13
- package/src/generators/init.js +12 -0
- 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/render.js +116 -2
- package/src/push/runner.js +223 -0
- package/src/release/runner.js +474 -0
- package/src/ship/runner.js +177 -21
- package/src/validator/index.js +270 -13
|
@@ -56,7 +56,7 @@ function buildPlans(kind, options = {}) {
|
|
|
56
56
|
for (const item of selected.itemEntries) {
|
|
57
57
|
for (const installPath of paths) {
|
|
58
58
|
const destination = installPath.path.replace('{{id}}', item.id)
|
|
59
|
-
let content = render({ target: targetEntry.id, kind, item, content: readAsset(item), capability, destination, identity: identityConfig })
|
|
59
|
+
let content = render({ target: targetEntry.id, kind, item, content: readAsset(item), capability, destination, identity: identityConfig, agentModels: options.agentModels || {} })
|
|
60
60
|
// D5/D9 extension point: reproduce any persisted third-party
|
|
61
61
|
// reference block so regenerating this exact artifact (e.g. a
|
|
62
62
|
// later `trackfw agents update`) settles at state "current"
|
|
@@ -97,6 +97,19 @@ function mapModelCursor(model) {
|
|
|
97
97
|
return CURSOR_MODEL_MAP[model] || ''
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// containsControlChar reports whether s contains any ASCII control character
|
|
101
|
+
// (U+0000–U+001F) or a Unicode line/paragraph separator (U+2028, U+2029).
|
|
102
|
+
// Mirrors internal/integrations/render.go:containsControlChar (ML-5C).
|
|
103
|
+
// In JavaScript, strings are UTF-16; U+2028/U+2029 are single code units
|
|
104
|
+
// accessible via charCodeAt, so no surrogate-pair handling is needed.
|
|
105
|
+
function containsControlChar(s) {
|
|
106
|
+
for (let i = 0; i < s.length; i++) {
|
|
107
|
+
const c = s.charCodeAt(i)
|
|
108
|
+
if (c < 0x20 || c === 0x2028 || c === 0x2029) return true
|
|
109
|
+
}
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
// rewriteFrontmatterModelLine substitui a linha "model:" do frontmatter de um
|
|
101
114
|
// markdown cru por value, preservando toda outra linha do frontmatter e o
|
|
102
115
|
// corpo byte a byte. Se o frontmatter não tiver linha "model:", uma é
|
|
@@ -105,7 +118,14 @@ function mapModelCursor(model) {
|
|
|
105
118
|
// espelha a detecção de fronteira usada por rewriteFrontmatterFields,
|
|
106
119
|
// escopada à chave única "model". Espelha
|
|
107
120
|
// internal/integrations/render.go:rewriteFrontmatterModelLine.
|
|
121
|
+
//
|
|
122
|
+
// Throws an Error if value contains any ASCII control character (U+0000–U+001F).
|
|
123
|
+
// Model IDs never require control characters; any such value is rejected to
|
|
124
|
+
// prevent frontmatter injection (ML-5A).
|
|
108
125
|
function rewriteFrontmatterModelLine(source, value) {
|
|
126
|
+
if (containsControlChar(value)) {
|
|
127
|
+
throw new Error(`model value contains control character and was rejected: model IDs never require newlines or other control characters (got ${JSON.stringify(value)})`)
|
|
128
|
+
}
|
|
109
129
|
const trimmed = String(source).trim()
|
|
110
130
|
if (!trimmed.startsWith('---\n')) return trimmed
|
|
111
131
|
const end = trimmed.indexOf('\n---', 4)
|
|
@@ -296,6 +316,27 @@ function frontmatterName(content) {
|
|
|
296
316
|
return undefined
|
|
297
317
|
}
|
|
298
318
|
|
|
319
|
+
// isVersionString reports whether s is a bare version string (digits and dots
|
|
320
|
+
// only, e.g. "5", "4.6", "1.0.2"). Mirrors internal/integrations/render.go:isVersionString.
|
|
321
|
+
// Values that don't match — pre-composed IDs like "claude-sonnet-4-5-20250929" (hyphens),
|
|
322
|
+
// "latest" (letters), or "" (empty) — return false and the caller uses the value literally
|
|
323
|
+
// (escape hatch, ADR-2026-08-21 §3).
|
|
324
|
+
function isVersionString(s) {
|
|
325
|
+
if (!s) return false
|
|
326
|
+
return /^[0-9]+(\.[0-9]+)*$/.test(s)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// composeClaudeModelID builds a Claude model identifier from tier and version,
|
|
330
|
+
// applying the three composition rules from the official Anthropic docs.
|
|
331
|
+
// Mirrors internal/integrations/render.go:composeClaudeModelID.
|
|
332
|
+
// Rule 1: dots become hyphens ("4.6" → "claude-sonnet-4-6")
|
|
333
|
+
// Rule 2: major-only version never gets "-0" ("5" → "claude-sonnet-5")
|
|
334
|
+
// Rule 3: handled via escape hatch — pre-composed IDs are never version strings
|
|
335
|
+
function composeClaudeModelID(tier, version) {
|
|
336
|
+
const hyphenated = version.replace(/\./g, '-')
|
|
337
|
+
return `claude-${tier}-${hyphenated}`
|
|
338
|
+
}
|
|
339
|
+
|
|
299
340
|
// render converte um item canônico do catálogo para a representação nativa
|
|
300
341
|
// declarada por uma surface alvo. Quando identity carrega uma identidade
|
|
301
342
|
// customizada para item.id, o name/description/body renderizados são
|
|
@@ -321,7 +362,12 @@ function frontmatterName(content) {
|
|
|
321
362
|
// normalize(content) — a mesma expressão usada antes de existir suporte a
|
|
322
363
|
// identidade — então a saída sem identidade é garantida byte a byte
|
|
323
364
|
// inalterada por construção, não por coincidência.
|
|
324
|
-
|
|
365
|
+
//
|
|
366
|
+
// agentModels (opcional) — objeto de tier→versão lido de trackfw.yaml's agent_models.
|
|
367
|
+
// Ausente ou vazio: saída idêntica a antes. Somente o alvo "claude" recebe IDs
|
|
368
|
+
// compostos; Codex, Cursor, Antigravity, OpenCode, Gemini, Kiro não são afetados
|
|
369
|
+
// (ADR-2026-08-21 §4 — gate, não cuidado).
|
|
370
|
+
function render({ kind, content, capability, item, identity: cfg, target, agentModels }) {
|
|
325
371
|
if (kind === 'skills') return normalize(content)
|
|
326
372
|
|
|
327
373
|
const id = item && cfg ? lookupIdentity(cfg, item.id) : undefined
|
|
@@ -407,6 +453,18 @@ function render({ kind, content, capability, item, identity: cfg, target }) {
|
|
|
407
453
|
if (target === 'cursor') {
|
|
408
454
|
const mappedModel = mapModelCursor(parts.model)
|
|
409
455
|
source = mappedModel ? rewriteFrontmatterModelLine(source, mappedModel) : removeFrontmatterModelLine(source)
|
|
456
|
+
} else if (target === 'claude' && agentModels && Object.keys(agentModels).length > 0) {
|
|
457
|
+
// Allowlist: only the "claude" target receives composed model IDs.
|
|
458
|
+
// Codex, Cursor, Antigravity, OpenCode, Gemini, Kiro and any other target are left
|
|
459
|
+
// untouched even when agentModels is populated (ADR-2026-08-21 §4 — gate, not cuidado).
|
|
460
|
+
const version = agentModels[parts.model]
|
|
461
|
+
if (version) {
|
|
462
|
+
const modelID = isVersionString(version)
|
|
463
|
+
? composeClaudeModelID(parts.model, version)
|
|
464
|
+
: version // escape hatch: non-version value used literally (ADR-2026-08-21 §3)
|
|
465
|
+
source = rewriteFrontmatterModelLine(source, modelID)
|
|
466
|
+
}
|
|
467
|
+
// Empty string version means "no pin": leave source unchanged (tier alias preserved).
|
|
410
468
|
}
|
|
411
469
|
if (!id) return normalize(source)
|
|
412
470
|
const withBody = insertBodyPrefix(source, greeting)
|
|
@@ -415,4 +473,60 @@ function render({ kind, content, capability, item, identity: cfg, target }) {
|
|
|
415
473
|
return normalize(withSignature)
|
|
416
474
|
}
|
|
417
475
|
|
|
418
|
-
|
|
476
|
+
// resolveAgentModel returns the model string that render() would write to the
|
|
477
|
+
// model field of an artifact with the given representation and targetID, for
|
|
478
|
+
// an agent with the given tier. Mirrors
|
|
479
|
+
// internal/integrations/models.go:ResolveAgentModel.
|
|
480
|
+
//
|
|
481
|
+
// Returns { resolved: string, present: boolean }.
|
|
482
|
+
// present=false means the artifact format omits the model field entirely — the
|
|
483
|
+
// caller should display "—" or equivalent rather than the tier alias.
|
|
484
|
+
//
|
|
485
|
+
// agentModels applies only for the "claude" target (ADR-2026-08-21 §4).
|
|
486
|
+
function resolveAgentModel(tier, representation, targetID, agentModels) {
|
|
487
|
+
if (representation === 'custom-agent-toml') {
|
|
488
|
+
const v = resolveModelCodex(tier)
|
|
489
|
+
return { resolved: v, present: v !== '' }
|
|
490
|
+
}
|
|
491
|
+
if (representation === 'cli-agent-json' || representation === 'agent-json') {
|
|
492
|
+
return { resolved: '', present: false }
|
|
493
|
+
}
|
|
494
|
+
if (representation === 'agent-directory') {
|
|
495
|
+
const v = resolveModel(tier)
|
|
496
|
+
return { resolved: v, present: v !== '' }
|
|
497
|
+
}
|
|
498
|
+
if (representation === 'opencode-agent') {
|
|
499
|
+
return { resolved: '', present: false }
|
|
500
|
+
}
|
|
501
|
+
// default branch — mirrors render()'s default case
|
|
502
|
+
if (targetID === 'cursor') {
|
|
503
|
+
const v = mapModelCursor(tier)
|
|
504
|
+
return { resolved: v, present: v !== '' }
|
|
505
|
+
}
|
|
506
|
+
if (targetID === 'claude') {
|
|
507
|
+
const am = agentModels || {}
|
|
508
|
+
const version = am[tier]
|
|
509
|
+
if (version) {
|
|
510
|
+
const modelID = isVersionString(version) ? composeClaudeModelID(tier, version) : version
|
|
511
|
+
return { resolved: modelID, present: true }
|
|
512
|
+
}
|
|
513
|
+
// no pin → tier alias unchanged
|
|
514
|
+
}
|
|
515
|
+
return { resolved: tier, present: true }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// looksLikeSuspectModelValue reports whether v is an agent_models value that
|
|
519
|
+
// will trigger the escape-hatch path and likely produce an invalid model
|
|
520
|
+
// identifier in the rendered artifact. Returns true when v is not a bare
|
|
521
|
+
// version string AND does not start with "claude-". Callers should emit a
|
|
522
|
+
// per-tier warning to stderr (not per-row) when this returns true.
|
|
523
|
+
// Mirrors internal/integrations/models.go:LooksLikeSuspectModelValue.
|
|
524
|
+
// looksLikeSuspectModelValue mirrors internal/integrations/models.go:LooksLikeSuspectModelValue.
|
|
525
|
+
// ML-5A: values with control characters are always suspect — rewriteFrontmatterModelLine
|
|
526
|
+
// rejects them outright, so this function must agree with the write path to keep the
|
|
527
|
+
// "agents models" inspection command aligned with "agents install/update" behavior.
|
|
528
|
+
function looksLikeSuspectModelValue(v) {
|
|
529
|
+
return containsControlChar(v) || (!isVersionString(v) && !v.startsWith('claude-'))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
module.exports = { render, markdownParts, frontmatterName, greetingLine, insertBodyPrefix, rewriteFrontmatterFields, rewriteFrontmatterModelLine, rewriteSignatureLine, isVersionString, composeClaudeModelID, resolveAgentModel, looksLikeSuspectModelValue }
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* push/runner.js — Core implementation of `trackfw push`.
|
|
5
|
+
*
|
|
6
|
+
* Empurra commits já criados, sem commitar e sem abrir PR/MR.
|
|
7
|
+
* Reusa os helpers de npm/src/ship/runner.js; nunca reimplementa a lógica de governança,
|
|
8
|
+
* detecção de squash-merge ou construção de push args.
|
|
9
|
+
*
|
|
10
|
+
* See ADR-2026-08-22-comandos-de-entrega-separados-push-proprio-e-ship-como-composicao.md.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
isShipBranch,
|
|
15
|
+
isGatedShipBranch,
|
|
16
|
+
isGitWriteCmd,
|
|
17
|
+
checkShipGovernance,
|
|
18
|
+
detectPendingSquashMerges,
|
|
19
|
+
defaultCheckPROpen,
|
|
20
|
+
buildPushArgs,
|
|
21
|
+
defaultExecGit,
|
|
22
|
+
} = require('../ship/runner')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* defaultCheckGovernance delegates to checkShipGovernance — the same hard gate used by
|
|
26
|
+
* `trackfw ship` and `trackfw commit`.
|
|
27
|
+
* @returns {string[]}
|
|
28
|
+
*/
|
|
29
|
+
function defaultCheckGovernance() {
|
|
30
|
+
return checkShipGovernance()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { resolve: forgeResolve } = require('../forge/resolve')
|
|
34
|
+
const { forgeAdapter } = require('../forge/adapter')
|
|
35
|
+
|
|
36
|
+
// push --force-with-lease refusal messages — "trackfw push" (not "ship") because this command
|
|
37
|
+
// closes the commit→push cycle without opening a PR. See
|
|
38
|
+
// ADR-2026-08-22-comandos-de-entrega-separados-push-proprio-e-ship-como-composicao.md.
|
|
39
|
+
// Defined here (not imported from ship/runner.js) to keep push's user-visible messages
|
|
40
|
+
// independent of ship's contract string.
|
|
41
|
+
const PUSH_FORCE_LEASE_NO_FORGE_CLI_MSG =
|
|
42
|
+
'trackfw push --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.'
|
|
43
|
+
|
|
44
|
+
function pushForceLeaseNoPROpenMsg(branch) {
|
|
45
|
+
return `trackfw push --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.`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pushForceLeaseCannotVerifyMsg(branch, cliName, errMessage) {
|
|
49
|
+
return `trackfw push --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.`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* runPush implements the push sequence: branch validation, governance, force-with-lease gate,
|
|
54
|
+
* squash-merge detection, and push. Never commits and never opens a PR/MR.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ dryRun?: boolean, forceWithLease?: boolean }} opts
|
|
57
|
+
* @param {{
|
|
58
|
+
* execGit?: function,
|
|
59
|
+
* checkGovernance?: function,
|
|
60
|
+
* writeErr?: function,
|
|
61
|
+
* configForge?: string,
|
|
62
|
+
* repoDir?: string,
|
|
63
|
+
* availFn?: function,
|
|
64
|
+
* checkPROpen?: function,
|
|
65
|
+
* }} deps
|
|
66
|
+
* @returns {number} exit code (0 = success)
|
|
67
|
+
*/
|
|
68
|
+
function runPush(opts, deps) {
|
|
69
|
+
opts = opts || {}
|
|
70
|
+
deps = deps || {}
|
|
71
|
+
|
|
72
|
+
const execGit = deps.execGit || defaultExecGit
|
|
73
|
+
const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
|
|
74
|
+
// writeln is injectable (mirrors ship/runner.js) so tests can capture stdout without spawning
|
|
75
|
+
// a real process. Production callers omit it and fall back to process.stdout.
|
|
76
|
+
const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
|
|
77
|
+
// "Error: " prefix — mirrors Go exactly: push.go returns messages as errors, and
|
|
78
|
+
// root.go's ExecuteC path emits `cmd.ErrPrefix() + " " + err.Error() + "\n"` —
|
|
79
|
+
// cmd.ErrPrefix() is "Error:", so the combined line is "Error: <message>\n".
|
|
80
|
+
const writeErr = deps.writeErr || ((s) => process.stderr.write(`Error: ${s}\n`))
|
|
81
|
+
|
|
82
|
+
// Inner wrapper: skips write commands in --dry-run mode.
|
|
83
|
+
function git(args) {
|
|
84
|
+
if (opts.dryRun && isGitWriteCmd(args)) {
|
|
85
|
+
writeln(`[dry-run] git ${args.join(' ')}`)
|
|
86
|
+
return { stdout: '', error: null }
|
|
87
|
+
}
|
|
88
|
+
return execGit(args)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Step 1: Branch validation ─────────────────────────────────────────────
|
|
92
|
+
const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
|
|
93
|
+
if (branchResult.error) {
|
|
94
|
+
writeErr(`could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
|
|
95
|
+
return 1
|
|
96
|
+
}
|
|
97
|
+
const branch = branchResult.stdout.trim()
|
|
98
|
+
|
|
99
|
+
// main/master is blocked unconditionally.
|
|
100
|
+
if (branch === 'main' || branch === 'master') {
|
|
101
|
+
writeErr(`trackfw push cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
|
|
102
|
+
return 1
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!isShipBranch(branch)) {
|
|
106
|
+
writeErr(
|
|
107
|
+
`branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
|
|
108
|
+
'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
|
|
109
|
+
)
|
|
110
|
+
return 1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeln(`Branch: ${branch}`)
|
|
114
|
+
|
|
115
|
+
// ─── Step 2: Governance ────────────────────────────────────────────────────
|
|
116
|
+
// push never reads the index — no doc-only exception. Governance is either
|
|
117
|
+
// skipped (chore/docs) or enforced (feat/fix/refactor), nothing in between.
|
|
118
|
+
if (!isGatedShipBranch(branch)) {
|
|
119
|
+
// chore/docs: housekeeping types already exempted from this gate by
|
|
120
|
+
// `trackfw branch new` and `trackfw commit` — push without it too.
|
|
121
|
+
writeln('Governance: skipped (chore/docs branch)')
|
|
122
|
+
} else {
|
|
123
|
+
const violations = checkGovernanceFn()
|
|
124
|
+
if (violations.length > 0) {
|
|
125
|
+
writeln('\nGovernance check failed:')
|
|
126
|
+
for (const v of violations) {
|
|
127
|
+
writeln(` ${v}`)
|
|
128
|
+
}
|
|
129
|
+
writeln('\nCreate the required artifacts before running push:')
|
|
130
|
+
writeln(' trackfw req new "<title>"')
|
|
131
|
+
writeln(' trackfw roadmap new "<title>"')
|
|
132
|
+
writeln(' trackfw roadmap move <name> wip')
|
|
133
|
+
writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
|
|
134
|
+
writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
|
|
135
|
+
writeln("passes but 'trackfw push' aborts here, you likely have lenient mode")
|
|
136
|
+
writeln("configured — push always requires REQ + roadmap in wip/.")
|
|
137
|
+
writeErr(`governance check failed: ${violations.length} violation(s)`)
|
|
138
|
+
return 1
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
writeln('Governance: OK')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Step 2.5: force-with-lease gate ───────────────────────────────────────
|
|
145
|
+
// Runs before any write (push) — a refusal here never leaves the caller unable to push.
|
|
146
|
+
// Read-only, so it runs in --dry-run too.
|
|
147
|
+
if (opts.forceWithLease) {
|
|
148
|
+
const { stdout: gateRemoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
|
|
149
|
+
const gateRemoteURL = (gateRemoteURLRaw || '').trim()
|
|
150
|
+
|
|
151
|
+
let resolution
|
|
152
|
+
try {
|
|
153
|
+
resolution = forgeResolve({
|
|
154
|
+
flagForge: '',
|
|
155
|
+
configForge: deps.configForge || '',
|
|
156
|
+
remoteURL: gateRemoteURL,
|
|
157
|
+
repoDir: deps.repoDir || '',
|
|
158
|
+
})
|
|
159
|
+
} catch (resErr) {
|
|
160
|
+
writeErr(resErr.message)
|
|
161
|
+
return 1
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const adapter = forgeAdapter(resolution.forge, deps.availFn || undefined)
|
|
165
|
+
if (resolution.forge === 'manual' || !adapter.available) {
|
|
166
|
+
writeErr(PUSH_FORCE_LEASE_NO_FORGE_CLI_MSG)
|
|
167
|
+
return 1
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const checkPROpen = deps.checkPROpen || defaultCheckPROpen
|
|
171
|
+
let open
|
|
172
|
+
try {
|
|
173
|
+
open = checkPROpen(adapter, branch)
|
|
174
|
+
} catch (prErr) {
|
|
175
|
+
writeErr(pushForceLeaseCannotVerifyMsg(branch, adapter.cliName, prErr.message))
|
|
176
|
+
return 1
|
|
177
|
+
}
|
|
178
|
+
if (!open) {
|
|
179
|
+
writeErr(pushForceLeaseNoPROpenMsg(branch))
|
|
180
|
+
return 1
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
writeln(`force-with-lease: open ${adapter.noun} confirmed for "${branch}" (${resolution.forge}).`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Step 3: Squash-merge detection ────────────────────────────────────────
|
|
187
|
+
if (opts.dryRun) {
|
|
188
|
+
writeln('[dry-run] git fetch origin --prune')
|
|
189
|
+
} else {
|
|
190
|
+
const { error: fetchErr } = execGit(['fetch', 'origin', '--prune'])
|
|
191
|
+
if (fetchErr) {
|
|
192
|
+
writeln('Warning: could not fetch origin (offline or no remote); skipping squash-merge check.')
|
|
193
|
+
} else {
|
|
194
|
+
detectPendingSquashMerges(branch, execGit, writeln)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Step 4: Push ──────────────────────────────────────────────────────────
|
|
199
|
+
let pushArgs = buildPushArgs(branch, execGit)
|
|
200
|
+
if (opts.forceWithLease) {
|
|
201
|
+
// Fixed position: push --force-with-lease [-u] origin <branch> — identical
|
|
202
|
+
// across the 3 CLIs (the parity gate compares this literally).
|
|
203
|
+
pushArgs = [pushArgs[0], '--force-with-lease', ...pushArgs.slice(1)]
|
|
204
|
+
}
|
|
205
|
+
const { error: pushErr } = git(pushArgs)
|
|
206
|
+
if (pushErr) {
|
|
207
|
+
writeErr(`git push failed: ${pushErr.message}`)
|
|
208
|
+
return 1
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!opts.dryRun) {
|
|
212
|
+
writeln(`Pushed: ${branch} → origin/${branch}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return 0
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
runPush,
|
|
220
|
+
PUSH_FORCE_LEASE_NO_FORGE_CLI_MSG,
|
|
221
|
+
pushForceLeaseNoPROpenMsg,
|
|
222
|
+
pushForceLeaseCannotVerifyMsg,
|
|
223
|
+
}
|