trackfw 6.10.0 → 7.1.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/README.md +0 -3
- package/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/branch/runner.js +33 -20
- package/src/changelog/index.js +114 -0
- package/src/commands/branch.js +59 -9
- package/src/commands/changelog.js +29 -0
- package/src/commands/commit.js +60 -0
- package/src/commands/discover.js +75 -0
- package/src/commands/doctor.js +32 -0
- package/src/commands/index.js +23 -4
- package/src/commands/integrations.js +23 -1
- package/src/commands/serve.js +52 -4
- package/src/commands/ship.js +4 -3
- package/src/commands/thirdparty.js +384 -0
- package/src/commands/update-harness.js +320 -12
- package/src/commands/update.js +3 -1
- package/src/commit/runner.js +352 -0
- package/src/config/index.js +27 -0
- package/src/generators/hooks.js +925 -6
- package/src/generators/init.js +28 -6
- package/src/i18n/locales/en-US.json +0 -21
- package/src/i18n/locales/es-ES.json +0 -21
- package/src/i18n/locales/pt-BR.json +0 -21
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +19 -2
- package/src/integrations/manager.js +133 -19
- package/src/lib/fatal-error.js +64 -0
- package/src/lib/unknown-command.js +82 -0
- package/src/ship/runner.js +259 -95
- package/src/thirdparty/fetch.js +140 -0
- package/src/thirdparty/markers.js +207 -0
- package/src/thirdparty/provenance.js +114 -0
- package/src/thirdparty/quarantine.js +113 -0
- package/src/thirdparty/references.js +194 -0
- package/src/validator/index.js +1060 -27
- package/src/commands/plugins.js +0 -184
package/README.md
CHANGED
|
@@ -157,9 +157,6 @@ trackfw ship -m "feat(auth): add login flow"
|
|
|
157
157
|
| `trackfw serve` | Start a local governance dashboard (no cloud, no accounts) |
|
|
158
158
|
| `trackfw status` | Show wip, blocked, REQs waiting on ADRs |
|
|
159
159
|
| `trackfw log [--tail N]` | Show roadmap state transition history |
|
|
160
|
-
| `trackfw plugins list` | List installed plugins |
|
|
161
|
-
| `trackfw plugins add <user/repo>` | Install a plugin from GitHub Releases |
|
|
162
|
-
| `trackfw plugins remove <name>` | Remove an installed plugin |
|
|
163
160
|
| `trackfw agents list` | List available agents and deployment state across AI CLIs |
|
|
164
161
|
| `trackfw agents install` | Install selected specialist agents |
|
|
165
162
|
| `trackfw agents update` | Safely update managed agents |
|
package/bin/trackfw
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
|
-
require('../src/
|
|
4
|
+
const { reportFatalError, installGlobalHandlers } = require('../src/lib/fatal-error')
|
|
5
|
+
|
|
6
|
+
// Registered before any command runs — see fatal-error.js for why this is a
|
|
7
|
+
// single entrypoint-level handler instead of a try/catch per command.
|
|
8
|
+
installGlobalHandlers()
|
|
9
|
+
|
|
10
|
+
const program = require('../src/commands/index').createProgram()
|
|
11
|
+
|
|
12
|
+
// trackfw sem argumento é uso legítimo (pedir ajuda), não erro — decisão do
|
|
13
|
+
// arquiteto no ML-1C (ROADMAP-2026-08-16-higiene-...). Sem intervenção,
|
|
14
|
+
// commander trata "nenhum operando" como "provavelmente faltou um
|
|
15
|
+
// subcomando" (Command._parseCommand: `this.commands.length && this.args
|
|
16
|
+
// .length === 0 && !this._actionHandler` → `this.help({error: true})`) —
|
|
17
|
+
// help em stderr, exit 1. Interceptar SÓ o caso de zero argumentos aqui,
|
|
18
|
+
// antes de entrar no parser, reusa o MESMO texto de ajuda (outputHelp) na
|
|
19
|
+
// saída canônica (stdout, exit 0) sem tocar no parsing normal — em
|
|
20
|
+
// particular sem registrar uma .action() no root, que reclassificaria um
|
|
21
|
+
// comando desconhecido (ex.: "trackfw naoexiste") como argumento posicional
|
|
22
|
+
// da action em vez de disparar o listener 'command:*' (regressão descartada
|
|
23
|
+
// em favor desta abordagem: ver ROADMAP ML-1C).
|
|
24
|
+
//
|
|
25
|
+
// Fica ANTES do parseAsync e DEPOIS de installGlobalHandlers(): o caminho de
|
|
26
|
+
// zero argumentos é sucesso e nunca deve passar pelo reportFatalError abaixo.
|
|
27
|
+
if (process.argv.length <= 2) {
|
|
28
|
+
program.outputHelp()
|
|
29
|
+
process.exit(0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.parseAsync(process.argv)
|
|
34
|
+
.catch((err) => {
|
|
35
|
+
// The primary path: an action (sync or async) threw, and since the
|
|
36
|
+
// program is driven by parseAsync() that surfaces here as a rejected
|
|
37
|
+
// promise — not as an uncaughtException/unhandledRejection, which is
|
|
38
|
+
// why this .catch exists in addition to installGlobalHandlers() above.
|
|
39
|
+
reportFatalError(err)
|
|
40
|
+
process.exitCode = 1
|
|
41
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.1.0",
|
|
4
4
|
"description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -0,0 +1,450 @@
|
|
|
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
CHANGED
|
@@ -16,12 +16,20 @@ const { spawnSync } = require('child_process')
|
|
|
16
16
|
const config = require('../config')
|
|
17
17
|
const validator = require('../validator')
|
|
18
18
|
|
|
19
|
-
// branchValidTypes
|
|
20
|
-
|
|
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'])
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
31
|
* parseBranchSpec splits "<type>/<slug>" and validates both parts. type must be one of
|
|
24
|
-
* feat, fix, refactor (branchValidTypes); slug must be non-empty.
|
|
32
|
+
* feat, fix, refactor, chore, docs (branchValidTypes); slug must be non-empty.
|
|
25
33
|
* @param {string} spec
|
|
26
34
|
* @returns {{ branchType: string, slug: string }}
|
|
27
35
|
* @throws {Error} when spec is malformed
|
|
@@ -30,12 +38,12 @@ function parseBranchSpec(spec) {
|
|
|
30
38
|
const value = String(spec)
|
|
31
39
|
const idx = value.indexOf('/')
|
|
32
40
|
if (idx === -1 || value.slice(0, idx) === '') {
|
|
33
|
-
throw new Error(`invalid branch spec "${spec}" — expected <type>/<slug> with type in feat, fix, refactor`)
|
|
41
|
+
throw new Error(`invalid branch spec "${spec}" — expected <type>/<slug> with type in feat, fix, refactor, chore, docs`)
|
|
34
42
|
}
|
|
35
43
|
const branchType = value.slice(0, idx)
|
|
36
44
|
const slug = value.slice(idx + 1)
|
|
37
45
|
if (!branchValidTypes.has(branchType)) {
|
|
38
|
-
throw new Error(`invalid branch type "${branchType}" — must be one of feat, fix, refactor`)
|
|
46
|
+
throw new Error(`invalid branch type "${branchType}" — must be one of feat, fix, refactor, chore, docs`)
|
|
39
47
|
}
|
|
40
48
|
if (slug.trim() === '') {
|
|
41
49
|
throw new Error(`branch slug is required — expected <type>/<slug>, got "${spec}"`)
|
|
@@ -102,24 +110,28 @@ function runBranchNew(spec, dryRun, deps = {}) {
|
|
|
102
110
|
|
|
103
111
|
const branchName = `${branchType}/${slug}`
|
|
104
112
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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)
|
|
108
119
|
|
|
109
|
-
|
|
110
|
-
|
|
120
|
+
const normalizedSlug = validator.normalizeBranchSlug(slug)
|
|
121
|
+
const { matched, candidates } = matchSlug(normalizedSlug, wipDirs, doneDirs)
|
|
111
122
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
120
134
|
}
|
|
121
|
-
writeErr(`blocked: no matching roadmap in wip/ nor done/ for "${branchName}"`)
|
|
122
|
-
return 1
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
if (dryRun) {
|
|
@@ -134,6 +146,7 @@ function runBranchNew(spec, dryRun, deps = {}) {
|
|
|
134
146
|
|
|
135
147
|
module.exports = {
|
|
136
148
|
branchValidTypes,
|
|
149
|
+
branchGatedTypes,
|
|
137
150
|
parseBranchSpec,
|
|
138
151
|
defaultGitCheckout,
|
|
139
152
|
runBranchNew,
|