specfuse-loop 0.3.13__tar.gz → 0.3.14__tar.gz

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 (150) hide show
  1. {specfuse_loop-0.3.13/specfuse_loop.egg-info → specfuse_loop-0.3.14}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.14/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/gh_backend.py +11 -6
  5. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/lint_plan.py +9 -0
  6. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/loop.py +154 -23
  7. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14/specfuse_loop.egg-info}/PKG-INFO +1 -1
  8. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/SOURCES.txt +3 -0
  9. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_backend.py +6 -1
  10. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_cost_tracking.py +6 -0
  11. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_driver_integration.py +18 -1
  12. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_duration_tracking.py +6 -0
  13. specfuse_loop-0.3.14/tests/test_ensure_feature_branch_base.py +159 -0
  14. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gh_backend.py +57 -14
  15. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_plan_errors.py +41 -0
  16. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_files_changed_guard.py +8 -1
  17. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_orchestration.py +6 -0
  18. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_zero_token_guard.py +6 -1
  19. specfuse_loop-0.3.14/tests/test_resolve_base.py +180 -0
  20. specfuse_loop-0.3.14/tests/test_untracked_file_absorption.py +218 -0
  21. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_wu_execution_metadata.py +6 -0
  22. specfuse_loop-0.3.13/specfuse/loop/data/VERSION +0 -1
  23. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/LICENSE +0 -0
  24. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/NOTICE +0 -0
  25. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/README.md +0 -0
  26. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/setup.cfg +0 -0
  27. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/__init__.py +0 -0
  28. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/_miniyaml.py +0 -0
  29. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/adopt_feature.py +0 -0
  30. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  31. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  32. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  33. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/getting-started.md +0 -0
  34. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/methodology.md +0 -0
  35. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/skills.md +0 -0
  36. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/gitignore.snippet +0 -0
  37. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/roadmap.template.md +0 -0
  38. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  39. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/never-touch.md +0 -0
  40. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/result-contract.md +0 -0
  41. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  42. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  43. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/event.schema.json +0 -0
  44. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  45. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  46. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  47. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  48. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/GATE.template.md +0 -0
  49. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  50. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/WU.template.md +0 -0
  51. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/verification.yml.example +0 -0
  52. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/gate_eval.py +0 -0
  53. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/gh_features.py +0 -0
  54. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/scaffold.py +0 -0
  55. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/validate_event.py +0 -0
  56. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  57. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/entry_points.txt +0 -0
  58. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/requires.txt +0 -0
  59. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/top_level.txt +0 -0
  60. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_adopt_feature.py +0 -0
  61. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  62. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_attempt_outcome_emission.py +0 -0
  63. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_autosync.py +0 -0
  64. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_autosync_consent.py +0 -0
  65. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_autosync_firstrun.py +0 -0
  66. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_autosync_plugin.py +0 -0
  67. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  68. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  69. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_close_wu_self_reference.py +0 -0
  70. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_closing_deliverable_guard.py +0 -0
  71. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_degraded_oracle.py +0 -0
  72. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_deliverable_presence_gate.py +0 -0
  73. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_doctor.py +0 -0
  74. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_driver_lock.py +0 -0
  75. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_empty_files_escalation.py +0 -0
  76. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_ensure_feature_branch.py +0 -0
  77. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_events_redaction.py +0 -0
  78. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_extra_gates.py +0 -0
  79. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_force_full_close.py +0 -0
  80. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gate_eval.py +0 -0
  81. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gate_eval_calibration.py +0 -0
  82. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  83. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gate_eval_terminal_wiring.py +0 -0
  84. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gh_features.py +0 -0
  85. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_git_env_isolation.py +0 -0
  86. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_hashed_denylist.py +0 -0
  87. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_hashed_denylist_ci.py +0 -0
  88. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_init_integration.py +0 -0
  89. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_leak_findings_redaction.py +0 -0
  90. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_leak_scan.py +0 -0
  91. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_leak_scan_content.py +0 -0
  92. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_learnings_query.py +0 -0
  93. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  94. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lifecycle_integration.py +0 -0
  95. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_bare_produces_path.py +0 -0
  96. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_close_intermediate.py +0 -0
  97. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_close_wu.py +0 -0
  98. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_correlation_id.py +0 -0
  99. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  100. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_oracle_env.py +0 -0
  101. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_plan_next_draft.py +0 -0
  102. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_plan_relative_import.py +0 -0
  103. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_produces_driver_helper.py +0 -0
  104. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_sections.py +0 -0
  105. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  106. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_auto_archive.py +0 -0
  107. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_caveman_preamble.py +0 -0
  108. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_close_intermediate.py +0 -0
  109. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_defaults_by_type.py +0 -0
  110. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_effort.py +0 -0
  111. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_failure_note_cap.py +0 -0
  112. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_gate_budget.py +0 -0
  113. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_model_alias.py +0 -0
  114. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_post_pass_invariant.py +0 -0
  115. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_reset_preserving_events.py +0 -0
  116. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_smoke_runner.py +0 -0
  117. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_unsandboxed.py +0 -0
  118. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_migrate_legacy.py +0 -0
  119. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_miniyaml_equivalence.py +0 -0
  120. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_miniyaml_negative.py +0 -0
  121. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_planned_cost_lint.py +0 -0
  122. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_prepare_feature.py +0 -0
  123. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_prepare_scaffold_sync.py +0 -0
  124. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_produces_field.py +0 -0
  125. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_result_block.py +0 -0
  126. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_roadmap_add_skill.py +0 -0
  127. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_roadmap_archive_skill.py +0 -0
  128. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_roadmap_row_parser.py +0 -0
  129. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_data_in_sync.py +0 -0
  130. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_doc_hygiene.py +0 -0
  131. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_docs.py +0 -0
  132. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_init.py +0 -0
  133. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_manifest.py +0 -0
  134. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_resources.py +0 -0
  135. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_seed_sanity.py +0 -0
  136. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_upgrade.py +0 -0
  137. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_wiring.py +0 -0
  138. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_skills_vendored_in_sync.py +0 -0
  139. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_squash_commit_hook_crash.py +0 -0
  140. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_template_closing_shapes.py +0 -0
  141. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_terminal_flip_ownership.py +0 -0
  142. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_terminal_flips.py +0 -0
  143. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_untracked_feature_folder.py +0 -0
  144. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_upgrade_integration.py +0 -0
  145. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_upgrade_merge_gate.py +0 -0
  146. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_validate_event.py +0 -0
  147. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_verdict_coupling.py +0 -0
  148. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_verify_empty_gate_set.py +0 -0
  149. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_version_consistency.py +0 -0
  150. {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_version_skew.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.13
3
+ Version: 0.3.14
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specfuse-loop"
7
- version = "0.3.13"
7
+ version = "0.3.14"
8
8
  description = "Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ 0.3.14
@@ -13,9 +13,14 @@ from typing import Callable, Optional
13
13
  from . import loop as _loop
14
14
 
15
15
 
16
- def _default_runner(args: list) -> None:
17
- """Shell out to gh with the given argument list. Not called in tests."""
18
- subprocess.run(args, check=True, capture_output=True, text=True)
16
+ def _default_runner(args: list, check: bool = True):
17
+ """Shell out to gh with the given argument list. Not called in tests.
18
+
19
+ Returns the completed process so probing callers (e.g. the `gh pr view`
20
+ idempotency check) can inspect `.returncode` without raising; side-effecting
21
+ callers pass no `check` override and get the prior raise-on-failure behavior.
22
+ """
23
+ return subprocess.run(args, check=check, capture_output=True, text=True)
19
24
 
20
25
 
21
26
  class GitHubBackend(_loop.Backend):
@@ -55,9 +60,9 @@ class GitHubBackend(_loop.Backend):
55
60
  title = feat_fm.get("title", feature_id)
56
61
 
57
62
  # Idempotent: skip PR creation if one already exists for this branch.
58
- check = subprocess.run(
63
+ check = self._runner(
59
64
  ["gh", "pr", "view", branch, "--repo", self.repo, "--json", "number"],
60
- capture_output=True, text=True,
65
+ check=False,
61
66
  )
62
67
  if check.returncode != 0:
63
68
  body = (
@@ -70,7 +75,7 @@ class GitHubBackend(_loop.Backend):
70
75
  "gh", "pr", "create",
71
76
  "--title", f"[{feature_id}] {title}",
72
77
  "--body", body,
73
- "--base", "main",
78
+ "--base", _loop.resolve_base(feat_fm),
74
79
  "--head", branch,
75
80
  ])
76
81
 
@@ -249,6 +249,15 @@ def lint(feature_dir: Path) -> list[str]:
249
249
  if missing:
250
250
  errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
251
251
 
252
+ if "base" in fm:
253
+ base_val = fm["base"]
254
+ feature_id_val = fm.get("feature_id", feature_dir.name)
255
+ if not isinstance(base_val, str) or not base_val.strip():
256
+ errs.append(
257
+ f"{feature_id_val}: PLAN.md frontmatter 'base' key is present but "
258
+ f"empty/whitespace-only/non-string: {base_val!r}"
259
+ )
260
+
252
261
  graph = _find_task_graph_block(body)
253
262
  if graph is None:
254
263
  return errs + ["PLAN.md has no ```yaml graph block"]
@@ -62,7 +62,7 @@ SPECFUSE_DIR = Path(".specfuse")
62
62
  REPO_ROOT = SPECFUSE_DIR.parent
63
63
  FEATURES_DIR = SPECFUSE_DIR / "features"
64
64
  VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
65
- DRIVER_VERSION = "0.3.13"
65
+ DRIVER_VERSION = "0.3.14"
66
66
  # Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
67
67
  # version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
68
68
  # the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
@@ -719,14 +719,34 @@ class FeatureBranchError(RuntimeError):
719
719
  """
720
720
 
721
721
 
722
+ def untracked_paths() -> set[str]:
723
+ """Repo-relative paths git reports as untracked, honoring .gitignore.
724
+
725
+ Snapshotted per-WU immediately before dispatch so `squash_commit` can tell
726
+ "the operator already had this file" from "this run created it" — only the
727
+ latter belongs in the WU's commit. See issue #150 and `squash_commit`.
728
+ """
729
+ out = subprocess.run(
730
+ ["git", "ls-files", "--others", "--exclude-standard"],
731
+ capture_output=True, text=True, check=True,
732
+ ).stdout
733
+ return {line.strip() for line in out.splitlines() if line.strip()}
734
+
735
+
722
736
  def _tracked_dirty_paths() -> set[str]:
723
737
  """Paths with TRACKED, uncommitted changes (staged or unstaged), repo-relative.
724
738
 
725
739
  Untracked files (porcelain `??`) are excluded: they never block a create
726
- (`checkout -B`) and carry harmlessly, so counting them would spuriously
727
- flag a leftover events.jsonl as an "unexpected" change. The dirty-tree
728
- failure in #48 is tracked local modifications ("your local changes would
729
- be overwritten by checkout"), which is exactly what this set captures.
740
+ (`checkout -B`), so counting them would spuriously flag a leftover
741
+ events.jsonl as an "unexpected" change. The dirty-tree failure in #48 is
742
+ tracked local modifications ("your local changes would be overwritten by
743
+ checkout"), which is exactly what this set captures.
744
+
745
+ This exclusion means untracked files DO carry onto a freshly created feature
746
+ branch. That is safe only because `squash_commit` refuses to stage untracked
747
+ paths that pre-date the WU's dispatch (#150) — before that fix, its
748
+ `git add -A` committed them, so this guard was strictly narrower than the
749
+ commit that followed it.
730
750
  """
731
751
  out = subprocess.run(
732
752
  ["git", "status", "--porcelain"],
@@ -790,6 +810,78 @@ def _default_branch() -> "str | None":
790
810
  return None
791
811
 
792
812
 
813
+ def resolve_base(feat_fm: dict) -> "str | None":
814
+ """The ref a feature's branch/PR should sit on top of.
815
+
816
+ Frontmatter `base` wins when set and non-empty (after stripping
817
+ whitespace). Absent, empty, or whitespace-only falls back to
818
+ `_default_branch()`; if that too is undeterminable, falls back to the
819
+ current branch. Callers (T02, T03) are responsible for wiring this in —
820
+ this function only resolves the name, it does not touch git state.
821
+ """
822
+ base = feat_fm.get("base")
823
+ if isinstance(base, str) and base.strip():
824
+ return base.strip()
825
+ default = _default_branch()
826
+ if default:
827
+ return default
828
+ return _current_branch()
829
+
830
+
831
+ class BaseBranchError(RuntimeError):
832
+ """Raised when a feature's declared `base` ref cannot be made available.
833
+
834
+ Carries an actionable, human-readable message distinguishing a probable
835
+ typo (remote confirms the ref does not exist) from an unreachable remote
836
+ (network/auth failure — the ref's existence is simply unknown), so the two
837
+ cases are never mistaken for each other.
838
+ """
839
+
840
+
841
+ def ensure_base_ref(base: str) -> None:
842
+ """Make sure git ref *base* is resolvable locally, fetching it if needed.
843
+
844
+ No-ops (no network call) when `git rev-parse --verify base` already
845
+ succeeds locally. Otherwise asks origin via `git ls-remote --exit-code`:
846
+ if origin has it, fetches it and prints one line naming what and why; if
847
+ origin explicitly does not have it, raises BaseBranchError naming likely
848
+ local-branch candidates (probable typo); if ls-remote itself fails
849
+ (offline/auth), raises a distinctly-worded BaseBranchError instead of
850
+ conflating "does not exist" with "could not check".
851
+ """
852
+ local = subprocess.run(
853
+ ["git", "rev-parse", "--verify", base],
854
+ capture_output=True, text=True,
855
+ )
856
+ if local.returncode == 0:
857
+ return
858
+ remote = subprocess.run(
859
+ ["git", "ls-remote", "--exit-code", "origin", base],
860
+ capture_output=True, text=True,
861
+ )
862
+ if remote.returncode == 0:
863
+ subprocess.run(["git", "fetch", "origin", base], capture_output=True, text=True, check=True)
864
+ print(f"ensure_base_ref: fetched '{base}' from origin (declared feature base, not present locally).")
865
+ return
866
+ if remote.returncode == 2:
867
+ # ls-remote's documented exit code for "ref not found on that remote".
868
+ branches = subprocess.run(
869
+ ["git", "branch", "--list", "--format=%(refname:short)"],
870
+ capture_output=True, text=True,
871
+ ).stdout.splitlines()
872
+ candidates = [b.strip() for b in branches if b.strip()]
873
+ listed = ", ".join(sorted(candidates)) if candidates else "(no local branches)"
874
+ raise BaseBranchError(
875
+ f"base '{base}' does not exist locally or on origin — probable typo. "
876
+ f"Local branches: {listed}"
877
+ )
878
+ stderr = remote.stderr.strip() or remote.stdout.strip() or "(no git output)"
879
+ raise BaseBranchError(
880
+ f"base '{base}' could not be verified against origin — remote unreachable "
881
+ f"(offline or auth failure), not confirmed missing: {stderr}"
882
+ )
883
+
884
+
793
885
  # Paths the scaffold overlay owns — auto_sync writes these on upgrade. They are
794
886
  # the driver's, not the user's edits: they may carry onto a feature branch and
795
887
  # are folded into the --prepare scaffold commit. Mirrors scaffold.py's versioned
@@ -1059,34 +1151,41 @@ def ensure_feature_branch(feat_fm: dict, feature_dir: "Path | None" = None) -> N
1059
1151
  ).stdout.strip()
1060
1152
  if current == branch:
1061
1153
  return
1154
+ # A declared `base` (T01's resolve_base/ensure_base_ref) is the branch's
1155
+ # true starting point, not wherever HEAD happens to be standing. Resolving
1156
+ # and fetching it here — before any checkout — lets both branch creation
1157
+ # and the staleness check below anchor on the base instead of HEAD.
1158
+ # BaseBranchError is intentionally left to propagate uncaught: the base
1159
+ # cause must reach the operator, not be reshaped into a FeatureBranchError.
1160
+ base = resolve_base(feat_fm)
1161
+ if base:
1162
+ ensure_base_ref(base)
1163
+ else:
1164
+ base = current
1062
1165
  exists = subprocess.run(
1063
1166
  ["git", "rev-parse", "--verify", branch],
1064
1167
  capture_output=True, text=True,
1065
1168
  ).returncode == 0
1066
1169
  if exists:
1067
- # Surface a stale branch that diverged from the current base instead of
1068
- # silently reusing it. `merge-base --is-ancestor B HEAD` exits 0 iff B
1069
- # is an ancestor of HEAD (i.e. HEAD already contains B — safe to reuse).
1170
+ # Surface a stale branch that diverged from the declared base instead
1171
+ # of silently reusing it. `merge-base --is-ancestor B <base>` exits 0
1172
+ # iff B is an ancestor of base (i.e. base already contains B — safe).
1070
1173
  is_ancestor = subprocess.run(
1071
- ["git", "merge-base", "--is-ancestor", branch, "HEAD"],
1174
+ ["git", "merge-base", "--is-ancestor", branch, base],
1072
1175
  capture_output=True, text=True,
1073
1176
  ).returncode == 0
1074
1177
  if not is_ancestor:
1075
1178
  raise FeatureBranchError(
1076
- f"branch '{branch}' already exists and has diverged from your "
1077
- f"current branch '{current}' (it carries commits HEAD does not). "
1078
- f"Refusing to silently check it out that could build on a stale "
1079
- f"base or lose work. Pick one, then re-run:\n"
1080
- f" - resume that branch's work: git checkout {branch}\n"
1081
- f" - rebase it onto here: git checkout {branch} && "
1082
- f"git rebase {current}\n"
1083
- f" - discard it (work not needed): git branch -D {branch} "
1084
- f"(the loop recreates it from HEAD)"
1179
+ f"branch '{branch}' has diverged from '{base}' — it carries "
1180
+ f"commits '{base}' does not, likely because it was created "
1181
+ f"from a different starting point or '{base}' has since moved "
1182
+ f"on. The safe action is to bring it up to date with the base:\n"
1183
+ f" git checkout {branch} && git rebase {base}"
1085
1184
  )
1086
1185
  _checked_checkout(["checkout", branch], f"checkout of existing branch '{branch}'")
1087
1186
  print(f"Switched to feature branch '{branch}' (was on '{current}').")
1088
1187
  else:
1089
- # Create-from-HEAD carries the working tree onto the new branch. Only
1188
+ # Create-from-base carries the working tree onto the new branch. Only
1090
1189
  # the expected /pick-feature flips may ride along; anything else stops.
1091
1190
  dirty = _tracked_dirty_paths()
1092
1191
  # Scaffold-overlay files (auto_sync's own upgrade writes) ride along too:
@@ -1101,8 +1200,8 @@ def ensure_feature_branch(feat_fm: dict, feature_dir: "Path | None" = None) -> N
1101
1200
  + f". Refusing to carry them onto new branch '{branch}'. "
1102
1201
  "Commit or stash them first, then re-run."
1103
1202
  )
1104
- _checked_checkout(["checkout", "-B", branch], f"create of branch '{branch}'")
1105
- print(f"Created feature branch '{branch}' from '{current}'.")
1203
+ _checked_checkout(["checkout", "-B", branch, base], f"create of branch '{branch}'")
1204
+ print(f"Created feature branch '{branch}' from '{base}'.")
1106
1205
 
1107
1206
 
1108
1207
  def acquire_tree_lock(specfuse_dir: Path):
@@ -1344,12 +1443,38 @@ class SquashCommitError(RuntimeError):
1344
1443
  """
1345
1444
 
1346
1445
 
1347
- def squash_commit(wu: WorkUnit, head_before: str) -> str | None:
1446
+ def squash_commit(
1447
+ wu: WorkUnit,
1448
+ head_before: str,
1449
+ untracked_before: "set[str] | frozenset[str]" = frozenset(),
1450
+ ) -> str | None:
1451
+ """Squash the WU's work into one commit; return its sha, or None if nothing.
1452
+
1453
+ `untracked_before` is the set of untracked paths captured immediately before
1454
+ this WU was dispatched (see `untracked_paths`). Those are the operator's —
1455
+ scratch notes, another harness's config, a local script — and are unstaged
1456
+ after `add -A` so they are not absorbed into the WU's commit (#150). Files
1457
+ the run itself created are still committed: the distinction is "did this
1458
+ dispatch create it", not "is it tracked".
1459
+
1460
+ Callers that omit the snapshot keep the legacy stage-everything behavior.
1461
+ """
1348
1462
  if git("rev-parse", "HEAD") != head_before:
1349
1463
  git("reset", "--soft", head_before) # fold away any commits the agent made
1350
1464
  if not git("status", "--porcelain"):
1351
1465
  return None
1352
1466
  git("add", "-A")
1467
+ if untracked_before:
1468
+ # Unstage the operator's pre-existing untracked files. Paths the agent
1469
+ # deleted during dispatch are skipped — `git reset -- <missing>` is fine,
1470
+ # but pathspec-matching nothing errors on some git versions.
1471
+ stale = sorted(p for p in untracked_before if Path(p).exists())
1472
+ if stale:
1473
+ git("reset", "-q", "--", *stale)
1474
+ # `add -A` may have had nothing else to stage: a WU whose only tree
1475
+ # change was the operator's WIP must not manufacture a commit from it.
1476
+ if not git("diff", "--cached", "--name-only"):
1477
+ return None
1353
1478
  msg = f"feat: {wu.title}\n\nFeature: {wu.wu_id}"
1354
1479
  res = subprocess.run(
1355
1480
  ["git", "commit", "-m", msg], capture_output=True, text=True,
@@ -3414,6 +3539,11 @@ def run(
3414
3539
  # Fall through to existing close-WU dispatch path
3415
3540
 
3416
3541
  head_before = git("rev-parse", "HEAD")
3542
+ # Snapshot the operator's untracked files alongside head_before:
3543
+ # squash_commit must not absorb WIP that pre-dates this dispatch
3544
+ # (#150). Captured here, not inside squash_commit, because by
3545
+ # then the agent's own new files are indistinguishable from it.
3546
+ untracked_before = untracked_paths()
3417
3547
  _is_rearm = detect_rearm_dispatch(wu)
3418
3548
  if _is_rearm:
3419
3549
  fold_cumulative_on_rearm(wu, backend)
@@ -3553,7 +3683,8 @@ def run(
3553
3683
  backend.set_wu(wu, "status", DONE)
3554
3684
  write_cost_to_wu(backend, wu, cum_usage)
3555
3685
  try:
3556
- sha = squash_commit(wu, head_before)
3686
+ sha = squash_commit(wu, head_before,
3687
+ untracked_before=untracked_before)
3557
3688
  except SquashCommitError as exc:
3558
3689
  # The squash commit was rejected (typically a
3559
3690
  # pre-commit hook). Treat as a failed attempt rather
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.13
3
+ Version: 0.3.14
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -62,6 +62,7 @@ tests/test_driver_lock.py
62
62
  tests/test_duration_tracking.py
63
63
  tests/test_empty_files_escalation.py
64
64
  tests/test_ensure_feature_branch.py
65
+ tests/test_ensure_feature_branch_base.py
65
66
  tests/test_events_redaction.py
66
67
  tests/test_extra_gates.py
67
68
  tests/test_force_full_close.py
@@ -115,6 +116,7 @@ tests/test_planned_cost_lint.py
115
116
  tests/test_prepare_feature.py
116
117
  tests/test_prepare_scaffold_sync.py
117
118
  tests/test_produces_field.py
119
+ tests/test_resolve_base.py
118
120
  tests/test_result_block.py
119
121
  tests/test_roadmap_add_skill.py
120
122
  tests/test_roadmap_archive_skill.py
@@ -134,6 +136,7 @@ tests/test_template_closing_shapes.py
134
136
  tests/test_terminal_flip_ownership.py
135
137
  tests/test_terminal_flips.py
136
138
  tests/test_untracked_feature_folder.py
139
+ tests/test_untracked_file_absorption.py
137
140
  tests/test_upgrade_integration.py
138
141
  tests/test_upgrade_merge_gate.py
139
142
  tests/test_validate_event.py
@@ -17,7 +17,7 @@ import unittest
17
17
  from pathlib import Path
18
18
 
19
19
  from tests._loop_loader import load_loop
20
- from tests._workspace import integration_workspace
20
+ from tests._workspace import integration_workspace, with_deliverable
21
21
 
22
22
  loop = load_loop()
23
23
 
@@ -154,6 +154,11 @@ class TestBackendLifecycleIntegration(unittest.TestCase):
154
154
 
155
155
  def _patch(self, name: str, replacement):
156
156
  self._patches.append((name, getattr(loop, name)))
157
+ # Dispatch stubs must write a deliverable or the presence gate
158
+ # (FEAT-2026-0022) rejects the WU as hollow. See #150 —
159
+ # `.specfuse/.loop.lock` used to stand in as the deliverable.
160
+ if name == "dispatch":
161
+ replacement = with_deliverable(replacement)
157
162
  setattr(loop, name, replacement)
158
163
 
159
164
  def test_on_feature_start_fires_before_dispatch_on_blocked_run(self):
@@ -22,6 +22,7 @@ import unittest
22
22
  from pathlib import Path
23
23
 
24
24
  from tests._loop_loader import load_loop
25
+ from tests._workspace import with_deliverable
25
26
  from tests.test_driver_integration import (
26
27
  integration_workspace,
27
28
  write_minimal_feature,
@@ -125,6 +126,11 @@ class TestCostTrackingIntegration(unittest.TestCase):
125
126
 
126
127
  def _patch(self, name: str, replacement):
127
128
  self._patches.append((name, getattr(loop, name)))
129
+ # Dispatch stubs must write a deliverable or the presence gate
130
+ # (FEAT-2026-0022) rejects the WU as hollow. See #150 —
131
+ # `.specfuse/.loop.lock` used to stand in as the deliverable.
132
+ if name == "dispatch":
133
+ replacement = with_deliverable(replacement)
128
134
  setattr(loop, name, replacement)
129
135
 
130
136
  def test_enabled_writes_cost_to_wu_frontmatter_and_events(self):
@@ -26,7 +26,7 @@ import unittest
26
26
  from pathlib import Path
27
27
 
28
28
  from tests._loop_loader import load_loop
29
- from tests._workspace import integration_workspace
29
+ from tests._workspace import integration_workspace, with_deliverable
30
30
 
31
31
  loop = load_loop()
32
32
 
@@ -90,6 +90,13 @@ gates:
90
90
  f"---\nid: {wu_id}\ntype: {wu_type}\nmodel: claude-haiku-4-5-20251001\n"
91
91
  f"status: {wu_status}\nattempts: 0\n---\n\n# {tnn}{body}"
92
92
  )
93
+ # Mirror the real repo's .gitignore for driver-owned runtime files. Without
94
+ # this the fixture repo leaves `.specfuse/.loop.lock` untracked, and before
95
+ # #150 `squash_commit`'s `git add -A` committed it — where it silently
96
+ # satisfied the deliverable-presence guard for stub dispatches that write
97
+ # nothing. The lock file is not a deliverable; the real .gitignore has always
98
+ # excluded it, so the fixture models reality by doing the same.
99
+ (root / ".gitignore").write_text(".specfuse/.loop.lock\n")
93
100
  # Stage and commit the scaffold so the driver starts from a clean tree.
94
101
  subprocess.run(["git", "-C", str(root), "add", "."], check=True)
95
102
  subprocess.run(["git", "-C", str(root), "commit", "-q", "-m",
@@ -147,6 +154,11 @@ class TestBug1StatusFlipSurvivesReset(unittest.TestCase):
147
154
 
148
155
  def _patch(self, name: str, replacement):
149
156
  self._patches.append((name, getattr(loop, name)))
157
+ # Dispatch stubs must write a deliverable or the presence gate
158
+ # (FEAT-2026-0022) rejects the WU as hollow. See #150 —
159
+ # `.specfuse/.loop.lock` used to stand in as the deliverable.
160
+ if name == "dispatch":
161
+ replacement = with_deliverable(replacement)
150
162
  setattr(loop, name, replacement)
151
163
 
152
164
  def test_passed_then_blocked_flips_persist(self):
@@ -253,6 +265,11 @@ class TestBug2FeatureBranchCheckout(unittest.TestCase):
253
265
 
254
266
  def _patch(self, name: str, replacement):
255
267
  self._patches.append((name, getattr(loop, name)))
268
+ # Dispatch stubs must write a deliverable or the presence gate
269
+ # (FEAT-2026-0022) rejects the WU as hollow. See #150 —
270
+ # `.specfuse/.loop.lock` used to stand in as the deliverable.
271
+ if name == "dispatch":
272
+ replacement = with_deliverable(replacement)
256
273
  setattr(loop, name, replacement)
257
274
 
258
275
  def test_driver_switches_to_declared_branch(self):
@@ -20,6 +20,7 @@ import os
20
20
  import unittest
21
21
 
22
22
  from tests._loop_loader import load_loop
23
+ from tests._workspace import with_deliverable
23
24
  from tests.test_driver_integration import (
24
25
  integration_workspace,
25
26
  write_minimal_feature,
@@ -44,6 +45,11 @@ class TestDurationTracking(unittest.TestCase):
44
45
 
45
46
  def _patch(self, name: str, replacement):
46
47
  self._patches.append((name, getattr(loop, name)))
48
+ # Dispatch stubs must write a deliverable or the presence gate
49
+ # (FEAT-2026-0022) rejects the WU as hollow. See #150 —
50
+ # `.specfuse/.loop.lock` used to stand in as the deliverable.
51
+ if name == "dispatch":
52
+ replacement = with_deliverable(replacement)
47
53
  setattr(loop, name, replacement)
48
54
 
49
55
  # ------------------------------------------------------------------ #
@@ -0,0 +1,159 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """ensure_feature_branch cut from the resolved base (FEAT-2026-0031/T02).
6
+
7
+ Before this WU, ensure_feature_branch always created and staleness-checked
8
+ branches against HEAD/the current branch. With a declared `base` in PLAN.md
9
+ frontmatter, that is wrong: the feature branch must be cut from the declared
10
+ base (via resolve_base/ensure_base_ref, T01), and the staleness guard must
11
+ measure divergence against that base — not against wherever the operator
12
+ happens to be standing. These tests use real git in a tmpdir; no mocking.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import subprocess
19
+ import unittest
20
+ from contextlib import contextmanager
21
+ from pathlib import Path
22
+ from tempfile import TemporaryDirectory
23
+
24
+ from tests._loop_loader import load_loop
25
+
26
+ loop = load_loop()
27
+
28
+
29
+ def _git(root: Path, *args: str) -> str:
30
+ return subprocess.run(
31
+ ["git", "-C", str(root), *args],
32
+ capture_output=True, text=True, check=True,
33
+ ).stdout.strip()
34
+
35
+
36
+ @contextmanager
37
+ def _chdir(path: Path):
38
+ prev = Path.cwd()
39
+ os.chdir(path)
40
+ try:
41
+ yield
42
+ finally:
43
+ os.chdir(prev)
44
+
45
+
46
+ @contextmanager
47
+ def _repo_no_remote():
48
+ """A temp git repo on `main` with one commit, no origin configured."""
49
+ with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
50
+ root = Path(tmp)
51
+ subprocess.run(["git", "init", "-q", "-b", "main", str(root)], check=True)
52
+ _git(root, "config", "user.email", "test@example.com")
53
+ _git(root, "config", "user.name", "Test")
54
+ _git(root, "config", "commit.gpgSign", "false")
55
+ (root / "README.md").write_text("# fixture\n")
56
+ _git(root, "add", ".")
57
+ _git(root, "commit", "-q", "-m", "init")
58
+ yield root
59
+
60
+
61
+ def _commit_file(root: Path, rel: str, content: str, msg: str) -> None:
62
+ p = root / rel
63
+ p.parent.mkdir(parents=True, exist_ok=True)
64
+ p.write_text(content)
65
+ _git(root, "add", rel)
66
+ _git(root, "commit", "-q", "-m", msg)
67
+
68
+
69
+ class TestEnsureFeatureBranchBase(unittest.TestCase):
70
+ # AC 1/4/12 — RED on HEAD (resolve_base/ensure_base_ref not wired in yet):
71
+ # with `base: release/2.0` declared and HEAD standing on `main`, the
72
+ # created feature branch must be cut from release/2.0, not from HEAD/main.
73
+ def test_branch_is_cut_from_declared_base(self):
74
+ with _repo_no_remote() as root:
75
+ _git(root, "checkout", "-q", "-b", "release/2.0")
76
+ _commit_file(root, "release_only.txt", "1\n", "release-only commit")
77
+ release_tip = _git(root, "rev-parse", "release/2.0")
78
+
79
+ _git(root, "checkout", "-q", "main")
80
+ _commit_file(root, "main_only.txt", "1\n", "main-only commit")
81
+
82
+ with _chdir(root):
83
+ loop.ensure_feature_branch({"branch": "feat/x", "base": "release/2.0"})
84
+ current = _git(root, "branch", "--show-current")
85
+ merge_base = _git(root, "merge-base", "feat/x", "release/2.0")
86
+
87
+ self.assertEqual(current, "feat/x")
88
+ self.assertEqual(merge_base, release_tip)
89
+
90
+ # AC 6 — no-regression path: a feature with no `base` key still resolves
91
+ # via _default_branch() and is cut from the default branch (main).
92
+ def test_no_base_key_still_cuts_from_default_branch(self):
93
+ with _repo_no_remote() as root:
94
+ main_tip = _git(root, "rev-parse", "main")
95
+
96
+ with _chdir(root):
97
+ loop.ensure_feature_branch({"branch": "feat/no-base"})
98
+ current = _git(root, "branch", "--show-current")
99
+ merge_base = _git(root, "merge-base", "feat/no-base", "main")
100
+
101
+ self.assertEqual(current, "feat/no-base")
102
+ self.assertEqual(merge_base, main_tip)
103
+
104
+ # AC 5/7/8/9/10 — staleness guard re-anchored to the resolved base: a
105
+ # branch that diverges from the declared base (even while being an
106
+ # ancestor of HEAD) is surfaced with an operator-readable message that
107
+ # names the branch and base, offers the rebase-onto-base hint, and never
108
+ # offers `git branch -D`.
109
+ def test_stale_branch_surfaced_against_base_not_head(self):
110
+ with _repo_no_remote() as root:
111
+ _git(root, "checkout", "-q", "-b", "release/2.0")
112
+ _commit_file(root, "release_only.txt", "1\n", "release-only commit")
113
+
114
+ _git(root, "checkout", "-q", "main")
115
+ _git(root, "checkout", "-q", "-b", "feat/x")
116
+ _commit_file(root, "feat_only.txt", "1\n", "feat-only commit")
117
+ _git(root, "checkout", "-q", "main")
118
+
119
+ with _chdir(root):
120
+ with self.assertRaises(loop.FeatureBranchError) as ctx:
121
+ loop.ensure_feature_branch({"branch": "feat/x", "base": "release/2.0"})
122
+ current = _git(root, "branch", "--show-current")
123
+
124
+ self.assertEqual(current, "main") # not silently switched
125
+ msg = str(ctx.exception)
126
+ self.assertIn("feat/x", msg)
127
+ self.assertIn("release/2.0", msg)
128
+ self.assertIn("git rebase release/2.0", msg)
129
+ self.assertNotIn("-D", msg)
130
+
131
+ # AC 11 — regression: the dirty-tree allowlist (_expected_flip_paths |
132
+ # _scaffold_managed_dirty) is unchanged; unexpected tracked edits still
133
+ # block branch creation even with a declared base.
134
+ def test_unexpected_dirty_paths_still_block_with_base(self):
135
+ with _repo_no_remote() as root:
136
+ _git(root, "checkout", "-q", "-b", "release/2.0")
137
+ _git(root, "checkout", "-q", "main")
138
+ _commit_file(root, "src/app.py", "x = 1\n", "app")
139
+ (root / "src/app.py").write_text("x = 2\n")
140
+
141
+ with _chdir(root):
142
+ with self.assertRaises(loop.FeatureBranchError) as ctx:
143
+ loop.ensure_feature_branch({"branch": "feat/x", "base": "release/2.0"})
144
+ exists = subprocess.run(
145
+ ["git", "rev-parse", "--verify", "feat/x"],
146
+ capture_output=True, text=True,
147
+ ).returncode == 0
148
+
149
+ self.assertFalse(exists)
150
+ self.assertIn("src/app.py", str(ctx.exception))
151
+
152
+ # AC 13 — a BaseBranchError raised by ensure_base_ref (declared base does
153
+ # not exist, no origin to check it against) propagates unchanged, not
154
+ # reshaped into a FeatureBranchError.
155
+ def test_base_branch_error_propagates_unwrapped(self):
156
+ with _repo_no_remote() as root:
157
+ with _chdir(root):
158
+ with self.assertRaises(loop.BaseBranchError):
159
+ loop.ensure_feature_branch({"branch": "feat/x", "base": "nonexistent/base"})