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.
- package/.claude/commands/premerge.md +2 -2
- package/.claude/commands/review.md +5 -2
- package/.claude/commands/ship.md +4 -3
- package/.claude/rules/greptile-review-process.md +4 -4
- package/.cline/workflows/premerge.md +2 -2
- package/.cline/workflows/review.md +5 -2
- package/.cline/workflows/ship.md +4 -3
- package/.codex/skills/premerge/SKILL.md +2 -2
- package/.codex/skills/review/SKILL.md +5 -2
- package/.codex/skills/ship/SKILL.md +4 -3
- package/.cursor/commands/premerge.md +2 -2
- package/.cursor/commands/review.md +5 -2
- package/.cursor/commands/ship.md +4 -3
- package/.github/prompts/premerge.prompt.md +2 -2
- package/.github/prompts/review.prompt.md +5 -2
- package/.github/prompts/ship.prompt.md +4 -3
- package/.github/workflows/beads-to-github.yml +1 -1
- package/.github/workflows/github-to-beads.yml +1 -1
- package/.kilocode/workflows/premerge.md +2 -2
- package/.kilocode/workflows/review.md +5 -2
- package/.kilocode/workflows/ship.md +4 -3
- package/.opencode/commands/premerge.md +2 -2
- package/.opencode/commands/review.md +5 -2
- package/.opencode/commands/ship.md +4 -3
- package/.roo/commands/premerge.md +2 -2
- package/.roo/commands/review.md +5 -2
- package/.roo/commands/ship.md +4 -3
- package/AGENTS.md +9 -9
- package/README.md +12 -6
- package/bin/forge.js +21 -3
- package/docs/BEADS_GITHUB_SYNC.md +6 -2
- package/docs/EXAMPLES.md +22 -22
- package/docs/ROADMAP.md +3 -3
- package/docs/TOOLCHAIN.md +60 -52
- package/lib/agents/codex.plugin.json +3 -0
- package/lib/agents-config.js +18 -12
- package/lib/codex-skills.js +54 -1
- package/lib/commands/_issue.js +172 -0
- package/lib/commands/claim.js +5 -0
- package/lib/commands/close.js +5 -0
- package/lib/commands/create.js +5 -0
- package/lib/commands/issue.js +5 -0
- package/lib/commands/list.js +5 -0
- package/lib/commands/plan.js +5 -2
- package/lib/commands/ready.js +5 -0
- package/lib/commands/setup.js +231 -17
- package/lib/commands/ship.js +188 -5
- package/lib/commands/show.js +5 -0
- package/lib/commands/status.js +20 -33
- package/lib/commands/sync.js +3 -1
- package/lib/commands/test.js +90 -25
- package/lib/commands/update.js +5 -0
- package/lib/commands/validate.js +218 -1
- package/lib/setup-action-log.js +2 -0
- package/lib/setup-summary-renderer.js +15 -11
- package/lib/workflow/enforce-stage.js +12 -8
- package/lib/workflow/state-manager.js +193 -0
- package/package.json +1 -1
- package/scripts/dep-guard.sh +11 -1
- package/scripts/forge-team/lib/hooks.sh +1 -1
- package/scripts/forge-team/lib/verify.sh +1 -1
- package/scripts/forge-team/lib/workload.sh +56 -27
- package/scripts/forge-team/tests/workload.test.sh +35 -4
- package/scripts/github-beads-sync/run-bd.mjs +4 -2
- package/scripts/lib/eval-runner.js +50 -0
- package/scripts/smart-status.sh +10 -1
- package/scripts/sync-utils.sh +39 -0
- 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
|
-
|
|
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 "$(
|
|
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
|
-
|
|
167
|
+
local arg="${1%$'\r'}"
|
|
168
|
+
case "$arg" in
|
|
145
169
|
--developer=*)
|
|
146
|
-
filter_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: ${
|
|
187
|
+
_workload_error "Unsupported format: ${arg#--format=}"
|
|
164
188
|
return 1
|
|
165
189
|
;;
|
|
166
190
|
*)
|
|
167
|
-
_workload_error "Unknown argument: $
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
264
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
first=false
|
|
289
|
+
local first_issue="true"
|
|
290
|
+
if [[ "$first_dev" == "true" ]]; then
|
|
291
|
+
first_dev="false"
|
|
272
292
|
else
|
|
273
|
-
|
|
293
|
+
printf ','
|
|
274
294
|
fi
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" |
|
|
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" |
|
|
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" |
|
|
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
|
-
|
|
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-
|
|
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,
|
package/scripts/smart-status.sh
CHANGED
|
@@ -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
|
-
|
|
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}"
|
package/scripts/sync-utils.sh
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
53
|
-
|
|
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
|
-
|
|
162
|
+
module.exports = {
|
|
163
|
+
classifyPushTests,
|
|
164
|
+
detectPackageManager,
|
|
165
|
+
runPrePushTests,
|
|
166
|
+
stripGitHookEnv,
|
|
167
|
+
};
|