Skip to content

Commit 40ea5be

Browse files
authored
chore: housekeeping sweep — truth-path dedup, per-repo default branch, executor hardening, drop vestigial uv.lock (#47)
* refactor(truth): single-source portfolio-truth-latest.json via truth_latest_path() Introduce TRUTH_LATEST_FILENAME + truth_latest_path(output_dir) in portfolio_truth_types (the domain leaf), and route all 19 path-join sites + the glob through it. The filename now lives in one place instead of being reconstructed across cli, serve/routes, report enrichment, weekly command center, excel export, and the publisher. Human-readable error/log copy and docstrings keep the literal as prose. No behavior change. * feat(automation): use per-repo default branch for context-PR base The bounded-automation executor opened context-improvement PRs against a hardcoded 'main' base. Detect each repo's actual default branch from the local origin/HEAD ref (no network) in the truth-build pipeline, carry it through IdentityFields, and resolve the PR base in precedence order: explicit caller override > repo-detected default > portfolio fallback. - portfolio_truth_sources: _git_default_branch() + thread through git facts (also DRYs _gather_git_facts' repeated dict construction) - IdentityFields.default_branch: new optional field (additive, '' default) - automation_workflow.build_context_pr_plan: resolve per-repo, '' = auto Covered: origin/HEAD detection incl. multi-segment branches + unset fallback; plan precedence (detected vs explicit override). * fix(automation): harden executor failure paths (orphan branch + missing PR URL) Two edge cases in execute_context_pr: - branch-orphan: when apply_change fails after 'git checkout -b', the executor returned to the default branch but left the created branch behind, so a retry hit 'branch already exists'. Now delete the orphan on rollback — the operation is retry-safe. - empty execution_ref: when gh pr create succeeds (rc 0) with no URL on stdout, the proposal silently recorded an empty audit ref. The PR was created (re-running would duplicate it), so keep 'applied' but surface the missing URL in the detail instead of losing it silently. * chore(deps): remove vestigial uv.lock Nothing reads this lockfile: every install path (ci.yml, audit.yml, release.yml) uses `pip install -e ".[extras]"` resolved from pyproject.toml. There is no [tool.uv] config and no CI `uv lock --check`, so the committed lock had drifted lean while a fresh `uv lock` resolves the full optional-extra tree (torch/CUDA/transformers). Removing it stops Dependabot from opening regressive weekly uv-lock PRs against an artifact that affects nothing. pip + pyproject is the real, authoritative install path. * fix(automation): force-checkout default branch on apply_change rollback Review follow-up to eeb910c. The orphan-branch cleanup only worked when apply_change failed without dirtying the tree. In the common case where apply_change writes a partial file then raises, a plain checkout of the default branch is blocked ('local changes would be overwritten'), its unchecked returncode leaves HEAD on the orphan, and the orphan delete is then refused ('currently checked out') — stranding the repo on the orphan, the exact failure the cleanup targets. Use a force checkout: the worktree is verified clean before apply_change (skip-dirty rail), so it only discards apply_change's partial writes, then the orphan deletes cleanly.
1 parent f26e607 commit 40ea5be

15 files changed

Lines changed: 206 additions & 1097 deletions

src/automation_executor.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,15 @@ def execute_context_pr(
159159
try:
160160
plan.apply_change(plan.repo_path)
161161
except Exception as error: # noqa: BLE001 - surface as a failed result, not a strand
162-
# The change failed after the branch was created; best-effort return to
163-
# the default branch so the repo is not left stranded on a partial branch.
164-
runner(["git", "checkout", default_branch], plan.repo_path)
162+
# The change failed after the branch was created. apply_change may have
163+
# left partial writes, so a plain checkout would be blocked ("local
164+
# changes would be overwritten") and we'd be stranded on the orphan with
165+
# the branch -D refused ("currently checked out"). Force-checkout the
166+
# default branch — the worktree was verified clean before apply_change,
167+
# so -f only discards its partial garbage — then delete the orphan so a
168+
# retry isn't blocked by an "already exists" branch.
169+
runner(["git", "checkout", "-f", default_branch], plan.repo_path)
170+
runner(["git", "branch", "-D", branch], plan.repo_path)
165171
return ExecutionResult(proposal.proposal_id, "failed", f"apply-change: {error}".strip())
166172

167173
for args in (
@@ -194,11 +200,21 @@ def execute_context_pr(
194200
if pr.returncode != 0:
195201
return ExecutionResult(proposal.proposal_id, "failed", f"gh pr create: {pr.stderr}".strip())
196202

203+
# gh prints the PR URL on success. If it somehow reported success with no
204+
# URL, the PR was still created (rc 0) so we must not fail and re-run (that
205+
# would duplicate it) — but surface the missing reference in the detail
206+
# rather than silently recording an empty audit ref.
207+
pr_url = pr.stdout.strip()
208+
detail = (
209+
f"Opened PR on branch {branch}."
210+
if pr_url
211+
else f"Opened PR on branch {branch} (gh returned no PR URL)."
212+
)
197213
return ExecutionResult(
198214
proposal.proposal_id,
199215
"applied",
200-
f"Opened PR on branch {branch}.",
201-
reference=pr.stdout.strip(),
216+
detail,
217+
reference=pr_url,
202218
)
203219

204220

src/automation_workflow.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@
5353
CONTRACT_VERSION = "automation_workflow_v1"
5454

5555
# Auto-PR branch defaults. ``default_branch`` is the PR base / the branch the
56-
# executor refuses to commit onto; it is a parameter (not a buried literal) so
57-
# callers can override per-repo, defaulting to the portfolio convention.
56+
# executor refuses to commit onto. It resolves in precedence order: an explicit
57+
# caller override, then the repo's own detected default branch
58+
# (``identity.default_branch``), then this portfolio-wide fallback. Passing ""
59+
# (the new default) means "auto-detect per repo".
5860
DEFAULT_BRANCH_PREFIX = "auto/context-"
5961
DEFAULT_DEFAULT_BRANCH = "main"
6062

@@ -75,15 +77,18 @@ def build_context_pr_plan(
7577
*,
7678
workspace_root: Path,
7779
branch_prefix: str = DEFAULT_BRANCH_PREFIX,
78-
default_branch: str = DEFAULT_DEFAULT_BRANCH,
80+
default_branch: str = "",
7981
) -> ExecutionPlan:
8082
"""Build the ``ExecutionPlan`` for a context-improvement auto-PR.
8183
8284
The plan's ``apply_change`` regenerates the managed context block from the
8385
project's current repository signals; the executor handles all git/gh
84-
mechanics (and every safety rail) around it.
86+
mechanics (and every safety rail) around it. The PR base resolves in
87+
precedence order: explicit ``default_branch`` arg, the repo's detected
88+
default branch, then the portfolio-wide fallback.
8589
"""
8690
repo_path = workspace_root / project.identity.path
91+
resolved_branch = default_branch or project.identity.default_branch or DEFAULT_DEFAULT_BRANCH
8792
display = project.identity.display_name
8893
commit_message = f"docs(context): refresh managed context block for {display}"
8994
pr_body = (
@@ -95,7 +100,7 @@ def build_context_pr_plan(
95100
)
96101
return ExecutionPlan(
97102
repo_path=repo_path,
98-
default_branch=default_branch,
103+
default_branch=resolved_branch,
99104
branch_name=f"{branch_prefix}{_branch_slug(project)}",
100105
commit_message=commit_message,
101106
pr_title=commit_message,
@@ -148,7 +153,7 @@ def execute_approved_proposals(
148153
dry_run: bool = True,
149154
runner: CommandRunner = default_command_runner,
150155
branch_prefix: str = DEFAULT_BRANCH_PREFIX,
151-
default_branch: str = DEFAULT_DEFAULT_BRANCH,
156+
default_branch: str = "",
152157
) -> list[ExecutionResult]:
153158
"""Execute every APPROVED proposal in the queue, behind the executor's rails.
154159

src/cli.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from src.cloner import clone_workspace
4242
from src.github_client import GitHubClient
4343
from src.models import AnalyzerResult, AuditReport, RepoAudit, RepoMetadata
44+
from src.portfolio_truth_types import TRUTH_LATEST_FILENAME, truth_latest_path
4445
from src.recurring_review import FULL_REFRESH_DAYS
4546
from src.report_enrichment import build_run_change_counts, build_run_change_summary
4647
from src.reporter import (
@@ -2742,7 +2743,7 @@ def _run_plan_campaign_mode(args) -> None:
27422743
reviewer: str = getattr(args, "approval_reviewer", None) or _default_reviewer()
27432744

27442745
# ── Load audit results from portfolio-truth-latest.json ───────────────────
2745-
truth_path = output_dir / "portfolio-truth-latest.json"
2746+
truth_path = truth_latest_path(output_dir)
27462747
if not truth_path.exists():
27472748
print_info(
27482749
f"portfolio-truth-latest.json not found in {output_dir}. "
@@ -2870,7 +2871,7 @@ def _run_draft_readmes_mode(args) -> None:
28702871

28712872
# ── Load audit results (portfolio-truth-latest.json or warehouse) ─────────
28722873
audit_results: list[dict] = []
2873-
truth_path = output_dir / "portfolio-truth-latest.json"
2874+
truth_path = truth_latest_path(output_dir)
28742875
if truth_path.exists():
28752876
try:
28762877
raw = json.loads(truth_path.read_text(encoding="utf-8"))
@@ -3036,7 +3037,7 @@ def _run_set_initiative_mode(args) -> None:
30363037
# Load portfolio-truth to validate repo and check current tier
30373038
import json as _json
30383039

3039-
pt_candidates = sorted(output_dir.glob("portfolio-truth-latest.json"))
3040+
pt_candidates = sorted(output_dir.glob(TRUTH_LATEST_FILENAME))
30403041
if not pt_candidates:
30413042
pt_candidates = sorted(output_dir.glob("portfolio-truth-*.json"))
30423043
if not pt_candidates:
@@ -3046,7 +3047,7 @@ def _run_set_initiative_mode(args) -> None:
30463047
)
30473048
sys.exit(2)
30483049

3049-
pt_path = Path(str(output_dir / "portfolio-truth-latest.json"))
3050+
pt_path = truth_latest_path(output_dir)
30503051
if not pt_path.exists():
30513052
pt_path = pt_candidates[-1]
30523053

@@ -3109,7 +3110,7 @@ def _run_list_initiatives_mode(args) -> None:
31093110

31103111
# Load portfolio-truth for current-tier lookup (best-effort)
31113112
projects_by_name: dict[str, dict] = {}
3112-
pt_path = output_dir / "portfolio-truth-latest.json"
3113+
pt_path = truth_latest_path(output_dir)
31133114
if pt_path.exists():
31143115
try:
31153116
pt_data = _json.loads(pt_path.read_text(encoding="utf-8"))
@@ -3194,7 +3195,7 @@ def _run_suggest_initiatives_mode(args) -> None:
31943195
from src.maturity_tiers import tier_name
31953196
from src.suggest_initiatives import generate_suggestions
31963197

3197-
truth_path = _Path(args.output_dir) / "portfolio-truth-latest.json"
3198+
truth_path = truth_latest_path(_Path(args.output_dir))
31983199
if not truth_path.exists():
31993200
print_warning(
32003201
"portfolio-truth-latest.json not found. "
@@ -3238,7 +3239,7 @@ def _run_accept_suggestion_mode(args) -> None:
32383239
from src.suggest_initiatives import accept_suggestion
32393240

32403241
output_dir = Path(args.output_dir)
3241-
truth_path = output_dir / "portfolio-truth-latest.json"
3242+
truth_path = truth_latest_path(output_dir)
32423243
if not truth_path.exists():
32433244
print_warning(
32443245
"portfolio-truth-latest.json not found. Run `audit run --portfolio-truth` first."
@@ -3369,7 +3370,7 @@ def _run_tier_gaps_export_mode(args) -> None:
33693370
from src.maturity_tiers import compute_tier, tier_gap, tier_name
33703371

33713372
output_dir = Path(args.output_dir)
3372-
truth_path = output_dir / "portfolio-truth-latest.json"
3373+
truth_path = truth_latest_path(output_dir)
33733374
if not truth_path.exists():
33743375
print_warning(
33753376
"portfolio-truth-latest.json not found. Run `audit run --portfolio-truth` first."
@@ -5114,7 +5115,7 @@ def _run_auto_apply_approved_mode(args, output_dir: Path) -> None:
51145115
print_info("No existing audit report found in output directory. Run a normal audit first.")
51155116
return
51165117

5117-
truth_path = output_dir / "portfolio-truth-latest.json"
5118+
truth_path = truth_latest_path(output_dir)
51185119
if not truth_path.exists():
51195120
print_info("No portfolio truth snapshot found. Run --portfolio-truth first.")
51205121
return
@@ -5586,7 +5587,7 @@ def _run_tier_recalibration_report_mode(args) -> None:
55865587
from src.tier_recalibration import tier_distribution_report
55875588

55885589
output_dir = Path(args.output_dir)
5589-
truth_path = output_dir / "portfolio-truth-latest.json"
5590+
truth_path = truth_latest_path(output_dir)
55905591
if not truth_path.exists():
55915592
print_warning(
55925593
"portfolio-truth-latest.json not found. Run `audit run --portfolio-truth` first."
@@ -5626,7 +5627,7 @@ def _run_context_triage_mode(args) -> None:
56265627
from src.portfolio_context_triage import run_triage
56275628

56285629
output_dir = Path(args.output_dir)
5629-
truth_path = output_dir / "portfolio-truth-latest.json"
5630+
truth_path = truth_latest_path(output_dir)
56305631
if not truth_path.exists():
56315632
print_warning(
56325633
"portfolio-truth-latest.json not found. Run `audit run --portfolio-truth` first."

src/excel_export_truth_helpers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import json
66
from pathlib import Path
77

8+
from src.portfolio_truth_types import truth_latest_path
9+
810

911
def load_risk_truth(truth_dir: Path | None) -> tuple[dict[str, str], dict[str, int]]:
1012
if not truth_dir:
1113
return {}, {}
1214

13-
truth_path = truth_dir / "portfolio-truth-latest.json"
15+
truth_path = truth_latest_path(truth_dir)
1416
if not truth_path.is_file():
1517
return {}, {}
1618

src/portfolio_truth_publish.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from src.portfolio_truth_reconcile import build_portfolio_truth_snapshot
99
from src.portfolio_truth_render import render_portfolio_report_markdown, render_registry_markdown
10+
from src.portfolio_truth_types import truth_latest_path
1011
from src.portfolio_truth_validate import (
1112
validate_portfolio_report_markdown,
1213
validate_publish_targets,
@@ -56,7 +57,7 @@ def publish_portfolio_truth(
5657

5758
snapshot_stamp = build_result.snapshot.generated_at.strftime("%Y-%m-%dT%H%M%SZ")
5859
snapshot_path = output_dir / f"portfolio-truth-{snapshot_stamp}.json"
59-
latest_path = output_dir / "portfolio-truth-latest.json"
60+
latest_path = truth_latest_path(output_dir)
6061
latest_name = latest_path.name
6162
snapshot_json = json.dumps(build_result.snapshot.to_dict(), indent=2) + "\n"
6263
registry_markdown = render_registry_markdown(build_result.snapshot)

src/portfolio_truth_reconcile.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ def _build_truth_project(
348348
section_label=_resolve_section_label(group_entry, raw_project),
349349
has_git=bool(raw_project["has_git"]),
350350
repo_full_name=str(raw_project.get("repo_full_name") or ""),
351+
default_branch=str(raw_project.get("default_branch") or ""),
351352
)
352353

353354
declared_values = {

src/portfolio_truth_sources.py

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ def _inspect_project_dir(
285285
"group_entry": group_entry,
286286
"has_git": bool(git_facts.get("has_git")),
287287
"repo_full_name": str(git_facts.get("repo_full_name", "") or "").strip(),
288+
"default_branch": str(git_facts.get("default_branch", "") or "").strip(),
288289
"context_files": context_files,
289290
"context_quality": context_analysis.context_quality,
290291
"primary_context_file": context_analysis.primary_context_file,
@@ -405,7 +406,21 @@ def _read_small_json(path: Path) -> dict[str, Any]:
405406
def _gather_git_facts(project_path: Path) -> dict[str, Any]:
406407
git_dir = project_path / ".git"
407408
if not git_dir.exists():
408-
return {"has_git": False, "last_commit_at": None, "repo_full_name": ""}
409+
return {
410+
"has_git": False,
411+
"last_commit_at": None,
412+
"repo_full_name": "",
413+
"default_branch": "",
414+
}
415+
416+
# Computed once; ``last_commit_at`` is the only field the git-log probe below
417+
# can refine, so every error path returns this base unchanged.
418+
base = {
419+
"has_git": True,
420+
"last_commit_at": None,
421+
"repo_full_name": _git_remote_full_name(project_path),
422+
"default_branch": _git_default_branch(project_path),
423+
}
409424

410425
try:
411426
result = subprocess.run(
@@ -416,31 +431,43 @@ def _gather_git_facts(project_path: Path) -> dict[str, Any]:
416431
check=False,
417432
)
418433
except (FileNotFoundError, subprocess.TimeoutExpired):
419-
return {
420-
"has_git": True,
421-
"last_commit_at": None,
422-
"repo_full_name": _git_remote_full_name(project_path),
423-
}
434+
return base
424435

425436
if result.returncode != 0 or not result.stdout.strip():
426-
return {
427-
"has_git": True,
428-
"last_commit_at": None,
429-
"repo_full_name": _git_remote_full_name(project_path),
430-
}
437+
return base
431438

432439
try:
433440
return {
434-
"has_git": True,
441+
**base,
435442
"last_commit_at": datetime.fromisoformat(result.stdout.strip().replace("Z", "+00:00")),
436-
"repo_full_name": _git_remote_full_name(project_path),
437443
}
438444
except ValueError:
439-
return {
440-
"has_git": True,
441-
"last_commit_at": None,
442-
"repo_full_name": _git_remote_full_name(project_path),
443-
}
445+
return base
446+
447+
448+
def _git_default_branch(project_path: Path) -> str:
449+
"""The repo's default branch from the local ``origin/HEAD`` ref, if set.
450+
451+
Resolves only local refs (no network). Returns "" when ``origin/HEAD`` is
452+
not set locally (common for repos that were ``git init``'d rather than
453+
cloned) — callers fall back to the portfolio default.
454+
"""
455+
try:
456+
result = subprocess.run(
457+
["git", "-C", str(project_path), "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
458+
capture_output=True,
459+
text=True,
460+
timeout=5,
461+
check=False,
462+
)
463+
except (FileNotFoundError, subprocess.TimeoutExpired):
464+
return ""
465+
466+
if result.returncode != 0:
467+
return ""
468+
# e.g. "origin/main" -> "main"; partition keeps multi-segment branch names
469+
# like "origin/release/v1" -> "release/v1" intact.
470+
return result.stdout.strip().partition("/")[2].strip()
444471

445472

446473
def _git_remote_full_name(project_path: Path) -> str:

src/portfolio_truth_types.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,22 @@
33
import dataclasses
44
from dataclasses import dataclass, field
55
from datetime import datetime
6+
from pathlib import Path
67
from typing import Any
78

89
SCHEMA_VERSION = "0.5.0"
910

11+
# The published "latest" portfolio-truth artifact. The producer
12+
# (portfolio_truth_publish) writes it; every reader resolves it through
13+
# truth_latest_path() so the filename lives in exactly one place.
14+
TRUTH_LATEST_FILENAME = "portfolio-truth-latest.json"
15+
16+
17+
def truth_latest_path(output_dir: Path) -> Path:
18+
"""Resolve the canonical portfolio-truth-latest.json under an output dir."""
19+
return output_dir / TRUTH_LATEST_FILENAME
20+
21+
1022
VALID_CONTEXT_QUALITY = {"full", "standard", "minimum-viable", "boilerplate", "none"}
1123
VALID_ACTIVITY_STATUS = {"active", "recent", "stale", "archived"}
1224
VALID_REGISTRY_STATUS = {"active", "recent", "parked", "archived"}
@@ -47,6 +59,9 @@ class IdentityFields:
4759
# metadata.name) and not only the local-dir display_name, which often differ
4860
# (e.g. "Signal & Noise" vs "signal-noise").
4961
repo_full_name: str = ""
62+
# The repo's default branch (from local ``origin/HEAD``), when detectable.
63+
# Empty when not set locally; consumers fall back to the portfolio default.
64+
default_branch: str = ""
5065

5166
def to_dict(self) -> dict[str, Any]:
5267
return dataclasses.asdict(self)

src/report_enrichment.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pathlib import Path
66
from typing import Any
77

8+
from src.portfolio_truth_types import truth_latest_path
89
from src.terminology import ACTION_SYNC_CANONICAL_LABELS
910
from src.weekly_packaging import finalize_weekly_pack
1011
from src.weekly_scheduling_overlay import apply_weekly_scheduling_overlay
@@ -164,7 +165,7 @@ def build_risk_lookup(output_dir: Path | None) -> dict[str, dict[str, str]]:
164165
"""
165166
if not output_dir:
166167
return {}
167-
truth_path = output_dir / "portfolio-truth-latest.json"
168+
truth_path = truth_latest_path(output_dir)
168169
if not truth_path.is_file():
169170
return {}
170171
try:

0 commit comments

Comments
 (0)