tasks-toolkit 0.1.3__py3-none-any.whl

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 (65) hide show
  1. scripts/__init__.py +0 -0
  2. scripts/agents_sync.py +61 -0
  3. scripts/apply-plan.sh +51 -0
  4. scripts/build.sh +7 -0
  5. scripts/codex-task.sh +198 -0
  6. scripts/install-hooks.sh +9 -0
  7. scripts/junie-status.sh +52 -0
  8. scripts/junie-task.sh +183 -0
  9. scripts/kilo-task.sh +226 -0
  10. scripts/needs-dotnet-checks.sh +24 -0
  11. scripts/plan-sprint.sh +92 -0
  12. scripts/run-task.sh +79 -0
  13. scripts/run.sh +7 -0
  14. scripts/run_stats.py +141 -0
  15. scripts/run_summary.py +94 -0
  16. scripts/spec-lint.sh +139 -0
  17. scripts/sprint-auto.sh +79 -0
  18. scripts/sprint_auto_execute.py +160 -0
  19. scripts/sprint_auto_preflight.py +309 -0
  20. scripts/sprint_auto_prepare.py +271 -0
  21. scripts/sprint_auto_resume.py +153 -0
  22. scripts/sprint_auto_state.py +191 -0
  23. scripts/sprint_plan.py +238 -0
  24. scripts/task-report.sh +45 -0
  25. scripts/tasks.py +1813 -0
  26. scripts/tasks_cli.py +14 -0
  27. scripts/tasks_commands.py +11 -0
  28. scripts/tasks_init.py +140 -0
  29. scripts/tasks_mcp.py +309 -0
  30. scripts/tasks_model.py +173 -0
  31. scripts/tasks_plan_import.py +269 -0
  32. scripts/tasks_render.py +148 -0
  33. scripts/tasks_storage.py +373 -0
  34. scripts/tasks_watch.py +159 -0
  35. scripts/test.sh +43 -0
  36. scripts/test_agent_config.py +97 -0
  37. scripts/test_plan_sprint.sh +172 -0
  38. scripts/test_python_fast.sh +26 -0
  39. scripts/test_python_lint.sh +22 -0
  40. scripts/test_python_slow.sh +14 -0
  41. scripts/test_run_stats.py +64 -0
  42. scripts/test_runners.sh +588 -0
  43. scripts/test_spec_lint.sh +438 -0
  44. scripts/test_sprint_auto.py +50 -0
  45. scripts/test_sprint_auto_execute.py +112 -0
  46. scripts/test_sprint_auto_preflight.py +143 -0
  47. scripts/test_sprint_auto_prepare.py +150 -0
  48. scripts/test_sprint_auto_resume.py +104 -0
  49. scripts/test_sprint_auto_state.py +157 -0
  50. scripts/test_tasks.py +1678 -0
  51. scripts/test_tasks_mcp.py +233 -0
  52. scripts/test_tasks_plan_import.py +129 -0
  53. scripts/test_tasks_storage.py +67 -0
  54. scripts/test_tasks_watch.py +239 -0
  55. scripts/test_worktree.sh +2112 -0
  56. scripts/worktree.sh +992 -0
  57. scripts/worktree_mr.py +133 -0
  58. scripts/worktree_path.py +70 -0
  59. scripts/worktree_sprint.py +70 -0
  60. tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
  61. tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
  62. tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
  63. tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
  64. tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
  65. tasks_toolkit-0.1.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2112 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for scripts/worktree.sh — plain bash, no external dependencies.
3
+ # Each test runs in its own temporary git repo. Prints PASS/FAIL per case.
4
+ # Exit code: 0 if all pass, 1 if any fail.
5
+ set -uo pipefail
6
+
7
+ passed=0
8
+ failed=0
9
+ total=0
10
+
11
+ pass() { passed=$((passed + 1)); total=$((total + 1)); echo " PASS: $1"; }
12
+ fail() { failed=$((failed + 1)); total=$((total + 1)); echo " FAIL: $1 — $2"; }
13
+
14
+ # ── helpers ──────────────────────────────────────────────────────────────
15
+
16
+ make_repo() {
17
+ # Create a temp git repo, copy worktree.sh into scripts/, and cd into it.
18
+ # Returns the repo path via the global variable $TEST_REPO.
19
+ TEST_REPO="$(mktemp -d)"
20
+ cd "$TEST_REPO"
21
+ git init -q -b main
22
+ git config user.name "test"
23
+ git config user.email "test@localhost"
24
+ # worktree.sh computes root as dirname(BASH_SOURCE)/.., so it must live in scripts/
25
+ mkdir -p scripts
26
+ cp "$PROJECT_ROOT/scripts/worktree.sh" scripts/
27
+ cp "$PROJECT_ROOT/scripts/needs-dotnet-checks.sh" scripts/
28
+ chmod +x scripts/worktree.sh scripts/needs-dotnet-checks.sh
29
+ mkdir -p tmp/worktrees tmp/meta tmp/patches
30
+ # like the real repo: the agent workspace tmp/ is git-ignored (keeps the tree "clean")
31
+ printf 'tmp/\n' > .gitignore
32
+ }
33
+
34
+ cleanup_repo() {
35
+ rm -rf "$TEST_REPO"
36
+ }
37
+
38
+ # Simulate worktree.sh "new" without actually creating a git worktree
39
+ # (we just need the baseline commit hash and the meta file).
40
+ fake_new() {
41
+ local name="$1"
42
+ mkdir -p tmp/worktrees/"$name" tmp/meta
43
+ # The baseline is HEAD — the current state of the repo
44
+ git rev-parse HEAD > "tmp/meta/$name.base"
45
+ }
46
+
47
+ # Simulate worktree.sh "patch" — generate a diff between baseline and current state.
48
+ # Only stage tracked files and untracked files outside tmp/ and scripts/.
49
+ fake_patch() {
50
+ local name="$1"
51
+ local base
52
+ base="$(cat "tmp/meta/$name.base")"
53
+ # Stage only project files (exclude tmp/ and scripts/ which are test infrastructure)
54
+ git add -A
55
+ git reset -q "tmp/" "scripts/" 2>/dev/null || true
56
+ git diff --cached --binary "$base" > "tmp/patches/$name.patch"
57
+ }
58
+
59
+ # ── test cases ───────────────────────────────────────────────────────────
60
+
61
+ test_clean_apply_modified_new_deleted() {
62
+ # Apply a patch that modifies one file, adds a new one, and deletes another.
63
+ make_repo
64
+ trap cleanup_repo RETURN
65
+
66
+ # Seed the repo with three files
67
+ echo "line1" > file-a.txt
68
+ echo "line2" > file-b.txt
69
+ echo "line3" > file-c.txt
70
+ git add -A && git commit -q -m "initial"
71
+
72
+ # Create a "worktree" baseline at HEAD
73
+ fake_new "clean"
74
+
75
+ # Simulate agent changes in the "worktree"
76
+ echo "modified line1" > file-a.txt # modify
77
+ echo "brand new file" > file-d.txt # new
78
+ rm file-c.txt # delete
79
+
80
+ # Generate the patch
81
+ fake_patch "clean"
82
+
83
+ # Reset working tree to baseline (simulates main checkout at baseline state)
84
+ git reset --hard -q HEAD
85
+
86
+ # Apply the patch
87
+ if bash scripts/worktree.sh apply clean > /dev/null 2>&1; then
88
+ # Verify results
89
+ local a d c
90
+ a="$(cat file-a.txt)"
91
+ d="$(cat file-d.txt 2>/dev/null || true)"
92
+ c="$(cat file-c.txt 2>/dev/null || true)"
93
+ if [ "$a" = "modified line1" ] && [ "$d" = "brand new file" ] && [ -z "$c" ]; then
94
+ pass "clean apply of modified+new+deleted"
95
+ else
96
+ fail "clean apply of modified+new+deleted" "content mismatch: a='$a' d='$d' c='$c'"
97
+ fi
98
+ else
99
+ fail "clean apply of modified+new+deleted" "apply command failed"
100
+ fi
101
+ }
102
+
103
+ test_apply_with_unrelated_uncommitted_edits() {
104
+ # The original bug: main checkout has uncommitted edits to a file the patch touches,
105
+ # but those edits match the baseline state the patch was generated from.
106
+ # The patch should still apply cleanly.
107
+ make_repo
108
+ trap cleanup_repo RETURN
109
+
110
+ # Seed the repo
111
+ echo "original content" > shared.txt
112
+ echo "other file" > other.txt
113
+ git add -A && git commit -q -m "initial"
114
+
115
+ # Simulate: user edits shared.txt (uncommitted), then creates worktree.
116
+ # In real worktree.sh "new", uncommitted changes are carried to the worktree
117
+ # and committed as the baseline. We simulate that here.
118
+ echo "edited before worktree" > shared.txt
119
+ git add -A
120
+ local baseline_commit
121
+ baseline_commit="$(git -c user.name=test -c user.email=test commit -q -m "baseline")"
122
+ baseline_commit="$(git rev-parse HEAD)"
123
+ mkdir -p tmp/worktrees/unrelated tmp/meta
124
+ echo "$baseline_commit" > tmp/meta/unrelated.base
125
+
126
+ # Now simulate the agent modifying shared.txt further in the worktree
127
+ echo "agent edited shared" > shared.txt
128
+ git add -A
129
+ git reset -q "tmp/" "scripts/" 2>/dev/null || true
130
+ git diff --cached --binary "$baseline_commit" > tmp/patches/unrelated.patch
131
+
132
+ # Reset main checkout to the baseline state (matching what the worktree started from)
133
+ git reset --hard -q "$baseline_commit"
134
+ # Re-create the meta file (git reset --hard may have affected it)
135
+ echo "$baseline_commit" > tmp/meta/unrelated.base
136
+
137
+ # Apply — should succeed because main's content matches baseline
138
+ if bash scripts/worktree.sh apply unrelated > /dev/null 2>&1; then
139
+ local content
140
+ content="$(cat shared.txt)"
141
+ if [ "$content" = "agent edited shared" ]; then
142
+ pass "apply with unrelated uncommitted edits (original bug)"
143
+ else
144
+ fail "apply with unrelated uncommitted edits" "content='$content', expected 'agent edited shared'"
145
+ fi
146
+ else
147
+ fail "apply with unrelated uncommitted edits" "apply command failed"
148
+ fi
149
+ }
150
+
151
+ test_drift_after_creation() {
152
+ # If the main checkout changes a file after the worktree was created,
153
+ # apply should detect drift and abort with exit 2.
154
+ make_repo
155
+ trap cleanup_repo RETURN
156
+
157
+ echo "baseline content" > drift-file.txt
158
+ git add -A && git commit -q -m "initial"
159
+
160
+ fake_new "drift"
161
+
162
+ # Simulate agent changes
163
+ echo "agent changed" > drift-file.txt
164
+ fake_patch "drift"
165
+
166
+ # Reset to baseline
167
+ git reset --hard -q HEAD
168
+
169
+ # Now someone edits the file in main AFTER the worktree was created
170
+ echo "someone else edited" > drift-file.txt
171
+
172
+ # Apply should fail with exit 2
173
+ local rc=0
174
+ bash scripts/worktree.sh apply drift > /dev/null 2>&1 || rc=$?
175
+ if [ "$rc" -eq 2 ]; then
176
+ # Verify tree is untouched (still has "someone else edited")
177
+ local content
178
+ content="$(cat drift-file.txt)"
179
+ if [ "$content" = "someone else edited" ]; then
180
+ pass "drift after creation -> exit 2, tree untouched"
181
+ else
182
+ fail "drift after creation" "tree was modified despite drift"
183
+ fi
184
+ else
185
+ fail "drift after creation" "expected exit 2, got $rc"
186
+ fi
187
+ }
188
+
189
+ test_patch_does_not_apply() {
190
+ # A patch that conflicts with current content should yield exit 3.
191
+ # The file must match the baseline (no drift), but the patch itself must not apply.
192
+ make_repo
193
+ trap cleanup_repo RETURN
194
+
195
+ echo "line A" > conflict.txt
196
+ echo "line B" >> conflict.txt
197
+ echo "line C" >> conflict.txt
198
+ echo "line D" >> conflict.txt
199
+ git add -A && git commit -q -m "initial"
200
+
201
+ fake_new "bad-patch"
202
+
203
+ # Create a patch against a different version of the file that won't apply
204
+ # to the current content. We craft a patch whose context lines don't exist.
205
+ cat > tmp/patches/bad-patch.patch <<'PATCH'
206
+ diff --git a/conflict.txt b/conflict.txt
207
+ index 0000000..1111111 100644
208
+ --- a/conflict.txt
209
+ +++ b/conflict.txt
210
+ @@ -1,4 +1,4 @@
211
+ XXXX NOT IN FILE
212
+ -line B
213
+ +REPLACED B
214
+ YYYY NOT IN FILE
215
+ -line D
216
+ +REPLACED D
217
+ PATCH
218
+
219
+ # File matches baseline (no drift), but patch context doesn't match
220
+ local rc=0
221
+ bash scripts/worktree.sh apply bad-patch > /dev/null 2>&1 || rc=$?
222
+ if [ "$rc" -eq 3 ]; then
223
+ # Verify tree is untouched
224
+ local content
225
+ content="$(cat conflict.txt)"
226
+ if [ "$content" = "line A
227
+ line B
228
+ line C
229
+ line D" ]; then
230
+ pass "patch that does not apply -> exit 3, tree untouched"
231
+ else
232
+ fail "patch that does not apply" "tree was modified"
233
+ fi
234
+ else
235
+ fail "patch that does not apply" "expected exit 3, got $rc"
236
+ fi
237
+ }
238
+
239
+ test_missing_patch() {
240
+ # No patch file -> exit 1.
241
+ make_repo
242
+ trap cleanup_repo RETURN
243
+
244
+ echo "x" > f.txt
245
+ git add -A && git commit -q -m "initial"
246
+ fake_new "no-patch"
247
+ # Don't create the patch file
248
+
249
+ local rc=0
250
+ bash scripts/worktree.sh apply no-patch > /dev/null 2>&1 || rc=$?
251
+ if [ "$rc" -eq 1 ]; then
252
+ pass "missing patch -> exit 1"
253
+ else
254
+ fail "missing patch" "expected exit 1, got $rc"
255
+ fi
256
+ }
257
+
258
+ test_dry_run_changes_nothing() {
259
+ # --dry-run should check the patch but not modify any files.
260
+ make_repo
261
+ trap cleanup_repo RETURN
262
+
263
+ echo "before" > dryfile.txt
264
+ git add -A && git commit -q -m "initial"
265
+
266
+ fake_new "dry"
267
+ echo "after" > dryfile.txt
268
+ fake_patch "dry"
269
+
270
+ # Reset to baseline
271
+ git reset --hard -q HEAD
272
+
273
+ local rc=0
274
+ bash scripts/worktree.sh apply --dry-run dry > /dev/null 2>&1 || rc=$?
275
+ if [ "$rc" -eq 0 ]; then
276
+ local content
277
+ content="$(cat dryfile.txt)"
278
+ if [ "$content" = "before" ]; then
279
+ pass "--dry-run changes nothing"
280
+ else
281
+ fail "--dry-run changes nothing" "file was modified: '$content'"
282
+ fi
283
+ else
284
+ fail "--dry-run changes nothing" "expected exit 0, got $rc"
285
+ fi
286
+ }
287
+
288
+ test_index_unchanged_after_apply() {
289
+ # After apply, git diff --cached should be empty (nothing staged).
290
+ make_repo
291
+ trap cleanup_repo RETURN
292
+
293
+ echo "original" > index-test.txt
294
+ git add -A && git commit -q -m "initial"
295
+
296
+ fake_new "idx"
297
+ echo "modified" > index-test.txt
298
+ fake_patch "idx"
299
+
300
+ # Reset to baseline
301
+ git reset --hard -q HEAD
302
+
303
+ bash scripts/worktree.sh apply idx > /dev/null 2>&1
304
+
305
+ # Check index is clean
306
+ local cached
307
+ cached="$(git diff --cached --stat)"
308
+ if [ -z "$cached" ]; then
309
+ # But working tree should have the change
310
+ local content
311
+ content="$(cat index-test.txt)"
312
+ if [ "$content" = "modified" ]; then
313
+ pass "index stays unchanged after apply"
314
+ else
315
+ fail "index stays unchanged" "working tree not modified"
316
+ fi
317
+ else
318
+ fail "index stays unchanged" "index has changes: $cached"
319
+ fi
320
+ }
321
+
322
+ test_missing_baseline_meta() {
323
+ # Missing tmp/meta/<name>.base -> exit 1.
324
+ make_repo
325
+ trap cleanup_repo RETURN
326
+
327
+ echo "x" > f.txt
328
+ git add -A && git commit -q -m "initial"
329
+ # Create a patch but no meta file
330
+ mkdir -p tmp/patches
331
+ echo "some patch content" > tmp/patches/no-meta.patch
332
+
333
+ local rc=0
334
+ bash scripts/worktree.sh apply no-meta > /dev/null 2>&1 || rc=$?
335
+ if [ "$rc" -eq 1 ]; then
336
+ pass "missing baseline meta -> exit 1"
337
+ else
338
+ fail "missing baseline meta" "expected exit 1, got $rc"
339
+ fi
340
+ }
341
+
342
+ # ── path validation helpers ──────────────────────────────────────────────
343
+
344
+ # Create a patch file that adds a single file with the given path.
345
+ # Args: $1=patch name, $2=file path inside the patch
346
+ make_add_patch() {
347
+ local name="$1"
348
+ local fpath="$2"
349
+ mkdir -p "tmp/patches"
350
+ cat > "tmp/patches/$name.patch" <<EOF
351
+ diff --git a/$fpath b/$fpath
352
+ new file mode 100644
353
+ index 0000000..e69de29
354
+ --- /dev/null
355
+ +++ b/$fpath
356
+ @@ -0,0 +1 @@
357
+ +hello
358
+ EOF
359
+ }
360
+
361
+ # Create a patch file that deletes a file.
362
+ # Args: $1=patch name, $2=file path
363
+ make_delete_patch() {
364
+ local name="$1"
365
+ local fpath="$2"
366
+ mkdir -p "tmp/patches"
367
+ cat > "tmp/patches/$name.patch" <<EOF
368
+ diff --git a/$fpath b/$fpath
369
+ deleted file mode 100644
370
+ index e69de29..0000000
371
+ --- a/$fpath
372
+ +++ /dev/null
373
+ @@ -1 +0,0 @@
374
+ -hello
375
+ EOF
376
+ }
377
+
378
+ # Create a patch file that renames a file.
379
+ # Args: $1=patch name, $2=source path, $3=dest path
380
+ make_rename_patch() {
381
+ local name="$1"
382
+ local src="$2"
383
+ local dst="$3"
384
+ mkdir -p "tmp/patches"
385
+ cat > "tmp/patches/$name.patch" <<EOF
386
+ diff --git a/$dst b/$dst
387
+ similarity index 100%
388
+ rename from $src
389
+ rename to $dst
390
+ EOF
391
+ }
392
+
393
+ # Create a patch file that adds a symlink.
394
+ # Args: $1=patch name, $2=file path, $3=link target
395
+ make_symlink_patch() {
396
+ local name="$1"
397
+ local fpath="$2"
398
+ local target="$3"
399
+ mkdir -p "tmp/patches"
400
+ cat > "tmp/patches/$name.patch" <<EOF
401
+ diff --git a/$fpath b/$fpath
402
+ new file mode 120000
403
+ index 0000000..e69de29
404
+ --- /dev/null
405
+ +++ b/$fpath
406
+ @@ -0,0 +1 @@
407
+ +${target}
408
+ EOF
409
+ }
410
+
411
+ # ── path validation test cases ───────────────────────────────────────────
412
+
413
+ # Helper: source worktree.sh helpers and test path_ok directly.
414
+ # Args: $1=test name, $2=path to test, $3=expected result (0=ok, 1=forbidden)
415
+ assert_path_ok() {
416
+ local test_name="$1"
417
+ local test_path="$2"
418
+ local expected="$3"
419
+ # Source just the helper functions from worktree.sh
420
+ # We need GENERATED_PATHS and path_ok + is_generated_path
421
+ local saved_root="$root"
422
+ root="$TEST_REPO"
423
+ if path_ok "$test_path"; then
424
+ local actual=0
425
+ else
426
+ local actual=1
427
+ fi
428
+ root="$saved_root"
429
+ if [ "$actual" -eq "$expected" ]; then
430
+ pass "$test_name -> path_ok returns $expected"
431
+ else
432
+ fail "$test_name" "expected $expected, got $actual for path '$test_path'"
433
+ fi
434
+ }
435
+
436
+ test_forbidden_githooks_pre_commit() {
437
+ make_repo; trap cleanup_repo RETURN
438
+ assert_path_ok ".githooks/pre-commit" ".githooks/pre-commit" 1
439
+ }
440
+
441
+ test_forbidden_git_hooks() {
442
+ make_repo; trap cleanup_repo RETURN
443
+ assert_path_ok ".git/hooks/x" ".git/hooks/x" 1
444
+ }
445
+
446
+ test_forbidden_dotdot() {
447
+ make_repo; trap cleanup_repo RETURN
448
+ assert_path_ok "../outside.txt" "../outside.txt" 1
449
+ }
450
+
451
+ test_forbidden_absolute_path() {
452
+ make_repo; trap cleanup_repo RETURN
453
+ assert_path_ok "/abs/path" "/abs/path" 1
454
+ }
455
+
456
+ test_forbidden_space_in_name() {
457
+ make_repo; trap cleanup_repo RETURN
458
+ assert_path_ok "a b.txt" "a b.txt" 1
459
+ }
460
+
461
+ test_forbidden_non_ascii() {
462
+ make_repo; trap cleanup_repo RETURN
463
+ assert_path_ok "café.txt" "café.txt" 1
464
+ }
465
+
466
+ test_forbidden_dash_prefix() {
467
+ make_repo; trap cleanup_repo RETURN
468
+ assert_path_ok "-bad/file.txt" "-bad/file.txt" 1
469
+ }
470
+
471
+ test_forbidden_tmp() {
472
+ make_repo; trap cleanup_repo RETURN
473
+ assert_path_ok "tmp/x" "tmp/x" 1
474
+ }
475
+
476
+ test_forbidden_claude_settings() {
477
+ make_repo; trap cleanup_repo RETURN
478
+ assert_path_ok ".claude/settings.json" ".claude/settings.json" 1
479
+ }
480
+
481
+ test_forbidden_kilo_json() {
482
+ make_repo; trap cleanup_repo RETURN
483
+ assert_path_ok "kilo.json" "kilo.json" 1
484
+ }
485
+
486
+ test_forbidden_symlink_outside_scripts() {
487
+ # Symlinks outside scripts/ are rejected by validate_symlinks, not path_ok.
488
+ # path_ok itself allows src/link — the symlink check is a separate layer.
489
+ # Test that path_ok allows the path (symlink validation is in apply/mr).
490
+ make_repo; trap cleanup_repo RETURN
491
+ assert_path_ok "src/link (symlink path ok)" "src/link" 0
492
+ }
493
+
494
+ test_forbidden_double_slash() {
495
+ make_repo; trap cleanup_repo RETURN
496
+ assert_path_ok "a//b.txt (double slash)" "a//b.txt" 1
497
+ }
498
+
499
+ test_forbidden_junie_dir() {
500
+ make_repo; trap cleanup_repo RETURN
501
+ assert_path_ok ".junie/config.json" ".junie/config.json" 1
502
+ }
503
+
504
+ test_forbidden_mcp_json() {
505
+ make_repo; trap cleanup_repo RETURN
506
+ assert_path_ok ".mcp.json" ".mcp.json" 1
507
+ }
508
+
509
+ test_forbidden_tasks_db() {
510
+ make_repo; trap cleanup_repo RETURN
511
+ assert_path_ok "tasks/tasks.db" "tasks/tasks.db" 1
512
+ }
513
+
514
+ test_forbidden_tasks_lock() {
515
+ make_repo; trap cleanup_repo RETURN
516
+ assert_path_ok "tasks/.lock" "tasks/.lock" 1
517
+ }
518
+
519
+ test_forbidden_kilo_dir() {
520
+ make_repo; trap cleanup_repo RETURN
521
+ assert_path_ok ".kilo/settings.json" ".kilo/settings.json" 1
522
+ }
523
+
524
+ # Allowed paths — each must return 0 from path_ok
525
+ test_allowed_src_path() {
526
+ make_repo; trap cleanup_repo RETURN
527
+ assert_path_ok "src/A/B.cs" "src/A/B.cs" 0
528
+ }
529
+
530
+ test_allowed_scripts_path() {
531
+ make_repo; trap cleanup_repo RETURN
532
+ assert_path_ok "scripts/x.sh" "scripts/x.sh" 0
533
+ }
534
+
535
+ test_allowed_knowledge_path() {
536
+ make_repo; trap cleanup_repo RETURN
537
+ assert_path_ok "knowledge/n.md" "knowledge/n.md" 0
538
+ }
539
+
540
+ test_allowed_tests_path() {
541
+ make_repo; trap cleanup_repo RETURN
542
+ assert_path_ok "tests/T/File.Name.cs" "tests/T/File.Name.cs" 0
543
+ }
544
+
545
+ test_allowed_editorconfig() {
546
+ make_repo; trap cleanup_repo RETURN
547
+ assert_path_ok ".editorconfig" ".editorconfig" 0
548
+ }
549
+
550
+ test_rename_allowed_path() {
551
+ # Both source and dest of a rename must pass path_ok
552
+ make_repo; trap cleanup_repo RETURN
553
+ assert_path_ok "src/old.cs (rename src)" "src/old.cs" 0
554
+ assert_path_ok "src/new.cs (rename dst)" "src/new.cs" 0
555
+ }
556
+
557
+ test_delete_allowed_path() {
558
+ make_repo; trap cleanup_repo RETURN
559
+ assert_path_ok "src/deleteme.cs (delete)" "src/deleteme.cs" 0
560
+ }
561
+
562
+ test_dry_run_forbidden_path() {
563
+ # Old test for apply --dry-run with forbidden path — apply is retired.
564
+ # Keep as a no-op placeholder.
565
+ : # intentionally empty
566
+ }
567
+
568
+ # ── sprint lifecycle tests ───────────────────────────────────────────────
569
+
570
+ test_sprint_start_creates_branch() {
571
+ make_repo
572
+ trap cleanup_repo RETURN
573
+
574
+ echo "initial" > file.txt
575
+ git add -A && git commit -q -m "initial"
576
+
577
+ local out
578
+ out="$(bash scripts/worktree.sh sprint start 1 test)"
579
+ if [[ "$out" == "sprint sprint/1-test (from main)" ]]; then
580
+ local branch
581
+ branch="$(git symbolic-ref --short HEAD)"
582
+ if [ "$branch" = "sprint/1-test" ]; then
583
+ pass "sprint start creates and checks out sprint branch"
584
+ else
585
+ fail "sprint start checkout" "on branch '$branch', expected 'sprint/1-test'"
586
+ fi
587
+ else
588
+ fail "sprint start output" "got '$out'"
589
+ fi
590
+ }
591
+
592
+ test_sprint_start_refuses_existing() {
593
+ make_repo
594
+ trap cleanup_repo RETURN
595
+
596
+ echo "initial" > file.txt
597
+ git add -A && git commit -q -m "initial"
598
+
599
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
600
+ local rc=0
601
+ bash scripts/worktree.sh sprint start 1 test >/dev/null 2>&1 || rc=$?
602
+ if [ "$rc" -eq 1 ]; then
603
+ pass "sprint start refuses existing branch"
604
+ else
605
+ fail "sprint start refuses existing" "expected exit 1, got $rc"
606
+ fi
607
+ }
608
+
609
+ test_sprint_start_refuses_dirty_tree() {
610
+ make_repo
611
+ trap cleanup_repo RETURN
612
+
613
+ echo "initial" > file.txt
614
+ git add -A && git commit -q -m "initial"
615
+ echo "dirty" > dirty.txt
616
+
617
+ local rc=0
618
+ bash scripts/worktree.sh sprint start 1 test >/dev/null 2>&1 || rc=$?
619
+ if [ "$rc" -eq 1 ]; then
620
+ pass "sprint start refuses dirty tree"
621
+ else
622
+ fail "sprint start refuses dirty" "expected exit 1, got $rc"
623
+ fi
624
+ }
625
+
626
+ test_sprint_start_ignores_generated_paths() {
627
+ make_repo
628
+ trap cleanup_repo RETURN
629
+
630
+ echo "initial" > file.txt
631
+ git add -A && git commit -q -m "initial"
632
+ # Modify a generated path — should be ignored by clean check
633
+ mkdir -p tasks
634
+ echo "modified" > tasks/tasks.snapshot.json
635
+
636
+ local rc=0
637
+ bash scripts/worktree.sh sprint start 1 test >/dev/null 2>&1 || rc=$?
638
+ if [ "$rc" -eq 0 ]; then
639
+ pass "sprint start ignores generated paths in clean check"
640
+ else
641
+ fail "sprint start ignores generated" "expected exit 0, got $rc"
642
+ fi
643
+ }
644
+
645
+ test_sprint_status_on_sprint() {
646
+ make_repo
647
+ trap cleanup_repo RETURN
648
+
649
+ echo "initial" > file.txt
650
+ git add -A && git commit -q -m "initial"
651
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
652
+
653
+ local out
654
+ out="$(bash scripts/worktree.sh sprint status)"
655
+ if printf '%s\n' "$out" | grep -q "branch: sprint/1-test"; then
656
+ pass "sprint status shows sprint branch"
657
+ else
658
+ fail "sprint status" "output: $out"
659
+ fi
660
+ }
661
+
662
+ test_sprint_status_off_sprint() {
663
+ make_repo
664
+ trap cleanup_repo RETURN
665
+
666
+ echo "initial" > file.txt
667
+ git add -A && git commit -q -m "initial"
668
+
669
+ local out
670
+ out="$(bash scripts/worktree.sh sprint status)"
671
+ if printf '%s\n' "$out" | grep -q "(not on a sprint branch)"; then
672
+ pass "sprint status off sprint"
673
+ else
674
+ fail "sprint status off sprint" "output: $out"
675
+ fi
676
+ }
677
+
678
+ test_sprint_finish_no_merge() {
679
+ make_repo
680
+ trap cleanup_repo RETURN
681
+
682
+ # Create main branch for the finish command
683
+ echo "initial" > file.txt
684
+ git add -A && git commit -q -m "initial"
685
+ git branch -m main 2>/dev/null || git branch -m master main 2>/dev/null || true
686
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
687
+
688
+ local out rc=0
689
+ out="$(bash scripts/worktree.sh sprint finish 2>&1)" || rc=$?
690
+ # Without dotnet, it should still print the no-merge message
691
+ if printf '%s\n' "$out" | grep -q "(no merge"; then
692
+ pass "sprint finish without --merge"
693
+ else
694
+ fail "sprint finish no merge" "rc=$rc output: $out"
695
+ fi
696
+ }
697
+
698
+ test_sprint_finish_merge() {
699
+ make_repo
700
+ trap cleanup_repo RETURN
701
+
702
+ # Set up main branch
703
+ echo "initial" > file.txt
704
+ git add -A && git commit -q -m "initial"
705
+ git branch -m main 2>/dev/null || true
706
+
707
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
708
+ # Make a commit on the sprint branch
709
+ echo "sprint work" > sprint-file.txt
710
+ git add -A && git commit -q -m "sprint work"
711
+
712
+ local out rc=0
713
+ out="$(bash scripts/worktree.sh sprint finish --merge 2>&1)" || rc=$?
714
+ if printf '%s\n' "$out" | grep -q "merged sprint/1-test into main"; then
715
+ local branch
716
+ branch="$(git symbolic-ref --short HEAD)"
717
+ if [ "$branch" = "main" ]; then
718
+ pass "sprint finish --merge merges and checks out main"
719
+ else
720
+ fail "sprint finish --merge checkout" "on branch '$branch'"
721
+ fi
722
+ else
723
+ fail "sprint finish --merge" "rc=$rc output: $out"
724
+ fi
725
+ }
726
+
727
+ test_sprint_finish_refuses_off_sprint() {
728
+ make_repo
729
+ trap cleanup_repo RETURN
730
+
731
+ echo "initial" > file.txt
732
+ git add -A && git commit -q -m "initial"
733
+
734
+ local rc=0
735
+ bash scripts/worktree.sh sprint finish >/dev/null 2>&1 || rc=$?
736
+ if [ "$rc" -eq 1 ]; then
737
+ pass "sprint finish refuses off sprint branch"
738
+ else
739
+ fail "sprint finish off sprint" "expected exit 1, got $rc"
740
+ fi
741
+ }
742
+
743
+ # ── new command tests (sprint-based) ─────────────────────────────────────
744
+
745
+ test_new_refuses_off_sprint() {
746
+ make_repo
747
+ trap cleanup_repo RETURN
748
+
749
+ echo "initial" > file.txt
750
+ git add -A && git commit -q -m "initial"
751
+
752
+ local rc=0
753
+ bash scripts/worktree.sh new my-task >/dev/null 2>&1 || rc=$?
754
+ if [ "$rc" -eq 1 ]; then
755
+ pass "new refuses off sprint branch"
756
+ else
757
+ fail "new refuses off sprint" "expected exit 1, got $rc"
758
+ fi
759
+ }
760
+
761
+ test_new_refuses_dirty_tree() {
762
+ make_repo
763
+ trap cleanup_repo RETURN
764
+
765
+ echo "initial" > file.txt
766
+ git add -A && git commit -q -m "initial"
767
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
768
+ echo "dirty" > dirty.txt
769
+
770
+ local rc=0
771
+ bash scripts/worktree.sh new my-task >/dev/null 2>&1 || rc=$?
772
+ if [ "$rc" -eq 1 ]; then
773
+ pass "new refuses dirty tree"
774
+ else
775
+ fail "new refuses dirty" "expected exit 1, got $rc"
776
+ fi
777
+ }
778
+
779
+ test_new_ignores_generated_paths() {
780
+ make_repo
781
+ trap cleanup_repo RETURN
782
+
783
+ echo "initial" > file.txt
784
+ git add -A && git commit -q -m "initial"
785
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
786
+ # Modify a generated path — should be ignored
787
+ mkdir -p tasks
788
+ echo "modified" > tasks/tasks.snapshot.json
789
+
790
+ local out rc=0
791
+ out="$(bash scripts/worktree.sh new my-task 2>&1)" || rc=$?
792
+ if [ "$rc" -eq 0 ]; then
793
+ pass "new ignores generated paths in clean check"
794
+ else
795
+ fail "new ignores generated" "expected exit 0, rc=$rc output: $out"
796
+ fi
797
+ }
798
+
799
+ test_new_creates_task_branch_and_meta() {
800
+ make_repo
801
+ trap cleanup_repo RETURN
802
+
803
+ echo "initial" > file.txt
804
+ git add -A && git commit -q -m "initial"
805
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
806
+
807
+ local out
808
+ out="$(bash scripts/worktree.sh new my-task)"
809
+ if [ -d "$TEST_REPO/tmp/worktrees/my-task" ]; then
810
+ # Check branch exists
811
+ if git rev-parse --verify "task/my-task" >/dev/null 2>&1; then
812
+ # Check meta files
813
+ local base sprint
814
+ base="$(cat tmp/meta/my-task.base)"
815
+ sprint="$(cat tmp/meta/my-task.sprint)"
816
+ local sprint_head
817
+ sprint_head="$(git rev-parse HEAD)"
818
+ if [ "$base" = "$sprint_head" ] && [ "$sprint" = "sprint/1-test" ]; then
819
+ pass "new creates task branch, worktree and meta files"
820
+ else
821
+ fail "new meta files" "base=$base sprint=$sprint sprint_head=$sprint_head"
822
+ fi
823
+ else
824
+ fail "new branch" "task/my-task branch not found"
825
+ fi
826
+ else
827
+ fail "new worktree" "worktree dir not found: $out"
828
+ fi
829
+ }
830
+
831
+ test_new_with_task_id() {
832
+ make_repo
833
+ trap cleanup_repo RETURN
834
+
835
+ echo "initial" > file.txt
836
+ git add -A && git commit -q -m "initial"
837
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
838
+
839
+ bash scripts/worktree.sh new my-task --task-id 1.3 >/dev/null
840
+ if [ -f "tmp/meta/my-task.task" ]; then
841
+ local tid
842
+ tid="$(cat tmp/meta/my-task.task)"
843
+ if [ "$tid" = "1.3" ]; then
844
+ pass "new stores --task-id in meta"
845
+ else
846
+ fail "new task-id" "got '$tid', expected '1.3'"
847
+ fi
848
+ else
849
+ fail "new task-id" "tmp/meta/my-task.task not found"
850
+ fi
851
+ }
852
+
853
+ test_new_baseline_is_sprint_head_not_carried() {
854
+ # Uncommitted changes on sprint branch should NOT be carried to the worktree.
855
+ # The baseline is the sprint HEAD commit, not the dirty working tree.
856
+ make_repo
857
+ trap cleanup_repo RETURN
858
+
859
+ echo "initial" > file.txt
860
+ git add -A && git commit -q -m "initial"
861
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
862
+
863
+ # Make uncommitted changes on the sprint branch — these should NOT be carried
864
+ echo "uncommitted junk" > junk.txt
865
+ echo "modified initial" > file.txt
866
+
867
+ # tree_clean will reject because of junk.txt and modified file.txt.
868
+ # But if we only dirty with generated files, new should succeed and the
869
+ # worktree should have the committed state (sprint HEAD), not the dirty state.
870
+ # First, let's test that non-generated dirty state IS rejected:
871
+ local rc=0
872
+ bash scripts/worktree.sh new reject-dirty >/dev/null 2>&1 || rc=$?
873
+ if [ "$rc" -eq 1 ]; then
874
+ # Good — dirty tree is rejected. Now clean up and test with only generated dirt.
875
+ rm -f junk.txt
876
+ echo "initial" > file.txt # restore to committed state
877
+ mkdir -p tasks
878
+ echo "generated" > tasks/tasks.snapshot.json
879
+
880
+ bash scripts/worktree.sh new my-task >/dev/null
881
+
882
+ # The worktree should have the committed state
883
+ local wt="$TEST_REPO/tmp/worktrees/my-task"
884
+ if [ -d "$wt" ]; then
885
+ local wt_content
886
+ wt_content="$(cat "$wt/file.txt")"
887
+ if [ "$wt_content" = "initial" ]; then
888
+ pass "new baseline = sprint HEAD, uncommitted changes NOT carried"
889
+ else
890
+ fail "new baseline content" "worktree has '$wt_content', expected 'initial'"
891
+ fi
892
+ else
893
+ fail "new worktree" "worktree dir not created"
894
+ fi
895
+ else
896
+ fail "new rejects dirty tree" "expected exit 1 for dirty tree, got $rc"
897
+ fi
898
+ }
899
+
900
+ test_new_refuses_existing_worktree() {
901
+ make_repo
902
+ trap cleanup_repo RETURN
903
+
904
+ echo "initial" > file.txt
905
+ git add -A && git commit -q -m "initial"
906
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
907
+ bash scripts/worktree.sh new my-task >/dev/null
908
+
909
+ local rc=0
910
+ bash scripts/worktree.sh new my-task >/dev/null 2>&1 || rc=$?
911
+ if [ "$rc" -eq 1 ]; then
912
+ pass "new refuses existing worktree"
913
+ else
914
+ fail "new refuses existing" "expected exit 1, got $rc"
915
+ fi
916
+ }
917
+
918
+ # ── GENERATED_PATHS tests ────────────────────────────────────────────────
919
+
920
+ test_generated_paths_forbidden_in_patch() {
921
+ # GENERATED_PATHS should be rejected by path_ok
922
+ make_repo
923
+ trap cleanup_repo RETURN
924
+ assert_path_ok "tasks/tasks.snapshot.json (generated)" "tasks/tasks.snapshot.json" 1
925
+ }
926
+
927
+ test_generated_paths_forbidden_view() {
928
+ make_repo
929
+ trap cleanup_repo RETURN
930
+ assert_path_ok "tasks/README.md (generated)" "tasks/README.md" 1
931
+ assert_path_ok "tasks/sprint-1-x.md (generated)" "tasks/sprint-1-x.md" 1
932
+ }
933
+
934
+ test_clean_tree_ignores_generated_snapshot() {
935
+ # tree_clean should ignore tasks/tasks.snapshot.json
936
+ make_repo
937
+ trap cleanup_repo RETURN
938
+
939
+ echo "initial" > file.txt
940
+ git add -A && git commit -q -m "initial"
941
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
942
+
943
+ # Modify generated file — tree should be considered clean
944
+ mkdir -p tasks
945
+ echo "modified snapshot" > tasks/tasks.snapshot.json
946
+
947
+ local rc=0
948
+ bash scripts/worktree.sh new clean-gen >/dev/null 2>&1 || rc=$?
949
+ if [ "$rc" -eq 0 ]; then
950
+ pass "clean tree ignores tasks/tasks.snapshot.json"
951
+ else
952
+ fail "clean tree ignores snapshot" "expected exit 0, got $rc"
953
+ fi
954
+ }
955
+
956
+ test_clean_tree_ignores_generated_view() {
957
+ make_repo
958
+ trap cleanup_repo RETURN
959
+
960
+ echo "initial" > file.txt
961
+ git add -A && git commit -q -m "initial"
962
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
963
+
964
+ mkdir -p tasks
965
+ echo "modified view" > tasks/README.md
966
+
967
+ local rc=0
968
+ bash scripts/worktree.sh new clean-view >/dev/null 2>&1 || rc=$?
969
+ if [ "$rc" -eq 0 ]; then
970
+ pass "clean tree ignores tasks/README.md"
971
+ else
972
+ fail "clean tree ignores view" "expected exit 0, got $rc"
973
+ fi
974
+ }
975
+
976
+ # ── abandon tests ────────────────────────────────────────────────────────
977
+
978
+ test_abandon_cleans_worktree_branch_meta() {
979
+ make_repo
980
+ trap cleanup_repo RETURN
981
+
982
+ echo "initial" > file.txt
983
+ git add -A && git commit -q -m "initial"
984
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
985
+ bash scripts/worktree.sh new my-task --task-id 1.3 >/dev/null
986
+
987
+ local out
988
+ out="$(bash scripts/worktree.sh abandon my-task)"
989
+ if [[ "$out" == "abandoned my-task" ]]; then
990
+ # Verify cleanup
991
+ local wt_exists=0 branch_exists=0 base_exists=0 sprint_exists=0 task_exists=0
992
+ [ -d "$TEST_REPO/tmp/worktrees/my-task" ] && wt_exists=1
993
+ git rev-parse --verify "task/my-task" >/dev/null 2>&1 && branch_exists=1
994
+ [ -f "tmp/meta/my-task.base" ] && base_exists=1
995
+ [ -f "tmp/meta/my-task.sprint" ] && sprint_exists=1
996
+ [ -f "tmp/meta/my-task.task" ] && task_exists=1
997
+ if [ "$wt_exists" -eq 0 ] && [ "$branch_exists" -eq 0 ] && \
998
+ [ "$base_exists" -eq 0 ] && [ "$sprint_exists" -eq 0 ] && [ "$task_exists" -eq 0 ]; then
999
+ pass "abandon cleans worktree, branch and all meta files"
1000
+ else
1001
+ fail "abandon cleanup" "wt=$wt_exists branch=$branch_exists base=$base_exists sprint=$sprint_exists task=$task_exists"
1002
+ fi
1003
+ else
1004
+ fail "abandon output" "got '$out'"
1005
+ fi
1006
+ }
1007
+
1008
+ test_abandon_idempotent() {
1009
+ make_repo
1010
+ trap cleanup_repo RETURN
1011
+
1012
+ echo "initial" > file.txt
1013
+ git add -A && git commit -q -m "initial"
1014
+
1015
+ local rc=0
1016
+ bash scripts/worktree.sh abandon nonexistent >/dev/null 2>&1 || rc=$?
1017
+ if [ "$rc" -eq 0 ]; then
1018
+ pass "abandon is idempotent on nonexistent worktree"
1019
+ else
1020
+ fail "abandon idempotent" "expected exit 0, got $rc"
1021
+ fi
1022
+ }
1023
+
1024
+ # ── remove still works ───────────────────────────────────────────────────
1025
+
1026
+ test_remove_still_works() {
1027
+ make_repo
1028
+ trap cleanup_repo RETURN
1029
+
1030
+ echo "initial" > file.txt
1031
+ git add -A && git commit -q -m "initial"
1032
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
1033
+ bash scripts/worktree.sh new my-task >/dev/null
1034
+
1035
+ local out
1036
+ out="$(bash scripts/worktree.sh remove my-task)"
1037
+ if [[ "$out" == "removed my-task" ]]; then
1038
+ local wt_exists=0 branch_exists=0
1039
+ [ -d "$TEST_REPO/tmp/worktrees/my-task" ] && wt_exists=1
1040
+ git rev-parse --verify "task/my-task" >/dev/null 2>&1 && branch_exists=1
1041
+ if [ "$wt_exists" -eq 0 ] && [ "$branch_exists" -eq 0 ]; then
1042
+ pass "remove still works with task branches"
1043
+ else
1044
+ fail "remove cleanup" "wt=$wt_exists branch=$branch_exists"
1045
+ fi
1046
+ else
1047
+ fail "remove output" "got '$out'"
1048
+ fi
1049
+ }
1050
+
1051
+ # ── assert real repo untouched ───────────────────────────────────────────
1052
+
1053
+ test_tests_dont_touch_real_repo() {
1054
+ # This test verifies that our test harness (make_repo) creates isolated
1055
+ # temp repos and doesn't affect the real repository.
1056
+ # We just check that PROJECT_ROOT is different from TEST_REPO.
1057
+ make_repo
1058
+ trap cleanup_repo RETURN
1059
+
1060
+ if [ "$TEST_REPO" != "$PROJECT_ROOT" ]; then
1061
+ pass "tests use isolated temp repos, not PROJECT_ROOT"
1062
+ else
1063
+ fail "isolated temp repos" "TEST_REPO == PROJECT_ROOT"
1064
+ fi
1065
+ }
1066
+
1067
+ # ── sprint status ahead/behind tests ─────────────────────────────────────
1068
+
1069
+ test_sprint_status_ahead_behind() {
1070
+ # sprint status must compare against main, not HEAD.
1071
+ # After 2 commits on sprint, ahead should be 2.
1072
+ make_repo
1073
+ trap cleanup_repo RETURN
1074
+
1075
+ echo "initial" > file.txt
1076
+ git add -A && git commit -q -m "initial"
1077
+ git branch -m main 2>/dev/null || true
1078
+
1079
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
1080
+
1081
+ # Two commits on sprint branch
1082
+ echo "sprint 1" > s1.txt
1083
+ git add -A && git commit -q -m "sprint commit 1"
1084
+ echo "sprint 2" > s2.txt
1085
+ git add -A && git commit -q -m "sprint commit 2"
1086
+
1087
+ local out
1088
+ out="$(bash scripts/worktree.sh sprint status)"
1089
+ if printf '%s\n' "$out" | grep -q "ahead: 2 behind: 0"; then
1090
+ pass "sprint status shows ahead: 2 behind: 0"
1091
+ else
1092
+ fail "sprint status ahead/behind" "output: $out"
1093
+ fi
1094
+ }
1095
+
1096
+ test_sprint_status_ahead_zero_at_start() {
1097
+ # Right after sprint start (no new commits), ahead should be 0.
1098
+ make_repo
1099
+ trap cleanup_repo RETURN
1100
+
1101
+ echo "initial" > file.txt
1102
+ git add -A && git commit -q -m "initial"
1103
+ git branch -m main 2>/dev/null || true
1104
+
1105
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
1106
+
1107
+ local out
1108
+ out="$(bash scripts/worktree.sh sprint status)"
1109
+ if printf '%s\n' "$out" | grep -q "ahead: 0 behind: 0"; then
1110
+ pass "sprint status shows ahead: 0 behind: 0 at start"
1111
+ else
1112
+ fail "sprint status at start" "output: $out"
1113
+ fi
1114
+ }
1115
+
1116
+ # ── mr command tests ─────────────────────────────────────────────────────
1117
+
1118
+ # Helper: set up a repo with sprint + task worktree for mr/merge tests.
1119
+ # Creates: main branch, sprint/1-test, task/<name> worktree with a commit.
1120
+ # NOTE: Does NOT set trap — the caller must do `trap cleanup_repo RETURN`.
1121
+ setup_mr_repo() {
1122
+ local task_name="$1"
1123
+ make_repo
1124
+
1125
+ echo "initial" > file.txt
1126
+ git add -A && git commit -q -m "initial"
1127
+ git branch -m main 2>/dev/null || true
1128
+
1129
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
1130
+
1131
+ # Make a commit on sprint so it diverges from main
1132
+ echo "sprint work" > sprint-file.txt
1133
+ git add -A && git commit -q -m "sprint work"
1134
+
1135
+ # Create task worktree from sprint HEAD
1136
+ bash scripts/worktree.sh new "$task_name" --task-id 1.1 >/dev/null
1137
+
1138
+ # Make a commit in the task worktree
1139
+ local wt
1140
+ wt="$(bash scripts/worktree.sh path "$task_name")"
1141
+ echo "task work" > "$wt/task-file.txt"
1142
+ git -C "$wt" add -A
1143
+ git -C "$wt" commit -q -m "task work"
1144
+ }
1145
+
1146
+ test_run_requires_a_spec() {
1147
+ make_repo
1148
+ trap cleanup_repo RETURN
1149
+ local rc=0
1150
+ bash scripts/worktree.sh run foo >/dev/null 2>&1 || rc=$?
1151
+ if [ "$rc" -eq 1 ]; then
1152
+ pass "run without --file exits 1 (usage)"
1153
+ else
1154
+ fail "run usage" "expected exit 1, got $rc"
1155
+ fi
1156
+ }
1157
+
1158
+ test_retry_usage_and_limit() {
1159
+ make_repo
1160
+ trap cleanup_repo RETURN
1161
+ printf 'fix\n' > fixes.md
1162
+ local rc=0
1163
+
1164
+ bash scripts/worktree.sh retry x >/dev/null 2>&1 || rc=$?
1165
+ if [ "$rc" -eq 1 ]; then pass "retry without --fixes exits 1"; else fail "retry usage" "got $rc"; fi
1166
+
1167
+ rc=0
1168
+ bash scripts/worktree.sh retry y --fixes fixes.md >/dev/null 2>&1 || rc=$?
1169
+ if [ "$rc" -eq 1 ]; then pass "retry without a worktree exits 1"; else fail "retry no worktree" "got $rc"; fi
1170
+
1171
+ mkdir -p tmp/worktrees/rl tmp/meta
1172
+ echo 2 > tmp/meta/rl.retries
1173
+ rc=0
1174
+ bash scripts/worktree.sh retry rl --fixes fixes.md >/dev/null 2>&1 || rc=$?
1175
+ if [ "$rc" -eq 1 ]; then pass "retry over the limit (max 2) exits 1"; else fail "retry limit" "got $rc"; fi
1176
+ }
1177
+
1178
+ test_sprint_run_dry_run() {
1179
+ make_repo
1180
+ trap cleanup_repo RETURN
1181
+ printf 'import sys\nprint("no disjoint ready tasks")\n' > scripts/tasks.py
1182
+ git add -A && git commit -q -m base
1183
+ local rc=0
1184
+ bash scripts/worktree.sh sprint run --dry-run >/dev/null 2>&1 || rc=$?
1185
+ if [ "$rc" -eq 0 ]; then
1186
+ pass "sprint run --dry-run exits 0"
1187
+ else
1188
+ fail "sprint run dry-run" "rc=$rc"
1189
+ fi
1190
+ }
1191
+
1192
+ test_sprint_run_reports_results_and_planner_failure() {
1193
+ make_repo
1194
+ trap cleanup_repo RETURN
1195
+ # Stub tracker: plan-batch offers one task that has no spec link; `show` returns no links.
1196
+ cat > scripts/tasks.py <<'PY'
1197
+ import sys
1198
+ if sys.argv[1] == "plan-batch":
1199
+ print("9.9 Needs a spec")
1200
+ elif sys.argv[1] == "show":
1201
+ print("{}")
1202
+ PY
1203
+ git add -A && git commit -q -m base
1204
+ local out rc=0
1205
+ out="$(bash scripts/worktree.sh sprint run 2>&1)" || rc=$?
1206
+ if [ "$rc" -eq 0 ] && printf '%s\n' "$out" | grep -q "9.9 skipped (no spec link)" \
1207
+ && printf '%s\n' "$out" | grep -q "(none)"; then
1208
+ pass "sprint run reports skipped tasks and an empty MR list"
1209
+ else
1210
+ fail "sprint run results" "rc=$rc output: $out"
1211
+ fi
1212
+
1213
+ printf 'import sys\nsys.exit(2)\n' > scripts/tasks.py
1214
+ rc=0
1215
+ bash scripts/worktree.sh sprint run >/dev/null 2>&1 || rc=$?
1216
+ if [ "$rc" -eq 1 ]; then pass "sprint run exits 1 when plan-batch fails"; else fail "sprint run planner failure" "rc=$rc"; fi
1217
+ }
1218
+
1219
+ test_sprint_run_reports_failed_task_without_stale_mrs() {
1220
+ make_repo
1221
+ trap cleanup_repo RETURN
1222
+ mkdir -p tmp/tasks tmp/mr
1223
+ printf 'spec\n' > tmp/tasks/failed.md
1224
+ printf 'old review\n' > tmp/mr/failed.md
1225
+ cat > scripts/tasks.py <<'PY'
1226
+ import sys
1227
+ if sys.argv[1] == "plan-batch":
1228
+ print("9.9 Failed task")
1229
+ elif sys.argv[1] == "show":
1230
+ print('{"links": [{"kind": "spec", "value": "tmp/tasks/failed.md"}]}')
1231
+ PY
1232
+ printf 'exit 7\n' > scripts/run-task.sh
1233
+ git add -A && git commit -q -m base
1234
+
1235
+ local out rc=0
1236
+ out="$(bash scripts/worktree.sh sprint run 2>&1)" || rc=$?
1237
+ if [ "$rc" -eq 1 ] && printf '%s\n' "$out" | grep -q "9.9 failed exit 7" \
1238
+ && printf '%s\n' "$out" | grep -q "1 task(s) failed" \
1239
+ && printf '%s\n' "$out" | grep -q "MRs awaiting review:" \
1240
+ && printf '%s\n' "$out" | grep -q "(none)" \
1241
+ && ! printf '%s\n' "$out" | grep -q "failed.md"; then
1242
+ pass "sprint run exits 1 for a failed task and excludes its stale MR"
1243
+ else
1244
+ fail "sprint run failed task summary" "rc=$rc output: $out"
1245
+ fi
1246
+ }
1247
+
1248
+ test_sprint_run_lists_only_mrs_created_by_run() {
1249
+ setup_mr_repo "run-only"
1250
+ trap cleanup_repo RETURN
1251
+ mkdir -p tmp/tasks tmp/mr
1252
+ printf 'spec\n' > tmp/tasks/run-only.md
1253
+ printf 'old review\n' > tmp/mr/stale.md
1254
+ cat > scripts/tasks.py <<'PY'
1255
+ import sys
1256
+ if sys.argv[1] == "plan-batch":
1257
+ print("1.1 Run task")
1258
+ elif sys.argv[1] == "show":
1259
+ print('{"links": [{"kind": "spec", "value": "tmp/tasks/run-only.md"}]}')
1260
+ PY
1261
+ printf 'exit 0\n' > scripts/run-task.sh
1262
+
1263
+ local out rc=0 mr_section
1264
+ out="$(ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh sprint run 2>&1)" || rc=$?
1265
+ mr_section="${out#*MRs awaiting review:}"
1266
+ if [ "$rc" -eq 0 ] && printf '%s\n' "$out" | grep -q "1.1 run-only exit 0" \
1267
+ && printf '%s\n' "$mr_section" | grep -q "run-only" \
1268
+ && ! printf '%s\n' "$mr_section" | grep -q "stale"; then
1269
+ pass "sprint run lists only MRs created by this run"
1270
+ else
1271
+ fail "sprint run MR scope" "rc=$rc output: $out"
1272
+ fi
1273
+ }
1274
+
1275
+ test_accept_requires_a_review_marker() {
1276
+ make_repo
1277
+ trap cleanup_repo RETURN
1278
+ mkdir -p tmp/meta
1279
+ local rc=0
1280
+
1281
+ bash scripts/worktree.sh accept nometa >/dev/null 2>&1 || rc=$?
1282
+ if [ "$rc" -eq 1 ]; then pass "accept without a task id exits 1"; else fail "accept task id" "got $rc"; fi
1283
+
1284
+ echo 1.1 > tmp/meta/ac.task
1285
+ rc=0
1286
+ bash scripts/worktree.sh accept ac >/dev/null 2>&1 || rc=$?
1287
+ if [ "$rc" -eq 6 ]; then pass "accept without an MR marker exits 6"; else fail "accept marker" "got $rc"; fi
1288
+ }
1289
+
1290
+ test_accept_fails_loudly_when_links_cannot_be_recorded() {
1291
+ setup_mr_repo "acc-links"
1292
+ trap cleanup_repo RETURN
1293
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr acc-links >/dev/null 2>&1
1294
+ # Task 1.1 does not exist in this fixture's tracker, so every `link` call fails.
1295
+ local out rc=0
1296
+ out="$(bash scripts/worktree.sh accept acc-links 2>&1)" || rc=$?
1297
+ if [ "$rc" -eq 13 ] && printf '%s\n' "$out" | grep -q "could not record links"; then
1298
+ pass "accept: failed link recording exits 13"
1299
+ else
1300
+ fail "accept link failure" "rc=$rc output: $out"
1301
+ fi
1302
+ }
1303
+
1304
+ test_accept_links_only_retries_the_links() {
1305
+ setup_mr_repo "acc-retry"
1306
+ trap cleanup_repo RETURN
1307
+ # Stub tracker that records every call; `link` succeeds only when LINK_OK=1.
1308
+ cat > scripts/tasks.py <<'PY'
1309
+ import os, sys
1310
+ open(os.environ["TRACKER_LOG"], "a").write(" ".join(sys.argv[1:]) + "\n")
1311
+ sys.exit(0 if sys.argv[1] != "link" or os.environ.get("LINK_OK") == "1" else 1)
1312
+ PY
1313
+ git add -A && git commit -q -m "stub tracker"
1314
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr acc-retry >/dev/null 2>&1
1315
+ export TRACKER_LOG="$PWD/tracker.log"
1316
+ local rc=0 out
1317
+ out="$(LINK_OK=0 bash scripts/worktree.sh accept acc-retry 2>&1)" || rc=$?
1318
+ if [ "$rc" -eq 13 ] && printf '%s\n' "$out" | grep -q "retry: .* accept acc-retry --links-only --task-id 1.1"; then
1319
+ pass "accept: exit 13 prints the --links-only retry command"
1320
+ else
1321
+ fail "accept retry hint" "rc=$rc output: $out"
1322
+ fi
1323
+
1324
+ rc=0
1325
+ bash scripts/worktree.sh accept acc-retry --links-only >/dev/null 2>&1 || rc=$?
1326
+ [ "$rc" -eq 1 ] && pass "accept --links-only without --task-id exits 1" || fail "links-only needs id" "rc=$rc"
1327
+
1328
+ : > "$TRACKER_LOG"
1329
+ rc=0
1330
+ LINK_OK=1 bash scripts/worktree.sh accept acc-retry --links-only --task-id 1.1 >/dev/null 2>&1 || rc=$?
1331
+ if [ "$rc" -eq 0 ] && grep -q "^link 1.1 commit " "$TRACKER_LOG" && ! grep -q "^done" "$TRACKER_LOG"; then
1332
+ pass "accept --links-only records the links without merging"
1333
+ else
1334
+ fail "links-only" "rc=$rc log: $(cat "$TRACKER_LOG")"
1335
+ fi
1336
+ unset TRACKER_LOG
1337
+ }
1338
+
1339
+ test_mr_happy_path() {
1340
+ setup_mr_repo "mr-test"
1341
+ trap cleanup_repo RETURN
1342
+
1343
+ local out rc=0
1344
+ out="$(ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr mr-test 2>&1)" || rc=$?
1345
+ if [ "$rc" -eq 0 ]; then
1346
+ if [ -f "tmp/mr/mr-test.md" ]; then
1347
+ local mr_content
1348
+ mr_content="$(cat tmp/mr/mr-test.md)"
1349
+ if printf '%s\n' "$mr_content" | grep -q "# MR: mr-test" && \
1350
+ printf '%s\n' "$mr_content" | grep -q "1.1" && \
1351
+ printf '%s\n' "$mr_content" | grep -q "passed" && \
1352
+ printf '%s\n' "$mr_content" | grep -q "Diffstat"; then
1353
+ pass "mr happy path creates MR record"
1354
+ else
1355
+ fail "mr record content" "$mr_content"
1356
+ fi
1357
+ else
1358
+ fail "mr record file" "tmp/mr/mr-test.md not found"
1359
+ fi
1360
+ else
1361
+ fail "mr happy path" "rc=$rc output: $out"
1362
+ fi
1363
+ }
1364
+
1365
+ test_mr_with_remote_flag() {
1366
+ setup_mr_repo "mr-remote"
1367
+ trap cleanup_repo RETURN
1368
+
1369
+ local out rc=0
1370
+ out="$(ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr mr-remote --remote 2>&1)" || rc=$?
1371
+ if [ "$rc" -eq 0 ] && [ -f "tmp/mr/mr-remote.md" ]; then
1372
+ local mr_content
1373
+ mr_content="$(cat tmp/mr/mr-remote.md)"
1374
+ if printf '%s\n' "$mr_content" | grep -q "Remote hint"; then
1375
+ pass "mr --remote adds remote hint"
1376
+ else
1377
+ fail "mr --remote hint" "$mr_content"
1378
+ fi
1379
+ else
1380
+ fail "mr --remote" "rc=$rc"
1381
+ fi
1382
+ }
1383
+
1384
+ test_mr_no_worktree() {
1385
+ make_repo
1386
+ trap cleanup_repo RETURN
1387
+
1388
+ echo "initial" > file.txt
1389
+ git add -A && git commit -q -m "initial"
1390
+
1391
+ local rc=0
1392
+ bash scripts/worktree.sh mr nonexistent >/dev/null 2>&1 || rc=$?
1393
+ if [ "$rc" -eq 1 ]; then
1394
+ pass "mr exits 1 when worktree missing"
1395
+ else
1396
+ fail "mr no worktree" "expected exit 1, got $rc"
1397
+ fi
1398
+ }
1399
+
1400
+ test_mr_no_sprint_meta() {
1401
+ make_repo
1402
+ trap cleanup_repo RETURN
1403
+
1404
+ echo "initial" > file.txt
1405
+ git add -A && git commit -q -m "initial"
1406
+ mkdir -p tmp/worktrees/no-meta
1407
+ # No sprint meta file
1408
+
1409
+ local rc=0
1410
+ bash scripts/worktree.sh mr no-meta >/dev/null 2>&1 || rc=$?
1411
+ if [ "$rc" -eq 1 ]; then
1412
+ pass "mr exits 1 when sprint meta missing"
1413
+ else
1414
+ fail "mr no sprint meta" "expected exit 1, got $rc"
1415
+ fi
1416
+ }
1417
+
1418
+ test_mr_merge_conflict_exit9() {
1419
+ setup_mr_repo "mr-conflict"
1420
+ trap cleanup_repo RETURN
1421
+
1422
+ local wt
1423
+ wt="$(bash scripts/worktree.sh path "mr-conflict")"
1424
+
1425
+ # Create a conflicting commit on the sprint branch (same file, different content)
1426
+ echo "sprint changed task-file" > task-file.txt
1427
+ git add -A && git commit -q -m "sprint changes task-file"
1428
+
1429
+ # Now the task branch has "task work" and sprint has "sprint changed task-file"
1430
+ # Merging sprint into task should conflict
1431
+ local rc=0
1432
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr mr-conflict >/dev/null 2>&1 || rc=$?
1433
+ if [ "$rc" -eq 9 ]; then
1434
+ pass "mr exits 9 on conflict"
1435
+ else
1436
+ fail "mr conflict" "expected exit 9, got $rc"
1437
+ fi
1438
+ }
1439
+
1440
+ test_mr_checks_fail_exit8() {
1441
+ setup_mr_repo "mr-checks"
1442
+ trap cleanup_repo RETURN
1443
+
1444
+ local rc=0
1445
+ ACCEPT_CHECKS_CMD='exit 1' bash scripts/worktree.sh mr mr-checks >/dev/null 2>&1 || rc=$?
1446
+ if [ "$rc" -eq 8 ]; then
1447
+ pass "mr exits 8 when checks fail"
1448
+ else
1449
+ fail "mr checks fail" "expected exit 8, got $rc"
1450
+ fi
1451
+ }
1452
+
1453
+ test_mr_path_guard_exit4() {
1454
+ setup_mr_repo "mr-guard"
1455
+ trap cleanup_repo RETURN
1456
+
1457
+ local wt
1458
+ wt="$(bash scripts/worktree.sh path "mr-guard")"
1459
+
1460
+ # Add a forbidden file (kilo.json) to the task branch
1461
+ echo "forbidden" > "$wt/kilo.json"
1462
+ git -C "$wt" add -A
1463
+ git -C "$wt" commit -q -m "add forbidden file"
1464
+
1465
+ local rc=0
1466
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr mr-guard >/dev/null 2>&1 || rc=$?
1467
+ if [ "$rc" -eq 4 ]; then
1468
+ pass "mr exits 4 on forbidden path"
1469
+ else
1470
+ fail "mr path guard" "expected exit 4, got $rc"
1471
+ fi
1472
+ }
1473
+
1474
+ # ── merge command tests ──────────────────────────────────────────────────
1475
+
1476
+ test_merge_happy_path() {
1477
+ setup_mr_repo "merge-test"
1478
+ trap cleanup_repo RETURN
1479
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr merge-test >/dev/null 2>&1
1480
+
1481
+ local out rc=0
1482
+ out="$(bash scripts/worktree.sh merge merge-test 2>&1)" || rc=$?
1483
+ if [ "$rc" -eq 0 ]; then
1484
+ if printf '%s\n' "$out" | grep -q "merged task/merge-test into sprint/1-test"; then
1485
+ # Verify task branch was deleted
1486
+ local branch_exists=0
1487
+ git rev-parse --verify "task/merge-test" >/dev/null 2>&1 && branch_exists=1
1488
+ if [ "$branch_exists" -eq 0 ]; then
1489
+ pass "merge happy path merges and cleans up"
1490
+ else
1491
+ fail "merge cleanup" "task branch still exists"
1492
+ fi
1493
+ else
1494
+ fail "merge output" "output: $out"
1495
+ fi
1496
+ else
1497
+ fail "merge happy path" "rc=$rc output: $out"
1498
+ fi
1499
+ }
1500
+
1501
+ test_merge_red_flags_require_accept_flags() {
1502
+ setup_mr_repo "merge-flags"
1503
+ trap cleanup_repo RETURN
1504
+ local wt
1505
+ wt="$(bash scripts/worktree.sh path merge-flags)"
1506
+ printf '#pragma warning disable SA0001\n' >> "$wt/task-file.txt"
1507
+ git -C "$wt" add -A && git -C "$wt" commit -q -m "1.1 add a pragma"
1508
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr merge-flags >/dev/null 2>&1
1509
+
1510
+ local rc=0
1511
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh merge merge-flags >/dev/null 2>&1 || rc=$?
1512
+ if [ "$rc" -eq 12 ]; then
1513
+ pass "merge: red flags refused without --accept-flags (exit 12)"
1514
+ else
1515
+ fail "merge red-flag gate" "rc=$rc"
1516
+ return
1517
+ fi
1518
+
1519
+ rc=0
1520
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh merge merge-flags --accept-flags >/dev/null 2>&1 || rc=$?
1521
+ if [ "$rc" -eq 0 ]; then
1522
+ pass "merge: --accept-flags accepts the red flags"
1523
+ else
1524
+ fail "merge accept-flags" "rc=$rc"
1525
+ fi
1526
+ }
1527
+
1528
+ test_merge_auto_builds_mr() {
1529
+ setup_mr_repo "merge-auto"
1530
+ trap cleanup_repo RETURN
1531
+ # No `mr` first: `merge --auto` must build the MR (checks overridden green) then merge.
1532
+ local out rc=0
1533
+ out="$(ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh merge merge-auto --auto 2>&1)" || rc=$?
1534
+ if [ "$rc" -eq 0 ] && printf '%s\n' "$out" | grep -q "merged task/merge-auto into sprint/1-test"; then
1535
+ pass "merge --auto builds the MR and merges on green"
1536
+ else
1537
+ fail "merge --auto" "rc=$rc out=$out"
1538
+ fi
1539
+ }
1540
+
1541
+ test_needs_dotnet_checks() {
1542
+ make_repo
1543
+ trap cleanup_repo RETURN
1544
+ git add -A && git commit -q -m "base"
1545
+ local base base2
1546
+ base="$(git rev-parse HEAD)"
1547
+ echo "class A {}" > a.cs
1548
+ git add -A && git commit -q -m "add cs"
1549
+ if bash scripts/needs-dotnet-checks.sh . "$base" HEAD; then
1550
+ pass "needs-dotnet-checks: a .cs change needs dotnet"
1551
+ else
1552
+ fail "needs-dotnet-checks .cs" "expected exit 0"
1553
+ fi
1554
+ base2="$(git rev-parse HEAD)"
1555
+ echo "note" >> README.md
1556
+ git add -A && git commit -q -m "docs"
1557
+ if bash scripts/needs-dotnet-checks.sh . "$base2" HEAD; then
1558
+ fail "needs-dotnet-checks docs" "expected exit 1"
1559
+ else
1560
+ pass "needs-dotnet-checks: a docs-only change skips dotnet"
1561
+ fi
1562
+ }
1563
+
1564
+ test_merge_no_worktree() {
1565
+ make_repo
1566
+ trap cleanup_repo RETURN
1567
+
1568
+ echo "initial" > file.txt
1569
+ git add -A && git commit -q -m "initial"
1570
+
1571
+ local rc=0
1572
+ bash scripts/worktree.sh merge nonexistent >/dev/null 2>&1 || rc=$?
1573
+ if [ "$rc" -eq 1 ]; then
1574
+ pass "merge exits 1 when worktree missing"
1575
+ else
1576
+ fail "merge no worktree" "expected exit 1, got $rc"
1577
+ fi
1578
+ }
1579
+
1580
+ test_merge_no_sprint_meta() {
1581
+ make_repo
1582
+ trap cleanup_repo RETURN
1583
+
1584
+ echo "initial" > file.txt
1585
+ git add -A && git commit -q -m "initial"
1586
+ mkdir -p tmp/worktrees/no-meta
1587
+
1588
+ local rc=0
1589
+ bash scripts/worktree.sh merge no-meta >/dev/null 2>&1 || rc=$?
1590
+ if [ "$rc" -eq 1 ]; then
1591
+ pass "merge exits 1 when sprint meta missing"
1592
+ else
1593
+ fail "merge no sprint meta" "expected exit 1, got $rc"
1594
+ fi
1595
+ }
1596
+
1597
+ test_merge_sprint_branch_missing_exit6() {
1598
+ setup_mr_repo "merge-no-sprint"
1599
+ trap cleanup_repo RETURN
1600
+
1601
+ # Switch to main first so we can delete the sprint branch
1602
+ git checkout -q main
1603
+ git branch -D "sprint/1-test" >/dev/null 2>&1
1604
+
1605
+ local rc=0
1606
+ bash scripts/worktree.sh merge merge-no-sprint >/dev/null 2>&1 || rc=$?
1607
+ if [ "$rc" -eq 6 ]; then
1608
+ pass "merge exits 6 when sprint branch missing"
1609
+ else
1610
+ fail "merge sprint missing" "expected exit 6, got $rc"
1611
+ fi
1612
+ }
1613
+
1614
+ test_merge_stale_task_exit6() {
1615
+ setup_mr_repo "merge-stale"
1616
+ trap cleanup_repo RETURN
1617
+
1618
+ # MR is built, then the sprint branch moves on: the tested merge is no longer the real one.
1619
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr merge-stale >/dev/null 2>&1
1620
+ echo "later sprint work" > later.txt
1621
+ git add -A && git commit -q -m "sprint moved after the MR"
1622
+
1623
+ local rc=0
1624
+ bash scripts/worktree.sh merge merge-stale >/dev/null 2>&1 || rc=$?
1625
+ if [ "$rc" -eq 6 ]; then
1626
+ pass "merge exits 6 when the sprint branch moved after the MR was built"
1627
+ else
1628
+ fail "merge stale" "expected exit 6, got $rc"
1629
+ fi
1630
+ }
1631
+
1632
+ test_merge_without_mr_record_exit6() {
1633
+ setup_mr_repo "merge-nomr"
1634
+ trap cleanup_repo RETURN
1635
+ local rc=0
1636
+ bash scripts/worktree.sh merge merge-nomr >/dev/null 2>&1 || rc=$?
1637
+ if [ "$rc" -eq 6 ] && ! git log --merges --oneline | grep -q .; then
1638
+ pass "merge exits 6 without an MR record (checks cannot be skipped), nothing merged"
1639
+ else
1640
+ fail "merge without MR" "expected exit 6, got $rc"
1641
+ fi
1642
+ }
1643
+
1644
+ test_merge_real_trailers_and_dirty_tree() {
1645
+ setup_mr_repo "merge-trl"
1646
+ trap cleanup_repo RETURN
1647
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr merge-trl >/dev/null 2>&1
1648
+ echo "uncommitted" > dirty.txt
1649
+ local rc=0
1650
+ bash scripts/worktree.sh merge merge-trl >/dev/null 2>&1 || rc=$?
1651
+ if [ "$rc" -eq 1 ]; then pass "merge refuses a dirty main tree (exit 1)"; else fail "merge dirty tree" "expected 1, got $rc"; fi
1652
+ rm dirty.txt
1653
+ bash scripts/worktree.sh merge merge-trl >/dev/null 2>&1 || { fail "merge trailers" "merge failed"; return; }
1654
+ local trailers
1655
+ trailers="$(git log -1 --format='%(trailers:only,unfold)')"
1656
+ if printf '%s' "$trailers" | grep -q "^Task: 1.1" && printf '%s' "$trailers" | grep -q "^Branch: task/merge-trl"; then
1657
+ pass "merge commit carries real git trailers (Task, Branch)"
1658
+ else
1659
+ fail "merge trailers" "trailers: $trailers"
1660
+ fi
1661
+ }
1662
+
1663
+ test_merge_conflict_exit10() {
1664
+ setup_mr_repo "merge-conflict"
1665
+ trap cleanup_repo RETURN
1666
+
1667
+ local wt
1668
+ wt="$(bash scripts/worktree.sh path "merge-conflict")"
1669
+
1670
+ # Create a conflicting file on the sprint branch
1671
+ echo "sprint version" > conflict-file.txt
1672
+ git add -A && git commit -q -m "sprint conflict file"
1673
+
1674
+ # Create the same file with different content in the task worktree
1675
+ echo "task version" > "$wt/conflict-file.txt"
1676
+ git -C "$wt" add -A
1677
+ git -C "$wt" commit -q -m "task conflict file"
1678
+
1679
+ # With the MR gate a conflict surfaces in `mr` (exit 9, merge aborted), never in `merge`.
1680
+ local rc=0
1681
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr merge-conflict >/dev/null 2>&1 || rc=$?
1682
+ if [ "$rc" -eq 9 ]; then
1683
+ pass "conflict is caught by mr (exit 9)"
1684
+ else
1685
+ fail "mr conflict" "expected exit 9, got $rc"
1686
+ fi
1687
+ }
1688
+
1689
+ test_merge_with_accept_flags() {
1690
+ setup_mr_repo "merge-flags"
1691
+ trap cleanup_repo RETURN
1692
+ ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr merge-flags >/dev/null 2>&1
1693
+
1694
+ local out rc=0
1695
+ out="$(bash scripts/worktree.sh merge merge-flags --accept-flags --some-flag 2>&1)" || rc=$?
1696
+ if [ "$rc" -eq 0 ]; then
1697
+ pass "merge accepts --accept-flags without error"
1698
+ else
1699
+ fail "merge --accept-flags" "rc=$rc output: $out"
1700
+ fi
1701
+ }
1702
+
1703
+ # ── apply removed tests ──────────────────────────────────────────────────
1704
+
1705
+ test_apply_command_removed() {
1706
+ # The 'apply' command should no longer exist.
1707
+ make_repo
1708
+ trap cleanup_repo RETURN
1709
+
1710
+ echo "initial" > file.txt
1711
+ git add -A && git commit -q -m "initial"
1712
+
1713
+ local rc=0
1714
+ bash scripts/worktree.sh apply some-name >/dev/null 2>&1 || rc=$?
1715
+ # Should exit with code 2 (usage error — unknown command)
1716
+ if [ "$rc" -eq 2 ]; then
1717
+ pass "apply command removed (exit 2)"
1718
+ else
1719
+ fail "apply removed" "expected exit 2, got $rc"
1720
+ fi
1721
+ }
1722
+
1723
+ test_patch_command_still_works() {
1724
+ # 'patch' should still work as a read-only export.
1725
+ make_repo
1726
+ trap cleanup_repo RETURN
1727
+
1728
+ echo "initial" > file.txt
1729
+ git add -A && git commit -q -m "initial"
1730
+ git branch -m main 2>/dev/null || true
1731
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
1732
+ bash scripts/worktree.sh new patch-test >/dev/null
1733
+
1734
+ local wt
1735
+ wt="$(bash scripts/worktree.sh path "patch-test")"
1736
+ echo "patched" > "$wt/patched.txt"
1737
+ git -C "$wt" add -A
1738
+ git -C "$wt" commit -q -m "patch commit"
1739
+
1740
+ local out rc=0
1741
+ out="$(bash scripts/worktree.sh patch patch-test 2>&1)" || rc=$?
1742
+ if [ "$rc" -eq 0 ] && [ -f "$TEST_REPO/tmp/patches/patch-test.patch" ]; then
1743
+ pass "patch command still works"
1744
+ else
1745
+ fail "patch command" "rc=$rc output: $out"
1746
+ fi
1747
+ }
1748
+
1749
+ # ── main ─────────────────────────────────────────────────────────────────
1750
+
1751
+ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
1752
+
1753
+ # Source helper functions from worktree.sh (path_ok, is_generated_path, GENERATED_PATHS).
1754
+ # We cannot source worktree.sh directly (it has set -euo pipefail and a case statement).
1755
+ # Instead, we replicate the helper functions here for testing.
1756
+ GENERATED_PATHS="tasks/tasks.snapshot.json
1757
+ tasks/README.md
1758
+ tasks/sprint-*.md
1759
+ TASKS.md"
1760
+
1761
+ is_generated_path() {
1762
+ local p="$1"
1763
+ while IFS= read -r gp; do
1764
+ [[ "$p" == $gp ]] && return 0
1765
+ done <<< "$GENERATED_PATHS"
1766
+ return 1
1767
+ }
1768
+
1769
+ path_ok() {
1770
+ local p="$1"
1771
+ [[ -n "$p" ]] || { echo "empty path" >&2; return 1; }
1772
+ if [[ "$p" =~ [^A-Za-z0-9._/\-] ]]; then
1773
+ echo "path '$p' contains forbidden characters" >&2; return 1
1774
+ fi
1775
+ [[ "${p:0:1}" != "/" ]] || { echo "path '$p' must not start with /" >&2; return 1; }
1776
+ local IFS='/'
1777
+ local -a segments
1778
+ read -ra segments <<< "$p"
1779
+ for seg in "${segments[@]}"; do
1780
+ [[ -n "$seg" ]] || { echo "path '$p' contains empty segment" >&2; return 1; }
1781
+ [[ "$seg" != ".." ]] || { echo "path '$p' contains '..' segment" >&2; return 1; }
1782
+ [[ "${seg:0:1}" != "-" ]] || { echo "path '$p' has segment starting with '-'" >&2; return 1; }
1783
+ done
1784
+ for dir in .git .githooks .claude .junie .kilo tmp; do
1785
+ if [[ "$p" == "$dir" || "$p" == "$dir/"* || "$p" == */"$dir" || "$p" == */"$dir/"* ]]; then
1786
+ echo "path '$p' is inside protected directory $dir/" >&2; return 1
1787
+ fi
1788
+ done
1789
+ case "$p" in
1790
+ kilo.json|.mcp.json|tasks/tasks.db|tasks/.lock)
1791
+ echo "path '$p' is a protected file" >&2; return 1 ;;
1792
+ esac
1793
+ if is_generated_path "$p"; then
1794
+ echo "path '$p' is a generated file" >&2; return 1
1795
+ fi
1796
+ return 0
1797
+ }
1798
+
1799
+ root="$PROJECT_ROOT" # default root for path_ok tests
1800
+
1801
+ echo "=== worktree.sh core tests ==="
1802
+
1803
+ # Note: apply command tests removed — apply is retired; see 'apply removed tests' section
1804
+
1805
+ echo ""
1806
+ echo "=== path validation tests ==="
1807
+
1808
+ # Forbidden paths — each must exit 4
1809
+ test_forbidden_githooks_pre_commit
1810
+ test_forbidden_git_hooks
1811
+ test_forbidden_dotdot
1812
+ test_forbidden_absolute_path
1813
+ test_forbidden_space_in_name
1814
+ test_forbidden_non_ascii
1815
+ test_forbidden_dash_prefix
1816
+ test_forbidden_tmp
1817
+ test_forbidden_claude_settings
1818
+ test_forbidden_kilo_json
1819
+ test_forbidden_symlink_outside_scripts
1820
+ test_forbidden_double_slash
1821
+ test_forbidden_junie_dir
1822
+ test_forbidden_mcp_json
1823
+ test_forbidden_tasks_db
1824
+ test_forbidden_tasks_lock
1825
+ test_forbidden_kilo_dir
1826
+
1827
+ # Allowed paths — each must exit 0
1828
+ test_allowed_src_path
1829
+ test_allowed_scripts_path
1830
+ test_allowed_knowledge_path
1831
+ test_allowed_tests_path
1832
+ test_allowed_editorconfig
1833
+
1834
+ # Rename and delete of allowed paths
1835
+ test_rename_allowed_path
1836
+ test_delete_allowed_path
1837
+
1838
+ echo ""
1839
+ echo "=== sprint lifecycle tests ==="
1840
+
1841
+ test_sprint_start_creates_branch
1842
+ test_sprint_start_refuses_existing
1843
+ test_sprint_start_refuses_dirty_tree
1844
+ test_sprint_start_ignores_generated_paths
1845
+ test_sprint_status_on_sprint
1846
+ test_sprint_status_off_sprint
1847
+ test_sprint_finish_no_merge
1848
+ test_sprint_finish_merge
1849
+ test_sprint_finish_refuses_off_sprint
1850
+
1851
+ echo ""
1852
+ echo "=== new command tests (sprint-based) ==="
1853
+
1854
+ test_new_refuses_off_sprint
1855
+ test_new_refuses_dirty_tree
1856
+ test_new_ignores_generated_paths
1857
+ test_new_creates_task_branch_and_meta
1858
+ test_new_with_task_id
1859
+ test_new_baseline_is_sprint_head_not_carried
1860
+ test_new_refuses_existing_worktree
1861
+
1862
+ echo ""
1863
+ echo "=== GENERATED_PATHS tests ==="
1864
+
1865
+ test_generated_paths_forbidden_in_patch
1866
+ test_generated_paths_forbidden_view
1867
+ test_clean_tree_ignores_generated_snapshot
1868
+ test_clean_tree_ignores_generated_view
1869
+
1870
+ echo ""
1871
+ echo "=== abandon tests ==="
1872
+
1873
+ test_abandon_cleans_worktree_branch_meta
1874
+ test_abandon_idempotent
1875
+
1876
+ echo ""
1877
+ echo "=== remove compatibility tests ==="
1878
+
1879
+ test_remove_still_works
1880
+
1881
+ echo ""
1882
+ echo "=== isolation tests ==="
1883
+
1884
+ test_tests_dont_touch_real_repo
1885
+
1886
+ echo ""
1887
+ echo "=== sprint status ahead/behind tests ==="
1888
+
1889
+ test_sprint_status_ahead_behind
1890
+ test_sprint_status_ahead_zero_at_start
1891
+
1892
+ echo ""
1893
+ echo "=== mr command tests ==="
1894
+
1895
+ test_mr_happy_path
1896
+ test_run_requires_a_spec
1897
+ test_retry_usage_and_limit
1898
+ test_accept_requires_a_review_marker
1899
+ test_sprint_run_dry_run
1900
+ test_mr_with_remote_flag
1901
+ test_mr_no_worktree
1902
+ test_mr_no_sprint_meta
1903
+ test_mr_merge_conflict_exit9
1904
+ test_mr_checks_fail_exit8
1905
+ test_mr_path_guard_exit4
1906
+
1907
+ echo ""
1908
+ echo "=== merge command tests ==="
1909
+
1910
+ test_merge_happy_path
1911
+ test_merge_red_flags_require_accept_flags
1912
+ test_merge_auto_builds_mr
1913
+ test_needs_dotnet_checks
1914
+ test_merge_no_worktree
1915
+ test_merge_no_sprint_meta
1916
+ test_merge_sprint_branch_missing_exit6
1917
+ test_merge_stale_task_exit6
1918
+ test_merge_without_mr_record_exit6
1919
+ test_merge_real_trailers_and_dirty_tree
1920
+ test_merge_conflict_exit10
1921
+ test_merge_with_accept_flags
1922
+
1923
+ # ── commit command (runner commits on task/<name>) ───────────────────────
1924
+
1925
+ setup_commit_repo() {
1926
+ make_repo
1927
+ echo "initial" > file.txt
1928
+ git add -A && git commit -q -m "initial"
1929
+ git branch -m main 2>/dev/null || true
1930
+ bash scripts/worktree.sh sprint start 7 test >/dev/null
1931
+ bash scripts/worktree.sh new "$1" --task-id 7.7 >/dev/null
1932
+ CWT="$(bash scripts/worktree.sh path "$1")"
1933
+ }
1934
+
1935
+ test_commit_identity_message_and_flow() {
1936
+ setup_commit_repo "c1"
1937
+ trap cleanup_repo RETURN
1938
+ echo "agent work" > "$CWT/new-file.txt"
1939
+ local rc=0 sha
1940
+ sha="$(bash scripts/worktree.sh commit c1 --runner junie --model junie-lite --task-id 7.7 --title "Do the thing")" || rc=$?
1941
+ local author msg
1942
+ author="$(git -C "$CWT" log -1 --format=%an)"
1943
+ msg="$(git -C "$CWT" log -1 --format=%B)"
1944
+ if [ "$rc" -eq 0 ] && [ "$author" = "agent:junie" ] && printf '%s' "$msg" | grep -q "^7.7 Do the thing" \
1945
+ && printf '%s' "$msg" | grep -q "runner: junie" && printf '%s' "$msg" | grep -q "model: junie-lite" \
1946
+ && [ "$sha" = "$(git -C "$CWT" rev-parse HEAD)" ]; then
1947
+ pass "commit: identity, message trailers and printed sha"
1948
+ else
1949
+ fail "commit identity/message" "rc=$rc author=$author msg=$msg"
1950
+ fi
1951
+ # end to end: mr then merge bring the agent's file into the sprint branch
1952
+ local out
1953
+ out="$(ACCEPT_CHECKS_CMD='exit 0' bash scripts/worktree.sh mr c1 2>&1)" || { fail "commit -> mr" "$out"; return; }
1954
+ out="$(bash scripts/worktree.sh merge c1 2>&1)" || { fail "commit -> mr -> merge" "$out"; return; }
1955
+ if [ -f new-file.txt ] && [ "$(git log --merges --oneline | wc -l)" -ge 1 ]; then
1956
+ pass "commit -> mr -> merge end to end (--no-ff merge commit, file on sprint branch)"
1957
+ else
1958
+ fail "commit end to end" "new-file.txt missing or no merge commit"
1959
+ fi
1960
+ }
1961
+
1962
+ test_commit_forbidden_path_nothing_committed() {
1963
+ setup_commit_repo "c2"
1964
+ trap cleanup_repo RETURN
1965
+ local before rc=0
1966
+ before="$(git -C "$CWT" rev-parse HEAD)"
1967
+ mkdir -p "$CWT/.githooks"; echo "evil" > "$CWT/.githooks/pre-commit"
1968
+ echo "fine" > "$CWT/ok.txt"
1969
+ bash scripts/worktree.sh commit c2 --runner kilo >/dev/null 2>&1 || rc=$?
1970
+ if [ "$rc" -eq 4 ] && [ "$(git -C "$CWT" rev-parse HEAD)" = "$before" ]; then
1971
+ pass "commit: forbidden path -> exit 4, nothing committed"
1972
+ else
1973
+ fail "commit forbidden path" "rc=$rc head moved=$([ "$(git -C "$CWT" rev-parse HEAD)" = "$before" ] && echo no || echo yes)"
1974
+ fi
1975
+ }
1976
+
1977
+ test_commit_generated_file_forbidden() {
1978
+ setup_commit_repo "c3"
1979
+ trap cleanup_repo RETURN
1980
+ local rc=0
1981
+ mkdir -p "$CWT/tasks"; echo "{}" > "$CWT/tasks/tasks.snapshot.json"
1982
+ bash scripts/worktree.sh commit c3 --runner junie >/dev/null 2>&1 || rc=$?
1983
+ if [ "$rc" -eq 4 ]; then
1984
+ pass "commit: generated tracker files are refused (exit 4)"
1985
+ else
1986
+ fail "commit generated file" "expected 4, got $rc"
1987
+ fi
1988
+ }
1989
+
1990
+ test_commit_no_changes_exit1() {
1991
+ setup_commit_repo "c4"
1992
+ trap cleanup_repo RETURN
1993
+ local rc=0
1994
+ bash scripts/worktree.sh commit c4 --runner junie >/dev/null 2>&1 || rc=$?
1995
+ if [ "$rc" -eq 1 ]; then
1996
+ pass "commit: no changes -> exit 1"
1997
+ else
1998
+ fail "commit no changes" "expected 1, got $rc"
1999
+ fi
2000
+ }
2001
+
2002
+ echo ""
2003
+ echo "=== commit command tests ==="
2004
+
2005
+ test_commit_identity_message_and_flow
2006
+ test_commit_forbidden_path_nothing_committed
2007
+ test_commit_generated_file_forbidden
2008
+ test_commit_no_changes_exit1
2009
+
2010
+ test_sprint_start_from_main_and_from_flag() {
2011
+ make_repo
2012
+ trap cleanup_repo RETURN
2013
+
2014
+ echo "initial" > file.txt
2015
+ git add -A && git commit -q -m "initial"
2016
+ git checkout -q -b other && echo x > x.txt && git add -A && git commit -q -m "other work"
2017
+ bash scripts/worktree.sh sprint start 2 base >/dev/null
2018
+ if [ "$(git rev-parse sprint/2-base)" = "$(git rev-parse main)" ]; then
2019
+ pass "sprint start defaults to main, not the checked-out branch"
2020
+ else
2021
+ fail "sprint start base" "sprint/2-base is not at main"
2022
+ fi
2023
+ git checkout -q other
2024
+ bash scripts/worktree.sh sprint start 3 cascade --from other >/dev/null
2025
+ if [ "$(git rev-parse sprint/3-cascade)" = "$(git rev-parse other)" ]; then
2026
+ pass "sprint start --from builds on an explicit branch (cascade)"
2027
+ else
2028
+ fail "sprint start --from" "sprint/3-cascade is not at other"
2029
+ fi
2030
+ local rc=0
2031
+ bash scripts/worktree.sh sprint start 4 bad --from nope >/dev/null 2>&1 || rc=$?
2032
+ [ "$rc" -eq 1 ] && pass "sprint start refuses unknown --from" || fail "sprint start unknown --from" "exit $rc"
2033
+ }
2034
+
2035
+ test_new_refuses_task_of_another_sprint() {
2036
+ make_repo
2037
+ trap cleanup_repo RETURN
2038
+
2039
+ echo "initial" > file.txt
2040
+ git add -A && git commit -q -m "initial"
2041
+ bash scripts/worktree.sh sprint start 7 other >/dev/null
2042
+ local rc=0
2043
+ bash scripts/worktree.sh new wrong --task-id 6.2 >/dev/null 2>&1 || rc=$?
2044
+ if [ "$rc" -eq 11 ] && [ ! -e tmp/worktrees/wrong ] && ! git rev-parse --verify -q task/wrong >/dev/null; then
2045
+ pass "new refuses task 6.2 on sprint/7-* (exit 11, nothing created)"
2046
+ else
2047
+ fail "new wrong sprint" "exit $rc"
2048
+ fi
2049
+ rc=0
2050
+ bash scripts/worktree.sh new right --task-id 7.1 >/dev/null 2>&1 || rc=$?
2051
+ [ "$rc" -eq 0 ] && pass "new accepts task 7.1 on sprint/7-*" || fail "new right sprint" "exit $rc"
2052
+ }
2053
+
2054
+ test_merge_refuses_when_main_tree_on_other_sprint() {
2055
+ make_repo
2056
+ trap cleanup_repo RETURN
2057
+
2058
+ echo "initial" > file.txt
2059
+ git add -A && git commit -q -m "initial"
2060
+ bash scripts/worktree.sh sprint start 6 mine >/dev/null
2061
+ bash scripts/worktree.sh new t6 --task-id 6.1 >/dev/null
2062
+ git checkout -q main
2063
+ bash scripts/worktree.sh sprint start 7 theirs >/dev/null
2064
+ local rc=0
2065
+ bash scripts/worktree.sh merge t6 >/dev/null 2>&1 || rc=$?
2066
+ if [ "$rc" -eq 11 ] && [ "$(git symbolic-ref --short HEAD)" = "sprint/7-theirs" ]; then
2067
+ pass "merge refuses across sprints and does not switch the main tree"
2068
+ else
2069
+ fail "merge across sprints" "exit $rc, branch $(git symbolic-ref --short HEAD)"
2070
+ fi
2071
+ }
2072
+
2073
+ test_new_on_fresh_clone_without_tmp() {
2074
+ make_repo
2075
+ trap cleanup_repo RETURN
2076
+ echo "initial" > file.txt
2077
+ git add -A && git commit -q -m "initial"
2078
+ git branch -m main 2>/dev/null || true
2079
+ bash scripts/worktree.sh sprint start 1 test >/dev/null
2080
+ rm -rf tmp # a fresh clone has no git-ignored tmp/ at all
2081
+ local rc=0
2082
+ bash scripts/worktree.sh new fresh >/dev/null 2>&1 || rc=$?
2083
+ if [ "$rc" -eq 0 ] && [ -f tmp/meta/fresh.base ]; then
2084
+ pass "new works on a fresh clone without tmp/ (creates tmp/meta)"
2085
+ else
2086
+ fail "new on fresh clone" "rc=$rc meta=$([ -f tmp/meta/fresh.base ] && echo yes || echo missing)"
2087
+ fi
2088
+ }
2089
+
2090
+ test_new_on_fresh_clone_without_tmp
2091
+ test_accept_fails_loudly_when_links_cannot_be_recorded
2092
+ test_accept_links_only_retries_the_links
2093
+ test_sprint_run_reports_results_and_planner_failure
2094
+ test_sprint_run_reports_failed_task_without_stale_mrs
2095
+ test_sprint_run_lists_only_mrs_created_by_run
2096
+ test_sprint_start_from_main_and_from_flag
2097
+ test_new_refuses_task_of_another_sprint
2098
+ test_merge_refuses_when_main_tree_on_other_sprint
2099
+
2100
+ echo ""
2101
+ echo "=== apply removed tests ==="
2102
+
2103
+ test_apply_command_removed
2104
+ test_patch_command_still_works
2105
+
2106
+ echo ""
2107
+ echo "=== Results: $passed/$total passed, $failed failed ==="
2108
+
2109
+ if [ "$failed" -gt 0 ]; then
2110
+ exit 1
2111
+ fi
2112
+ exit 0