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.
@@ -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
+ }