trackfw 7.0.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/bin/trackfw CHANGED
@@ -1,4 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- require('../src/commands/index').createProgram().parseAsync(process.argv)
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": "7.0.0",
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
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  const { Command } = require('commander')
9
9
  const { runBranchNew } = require('../branch/runner')
10
+ const { runBranchPrune } = require('../branch/prune')
10
11
 
11
12
  function createBranchCommand() {
12
13
  const cmd = new Command('branch')
@@ -43,6 +44,52 @@ function createBranchCommand() {
43
44
  })
44
45
 
45
46
  cmd.addCommand(newCmd)
47
+
48
+ const pruneCmd = new Command('prune')
49
+ pruneCmd
50
+ .description('Report (and, with --apply, delete) local branches already integrated into origin/main')
51
+ .addHelpText(
52
+ 'after',
53
+ '\n' +
54
+ 'trackfw branch prune automates the "one active branch at a time" check documented in\n' +
55
+ 'CLAUDE.md §1 — it does not remove human judgment from every case: a branch whose only\n' +
56
+ 'remaining divergence is doc/config files is flagged for manual review, never deleted\n' +
57
+ 'automatically.\n\n' +
58
+ "A best-effort 'git fetch origin --prune' runs first. Failure (offline, no remote) is\n" +
59
+ 'non-blocking: a warning is printed and evaluation proceeds against the local origin/main\n' +
60
+ 'ref, whatever its state. A stale origin/main only ever makes the result MORE conservative —\n' +
61
+ 'it can miss a branch that was in fact integrated since the last fetch (reporting it kept\n' +
62
+ 'when a fresh fetch would show it deletable), but it never reports one as deletable that a\n' +
63
+ 'fresh fetch would show as pending.\n\n' +
64
+ "Decides integration with the touched-files heuristic, NOT git's own ancestry check (which\n" +
65
+ 'always refuses squash-merged branches) and NOT a naive bidirectional diff against origin/main\n' +
66
+ '(which false-positives on a branch that is merged but stale, once main has advanced further):\n\n' +
67
+ ' mb = git merge-base origin/main <branch>\n' +
68
+ ' touched = git diff --name-only mb <branch> (what the branch touched)\n' +
69
+ ' diverg = git diff --name-only origin/main <branch> -- touched (what still differs there)\n\n' +
70
+ 'touched empty -> integrated (safe to delete)\n' +
71
+ 'diverg empty -> integrated (safe to delete) -- squash-merged, stale, main advanced since\n' +
72
+ 'diverg doc/config only -> flagged for review (kept; probable housekeeping, confirm and delete manually)\n' +
73
+ 'otherwise -> kept, with the diverging files named\n\n' +
74
+ 'Every local branch is reported, always, with its decision and reason. The current branch, any\n' +
75
+ 'branch checked out in another worktree, and the default branch (main) are always kept and\n' +
76
+ 'never evaluated for deletion. Without origin/main resolvable at all (offline with no prior\n' +
77
+ 'fetch ever having run, or no remote configured), the whole command refuses and deletes\n' +
78
+ 'nothing.\n\n' +
79
+ '--dry-run is the default: without --apply, nothing is ever deleted, even the clearly\n' +
80
+ "integrated. Deletion tries 'git branch -d' first — confirming the integration via git's own\n" +
81
+ "ancestry check too, when possible — and falls back to 'git branch -D' only when -d refuses,\n" +
82
+ 'the expected case for squash-merged branches, which never have fast-forward ancestry with main.'
83
+ )
84
+ .option('--apply', 'Actually delete branches decided as integrated (default: report only, delete nothing)', false)
85
+ .action((options) => {
86
+ const exitCode = runBranchPrune(!!options.apply, {})
87
+ if (exitCode !== 0) {
88
+ process.exit(exitCode)
89
+ }
90
+ })
91
+
92
+ cmd.addCommand(pruneCmd)
46
93
  return cmd
47
94
  }
48
95
 
@@ -515,6 +515,12 @@ cmd.action((opts) => {
515
515
  } catch (e) {
516
516
  console.warn(`⚠ credential guard script: ${e.message}`);
517
517
  }
518
+ try {
519
+ const { generateGitBranchGuardScript } = require('../generators/hooks');
520
+ generateGitBranchGuardScript(cwd);
521
+ } catch (e) {
522
+ console.warn(`⚠ git branch guard script: ${e.message}`);
523
+ }
518
524
  try {
519
525
  const { injectHooksDetected } = require('../generators/hooks');
520
526
  injectHooksDetected(cwd);
@@ -0,0 +1,32 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const { runDoctor, UNREGISTERED_WRITE, HAND_MODIFIED, UNKNOWN_CONTENT } = require('../integrations/doctor')
5
+
6
+ function printReport(findings) {
7
+ if (findings.length === 0) {
8
+ return 'trackfw doctor: no mismatches found -- disk matches the manifest for every catalog-managed artifact.'
9
+ }
10
+ const unregistered = findings.filter(finding => finding.finding === UNREGISTERED_WRITE).length
11
+ const handModified = findings.filter(finding => finding.finding === HAND_MODIFIED).length
12
+ const unknownContent = findings.filter(finding => finding.finding === UNKNOWN_CONTENT).length
13
+ const lines = [`trackfw doctor: ${findings.length} finding(s) -- ${unregistered} unregistered-write, ${handModified} hand-modified, ${unknownContent} unknown-content`, '']
14
+ for (const finding of findings) {
15
+ lines.push(`[${finding.finding}] ${finding.destination}`, ` remedy: ${finding.remedy}`, '')
16
+ }
17
+ return lines.join('\n').replace(/\n$/, '')
18
+ }
19
+
20
+ const cmd = new Command('doctor')
21
+ cmd.description('Detect artifacts on disk missing from the manifest, distinguishing hand-modified artifacts from unknown content')
22
+ cmd.option('--json', 'Emit findings as a JSON array instead of the text report')
23
+ cmd.action(async options => {
24
+ const findings = runDoctor({})
25
+ if (options.json) {
26
+ process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`)
27
+ return
28
+ }
29
+ process.stdout.write(`${printReport(findings)}\n`)
30
+ })
31
+
32
+ module.exports = cmd
@@ -54,6 +54,7 @@ function createProgram() {
54
54
  program.addCommand(require('./barrier'))
55
55
  program.addCommand(require('./branch'))
56
56
  program.addCommand(require('./commit'))
57
+ program.addCommand(require('./doctor'))
57
58
 
58
59
  const { createServeCommand } = require('./serve')
59
60
  program.addCommand(createServeCommand())
@@ -11,6 +11,21 @@ const { createThirdPartyCommand } = require('./thirdparty')
11
11
  const csv = value => String(value).split(',').map(entry => entry.trim()).filter(Boolean)
12
12
  const collect = (value, previous) => previous.concat(value)
13
13
 
14
+ // forceHelp returns the --force help text for a mutation subcommand. The
15
+ // three operations grant --force different powers, and a single shared
16
+ // string previously overstated update/uninstall's reach while never
17
+ // mentioning install's ability to adopt unmanaged bytes — that ambiguity is
18
+ // what sent a user straight into the "Unmanaged artifact does not match a
19
+ // trackfw template" error on `update --force` (see unmanagedArtifactError in
20
+ // src/integrations/manager.js for the matching remediation). Mirrors
21
+ // internal/commands/integrations_flags.go:forceHelp and
22
+ // pypi/trackfw/integrations/command.py.
23
+ function forceHelp(operation) {
24
+ if (operation === 'install') return 'Replace a modified managed artifact, or adopt/overwrite an unmanaged file already on disk'
25
+ if (operation === 'uninstall') return 'Remove a modified managed artifact'
26
+ return "Replace a modified managed artifact; never adopts unmanaged bytes — use 'install --force' for that"
27
+ }
28
+
14
29
  function human(result) {
15
30
  const lines = [`Available ${result.kind} (catalog ${result.catalog_version}):`]
16
31
  for (const item of result.items) lines.push(` ${item.id.padEnd(14)} ${item.name} — ${item.description}`)
@@ -117,7 +132,7 @@ function createLifecycleCommand(kind) {
117
132
  .option('--scope <scope>', 'Installation scope: project or global (default: global; asks interactively)')
118
133
  .option('--surface <target=surface>', 'Surface selection (repeatable)', collect, [])
119
134
  .option('--json', 'Print deterministic JSON')
120
- .option('--force', 'Replace or remove modified artifacts')
135
+ .option('--force', forceHelp(operation))
121
136
 
122
137
  // Flags de identidade são exclusivas de agents (ADR D5): skills não têm
123
138
  // identidade, e createLifecycleCommand é compartilhado entre `agents` e