forge-workflow 0.0.7 → 0.0.9

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.
Files changed (68) hide show
  1. package/.claude/commands/premerge.md +2 -2
  2. package/.claude/commands/review.md +5 -2
  3. package/.claude/commands/ship.md +4 -3
  4. package/.claude/rules/greptile-review-process.md +4 -4
  5. package/.cline/workflows/premerge.md +2 -2
  6. package/.cline/workflows/review.md +5 -2
  7. package/.cline/workflows/ship.md +4 -3
  8. package/.codex/skills/premerge/SKILL.md +2 -2
  9. package/.codex/skills/review/SKILL.md +5 -2
  10. package/.codex/skills/ship/SKILL.md +4 -3
  11. package/.cursor/commands/premerge.md +2 -2
  12. package/.cursor/commands/review.md +5 -2
  13. package/.cursor/commands/ship.md +4 -3
  14. package/.github/prompts/premerge.prompt.md +2 -2
  15. package/.github/prompts/review.prompt.md +5 -2
  16. package/.github/prompts/ship.prompt.md +4 -3
  17. package/.github/workflows/beads-to-github.yml +1 -1
  18. package/.github/workflows/github-to-beads.yml +1 -1
  19. package/.kilocode/workflows/premerge.md +2 -2
  20. package/.kilocode/workflows/review.md +5 -2
  21. package/.kilocode/workflows/ship.md +4 -3
  22. package/.opencode/commands/premerge.md +2 -2
  23. package/.opencode/commands/review.md +5 -2
  24. package/.opencode/commands/ship.md +4 -3
  25. package/.roo/commands/premerge.md +2 -2
  26. package/.roo/commands/review.md +5 -2
  27. package/.roo/commands/ship.md +4 -3
  28. package/AGENTS.md +9 -9
  29. package/README.md +12 -6
  30. package/bin/forge.js +21 -3
  31. package/docs/BEADS_GITHUB_SYNC.md +6 -2
  32. package/docs/EXAMPLES.md +22 -22
  33. package/docs/ROADMAP.md +3 -3
  34. package/docs/TOOLCHAIN.md +60 -52
  35. package/lib/agents/codex.plugin.json +3 -0
  36. package/lib/agents-config.js +18 -12
  37. package/lib/codex-skills.js +54 -1
  38. package/lib/commands/_issue.js +172 -0
  39. package/lib/commands/claim.js +5 -0
  40. package/lib/commands/close.js +5 -0
  41. package/lib/commands/create.js +5 -0
  42. package/lib/commands/issue.js +5 -0
  43. package/lib/commands/list.js +5 -0
  44. package/lib/commands/plan.js +5 -2
  45. package/lib/commands/ready.js +5 -0
  46. package/lib/commands/setup.js +231 -17
  47. package/lib/commands/ship.js +188 -5
  48. package/lib/commands/show.js +5 -0
  49. package/lib/commands/status.js +20 -33
  50. package/lib/commands/sync.js +3 -1
  51. package/lib/commands/test.js +90 -25
  52. package/lib/commands/update.js +5 -0
  53. package/lib/commands/validate.js +218 -1
  54. package/lib/setup-action-log.js +2 -0
  55. package/lib/setup-summary-renderer.js +15 -11
  56. package/lib/workflow/enforce-stage.js +12 -8
  57. package/lib/workflow/state-manager.js +193 -0
  58. package/package.json +1 -1
  59. package/scripts/dep-guard.sh +11 -1
  60. package/scripts/forge-team/lib/hooks.sh +1 -1
  61. package/scripts/forge-team/lib/verify.sh +1 -1
  62. package/scripts/forge-team/lib/workload.sh +56 -27
  63. package/scripts/forge-team/tests/workload.test.sh +35 -4
  64. package/scripts/github-beads-sync/run-bd.mjs +4 -2
  65. package/scripts/lib/eval-runner.js +50 -0
  66. package/scripts/smart-status.sh +10 -1
  67. package/scripts/sync-utils.sh +39 -0
  68. package/scripts/test.js +144 -38
@@ -77,15 +77,38 @@ _status_icon() {
77
77
  esac
78
78
  }
79
79
 
80
+ _json_escape() {
81
+ local value="${1:-}"
82
+ value="${value//\\/\\\\}"
83
+ value="${value//\"/\\\"}"
84
+ value="${value//$'\n'/\\n}"
85
+ value="${value//$'\r'/\\r}"
86
+ value="${value//$'\t'/\\t}"
87
+ printf '%s' "$value"
88
+ }
89
+
80
90
  # _collect_issues — Gather all open/in_progress issues with details
81
91
  # Outputs lines: id|title|status|owner|updated|depends_on
82
92
  _collect_issues() {
83
93
  local bd_cmd="${BD_CMD:-bd}"
84
94
  local list_output
85
- list_output="$("$bd_cmd" list --status=open,in_progress 2>/dev/null)" || return 1
95
+ local combined_exit=0
96
+
97
+ # beads 0.62 rejects comma-separated status filters, so fall back to
98
+ # separate queries when the combined form fails.
99
+ list_output="$("$bd_cmd" list --status=open,in_progress 2>/dev/null)"
100
+ combined_exit=$?
101
+ if [[ "$combined_exit" -ne 0 ]]; then
102
+ list_output="$(
103
+ {
104
+ "$bd_cmd" list --status=open 2>/dev/null || true
105
+ "$bd_cmd" list --status=in_progress 2>/dev/null || true
106
+ } | awk 'NF && !seen[$0]++'
107
+ )"
108
+ fi
86
109
 
87
110
  # Filter empty lines
88
- if [[ -z "$list_output" ]] || [[ -z "$(echo "$list_output" | tr -d '[:space:]')" ]]; then
111
+ if [[ -z "$list_output" ]] || [[ -z "$(printf '%s' "$list_output" | tr -d '[:space:]')" ]]; then
89
112
  return 0
90
113
  fi
91
114
 
@@ -95,7 +118,7 @@ _collect_issues() {
95
118
 
96
119
  # Extract issue id: first field like "forge-xxx" after optional icon
97
120
  local issue_id
98
- issue_id="$(echo "$line" | grep -oE 'forge-[a-zA-Z0-9]+' | head -1)"
121
+ issue_id="$(echo "$line" | grep -oE 'forge-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*' | head -1)"
99
122
  [[ -z "$issue_id" ]] && continue
100
123
 
101
124
  # Get details via bd show
@@ -141,9 +164,10 @@ cmd_workload() {
141
164
 
142
165
  # Parse arguments
143
166
  while [[ $# -gt 0 ]]; do
144
- case "$1" in
167
+ local arg="${1%$'\r'}"
168
+ case "$arg" in
145
169
  --developer=*)
146
- filter_developer="${1#--developer=}"
170
+ filter_developer="${arg#--developer=}"
147
171
  shift
148
172
  ;;
149
173
  --me)
@@ -160,11 +184,11 @@ cmd_workload() {
160
184
  shift
161
185
  ;;
162
186
  --format=*)
163
- _workload_error "Unsupported format: ${1#--format=}"
187
+ _workload_error "Unsupported format: ${arg#--format=}"
164
188
  return 1
165
189
  ;;
166
190
  *)
167
- _workload_error "Unknown argument: $1"
191
+ _workload_error "Unknown argument: $arg"
168
192
  return 1
169
193
  ;;
170
194
  esac
@@ -232,16 +256,13 @@ cmd_workload() {
232
256
  [[ -n "$stale_flag" ]] && stale_bool="true"
233
257
  local blocked_ids=""
234
258
  [[ -n "$depends_on" ]] && blocked_ids="$depends_on"
235
-
236
- # Use jq to build safe JSON
237
- jq -n -c \
238
- --arg id "$issue_id" \
239
- --arg title "$title" \
240
- --arg status "$status" \
241
- --arg updated "$updated" \
242
- --argjson stale "$stale_bool" \
243
- --arg blocked_by "$blocked_ids" \
244
- '{id: $id, title: $title, status: $status, updated: $updated, stale: $stale, blocked_by: $blocked_by}' \
259
+ printf '{"id":"%s","title":"%s","status":"%s","updated":"%s","stale":%s,"blocked_by":"%s"}\n' \
260
+ "$(_json_escape "$issue_id")" \
261
+ "$(_json_escape "$title")" \
262
+ "$(_json_escape "$status")" \
263
+ "$(_json_escape "$updated")" \
264
+ "$stale_bool" \
265
+ "$(_json_escape "$blocked_ids")" \
245
266
  >> "$work_dir/json/${owner}"
246
267
  fi
247
268
  done <<< "$issues_data"
@@ -260,22 +281,30 @@ cmd_workload() {
260
281
  # Output
261
282
  if [[ "$format" == "json" ]]; then
262
283
  # Build JSON object: { "devone": [...], "devtwo": [...] }
263
- local json_result="{"
264
- local first=true
284
+ local first_dev="true"
285
+ printf '{'
265
286
  for dev_file in "$work_dir/json"/*; do
266
287
  local dev_name
267
288
  dev_name="$(basename "$dev_file")"
268
- local issues_array
269
- issues_array="$(jq -s '.' "$dev_file")"
270
- if [[ "$first" == "true" ]]; then
271
- first=false
289
+ local first_issue="true"
290
+ if [[ "$first_dev" == "true" ]]; then
291
+ first_dev="false"
272
292
  else
273
- json_result+=","
293
+ printf ','
274
294
  fi
275
- json_result+="$(jq -n -c --arg dev "$dev_name" --argjson issues "$issues_array" '{($dev): $issues}' | sed 's/^{//' | sed 's/}$//')"
295
+ printf '"%s":[' "$(_json_escape "$dev_name")"
296
+ while IFS= read -r issue_json; do
297
+ [[ -z "$issue_json" ]] && continue
298
+ if [[ "$first_issue" == "true" ]]; then
299
+ first_issue="false"
300
+ else
301
+ printf ','
302
+ fi
303
+ printf '%s' "$issue_json"
304
+ done < "$dev_file"
305
+ printf ']'
276
306
  done
277
- json_result+="}"
278
- echo "$json_result"
307
+ printf '}\n'
279
308
  else
280
309
  local first_dev=true
281
310
  for dev_file in "$work_dir/devs"/*; do
@@ -32,9 +32,15 @@ cat > "$mock_dir/bd" << 'MOCK'
32
32
  #!/usr/bin/env bash
33
33
  case "$1 $2" in
34
34
  "list --status=open,in_progress")
35
- echo "◐ forge-aaa · Feature A"
35
+ exit 1
36
+ ;;
37
+ "list --status=open")
36
38
  echo "○ forge-bbb · Feature B"
39
+ ;;
40
+ "list --status=in_progress")
41
+ echo "◐ forge-aaa · Feature A"
37
42
  echo "◐ forge-ccc · Feature C"
43
+ echo "◐ forge-m1n8.6 · Sub-feature X (dotted ID)"
38
44
  ;;
39
45
  "show forge-aaa")
40
46
  echo "◐ forge-aaa · Feature A [● P2 · IN_PROGRESS]"
@@ -54,6 +60,19 @@ case "$1 $2" in
54
60
  echo "DEPENDS ON"
55
61
  echo " → forge-aaa: Feature A"
56
62
  ;;
63
+ "show forge-m1n8.6")
64
+ # Dotted sub-ID — bd show must receive the full ID including .6
65
+ echo "◐ forge-m1n8.6 · Sub-feature X [● P2 · IN_PROGRESS]"
66
+ echo "Owner: devthree"
67
+ echo "Updated: 2026-03-27T08:00:00Z"
68
+ ;;
69
+ "show forge-m1n8")
70
+ # Parent epic — if a buggy parser truncates forge-m1n8.6 to forge-m1n8,
71
+ # the workload would get this WRONG owner and trigger the guard below.
72
+ echo "◐ forge-m1n8 · PARENT EPIC (WRONG — dotted ID was truncated)"
73
+ echo "Owner: WRONG_OWNER_DO_NOT_USE"
74
+ echo "Updated: 2026-03-20T10:00:00Z"
75
+ ;;
57
76
  esac
58
77
  MOCK
59
78
  chmod +x "$mock_dir/bd"
@@ -65,6 +84,12 @@ case "$1 $2" in
65
84
  "list --status=open,in_progress")
66
85
  echo ""
67
86
  ;;
87
+ "list --status=open")
88
+ echo "○ forge-should-not-appear · Open fallback sentinel"
89
+ ;;
90
+ "list --status=in_progress")
91
+ echo "◐ forge-should-not-appear-2 · In-progress fallback sentinel"
92
+ ;;
68
93
  esac
69
94
  MOCK
70
95
  chmod +x "$mock_dir/bd-empty"
@@ -125,6 +150,11 @@ assert_contains "shows devtwo header" "Developer: devtwo" "$output"
125
150
  assert_contains "shows forge-aaa" "forge-aaa" "$output"
126
151
  assert_contains "shows forge-bbb" "forge-bbb" "$output"
127
152
  assert_contains "shows forge-ccc" "forge-ccc" "$output"
153
+ # Dotted ID regression (forge-hpev): the full forge-m1n8.6 must be extracted,
154
+ # not truncated to forge-m1n8. devthree owns the sub-issue, not WRONG_OWNER.
155
+ assert_contains "shows devthree (dotted ID owner)" "Developer: devthree" "$output"
156
+ assert_contains "shows dotted ID forge-m1n8.6 in full" "forge-m1n8.6" "$output"
157
+ assert_not_contains "does NOT attribute to WRONG_OWNER (parent epic)" "WRONG_OWNER" "$output"
128
158
 
129
159
  # ── Test 2: --developer=devone filters correctly ─────────────────────────
130
160
  echo ""
@@ -158,6 +188,7 @@ rc=0
158
188
  output="$(cmd_workload 2>/dev/null)" || rc=$?
159
189
  assert_exit "exits 0" 0 "$rc"
160
190
  assert_contains "shows no active work message" "No active work" "$output"
191
+ assert_not_contains "does not fall back when combined query succeeds with no issues" "forge-should-not-appear" "$output"
161
192
  export BD_CMD="$mock_dir/bd"
162
193
 
163
194
  # ── Test 5: Stale assignment flagged (>48h) ──────────────────────────────
@@ -178,18 +209,18 @@ rc=0
178
209
  output="$(cmd_workload --format=json 2>/dev/null)" || rc=$?
179
210
  assert_exit "exits 0" 0 "$rc"
180
211
  # Validate it's parseable JSON
181
- if echo "$output" | jq . >/dev/null 2>&1; then
212
+ if echo "$output" | node -e 'JSON.parse(require("fs").readFileSync(0, "utf8"));' >/dev/null 2>&1; then
182
213
  PASS=$((PASS + 1)); echo " PASS: output is valid JSON"
183
214
  else
184
215
  FAIL=$((FAIL + 1)); echo " FAIL: output is not valid JSON: $output"
185
216
  fi
186
217
  # Check JSON has developer keys
187
- if echo "$output" | jq -e '.devone' >/dev/null 2>&1; then
218
+ if echo "$output" | node -e 'const data = JSON.parse(require("fs").readFileSync(0, "utf8")); process.exit(data.devone ? 0 : 1);' >/dev/null 2>&1; then
188
219
  PASS=$((PASS + 1)); echo " PASS: JSON contains devone key"
189
220
  else
190
221
  FAIL=$((FAIL + 1)); echo " FAIL: JSON missing devone key"
191
222
  fi
192
- if echo "$output" | jq -e '.devtwo' >/dev/null 2>&1; then
223
+ if echo "$output" | node -e 'const data = JSON.parse(require("fs").readFileSync(0, "utf8")); process.exit(data.devtwo ? 0 : 1);' >/dev/null 2>&1; then
193
224
  PASS=$((PASS + 1)); echo " PASS: JSON contains devtwo key"
194
225
  else
195
226
  FAIL=$((FAIL + 1)); echo " FAIL: JSON missing devtwo key"
@@ -79,10 +79,12 @@ export function buildSearchArgs(query) {
79
79
  * @returns {string|null}
80
80
  */
81
81
  export function parseCreateOutput(stdout) {
82
- const match = stdout.match(/Created issue:\s+(forge-[\w-]+)/);
82
+ // Match forge-xxx with optional dotted sub-IDs (e.g. forge-m1n8.6, forge-dq8j.1).
83
+ // \w in JS doesn't include '.', so the dotted sub-ID group is explicit.
84
+ const match = stdout.match(/Created issue:\s+(forge-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*)/);
83
85
  if (match) return match[1];
84
86
  // Fallback: loose match for any forge-prefixed ID anywhere in output
85
- const fallback = stdout.match(/(forge-[a-z0-9]+)/i);
87
+ const fallback = stdout.match(/(forge-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*)/);
86
88
  return fallback ? fallback[1] : null;
87
89
  }
88
90
 
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  const path = require('path');
12
+ const fs = require('fs');
12
13
  const { execSync } = require('node:child_process');
13
14
 
14
15
  // ── active worktree tracking (cleanup on crash) ─────────────────────
@@ -17,6 +18,17 @@ const { execSync } = require('node:child_process');
17
18
  // Note: execSync is safe here — all paths are internally generated, never user input.
18
19
  const activeEvalWorktrees = new Map(); // path -> branch
19
20
 
21
+ /**
22
+ * Force-remove a directory that git worktree remove may leave behind (Windows).
23
+ */
24
+ function forceRemoveDir(dirPath) {
25
+ try {
26
+ if (fs.existsSync(dirPath)) {
27
+ fs.rmSync(dirPath, { recursive: true, force: true });
28
+ }
29
+ } catch (_err) { /* best-effort */ }
30
+ }
31
+
20
32
  function cleanupActiveWorktrees() {
21
33
  if (activeEvalWorktrees.size === 0) return;
22
34
  let repoRoot;
@@ -25,6 +37,7 @@ function cleanupActiveWorktrees() {
25
37
  try {
26
38
  execSync(`git worktree remove --force "${wtPath}"`, { cwd: repoRoot, stdio: 'pipe' });
27
39
  } catch (_err) { /* already removed */ }
40
+ forceRemoveDir(wtPath);
28
41
  if (branch && branch.startsWith('eval-')) {
29
42
  try {
30
43
  execSync(`git branch -D "${branch}"`, { cwd: repoRoot, stdio: 'pipe' });
@@ -35,6 +48,37 @@ function cleanupActiveWorktrees() {
35
48
  activeEvalWorktrees.clear();
36
49
  }
37
50
 
51
+ /**
52
+ * Remove stale eval-* directories left behind by crashed runs.
53
+ * Git has already forgotten them (worktree prune), but the directories persist on Windows.
54
+ */
55
+ function cleanupStaleEvalWorktrees() {
56
+ try {
57
+ const worktreesDir = getWorktreesDir();
58
+ if (!fs.existsSync(worktreesDir)) return;
59
+
60
+ // Get the list of paths git still knows about
61
+ const repoRoot = getRepoRoot();
62
+ const knownRaw = execSync('git worktree list --porcelain', {
63
+ cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
64
+ });
65
+ const knownPaths = new Set(
66
+ knownRaw.split('\n')
67
+ .filter((l) => l.startsWith('worktree '))
68
+ .map((l) => l.slice('worktree '.length).replace(/\\/g, '/'))
69
+ );
70
+
71
+ const entries = fs.readdirSync(worktreesDir);
72
+ for (const entry of entries) {
73
+ if (!entry.startsWith('eval-')) continue;
74
+ const fullPath = path.join(worktreesDir, entry).replace(/\\/g, '/');
75
+ if (!knownPaths.has(fullPath)) {
76
+ forceRemoveDir(path.join(worktreesDir, entry));
77
+ }
78
+ }
79
+ } catch (_err) { /* best-effort — don't block eval creation */ }
80
+ }
81
+
38
82
  process.on('exit', cleanupActiveWorktrees);
39
83
  process.on('SIGINT', () => {
40
84
  const hadWork = activeEvalWorktrees.size > 0;
@@ -78,6 +122,9 @@ function getWorktreesDir() {
78
122
  * @returns {Promise<{ path: string, branch: string }>}
79
123
  */
80
124
  async function createEvalWorktree() {
125
+ // Self-heal: remove stale eval dirs from previous crashed runs
126
+ cleanupStaleEvalWorktrees();
127
+
81
128
  const timestamp = Date.now();
82
129
  const pid = process.pid;
83
130
  const name = `eval-${timestamp}-${pid}`;
@@ -128,6 +175,9 @@ async function destroyEvalWorktree(worktreePath) {
128
175
  stdio: ['pipe', 'pipe', 'pipe'],
129
176
  });
130
177
 
178
+ // Windows: git worktree remove often leaves the directory behind
179
+ forceRemoveDir(worktreePath);
180
+
131
181
  // Prune to clean up references
132
182
  execSync('git worktree prune', {
133
183
  cwd: repoRoot,
@@ -68,7 +68,9 @@ if ! command -v jq &>/dev/null; then
68
68
  fi
69
69
 
70
70
  # Warn if jq < 1.6 (fromdateiso8601 and sub/2 require 1.6+)
71
- _jq_version="$(jq --version 2>/dev/null | sed 's/jq-//')" || true
71
+ # Strip CR here as well because Windows jq.exe (or a CRLF-emitting wrapper in
72
+ # tests) can affect the version probe before the jq() shim is defined below.
73
+ _jq_version="$(command jq --version 2>/dev/null | tr -d '\r' | sed 's/jq-//')" || true
72
74
  if [ -n "$_jq_version" ]; then
73
75
  _jq_major="${_jq_version%%.*}"
74
76
  _jq_minor="${_jq_version#*.}"; _jq_minor="${_jq_minor%%.*}"
@@ -77,6 +79,13 @@ if [ -n "$_jq_version" ]; then
77
79
  fi
78
80
  fi
79
81
 
82
+ # On Windows/WSL we may be invoking jq.exe, which emits CRLF and breaks
83
+ # numeric comparisons like `[ "$count" -gt 0 ]`. Strip trailing CRs from
84
+ # all jq output after the dependency/version checks above.
85
+ jq() {
86
+ command jq "$@" | tr -d '\r'
87
+ }
88
+
80
89
  # ── Configuration ───────────────────────────────────────────────────────
81
90
 
82
91
  BD="${BD_CMD:-bd}"
@@ -271,16 +271,55 @@ _run_sync() {
271
271
  fi
272
272
  }
273
273
 
274
+ _has_dolt_remote() {
275
+ local remote_name="${1:-origin}"
276
+ local remote_list=""
277
+ local raw_output=""
278
+ local bd_cmd="${BD_CMD:-bd}"
279
+ local remote_status=0
280
+
281
+ if ! command -v "$bd_cmd" >/dev/null 2>&1; then
282
+ return 2
283
+ fi
284
+
285
+ raw_output="$("$bd_cmd" dolt remote list 2>/dev/null)"
286
+ remote_status=$?
287
+ if [[ "$remote_status" -ne 0 ]]; then
288
+ return 2
289
+ fi
290
+ remote_list="$(printf '%s' "$raw_output" | tr -d '\r')"
291
+
292
+ if [[ -z "$remote_list" ]]; then
293
+ return 1
294
+ fi
295
+
296
+ printf '%s\n' "$remote_list" | awk 'NF { print $1 }' | grep -Fqx -- "$remote_name"
297
+ }
298
+
274
299
  # Environment overrides (for testing):
275
300
  # BD_SYNC_CMD — single command to run instead of default pull+push (e.g. "echo mock-sync")
276
301
  # FILE_INDEX_ROOT — root directory for file-index.sh (default: repo_dir)
277
302
  auto_sync() {
278
303
  local repo_dir="${1:-.}"
279
304
  local last_sync_file="$repo_dir/.beads/.last-sync"
305
+ local sync_remote
306
+ sync_remote="$(get_sync_remote "$repo_dir")"
280
307
 
281
308
  # Ensure .beads directory exists
282
309
  mkdir -p "$repo_dir/.beads"
283
310
 
311
+ if _has_dolt_remote "$sync_remote"; then
312
+ :
313
+ else
314
+ local remote_status=$?
315
+ if [[ "$remote_status" -eq 2 ]]; then
316
+ echo "Warning: sync skipped, unable to inspect Beads Dolt remotes (is 'bd' installed and configured?)." >&2
317
+ else
318
+ echo "Warning: sync skipped, Beads Dolt remote '$sync_remote' is not configured (run 'bd dolt remote add $sync_remote <url>')." >&2
319
+ fi
320
+ return 0
321
+ fi
322
+
284
323
  # Run sync — helper function avoids eval while supporting compound default
285
324
  if _run_sync >/dev/null 2>&1; then
286
325
  # Success: record timestamp
package/scripts/test.js CHANGED
@@ -1,17 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Cross-platform test runner for lefthook pre-push hook.
4
- * Replaces bash-only: bun/pnpm/yarn/npm test detection with if/elif.
5
- * Works on Windows CMD, PowerShell, macOS, Linux.
3
+ * Cross-platform test runner for the lefthook pre-push hook.
4
+ *
5
+ * Runs only the tests affected by the changes being pushed when possible.
6
+ * Falls back to the full suite only for package-level changes.
6
7
  */
7
8
 
8
- const { spawnSync } = require('node:child_process');
9
+ const { execFileSync: defaultExecFileSync, spawnSync: defaultSpawnSync } = require('node:child_process');
9
10
  const fs = require('node:fs');
10
11
 
11
- // On Windows, package manager CLIs are .cmd files — shell: true resolves them
12
+ const {
13
+ getAffectedTestFiles,
14
+ getChangedFiles,
15
+ } = require('../lib/commands/test');
16
+
17
+ const PACKAGE_LEVEL_PATHS = new Set([
18
+ 'package.json',
19
+ 'bun.lockb',
20
+ 'pnpm-lock.yaml',
21
+ 'yarn.lock',
22
+ 'package-lock.json',
23
+ ]);
24
+
12
25
  const isWindows = process.platform === 'win32';
13
26
 
14
- // Detect package manager from lock files (same priority as forge.js)
15
27
  function detectPackageManager() {
16
28
  if (fs.existsSync('bun.lockb') || fs.existsSync('bun.lock')) return 'bun';
17
29
  if (fs.existsSync('pnpm-lock.yaml')) return 'pnpm';
@@ -19,43 +31,137 @@ function detectPackageManager() {
19
31
  return 'npm';
20
32
  }
21
33
 
22
- const pkgManager = detectPackageManager();
23
- console.log(`🧪 Running test suite (${pkgManager} test)...`);
24
-
25
- // Strip git hook environment variables so child processes (especially tests
26
- // that create temp git repos) never accidentally operate on the real worktree.
27
- // During pre-push hooks, git sets GIT_DIR pointing to the repo — any test that
28
- // runs `git init` / `git commit` in a temp dir inherits this and silently
29
- // commits into the worktree instead, creating rogue "initial commit" that
30
- // deletes the entire codebase.
31
- const env = { ...process.env };
32
- for (const key of Object.keys(env)) {
33
- if (key === 'GIT_DIR' || key === 'GIT_WORK_TREE' || key === 'GIT_INDEX_FILE'
34
- || key === 'GIT_OBJECT_DIRECTORY' || key === 'GIT_ALTERNATE_OBJECT_DIRECTORIES'
35
- || key === 'GIT_QUARANTINE_PATH') {
36
- delete env[key];
34
+ function stripGitHookEnv(sourceEnv = process.env) {
35
+ const env = { ...sourceEnv };
36
+ for (const key of Object.keys(env)) {
37
+ if (key === 'GIT_DIR' || key === 'GIT_WORK_TREE' || key === 'GIT_INDEX_FILE'
38
+ || key === 'GIT_OBJECT_DIRECTORY' || key === 'GIT_ALTERNATE_OBJECT_DIRECTORIES'
39
+ || key === 'GIT_QUARANTINE_PATH') {
40
+ delete env[key];
41
+ }
37
42
  }
43
+ return env;
38
44
  }
39
45
 
40
- // Use 'run test' to invoke the package.json script (which may include --timeout flags)
41
- // 'bun test' is a built-in that ignores package.json scripts
42
- const result = spawnSync(pkgManager, ['run', 'test'], { stdio: 'inherit', shell: isWindows, env });
46
+ function classifyPushTests(projectRoot, execFileSync = defaultExecFileSync) {
47
+ const changedFiles = getChangedFiles(execFileSync, { sinceUpstream: true });
48
+ const testTargets = getAffectedTestFiles(projectRoot, execFileSync, fs, { sinceUpstream: true });
49
+
50
+ let runFullSuite = false;
51
+ let runTestEnv = false;
52
+ let runE2E = false;
53
+ let hasUnmappedFiles = false;
54
+ const hasUnknownChangedFiles = changedFiles.length === 0 && testTargets.length === 0;
55
+
56
+ for (const file of changedFiles) {
57
+ if (PACKAGE_LEVEL_PATHS.has(file) || file.startsWith('packages/')) {
58
+ runFullSuite = true;
59
+ runTestEnv = true;
60
+ runE2E = true;
61
+ break;
62
+ }
63
+
64
+ if (file.startsWith('test-env/')) {
65
+ runTestEnv = true;
66
+ continue;
67
+ }
68
+
69
+ if (file.startsWith('test/e2e/')) {
70
+ runE2E = true;
71
+ continue;
72
+ }
73
+
74
+ if ((file.startsWith('lib/') || file.startsWith('scripts/')) && file.endsWith('.js')) {
75
+ runTestEnv = true;
76
+ continue;
77
+ }
78
+
79
+ if (file.startsWith('test/') || file.startsWith('.github/workflows/') || file.startsWith('.claude/commands/')) {
80
+ continue;
81
+ }
82
+
83
+ hasUnmappedFiles = true;
84
+ }
85
+
86
+ return {
87
+ changedFiles,
88
+ hasUnmappedFiles,
89
+ hasUnknownChangedFiles,
90
+ runE2E,
91
+ runFullSuite: runFullSuite || hasUnmappedFiles || hasUnknownChangedFiles,
92
+ runTestEnv,
93
+ testTargets,
94
+ };
95
+ }
43
96
 
44
- if (result.error) {
45
- console.error('');
46
- console.error(`❌ Failed to run ${pkgManager} test: ${result.error.message}`);
47
- console.error(` Is '${pkgManager}' installed and on PATH?`);
48
- console.error('');
49
- process.exit(1);
97
+ function runCommand(command, args, options = {}, spawnSync = defaultSpawnSync) {
98
+ const result = spawnSync(command, args, {
99
+ stdio: 'inherit',
100
+ shell: isWindows,
101
+ ...options,
102
+ });
103
+
104
+ if (result.error) {
105
+ throw result.error;
106
+ }
107
+
108
+ return result.status ?? 1;
109
+ }
110
+
111
+ function runPrePushTests(projectRoot = process.cwd(), deps = {}) {
112
+ const spawnSync = deps.spawnSync || defaultSpawnSync;
113
+ const execFileSync = deps.execFileSync || defaultExecFileSync;
114
+ const pkgManager = deps.pkgManager || detectPackageManager();
115
+ const env = deps.env || stripGitHookEnv(process.env);
116
+ const plan = classifyPushTests(projectRoot, execFileSync);
117
+
118
+ console.log(`Running pre-push tests (${pkgManager})...`);
119
+
120
+ try {
121
+ if (plan.runFullSuite) {
122
+ const reason = plan.hasUnmappedFiles
123
+ ? 'unmapped pushed files require full unit coverage'
124
+ : plan.hasUnknownChangedFiles
125
+ ? 'changed files could not be resolved safely'
126
+ : 'package-level changes detected';
127
+ console.log(` Mode: full suite (${reason})`);
128
+ const status = runCommand(pkgManager, ['run', 'test'], { env }, spawnSync);
129
+ if (status !== 0) return status;
130
+ } else if (plan.testTargets.length > 0) {
131
+ console.log(` Mode: targeted (${plan.testTargets.length} test file${plan.testTargets.length === 1 ? '' : 's'})`);
132
+ const status = runCommand(pkgManager, ['run', 'test', ...plan.testTargets], { env }, spawnSync);
133
+ if (status !== 0) return status;
134
+ }
135
+
136
+ if (plan.runE2E) {
137
+ console.log(' Extra: running affected e2e tests');
138
+ const status = runCommand('bun', ['test', 'test/e2e/'], { env }, spawnSync);
139
+ if (status !== 0) return status;
140
+ }
141
+
142
+ if (plan.runTestEnv) {
143
+ console.log(' Extra: running affected edge-case tests');
144
+ const status = runCommand('bun', ['test', 'test-env/'], { env }, spawnSync);
145
+ if (status !== 0) return status;
146
+ }
147
+
148
+ console.log('Relevant tests passed');
149
+ return 0;
150
+ } catch (error) {
151
+ console.error('');
152
+ console.error(`Failed to run tests: ${error.message}`);
153
+ console.error('');
154
+ return 1;
155
+ }
50
156
  }
51
157
 
52
- if (result.status !== 0) {
53
- console.error('');
54
- console.error('❌ Tests failed. Fix them before pushing.');
55
- console.error('');
56
- console.error('Fix the failing checks. Do not bypass hooks.');
57
- console.error('');
58
- process.exit(1);
158
+ if (require.main === module) {
159
+ process.exit(runPrePushTests());
59
160
  }
60
161
 
61
- console.log('✅ All tests passed');
162
+ module.exports = {
163
+ classifyPushTests,
164
+ detectPackageManager,
165
+ runPrePushTests,
166
+ stripGitHookEnv,
167
+ };