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.
- {specfuse_loop-0.3.13/specfuse_loop.egg-info → specfuse_loop-0.3.14}/PKG-INFO +1 -1
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/pyproject.toml +1 -1
- specfuse_loop-0.3.14/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/gh_backend.py +11 -6
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/lint_plan.py +9 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/loop.py +154 -23
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/SOURCES.txt +3 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_backend.py +6 -1
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_cost_tracking.py +6 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_driver_integration.py +18 -1
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_duration_tracking.py +6 -0
- specfuse_loop-0.3.14/tests/test_ensure_feature_branch_base.py +159 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gh_backend.py +57 -14
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_plan_errors.py +41 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_files_changed_guard.py +8 -1
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_orchestration.py +6 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_zero_token_guard.py +6 -1
- specfuse_loop-0.3.14/tests/test_resolve_base.py +180 -0
- specfuse_loop-0.3.14/tests/test_untracked_file_absorption.py +218 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_wu_execution_metadata.py +6 -0
- specfuse_loop-0.3.13/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/LICENSE +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/NOTICE +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/README.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/setup.cfg +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.13 → specfuse_loop-0.3.14}/tests/test_version_skew.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specfuse-loop"
|
|
7
|
-
version = "0.3.
|
|
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
|
|
17
|
-
"""Shell out to gh with the given argument list. Not called in tests.
|
|
18
|
-
|
|
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 =
|
|
63
|
+
check = self._runner(
|
|
59
64
|
["gh", "pr", "view", branch, "--repo", self.repo, "--json", "number"],
|
|
60
|
-
|
|
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",
|
|
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.
|
|
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`)
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
1068
|
-
# silently reusing it. `merge-base --is-ancestor B
|
|
1069
|
-
# is an ancestor of
|
|
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,
|
|
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}'
|
|
1077
|
-
f"
|
|
1078
|
-
f"
|
|
1079
|
-
f"
|
|
1080
|
-
f"
|
|
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-
|
|
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 '{
|
|
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(
|
|
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
|
|
@@ -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"})
|