trackfw 7.6.0 → 8.0.0-rc2
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/README.md +28 -11
- package/bin/trackfw.js +81 -0
- package/package.json +14 -8
- package/bin/trackfw +0 -41
- package/src/branch/prune.js +0 -450
- package/src/branch/runner.js +0 -153
- package/src/changelog/index.js +0 -114
- package/src/commands/adr.js +0 -56
- package/src/commands/agents.js +0 -3
- package/src/commands/audit-surface.js +0 -520
- package/src/commands/barrier.js +0 -822
- package/src/commands/baseline.js +0 -13
- package/src/commands/branch.js +0 -98
- package/src/commands/changelog.js +0 -29
- package/src/commands/commit.js +0 -66
- package/src/commands/configure.js +0 -106
- package/src/commands/context.js +0 -215
- package/src/commands/discover.js +0 -628
- package/src/commands/doctor.js +0 -66
- package/src/commands/help.js +0 -358
- package/src/commands/identity-wizard.js +0 -258
- package/src/commands/index.js +0 -68
- package/src/commands/init.js +0 -296
- package/src/commands/integrations.js +0 -305
- package/src/commands/log.js +0 -31
- package/src/commands/metrics.js +0 -242
- package/src/commands/note.js +0 -24
- package/src/commands/push.js +0 -51
- package/src/commands/release.js +0 -47
- package/src/commands/req.js +0 -98
- package/src/commands/roadmap.js +0 -44
- package/src/commands/serve.js +0 -345
- package/src/commands/ship.js +0 -58
- package/src/commands/skills.js +0 -3
- package/src/commands/status.js +0 -12
- package/src/commands/sync.js +0 -348
- package/src/commands/thirdparty.js +0 -390
- package/src/commands/update-harness.js +0 -1002
- package/src/commands/update.js +0 -500
- package/src/commands/validate.js +0 -57
- package/src/commands/version.js +0 -10
- package/src/commit/runner.js +0 -352
- package/src/config/index.js +0 -457
- package/src/forge/adapter.js +0 -189
- package/src/forge/resolve.js +0 -191
- package/src/generators/adr.js +0 -198
- package/src/generators/codex.js +0 -79
- package/src/generators/date.js +0 -23
- package/src/generators/hooks.js +0 -2414
- package/src/generators/init.js +0 -1563
- package/src/generators/note.js +0 -154
- package/src/generators/req.js +0 -436
- package/src/generators/roadmap.js +0 -841
- package/src/homedir.js +0 -30
- package/src/i18n/index.js +0 -53
- package/src/i18n/locales/en-US.json +0 -124
- package/src/i18n/locales/es-ES.json +0 -124
- package/src/i18n/locales/pt-BR.json +0 -124
- package/src/identity/config.js +0 -134
- package/src/identity/index.js +0 -19
- package/src/identity/preset.js +0 -219
- package/src/identity/slug.js +0 -62
- package/src/integrations/assets/agents/architect.md +0 -61
- package/src/integrations/assets/agents/backend.md +0 -41
- package/src/integrations/assets/agents/code-quality.md +0 -48
- package/src/integrations/assets/agents/data.md +0 -41
- package/src/integrations/assets/agents/dba.md +0 -41
- package/src/integrations/assets/agents/frontend.md +0 -41
- package/src/integrations/assets/agents/iac.md +0 -46
- package/src/integrations/assets/agents/infra.md +0 -43
- package/src/integrations/assets/agents/qa.md +0 -41
- package/src/integrations/assets/agents/security.md +0 -51
- package/src/integrations/assets/agents/tooling.md +0 -47
- package/src/integrations/assets/agents/ux.md +0 -48
- package/src/integrations/assets/catalog.json +0 -48
- package/src/integrations/assets/skills/architecture.md +0 -66
- package/src/integrations/assets/skills/backend.md +0 -67
- package/src/integrations/assets/skills/code-quality.md +0 -78
- package/src/integrations/assets/skills/data.md +0 -75
- package/src/integrations/assets/skills/dba.md +0 -77
- package/src/integrations/assets/skills/frontend.md +0 -70
- package/src/integrations/assets/skills/governance.md +0 -6
- package/src/integrations/assets/skills/iac.md +0 -90
- package/src/integrations/assets/skills/implement.md +0 -6
- package/src/integrations/assets/skills/infra.md +0 -62
- package/src/integrations/assets/skills/plan.md +0 -6
- package/src/integrations/assets/skills/qa.md +0 -63
- package/src/integrations/assets/skills/release.md +0 -6
- package/src/integrations/assets/skills/review.md +0 -6
- package/src/integrations/assets/skills/security.md +0 -74
- package/src/integrations/assets/skills/tooling.md +0 -80
- package/src/integrations/assets/skills/ux.md +0 -70
- package/src/integrations/catalog.js +0 -58
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/doctor_remote.js +0 -224
- package/src/integrations/index.js +0 -171
- package/src/integrations/legacy.js +0 -35
- package/src/integrations/manager.js +0 -438
- package/src/integrations/register-agent.js +0 -106
- package/src/integrations/render.js +0 -563
- package/src/integrations/scaffold_doctor.js +0 -395
- package/src/lib/fatal-error.js +0 -64
- package/src/lib/pathfmt.js +0 -43
- package/src/lib/unknown-command.js +0 -82
- package/src/lib/update-engine.js +0 -242
- package/src/push/runner.js +0 -226
- package/src/release/runner.js +0 -474
- package/src/serve/api_attention.js +0 -24
- package/src/serve/api_board.js +0 -105
- package/src/serve/api_chain.js +0 -246
- package/src/serve/api_file.js +0 -117
- package/src/serve/api_metrics.js +0 -139
- package/src/serve/static/app.js +0 -1151
- package/src/serve/static/index.html +0 -338
- package/src/serve/static/style.css +0 -297
- package/src/ship/runner.js +0 -775
- package/src/thirdparty/fetch.js +0 -140
- package/src/thirdparty/markers.js +0 -207
- package/src/thirdparty/provenance.js +0 -114
- package/src/thirdparty/quarantine.js +0 -113
- package/src/thirdparty/references.js +0 -194
- package/src/validator/git-exec.js +0 -72
- package/src/validator/index.js +0 -4086
- package/src/validator/traceid.js +0 -155
package/src/branch/prune.js
DELETED
|
@@ -1,450 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* branch/prune.js — Core implementation of `trackfw branch prune`.
|
|
5
|
-
*
|
|
6
|
-
* Mirrors internal/commands/branch_prune.go byte-for-byte in behavior and message text — Go is
|
|
7
|
-
* the behavioral reference (docs/cli-parity.md: "Go is the behavioral reference").
|
|
8
|
-
*
|
|
9
|
-
* Decides whether a local branch is safe to delete using the touched-files heuristic documented
|
|
10
|
-
* in CLAUDE.md §1 and REQ-2026-08-18-trackfw-branch-prune-apaga-branch-local-ja-integrada-com-
|
|
11
|
-
* deteccao-correta-de-squash-merge.md — NOT git's own ancestry check (`git branch -d`, which
|
|
12
|
-
* always refuses squash-merged branches) and NOT a naive bidirectional diff against origin/main
|
|
13
|
-
* (which false-positives on a branch that is merged but stale, once main has advanced further).
|
|
14
|
-
*
|
|
15
|
-
* evaluateBranchIntegration is the single shared decision function — ML-2A
|
|
16
|
-
* (npm/src/ship/runner.js's detectPendingSquashMerges) is expected to call it instead of its own
|
|
17
|
-
* bidirectional diff.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
const { spawnSync } = require('child_process')
|
|
21
|
-
|
|
22
|
-
// The only source of truth this command consults: the local tracking ref for the default branch.
|
|
23
|
-
// Per REQ-2026-08-18 decision 2, there is no forge lookup and no network call — offline and
|
|
24
|
-
// deterministic by construction. If this ref cannot be resolved (no remote configured, or never
|
|
25
|
-
// fetched), the whole command refuses and deletes nothing.
|
|
26
|
-
const DEFAULT_REMOTE_REF = 'origin/main'
|
|
27
|
-
|
|
28
|
-
// The local branch name matching DEFAULT_REMOTE_REF. Always excluded as a prune candidate —
|
|
29
|
-
// evaluating it against itself would report "no own work" and offer to delete the branch the user
|
|
30
|
-
// is meant to keep (merge-base origin/main main == main's own tip, so "touched" is trivially
|
|
31
|
-
// empty). This is the highest-severity bug the naive heuristic contains.
|
|
32
|
-
const DEFAULT_LOCAL_NAME = 'main'
|
|
33
|
-
|
|
34
|
-
const DECISION = {
|
|
35
|
-
DEFAULT_BRANCH: 'default_branch',
|
|
36
|
-
CURRENT_BRANCH: 'current_branch',
|
|
37
|
-
WORKTREE: 'worktree_branch',
|
|
38
|
-
NO_OWN_WORK: 'no_own_work',
|
|
39
|
-
IDENTICAL: 'content_identical',
|
|
40
|
-
PENDING_WORK: 'pending_work',
|
|
41
|
-
NO_MERGE_BASE: 'no_merge_base',
|
|
42
|
-
EVAL_ERROR: 'eval_error',
|
|
43
|
-
// REVIEW_DOC_CONFIG is a NON-deletable category, distinct from PENDING_WORK: every diverging
|
|
44
|
-
// file is doc/config (isDocOrConfigPath), so it is probably housekeeping residue from a
|
|
45
|
-
// squash-merge rather than genuine pending work — but it is never auto-deleted. CLAUDE.md §1's
|
|
46
|
-
// own procedure treats this case as "housekeeping, apagar" but with a human already in the
|
|
47
|
-
// loop reading the diff; a destructive command has no human in the loop by construction, so
|
|
48
|
-
// REQ-2026-08-18 (ML-1B) deliberately narrows that step to "flag for confirmation" instead of
|
|
49
|
-
// "delete automatically". See isDeletable below.
|
|
50
|
-
REVIEW_DOC_CONFIG: 'review_doc_config',
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// DOC_CONFIG_EXTENSIONS/DOC_CONFIG_BASENAMES mirror internal/commands/branch_prune.go's
|
|
54
|
-
// branchPruneDocConfigExtensions/branchPruneDocConfigBasenames — deliberately conservative and
|
|
55
|
-
// best-effort. Misclassifying a file here never causes a deletion: it only changes which
|
|
56
|
-
// non-deletable category (REVIEW_DOC_CONFIG vs PENDING_WORK) a kept branch is reported under.
|
|
57
|
-
const DOC_CONFIG_EXTENSIONS = ['.yaml', '.yml', '.json', '.toml', '.ini', '.cfg']
|
|
58
|
-
const DOC_CONFIG_BASENAMES = new Set(['.gitignore', '.gitattributes', '.editorconfig', 'trackfw.yaml', 'LICENSE'])
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* isDocsFile reports whether path lives under docs/ or vault/, or has a .md extension. Mirrors
|
|
62
|
-
* internal/commands/commit.go's isDocsFile (same package there; standalone here).
|
|
63
|
-
* @param {string} path
|
|
64
|
-
* @returns {boolean}
|
|
65
|
-
*/
|
|
66
|
-
function isDocsFile(path) {
|
|
67
|
-
if (path.startsWith('docs/') || path.startsWith('vault/')) return true
|
|
68
|
-
return path.endsWith('.md')
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* isDocOrConfigPath reports whether path is a doc file (isDocsFile) or a well-known non-runtime
|
|
73
|
-
* config file/extension. Used only to route an otherwise PENDING_WORK branch into the
|
|
74
|
-
* REVIEW_DOC_CONFIG category — never to make anything deletable.
|
|
75
|
-
* @param {string} path
|
|
76
|
-
* @returns {boolean}
|
|
77
|
-
*/
|
|
78
|
-
function isDocOrConfigPath(path) {
|
|
79
|
-
if (isDocsFile(path)) return true
|
|
80
|
-
const base = path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path
|
|
81
|
-
if (DOC_CONFIG_BASENAMES.has(base)) return true
|
|
82
|
-
return DOC_CONFIG_EXTENSIONS.some((ext) => path.endsWith(ext))
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* allDocOrConfig reports whether every path in paths is doc/config (isDocOrConfigPath). Empty
|
|
87
|
-
* input returns false.
|
|
88
|
-
* @param {string[]} paths
|
|
89
|
-
* @returns {boolean}
|
|
90
|
-
*/
|
|
91
|
-
function allDocOrConfig(paths) {
|
|
92
|
-
if (paths.length === 0) return false
|
|
93
|
-
return paths.every(isDocOrConfigPath)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* isDeletable reports whether decision, on its own, makes a branch a deletion candidate. Both
|
|
98
|
-
* no_own_work (squash-merge with no ancestry — the `git branch -d` false negative) and
|
|
99
|
-
* content_identical (defasada porém integrada — the naive `git diff` false positive) are safe to
|
|
100
|
-
* delete; every other decision keeps the branch.
|
|
101
|
-
* @param {string} decision
|
|
102
|
-
* @returns {boolean}
|
|
103
|
-
*/
|
|
104
|
-
function isDeletable(decision) {
|
|
105
|
-
return decision === DECISION.NO_OWN_WORK || decision === DECISION.IDENTICAL
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* defaultExecGit runs `git <args...>` and returns { stdout, error }. stdout is trimmed the same
|
|
110
|
-
* way internal/commands/ship.go's defaultGitExec trims via strings.TrimSpace — NUL bytes (used as
|
|
111
|
-
* the -z separator below) are not whitespace and are never stripped.
|
|
112
|
-
* @param {string[]} args
|
|
113
|
-
* @returns {{ stdout: string, error: Error|null }}
|
|
114
|
-
*/
|
|
115
|
-
function defaultExecGit(args) {
|
|
116
|
-
const result = spawnSync('git', args, { encoding: 'utf8' })
|
|
117
|
-
if (result.status !== 0) {
|
|
118
|
-
const msg = (result.stderr || '').trim() || `git ${args.join(' ')} exited with ${result.status}`
|
|
119
|
-
return { stdout: '', error: new Error(msg) }
|
|
120
|
-
}
|
|
121
|
-
return { stdout: (result.stdout || '').trim(), error: null }
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* splitNulPaths splits a NUL-separated `git diff --name-only -z` output into a sorted, non-empty
|
|
126
|
-
* path list. A trailing NUL (git always emits one after the last entry) produces one empty
|
|
127
|
-
* trailing element, which is dropped.
|
|
128
|
-
* @param {string} raw
|
|
129
|
-
* @returns {string[]}
|
|
130
|
-
*/
|
|
131
|
-
function splitNulPaths(raw) {
|
|
132
|
-
return raw.split('\x00').filter((p) => p !== '').sort()
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* evaluateBranchIntegration decides whether branch is safe to delete relative to
|
|
137
|
-
* DEFAULT_REMOTE_REF, using the touched-files heuristic:
|
|
138
|
-
*
|
|
139
|
-
* mb = git merge-base origin/main <branch>
|
|
140
|
-
* touched = git diff --name-only mb <branch> (what the branch touched)
|
|
141
|
-
* diverg = git diff --name-only origin/main <branch> -- touched (what still differs there)
|
|
142
|
-
*
|
|
143
|
-
* touched empty -> no_own_work (deletable) -- the squash-merge / -d false negative
|
|
144
|
-
* diverg empty -> content_identical (deletable) -- the naive-diff false positive (stale
|
|
145
|
-
* branch, main advanced by other PRs)
|
|
146
|
-
* diverg non-empty -> pending_work (kept, explained)
|
|
147
|
-
*
|
|
148
|
-
* Both diff calls use -z (NUL-separated, unquoted paths) so filenames with spaces or non-ASCII
|
|
149
|
-
* bytes are never mis-split — the exact class of bug that would make a branch with pending work in
|
|
150
|
-
* "foo bar.md" read as an empty diverg and get deleted.
|
|
151
|
-
*
|
|
152
|
-
* @param {string} branch
|
|
153
|
-
* @param {function(string[]): {stdout: string, error: Error|null}} execGit
|
|
154
|
-
* @returns {{ name: string, decision: string, reason: string, touched: string[], diverged: string[] }}
|
|
155
|
-
*/
|
|
156
|
-
function evaluateBranchIntegration(branch, execGit) {
|
|
157
|
-
const mbResult = execGit(['merge-base', DEFAULT_REMOTE_REF, branch])
|
|
158
|
-
const mb = (mbResult.stdout || '').trim()
|
|
159
|
-
if (mbResult.error || mb === '') {
|
|
160
|
-
return {
|
|
161
|
-
name: branch,
|
|
162
|
-
decision: DECISION.NO_MERGE_BASE,
|
|
163
|
-
reason: `no merge-base with ${DEFAULT_REMOTE_REF} — refusing (unrelated history or bad ref)`,
|
|
164
|
-
touched: [],
|
|
165
|
-
diverged: [],
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const touchedResult = execGit(['diff', '--name-only', '-z', mb, branch])
|
|
170
|
-
if (touchedResult.error) {
|
|
171
|
-
return {
|
|
172
|
-
name: branch,
|
|
173
|
-
decision: DECISION.EVAL_ERROR,
|
|
174
|
-
reason: `git diff --name-only -z ${mb} ${branch} failed: ${touchedResult.error.message}`,
|
|
175
|
-
touched: [],
|
|
176
|
-
diverged: [],
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
const touched = splitNulPaths(touchedResult.stdout)
|
|
180
|
-
|
|
181
|
-
if (touched.length === 0) {
|
|
182
|
-
return {
|
|
183
|
-
name: branch,
|
|
184
|
-
decision: DECISION.NO_OWN_WORK,
|
|
185
|
-
reason: `no own work relative to ${DEFAULT_REMOTE_REF} — safe to delete`,
|
|
186
|
-
touched: [],
|
|
187
|
-
diverged: [],
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const divergResult = execGit(['diff', '--name-only', '-z', DEFAULT_REMOTE_REF, branch, '--', ...touched])
|
|
192
|
-
if (divergResult.error) {
|
|
193
|
-
return {
|
|
194
|
-
name: branch,
|
|
195
|
-
decision: DECISION.EVAL_ERROR,
|
|
196
|
-
reason: `git diff --name-only -z ${DEFAULT_REMOTE_REF} ${branch} -- <touched> failed: ${divergResult.error.message}`,
|
|
197
|
-
touched,
|
|
198
|
-
diverged: [],
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
const diverg = splitNulPaths(divergResult.stdout)
|
|
202
|
-
|
|
203
|
-
if (diverg.length === 0) {
|
|
204
|
-
return {
|
|
205
|
-
name: branch,
|
|
206
|
-
decision: DECISION.IDENTICAL,
|
|
207
|
-
reason: `squash-merged into ${DEFAULT_REMOTE_REF} — content identical in touched files, safe to delete`,
|
|
208
|
-
touched,
|
|
209
|
-
diverged: [],
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// review_doc_config requires diverg to be a PROPER subset of touched (diverg.length <
|
|
214
|
-
// touched.length) — not just "all doc/config". diverg is always a subset of touched by
|
|
215
|
-
// construction (the second git diff is scoped `-- touched`), so this is the same as: at
|
|
216
|
-
// least one touched file made it into main. That distinguishes genuine housekeeping residue
|
|
217
|
-
// from a squash-merge from a branch whose ENTIRE touched set — doc/config or not — never
|
|
218
|
-
// reached main (diverg == touched): that is pending work, regardless of file type.
|
|
219
|
-
if (diverg.length < touched.length && allDocOrConfig(diverg)) {
|
|
220
|
-
return {
|
|
221
|
-
name: branch,
|
|
222
|
-
decision: DECISION.REVIEW_DOC_CONFIG,
|
|
223
|
-
reason: `only doc/config files diverge from ${DEFAULT_REMOTE_REF} (${diverg.join(', ')}) — probable housekeeping, confirm and delete manually`,
|
|
224
|
-
touched,
|
|
225
|
-
diverged: diverg,
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return {
|
|
230
|
-
name: branch,
|
|
231
|
-
decision: DECISION.PENDING_WORK,
|
|
232
|
-
reason: `pending work vs ${DEFAULT_REMOTE_REF}: ${diverg.join(', ')}`,
|
|
233
|
-
touched,
|
|
234
|
-
diverged: diverg,
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* defaultListLocalBranches runs `git branch --format=%(refname:short)` and returns one name per
|
|
240
|
-
* non-empty line.
|
|
241
|
-
* @param {function(string[]): {stdout: string, error: Error|null}} execGit
|
|
242
|
-
* @returns {{ branches: string[], error: Error|null }}
|
|
243
|
-
*/
|
|
244
|
-
function defaultListLocalBranches(execGit) {
|
|
245
|
-
const result = execGit(['branch', '--format=%(refname:short)'])
|
|
246
|
-
if (result.error) return { branches: [], error: result.error }
|
|
247
|
-
const branches = (result.stdout || '')
|
|
248
|
-
.split('\n')
|
|
249
|
-
.map((l) => l.trim())
|
|
250
|
-
.filter((l) => l !== '')
|
|
251
|
-
return { branches, error: null }
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* defaultCurrentBranch returns the checked-out branch's short name, or '' on detached HEAD.
|
|
256
|
-
* @param {function(string[]): {stdout: string, error: Error|null}} execGit
|
|
257
|
-
* @returns {string}
|
|
258
|
-
*/
|
|
259
|
-
function defaultCurrentBranch(execGit) {
|
|
260
|
-
const result = execGit(['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
|
261
|
-
if (result.error) return ''
|
|
262
|
-
return (result.stdout || '').trim()
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* defaultWorktreeBranches parses `git worktree list --porcelain` and returns the set of branch
|
|
267
|
-
* short names checked out in any worktree. Uses the porcelain "branch refs/heads/<name>" line, not
|
|
268
|
-
* the human-readable format.
|
|
269
|
-
* @param {function(string[]): {stdout: string, error: Error|null}} execGit
|
|
270
|
-
* @returns {Set<string>}
|
|
271
|
-
*/
|
|
272
|
-
function defaultWorktreeBranches(execGit) {
|
|
273
|
-
const result = execGit(['worktree', 'list', '--porcelain'])
|
|
274
|
-
const set = new Set()
|
|
275
|
-
if (result.error) return set
|
|
276
|
-
const prefix = 'branch refs/heads/'
|
|
277
|
-
for (const rawLine of (result.stdout || '').split('\n')) {
|
|
278
|
-
const line = rawLine.trim()
|
|
279
|
-
if (line.startsWith(prefix)) {
|
|
280
|
-
set.add(line.slice(prefix.length))
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
return set
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* defaultDeleteBranch tries `git branch -d <name>` first. When the branch happens to have
|
|
288
|
-
* fast-forward ancestry with main too (a plain merge, not a squash), -d succeeds and confirms the
|
|
289
|
-
* integration via git's own independent check — no need for -D at all. Falls back to
|
|
290
|
-
* `git branch -D <name>` only when -d refuses, the expected outcome for squash-merged branches
|
|
291
|
-
* (no ancestry by construction): all safety already lives in evaluateBranchIntegration and the
|
|
292
|
-
* re-check immediately before this call, not in which flag performs the deletion.
|
|
293
|
-
* @param {function(string[]): {stdout: string, error: Error|null}} execGit
|
|
294
|
-
* @param {string} name
|
|
295
|
-
* @returns {Error|null}
|
|
296
|
-
*/
|
|
297
|
-
function defaultDeleteBranch(execGit, name) {
|
|
298
|
-
const dResult = execGit(['branch', '-d', name])
|
|
299
|
-
if (!dResult.error) return null
|
|
300
|
-
return execGit(['branch', '-D', name]).error
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* runBranchPrune implements `trackfw branch prune`.
|
|
305
|
-
*
|
|
306
|
-
* --dry-run is the default (`apply === false`): without --apply, nothing is ever deleted, even
|
|
307
|
-
* the clearly integrated. The current branch, any branch checked out in another worktree, and the
|
|
308
|
-
* default branch (main) are always kept and never evaluated for deletion. Without origin/main
|
|
309
|
-
* resolvable (offline, no remote, never fetched), the whole command refuses and deletes nothing.
|
|
310
|
-
*
|
|
311
|
-
* @param {boolean} apply
|
|
312
|
-
* @param {{ execGit?: function, listLocalBranches?: function, currentBranch?: function,
|
|
313
|
-
* worktreeBranches?: function, deleteBranch?: function, writeln?: function,
|
|
314
|
-
* writeErr?: function }} deps
|
|
315
|
-
* @returns {number} exit code (0 = ran to completion, 1 = origin/main unresolvable)
|
|
316
|
-
*/
|
|
317
|
-
function runBranchPrune(apply, deps = {}) {
|
|
318
|
-
const execGit = deps.execGit || defaultExecGit
|
|
319
|
-
const listLocalBranches = deps.listLocalBranches || defaultListLocalBranches
|
|
320
|
-
const currentBranchFn = deps.currentBranch || defaultCurrentBranch
|
|
321
|
-
const worktreeBranchesFn = deps.worktreeBranches || defaultWorktreeBranches
|
|
322
|
-
const deleteBranch = deps.deleteBranch || defaultDeleteBranch
|
|
323
|
-
const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
|
|
324
|
-
const writeErr = deps.writeErr || ((s) => process.stderr.write(s + '\n'))
|
|
325
|
-
|
|
326
|
-
// Best-effort `git fetch origin --prune`, per CLAUDE.md §1 step 1. Failure is non-blocking —
|
|
327
|
-
// offline is a legitimate use case, the same posture `trackfw ship`'s squash-merge check
|
|
328
|
-
// already takes (ship/runner.js) — but unlike ship (which skips its check entirely on fetch
|
|
329
|
-
// failure), evaluation below still proceeds against whatever origin/main ref is already
|
|
330
|
-
// resolvable locally: a stale ref only ever makes the result MORE conservative, never less.
|
|
331
|
-
const fetchResult = execGit(['fetch', 'origin', '--prune'])
|
|
332
|
-
if (fetchResult.error) {
|
|
333
|
-
writeln(
|
|
334
|
-
'Warning: could not fetch origin (offline, no remote, or fetch failed) — evaluating with possibly stale data; a branch merged upstream since the last fetch may still be reported as pending.'
|
|
335
|
-
)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const originCheck = execGit(['rev-parse', '--verify', '-q', DEFAULT_REMOTE_REF])
|
|
339
|
-
if (originCheck.error) {
|
|
340
|
-
writeln(
|
|
341
|
-
`trackfw branch prune: ${DEFAULT_REMOTE_REF} not found — offline, no remote configured, or never fetched. Refusing to evaluate any branch; nothing deleted.`
|
|
342
|
-
)
|
|
343
|
-
// Mirrors internal/commands/branch_prune.go: the human-readable message goes to stdout
|
|
344
|
-
// above, and the bare error (matching Go's cmd.SilenceErrors, no "Error: " prefix,
|
|
345
|
-
// propagated once by Execute()) goes to stderr — same split branch new already uses.
|
|
346
|
-
writeErr(`branch prune: ${DEFAULT_REMOTE_REF} not resolvable`)
|
|
347
|
-
return 1
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const { branches: rawBranches, error: listErr } = listLocalBranches(execGit)
|
|
351
|
-
if (listErr) {
|
|
352
|
-
writeln(`trackfw branch prune: failed to list local branches: ${listErr.message}`)
|
|
353
|
-
// Mirrors Go: returns the raw error, propagated bare by Execute() — not a re-wrapped string.
|
|
354
|
-
writeErr(listErr.message)
|
|
355
|
-
return 1
|
|
356
|
-
}
|
|
357
|
-
const branches = [...rawBranches].sort()
|
|
358
|
-
|
|
359
|
-
const current = currentBranchFn(execGit)
|
|
360
|
-
const worktreed = worktreeBranchesFn(execGit)
|
|
361
|
-
|
|
362
|
-
writeln(`trackfw branch prune — evaluating ${branches.length} local branch(es) against ${DEFAULT_REMOTE_REF}\n`)
|
|
363
|
-
|
|
364
|
-
const toDelete = []
|
|
365
|
-
const toReview = []
|
|
366
|
-
for (const b of branches) {
|
|
367
|
-
let evalResult
|
|
368
|
-
if (b === DEFAULT_LOCAL_NAME) {
|
|
369
|
-
evalResult = { name: b, decision: DECISION.DEFAULT_BRANCH, reason: 'default branch — never pruned' }
|
|
370
|
-
} else if (b === current) {
|
|
371
|
-
evalResult = { name: b, decision: DECISION.CURRENT_BRANCH, reason: 'current branch — never pruned' }
|
|
372
|
-
} else if (worktreed.has(b)) {
|
|
373
|
-
evalResult = { name: b, decision: DECISION.WORKTREE, reason: 'checked out in another worktree — never pruned' }
|
|
374
|
-
} else {
|
|
375
|
-
evalResult = evaluateBranchIntegration(b, execGit)
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
let action = 'keep'
|
|
379
|
-
if (isDeletable(evalResult.decision)) {
|
|
380
|
-
action = 'delete'
|
|
381
|
-
toDelete.push(b)
|
|
382
|
-
} else if (evalResult.decision === DECISION.REVIEW_DOC_CONFIG) {
|
|
383
|
-
action = 'review'
|
|
384
|
-
toReview.push(b)
|
|
385
|
-
}
|
|
386
|
-
writeln(` ${evalResult.name.padEnd(30)} ${action.padEnd(7)} ${evalResult.reason}`)
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
writeln('')
|
|
390
|
-
if (toReview.length > 0) {
|
|
391
|
-
writeln(`${toReview.length} branch(es) need manual review (only doc/config diverges, never auto-deleted): ${toReview.join(', ')}`)
|
|
392
|
-
}
|
|
393
|
-
if (!apply) {
|
|
394
|
-
if (toDelete.length === 0) {
|
|
395
|
-
writeln('[dry-run] nothing to delete.')
|
|
396
|
-
} else {
|
|
397
|
-
writeln(`[dry-run] would delete ${toDelete.length} branch(es): ${toDelete.join(', ')}. Rerun with --apply to delete.`)
|
|
398
|
-
}
|
|
399
|
-
return 0
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (toDelete.length === 0) {
|
|
403
|
-
writeln('nothing to delete.')
|
|
404
|
-
return 0
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const deleted = []
|
|
408
|
-
for (const b of toDelete) {
|
|
409
|
-
// Re-check current/worktree status immediately before each delete — belt-and-suspenders
|
|
410
|
-
// against the branch changing state between the report above and this loop.
|
|
411
|
-
if (b === currentBranchFn(execGit)) {
|
|
412
|
-
writeln(`skip ${b}: became the current branch — refusing to delete`)
|
|
413
|
-
continue
|
|
414
|
-
}
|
|
415
|
-
if (worktreeBranchesFn(execGit).has(b)) {
|
|
416
|
-
writeln(`skip ${b}: became checked out in a worktree — refusing to delete`)
|
|
417
|
-
continue
|
|
418
|
-
}
|
|
419
|
-
const delErr = deleteBranch(execGit, b)
|
|
420
|
-
if (delErr) {
|
|
421
|
-
writeln(`failed to delete ${b}: ${delErr.message}`)
|
|
422
|
-
continue
|
|
423
|
-
}
|
|
424
|
-
deleted.push(b)
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
if (deleted.length === 0) {
|
|
428
|
-
writeln('deleted 0 branch(es).')
|
|
429
|
-
} else {
|
|
430
|
-
writeln(`deleted ${deleted.length} branch(es): ${deleted.join(', ')}`)
|
|
431
|
-
}
|
|
432
|
-
return 0
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
module.exports = {
|
|
436
|
-
DEFAULT_REMOTE_REF,
|
|
437
|
-
DEFAULT_LOCAL_NAME,
|
|
438
|
-
DECISION,
|
|
439
|
-
isDeletable,
|
|
440
|
-
isDocOrConfigPath,
|
|
441
|
-
allDocOrConfig,
|
|
442
|
-
splitNulPaths,
|
|
443
|
-
evaluateBranchIntegration,
|
|
444
|
-
defaultExecGit,
|
|
445
|
-
defaultListLocalBranches,
|
|
446
|
-
defaultCurrentBranch,
|
|
447
|
-
defaultWorktreeBranches,
|
|
448
|
-
defaultDeleteBranch,
|
|
449
|
-
runBranchPrune,
|
|
450
|
-
}
|
package/src/branch/runner.js
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* branch/runner.js — Core implementation of `trackfw branch new`.
|
|
5
|
-
*
|
|
6
|
-
* Moves the branch_has_wip_roadmap governance gate (already enforced by `trackfw validate` and
|
|
7
|
-
* `trackfw ship`) to before branch creation, instead of after. Mirrors internal/commands/branch.go
|
|
8
|
-
* byte-for-byte in behavior and message text — Go is the behavioral reference
|
|
9
|
-
* (docs/cli-parity.md: "Go is the behavioral reference").
|
|
10
|
-
*
|
|
11
|
-
* All git write operations are injectable for testability — never runs a real `git checkout -b`
|
|
12
|
-
* in unit tests.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const { spawnSync } = require('child_process')
|
|
16
|
-
const config = require('../config')
|
|
17
|
-
const validator = require('../validator')
|
|
18
|
-
|
|
19
|
-
// branchValidTypes is the full vocabulary accepted by `trackfw branch new`. feat/fix/refactor are
|
|
20
|
-
// gated on a matching REQ + roadmap already in wip/ or done/ (branchGatedTypes below); chore/docs
|
|
21
|
-
// are housekeeping types — already treated as roadmap-exempt by `trackfw ship` and `trackfw
|
|
22
|
-
// commit` — and create the branch without that gate.
|
|
23
|
-
const branchValidTypes = new Set(['feat', 'fix', 'refactor', 'chore', 'docs'])
|
|
24
|
-
|
|
25
|
-
// branchGatedTypes is the subset of branchValidTypes that requires a matching REQ + roadmap
|
|
26
|
-
// already in wip/ or done/ before the branch is created. Keep this in sync with the pattern
|
|
27
|
-
// `trackfw ship`/`trackfw commit` use to decide when the branch_has_wip_roadmap gate applies.
|
|
28
|
-
const branchGatedTypes = new Set(['feat', 'fix', 'refactor'])
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* parseBranchSpec splits "<type>/<slug>" and validates both parts. type must be one of
|
|
32
|
-
* feat, fix, refactor, chore, docs (branchValidTypes); slug must be non-empty.
|
|
33
|
-
* @param {string} spec
|
|
34
|
-
* @returns {{ branchType: string, slug: string }}
|
|
35
|
-
* @throws {Error} when spec is malformed
|
|
36
|
-
*/
|
|
37
|
-
function parseBranchSpec(spec) {
|
|
38
|
-
const value = String(spec)
|
|
39
|
-
const idx = value.indexOf('/')
|
|
40
|
-
if (idx === -1 || value.slice(0, idx) === '') {
|
|
41
|
-
throw new Error(`invalid branch spec "${spec}" — expected <type>/<slug> with type in feat, fix, refactor, chore, docs`)
|
|
42
|
-
}
|
|
43
|
-
const branchType = value.slice(0, idx)
|
|
44
|
-
const slug = value.slice(idx + 1)
|
|
45
|
-
if (!branchValidTypes.has(branchType)) {
|
|
46
|
-
throw new Error(`invalid branch type "${branchType}" — must be one of feat, fix, refactor, chore, docs`)
|
|
47
|
-
}
|
|
48
|
-
if (slug.trim() === '') {
|
|
49
|
-
throw new Error(`branch slug is required — expected <type>/<slug>, got "${spec}"`)
|
|
50
|
-
}
|
|
51
|
-
return { branchType, slug }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* defaultGitCheckout runs `git checkout -b <branchName>` with inherited stdio, so Git's own output
|
|
56
|
-
* (including branch-already-exists errors) reaches the user unmodified.
|
|
57
|
-
*
|
|
58
|
-
* Returns Git's own numeric exit code (0 on success), never a generic 1 — this command's contract
|
|
59
|
-
* is to propagate Git's exit status literally (see internal/commands/branch.go's
|
|
60
|
-
* defaultGitCheckout, the behavioral reference: it exits the process directly with
|
|
61
|
-
* exitErr.ExitCode() rather than a hardcoded 1). A spawn failure (e.g. the git binary is missing)
|
|
62
|
-
* or a signal-terminated process has no Git-produced numeric code to propagate, so both fall back
|
|
63
|
-
* to 1 — the same convention already used for signal-killed gate commands
|
|
64
|
-
* (docs/cli-parity.md, `trackfw barrier`, "Gate process terminated by a signal ... Recorded as exit 1").
|
|
65
|
-
* @param {string} branchName
|
|
66
|
-
* @returns {number} exit code
|
|
67
|
-
*/
|
|
68
|
-
function defaultGitCheckout(branchName) {
|
|
69
|
-
const result = spawnSync('git', ['checkout', '-b', branchName], { stdio: 'inherit' })
|
|
70
|
-
if (result.error) return 1
|
|
71
|
-
if (result.status === null) return 1
|
|
72
|
-
return result.status
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* runBranchNew implements the `trackfw branch new <type>/<slug>` flow described in
|
|
77
|
-
* docs/req/REQ-2026-08-04-comando-trackfw-branch-new-para-bloquear-criacao-de-branch-sem-req-roadmap-em-wip.md.
|
|
78
|
-
*
|
|
79
|
-
* Mirrors Go's stdout/stderr split exactly (internal/commands/branch.go + root.go Execute()):
|
|
80
|
-
* the primary governance/dry-run message goes to stdout via `writeln`; usage errors and the
|
|
81
|
-
* "blocked: ..." wrapper (Go's `Execute()` printing the returned error) go to stderr via
|
|
82
|
-
* `writeErr`. Git's own output is inherited directly by the child process and is never touched
|
|
83
|
-
* here.
|
|
84
|
-
*
|
|
85
|
-
* @param {string} spec "<type>/<slug>"
|
|
86
|
-
* @param {boolean} dryRun
|
|
87
|
-
* @param {{ loadConfig?: function, resolveWIPDirs?: function, resolveDoneDirs?: function,
|
|
88
|
-
* matchSlug?: function(string, string[], string[]): {matched: boolean, candidates: string[]},
|
|
89
|
-
* execGitCheckout?: function(string): number, writeln?: function(string): void,
|
|
90
|
-
* writeErr?: function(string): void }} deps
|
|
91
|
-
* @returns {number} exit code (0 = success, 1 = blocked/usage error, or Git's own exit code when
|
|
92
|
-
* `git checkout -b` fails — propagated literally, never hardcoded)
|
|
93
|
-
*/
|
|
94
|
-
function runBranchNew(spec, dryRun, deps = {}) {
|
|
95
|
-
const loadConfig = deps.loadConfig || config.load
|
|
96
|
-
const resolveWIPDirs = deps.resolveWIPDirs || validator.resolveWIPDirs
|
|
97
|
-
const resolveDoneDirs = deps.resolveDoneDirs || validator.resolveDoneDirs
|
|
98
|
-
const matchSlug = deps.matchSlug || validator.branchSlugMatchesRoadmap
|
|
99
|
-
const execGitCheckout = deps.execGitCheckout || defaultGitCheckout
|
|
100
|
-
const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
|
|
101
|
-
const writeErr = deps.writeErr || ((s) => process.stderr.write(s + '\n'))
|
|
102
|
-
|
|
103
|
-
let branchType, slug
|
|
104
|
-
try {
|
|
105
|
-
({ branchType, slug } = parseBranchSpec(spec))
|
|
106
|
-
} catch (err) {
|
|
107
|
-
writeErr(err.message)
|
|
108
|
-
return 1
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const branchName = `${branchType}/${slug}`
|
|
112
|
-
|
|
113
|
-
// chore/docs are housekeeping types — already treated as roadmap-exempt by `trackfw ship`
|
|
114
|
-
// and `trackfw commit` — so the branch_has_wip_roadmap gate below does not apply to them.
|
|
115
|
-
if (branchGatedTypes.has(branchType)) {
|
|
116
|
-
const cfg = loadConfig()
|
|
117
|
-
const wipDirs = resolveWIPDirs(cfg)
|
|
118
|
-
const doneDirs = resolveDoneDirs(cfg)
|
|
119
|
-
|
|
120
|
-
const normalizedSlug = validator.normalizeBranchSlug(slug)
|
|
121
|
-
const { matched, candidates } = matchSlug(normalizedSlug, wipDirs, doneDirs)
|
|
122
|
-
|
|
123
|
-
if (!matched) {
|
|
124
|
-
const msg = (!candidates || candidates.length === 0)
|
|
125
|
-
? validator.branchGovernanceOrientation(branchName)
|
|
126
|
-
: validator.branchNoMatchingRoadmapMessage(branchName, candidates)
|
|
127
|
-
if (dryRun) {
|
|
128
|
-
writeln(`[dry-run] would block: ${msg}`)
|
|
129
|
-
} else {
|
|
130
|
-
writeln(msg)
|
|
131
|
-
}
|
|
132
|
-
writeErr(`blocked: no matching roadmap in wip/ nor done/ for "${branchName}"`)
|
|
133
|
-
return 1
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (dryRun) {
|
|
138
|
-
writeln(`[dry-run] would create branch "${branchName}" (git checkout -b ${branchName})`)
|
|
139
|
-
return 0
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Git's own stderr (inherited stdio) already reached the user unmodified — do not reformat,
|
|
143
|
-
// duplicate, or replace this exit code with a generic 1.
|
|
144
|
-
return execGitCheckout(branchName)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
module.exports = {
|
|
148
|
-
branchValidTypes,
|
|
149
|
-
branchGatedTypes,
|
|
150
|
-
parseBranchSpec,
|
|
151
|
-
defaultGitCheckout,
|
|
152
|
-
runBranchNew,
|
|
153
|
-
}
|