froid-loop 0.11.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/install.py
ADDED
|
@@ -0,0 +1,2936 @@
|
|
|
1
|
+
"""`froid-loop init`: make a target project orchestratable.
|
|
2
|
+
|
|
3
|
+
- copies the hook relay script to <project>/.froid-loop/froid_loop_hook.py
|
|
4
|
+
- idempotently merges hook registrations into each selected CLI's hook config
|
|
5
|
+
(dialect + native->canonical event map come from the CLI profile)
|
|
6
|
+
- installs the bundled froid-loop-* skills into each selected CLI's skill tree
|
|
7
|
+
(.claude/skills for claude, .agents/skills for codex/gemini/copilot)
|
|
8
|
+
- writes .froid-loop/policy.toml from the template (if missing)
|
|
9
|
+
- gitignores generated dirs: .froid-loop/runs/ (per-run state) and
|
|
10
|
+
.froid-loop/cache/ (engine plugins' rebuildable caches, e.g. the Unity Library)
|
|
11
|
+
|
|
12
|
+
Every dialect registers the same relay script under the CLI's native event
|
|
13
|
+
names while passing the canonical event name as the script argument, so the
|
|
14
|
+
orchestrator's signal watcher is CLI-agnostic.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import errno
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import tomllib
|
|
25
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
26
|
+
from contextlib import ExitStack
|
|
27
|
+
from importlib import resources
|
|
28
|
+
from importlib.resources.abc import Traversable
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, NamedTuple
|
|
31
|
+
|
|
32
|
+
from .adapters.profile import ALIASES, CLIProfile, ProfileError, load_profiles
|
|
33
|
+
from .checks import Finding
|
|
34
|
+
from .platform_util import atomic_write_bytes, atomic_write_text, file_lock
|
|
35
|
+
from .policy import POLICY_TEMPLATE
|
|
36
|
+
from .process_host import get_process_host
|
|
37
|
+
from .verify import GitError, git_below_floor, git_bytes, git_floor_text, git_version_at_least
|
|
38
|
+
|
|
39
|
+
HOOK_SCRIPT_REL = ".froid-loop/froid_loop_hook.py"
|
|
40
|
+
# Markers for froid-loop-managed hook commands. RELAY_MARKER is shared by
|
|
41
|
+
# merge_hooks' dedup and validate/probe detection (via relay_registered) so init
|
|
42
|
+
# and the preflight can never disagree about whether the relay is installed. It
|
|
43
|
+
# matches the relay script name specifically: a hook command whose path merely
|
|
44
|
+
# contains "froid_loop" can't read as a registration — or suppress one.
|
|
45
|
+
RELAY_MARKER = "froid_loop_hook"
|
|
46
|
+
# The probe-adapter capture hook participates in merge_hooks' dedup only (a
|
|
47
|
+
# probe re-merge must stay idempotent) and never counts as a relay
|
|
48
|
+
# registration. Disjoint from RELAY_MARKER: "froid_loop_probe_hook" does not
|
|
49
|
+
# contain the substring "froid_loop_hook".
|
|
50
|
+
PROBE_MARKER = "froid_loop_probe_hook"
|
|
51
|
+
GEMINI_HOOK_TIMEOUT_MS = 60_000
|
|
52
|
+
COPILOT_HOOK_TIMEOUT_SEC = 60
|
|
53
|
+
ANTIGRAVITY_HOOK_TIMEOUT_SEC = 60 # agy hook timeouts are seconds (default 30)
|
|
54
|
+
# agy's .agents/hooks.json keys by hook NAME at the top level (not a "hooks"
|
|
55
|
+
# wrapper); froid-loop registers all its handlers under this single group.
|
|
56
|
+
ANTIGRAVITY_HOOK_GROUP = "froid-loop"
|
|
57
|
+
|
|
58
|
+
# The froid-loop-* skills bundled in the wheel (froid_loop/data/skills/) that
|
|
59
|
+
# `froid-loop init` lays down. The inner dev primitive (`froid-build-auto`, formerly
|
|
60
|
+
# `froid-dev-auto`) is upstream (not bundled here): the orchestrator drives it as an
|
|
61
|
+
# already-installed skill.
|
|
62
|
+
MODULE_SKILLS = (
|
|
63
|
+
"froid-loop-resolve",
|
|
64
|
+
"froid-loop-sweep",
|
|
65
|
+
"froid-loop-setup",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# The inner dev primitive, in both upstream eras. Froid Plane PR #2651 (shipped in
|
|
69
|
+
# froid-plane 6.10.1-next.33) renamed `froid-dev-auto` -> `froid-build-auto` and left a
|
|
70
|
+
# forwarding SHIM behind under the old name: a lone SKILL.md whose customization
|
|
71
|
+
# migration gate is INTERACTIVE, so an unattended session that dispatches to it can
|
|
72
|
+
# HALT having written nothing to disk — no spec, no result artifact, nothing the
|
|
73
|
+
# post-session verification can read. The orchestrator therefore never accepts the
|
|
74
|
+
# shim: it resolves the primitive on disk (:func:`resolve_dev_primitive`) and fails
|
|
75
|
+
# the preflight when only the shim is installed.
|
|
76
|
+
#
|
|
77
|
+
# The shim carries no step files and no customize.toml, so DEV_PRIMITIVE_MARKERS —
|
|
78
|
+
# which already pinned "a real, complete install" — doubles as the shim detector.
|
|
79
|
+
# Markers pin BOTH a step file (catches a truncated copy) AND customize.toml, the
|
|
80
|
+
# layer/handoff config step-04 resolves review_layers from (Froid Plane
|
|
81
|
+
# #2535/#2550): a pre-July bmm install predating it would let every dev run's
|
|
82
|
+
# step-04 fail.
|
|
83
|
+
DEV_PRIMITIVE_NEW = "froid-build-auto"
|
|
84
|
+
DEV_PRIMITIVE_LEGACY = "froid-dev-auto"
|
|
85
|
+
DEV_PRIMITIVE_MARKERS = ("step-04-review.md", "customize.toml")
|
|
86
|
+
|
|
87
|
+
# The adapter roles whose skill tree is asked for the dev primitive and for the
|
|
88
|
+
# review layers that primitive invokes inline — i.e. every question this module's
|
|
89
|
+
# skill checks ask. Triage is deliberately absent: its only prompt is
|
|
90
|
+
# `/froid-loop-sweep`, which is in MODULE_SKILLS and is laid into that tree by
|
|
91
|
+
# `froid-loop init`, so a triage-only CLI never needs one byte of the bmm module.
|
|
92
|
+
# Gating it anyway makes `[adapter.triage] name = "gemini"` under a claude dev/review
|
|
93
|
+
# pair demand the whole module in `.agents/skills` — a hard preflight FAIL over a
|
|
94
|
+
# tree no session ever dispatches one of these skills into.
|
|
95
|
+
#
|
|
96
|
+
# This must stay the same set `WorktreeFlow.worktree_profiles` provisions, which
|
|
97
|
+
# reads this constant for exactly that reason. A tree gated here that no worktree
|
|
98
|
+
# carries refuses runs over a skill no session will ever read; a tree provisioned but
|
|
99
|
+
# not gated ships a session into the `Unknown command` stall the preflight exists to
|
|
100
|
+
# catch. Neither has a defensible reading, so the two move together or not at all.
|
|
101
|
+
DEV_PRIMITIVE_ROLES: tuple[str, ...] = ("dev", "review")
|
|
102
|
+
|
|
103
|
+
# Where project-level customization of an upstream skill lives. This main-native
|
|
104
|
+
# Path remains the single source; renderer strings derive their `_froid` spelling
|
|
105
|
+
# from it so the install and validation surfaces cannot drift.
|
|
106
|
+
CUSTOMIZE_DIR = Path("_froid") / "custom"
|
|
107
|
+
|
|
108
|
+
# FROID's project-local configuration and tooling root. Renderer paths derive from
|
|
109
|
+
# this one spelling so the validate-side checks in this phase and the worktree seed
|
|
110
|
+
# added later cannot drift.
|
|
111
|
+
FROID_DIR = CUSTOMIZE_DIR.parent.as_posix()
|
|
112
|
+
|
|
113
|
+
# Since Froid Plane PR #2601 a skill's SKILL.md can be a renderer stub that shells
|
|
114
|
+
# out to this project-local script. Its absence makes the session HALT before the
|
|
115
|
+
# dev workflow can write a spec or result, so a content-confirmed stub makes this a
|
|
116
|
+
# preflight problem rather than a best-effort advisory.
|
|
117
|
+
RENDERER_SCRIPT_REL = f"{FROID_DIR}/scripts/render_skill.py"
|
|
118
|
+
RENDERER_SCRIPT_MARKER = "render_skill.py"
|
|
119
|
+
|
|
120
|
+
# The renderer currently imports this sibling helper at module scope. Require it
|
|
121
|
+
# only when the installed script still names that import surface; a false positive
|
|
122
|
+
# would refuse every run and has no --force escape hatch.
|
|
123
|
+
RENDERER_CONFIG_UTILS_REL = f"{FROID_DIR}/scripts/config_utils.py"
|
|
124
|
+
RENDERER_CONFIG_UTILS_MARKER = "config_utils"
|
|
125
|
+
RENDERER_SCRIPT_UNIT_REL: tuple[str, ...] = (
|
|
126
|
+
RENDERER_SCRIPT_REL,
|
|
127
|
+
RENDERER_CONFIG_UTILS_REL,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# The renderer's only required project-global config layer. This is deliberately
|
|
131
|
+
# not `_froid/bmm/config.yaml`, which configures froid-loop's artifact paths.
|
|
132
|
+
CENTRAL_CONFIG_REL = f"{FROID_DIR}/config.toml"
|
|
133
|
+
|
|
134
|
+
# The renderer surface a worktree must carry, in the exact strings provisioning
|
|
135
|
+
# reports when it comes up short. Worktree seeding reports these two from dedicated
|
|
136
|
+
# completeness checks, and escalates only when a renderer stub is what resolved.
|
|
137
|
+
FROID_SCRIPTS_SEED_REL = f"{FROID_DIR}/scripts"
|
|
138
|
+
RENDERER_SEED_SENTINELS: tuple[str, ...] = (FROID_SCRIPTS_SEED_REL, CENTRAL_CONFIG_REL)
|
|
139
|
+
|
|
140
|
+
# The skill-relative render entry plus snapshot-reference grammar. The regex is a
|
|
141
|
+
# byte-for-byte mirror of Froid Plane main's render_skill.py `_SNAPSHOT_TOKEN` at
|
|
142
|
+
# 57e70562e3776b47bce1a54710f1edb1f0bd3618. Loosening it invents failures the
|
|
143
|
+
# renderer ignores; tightening it misses a real HALT.
|
|
144
|
+
RENDERER_ENTRY_REL = "workflow.md"
|
|
145
|
+
SNAPSHOT_TOKEN_RE = re.compile(r"\[\[froid-snapshot:([A-Za-z0-9_./-]+\.md)\]\]")
|
|
146
|
+
|
|
147
|
+
# Renderer output is regenerated with checkout-absolute paths and must never be
|
|
148
|
+
# copied into a worktree or committed. FROID_SEED_EXCLUDES skips it while seeding
|
|
149
|
+
# `_froid/`; RENDER_DIR_REL is the one spelling init gitignores and validate checks.
|
|
150
|
+
RENDER_DIR_NAME = "render"
|
|
151
|
+
FROID_SEED_EXCLUDES = (RENDER_DIR_NAME,)
|
|
152
|
+
RENDER_DIR_REL = f"{FROID_DIR}/{RENDER_DIR_NAME}"
|
|
153
|
+
|
|
154
|
+
# Upstream skills the orchestrator invokes but does NOT bundle in the wheel — the
|
|
155
|
+
# Froid Plane (bmm) module installs them. Each must exist in every ``trees`` entry —
|
|
156
|
+
# callers pass the DEV_PRIMITIVE_ROLES trees — and carry its marker files (a
|
|
157
|
+
# half-installed or pre-automation skill is caught by the `froid-loop validate`
|
|
158
|
+
# preflight). `{skill: (marker-rel-path, ...)}`.
|
|
159
|
+
# - the dev primitive — always required, and never substitutable. Its entry is
|
|
160
|
+
# keyed on the LEGACY name because this map doubles as the "lay down a pre-rename
|
|
161
|
+
# install" catalog; missing_base_skills does NOT walk it for the primitive, it
|
|
162
|
+
# resolves the installed name per tree first (resolve_dev_primitive).
|
|
163
|
+
# - the two review hunters v6.10.0 ships. These are only the FALLBACK review
|
|
164
|
+
# requirement, used when the installed skill's shape can't be read: normally the
|
|
165
|
+
# reviewers are derived per tree from the dev primitive itself
|
|
166
|
+
# (resolve_review_layers), because which skills the review step invokes is a
|
|
167
|
+
# property of that skill version, not of a catalog pinned in here (#260).
|
|
168
|
+
# froid-review-verification-gap is not in the fallback set: no tagged Froid Plane
|
|
169
|
+
# release ships it standalone (on current sources it is a thin forwarder to the
|
|
170
|
+
# merged froid-review), so demanding it of every project made `validate`
|
|
171
|
+
# unsatisfiable on real installs. A project whose review layers DO name it still has
|
|
172
|
+
# it required — by derivation from its own config, not by this list.
|
|
173
|
+
DEV_BASE_SKILLS = {
|
|
174
|
+
DEV_PRIMITIVE_LEGACY: DEV_PRIMITIVE_MARKERS,
|
|
175
|
+
"froid-review-adversarial-general": (),
|
|
176
|
+
"froid-review-edge-case-hunter": (),
|
|
177
|
+
}
|
|
178
|
+
# The merged lens-based reviewer (Froid Plane core-streamline). It satisfies the
|
|
179
|
+
# layer topology of the releases that hand off to it: step-04 driven off
|
|
180
|
+
# customize.toml's [[workflow.review_layers]], with the hunter layers each invoking
|
|
181
|
+
# froid-review with one lens. On 6.11 sources the same four layers (blind-hunter,
|
|
182
|
+
# edge-case-hunter, verification-gap, intent-alignment) invoke no skill at all —
|
|
183
|
+
# two of them read the primitive's own review-prompts/*.md, two carry their prompt
|
|
184
|
+
# inline — so that tree derives an empty `required` map, which is satisfied rather
|
|
185
|
+
# than unsatisfiable: _review_findings
|
|
186
|
+
# falls back to the static catalog only when the resolution is None (an unreadable
|
|
187
|
+
# shape), never because it resolved to requiring nothing. Both topologies stay
|
|
188
|
+
# supported; this constant is named here for the fallback path only, and the derived
|
|
189
|
+
# path sees it named in the layers themselves.
|
|
190
|
+
MERGED_REVIEW_SKILL = "froid-review"
|
|
191
|
+
# The DEV_BASE_SKILLS entries MERGED_REVIEW_SKILL subsumes. froid-dev-auto (the dev
|
|
192
|
+
# primitive) is NOT here — the merged reviewer never substitutes for it.
|
|
193
|
+
_REVIEW_LAYER_SKILLS = frozenset(
|
|
194
|
+
{"froid-review-adversarial-general", "froid-review-edge-case-hunter"}
|
|
195
|
+
)
|
|
196
|
+
# Every non-bundled skill that might need copying into an isolated worktree: the
|
|
197
|
+
# preflight set above plus the merged reviewer and the pre-consolidation standalone
|
|
198
|
+
# verification-gap forwarder (carried so a hand-installed forwarder still resolves,
|
|
199
|
+
# never validated). provision_worktree skips skills the main repo lacks, so this
|
|
200
|
+
# copy-if-present superset is safe in both directions.
|
|
201
|
+
# BOTH primitive eras are listed: a worktree must carry whichever one the main
|
|
202
|
+
# checkout has, and copy-if-present makes naming both free. Adding the new name here
|
|
203
|
+
# is what keeps isolation working across the rename — provisioning unions this
|
|
204
|
+
# catalog with the resolved review layers, and the layers never name the primitive.
|
|
205
|
+
BASE_SKILLS = {
|
|
206
|
+
DEV_PRIMITIVE_NEW: DEV_PRIMITIVE_MARKERS,
|
|
207
|
+
**DEV_BASE_SKILLS,
|
|
208
|
+
"froid-review-verification-gap": (),
|
|
209
|
+
MERGED_REVIEW_SKILL: (),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# How the dev primitive names a skill it hands a review off to, in both shapes:
|
|
213
|
+
# "Invoke the `froid-review` skill with only the `adversarial` lens" (a
|
|
214
|
+
# customize.toml review layer) and "Invoke the `froid-review-edge-case-hunter`
|
|
215
|
+
# skill on this diff" (pre-consolidation step-04). Deliberately narrow —
|
|
216
|
+
# backticked, `froid-` prefixed — because a false match here is a false FAIL,
|
|
217
|
+
# the exact failure mode #260 was.
|
|
218
|
+
_INVOKE_SKILL_RE = re.compile(r"[Ii]nvoke the `(froid-[a-z0-9-]+)` skill")
|
|
219
|
+
# Any backticked froid-* token in a layer's instruction. What this matches and
|
|
220
|
+
# _INVOKE_SKILL_RE does not is a skill reference we cannot confirm is an
|
|
221
|
+
# invocation — upstream itself writes "use the `froid-code-review` skill"
|
|
222
|
+
# elsewhere, so the narrow phrasing is a convention, not a contract. Such a
|
|
223
|
+
# reference is reported as a WARNING and never hard-required: an override may
|
|
224
|
+
# legitimately mention a skill it does not invoke, and blocking on that would
|
|
225
|
+
# rebuild #260's false FAIL. Lens names (`adversarial`) lack the prefix, so the
|
|
226
|
+
# shipped layers add nothing here.
|
|
227
|
+
_SKILL_REF_RE = re.compile(r"`(froid-[a-z0-9-]+)`")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _customize_overrides(skill: str) -> tuple[Path, ...]:
|
|
231
|
+
"""Project overrides of ``skill``'s shipped customize.toml, in precedence order
|
|
232
|
+
(later wins), per customize.toml's own header. The `.user.toml` layer is personal
|
|
233
|
+
and gitignored by the upstream installer.
|
|
234
|
+
|
|
235
|
+
Takes the RESOLVED primitive name and reads that pair ONLY — never both eras.
|
|
236
|
+
Upstream's own resolver keys on the skill directory, so on a renamed project the
|
|
237
|
+
legacy pair is simply not read at run time; merging it in here would make the
|
|
238
|
+
preflight resolve layers the session never applies, which is the exact
|
|
239
|
+
disagreement :func:`resolve_review_layers` exists to prevent. The orphaned file
|
|
240
|
+
is surfaced instead, as the ``skills.customize-legacy`` warning in
|
|
241
|
+
:func:`dev_primitive_warnings`.
|
|
242
|
+
"""
|
|
243
|
+
return (
|
|
244
|
+
CUSTOMIZE_DIR / f"{skill}.toml",
|
|
245
|
+
CUSTOMIZE_DIR / f"{skill}.user.toml",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def resolve_dev_primitive(project: Path, tree: str) -> str | None:
|
|
250
|
+
"""The dev-primitive skill name to drive in ``tree``, or None when none is usable.
|
|
251
|
+
|
|
252
|
+
Prefers :data:`DEV_PRIMITIVE_NEW`; falls back to :data:`DEV_PRIMITIVE_LEGACY`
|
|
253
|
+
only when that install is marker-complete, which is exactly what the post-rename
|
|
254
|
+
forwarding shim is not (see the constants block). None means "fail the preflight"
|
|
255
|
+
— never "drive the old name and hope".
|
|
256
|
+
|
|
257
|
+
The new name needs only its SKILL.md to *resolve*: completeness is reported by
|
|
258
|
+
:func:`missing_base_skills` against the resolved dir. Requiring markers here
|
|
259
|
+
instead would make a truncated froid-build-auto silently resolve to a legacy
|
|
260
|
+
install (or to the shim's failure message), hiding the real problem.
|
|
261
|
+
"""
|
|
262
|
+
if _is_file(project / tree / DEV_PRIMITIVE_NEW / "SKILL.md"):
|
|
263
|
+
return DEV_PRIMITIVE_NEW
|
|
264
|
+
legacy = project / tree / DEV_PRIMITIVE_LEGACY
|
|
265
|
+
if _is_file(legacy / "SKILL.md") and all(
|
|
266
|
+
_is_file(legacy / marker) for marker in DEV_PRIMITIVE_MARKERS
|
|
267
|
+
):
|
|
268
|
+
return DEV_PRIMITIVE_LEGACY
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _is_dev_primitive_shim(project: Path, tree: str) -> bool:
|
|
273
|
+
"""True when ``tree`` holds a legacy-named skill that is only a forwarding shim
|
|
274
|
+
(SKILL.md present, at least one marker absent). Selects the failure *message* in
|
|
275
|
+
:func:`missing_base_skills`; it is never a resolution input."""
|
|
276
|
+
legacy = project / tree / DEV_PRIMITIVE_LEGACY
|
|
277
|
+
if not (legacy / "SKILL.md").is_file():
|
|
278
|
+
return False
|
|
279
|
+
return any(not (legacy / marker).is_file() for marker in DEV_PRIMITIVE_MARKERS)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _is_renderer_stub(skill_dir: Path) -> bool:
|
|
283
|
+
"""Whether ``skill_dir`` delegates to the project renderer.
|
|
284
|
+
|
|
285
|
+
Content, not the skill's era/name, is the discriminator. An unreadable or
|
|
286
|
+
non-UTF-8 SKILL.md cannot prove the renderer is involved, so this fails open;
|
|
287
|
+
the ordinary marker checks still report the damaged skill tree.
|
|
288
|
+
"""
|
|
289
|
+
try:
|
|
290
|
+
skill_md = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
|
|
291
|
+
except (OSError, UnicodeDecodeError):
|
|
292
|
+
return False
|
|
293
|
+
return RENDERER_SCRIPT_MARKER in skill_md
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _renderer_unit_required(project: Path, rel: str) -> bool:
|
|
297
|
+
"""Whether the installed renderer requires project-relative unit member ``rel``.
|
|
298
|
+
|
|
299
|
+
The entry point is unconditional. The sibling helper is content-keyed because
|
|
300
|
+
it is required only while ``render_skill.py`` imports it; this preflight has no
|
|
301
|
+
force override, so the installed script is the honest compatibility boundary.
|
|
302
|
+
An absent/unreadable script already earns the entry-point finding and does not
|
|
303
|
+
also fabricate a helper diagnosis.
|
|
304
|
+
"""
|
|
305
|
+
if rel != RENDERER_CONFIG_UTILS_REL:
|
|
306
|
+
return True
|
|
307
|
+
try:
|
|
308
|
+
script = (project / RENDERER_SCRIPT_REL).read_text(encoding="utf-8")
|
|
309
|
+
except (OSError, UnicodeDecodeError):
|
|
310
|
+
return False
|
|
311
|
+
return RENDERER_CONFIG_UTILS_MARKER in script
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _absent_renderer_sources(skill_dir: Path) -> list[str]:
|
|
315
|
+
"""Required renderer sources absent from ``skill_dir``, as POSIX rels.
|
|
316
|
+
|
|
317
|
+
This validates the two statically decidable source HALTs: the hard-coded
|
|
318
|
+
``workflow.md`` entry and any ``[[froid-snapshot:...]]`` target declared by a
|
|
319
|
+
Markdown source. Missing config-token values are deliberately outside this
|
|
320
|
+
presence-only check (#407).
|
|
321
|
+
|
|
322
|
+
The era guard lives here: an inline pre-renderer SKILL.md legitimately has no
|
|
323
|
+
workflow entry, so asking it renderer questions would refuse a healthy install.
|
|
324
|
+
|
|
325
|
+
The ``rglob`` walk deliberately mirrors upstream
|
|
326
|
+
``render_skill.py::_load_sources`` and must not be unified with
|
|
327
|
+
:func:`_copy_traversable`'s ``iterdir`` recursion. ``rglob`` does not descend a
|
|
328
|
+
symlinked sub-directory; the copier does. Using the copier's enumeration here
|
|
329
|
+
would invent sources the renderer never loads and turn a guaranteed HALT green.
|
|
330
|
+
|
|
331
|
+
Upstream keys nested sources by POSIX relative path and excludes every basename
|
|
332
|
+
``SKILL.md``. :func:`_is_file` enforces filesystem totality, including the FIFO
|
|
333
|
+
guard, before any source is read. Remaining read/decode faults fail open because
|
|
334
|
+
they have different remediation from an absent declared source.
|
|
335
|
+
"""
|
|
336
|
+
if not _is_renderer_stub(skill_dir):
|
|
337
|
+
return []
|
|
338
|
+
sources = {
|
|
339
|
+
path.relative_to(skill_dir).as_posix(): path
|
|
340
|
+
for path in skill_dir.rglob("*.md")
|
|
341
|
+
if path.name != "SKILL.md" and _is_file(path)
|
|
342
|
+
}
|
|
343
|
+
absent = [] if RENDERER_ENTRY_REL in sources else [RENDERER_ENTRY_REL]
|
|
344
|
+
for path in sources.values():
|
|
345
|
+
try:
|
|
346
|
+
body = path.read_text(encoding="utf-8")
|
|
347
|
+
except (OSError, UnicodeDecodeError):
|
|
348
|
+
continue
|
|
349
|
+
absent.extend(target for target in SNAPSHOT_TOKEN_RE.findall(body) if target not in sources)
|
|
350
|
+
return sorted(set(absent))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def renderer_stub_resolved(project: Path, trees: Sequence[str]) -> bool:
|
|
354
|
+
"""Whether any active tree resolves to a renderer-backed dev primitive.
|
|
355
|
+
|
|
356
|
+
Resolution is per tree and deduplicated exactly like :func:`missing_base_skills`.
|
|
357
|
+
Public because the engine's seed-escalation decision reuses this exact preflight
|
|
358
|
+
discriminator rather than re-deriving one (`worktree_flow`).
|
|
359
|
+
"""
|
|
360
|
+
for tree in dict.fromkeys(trees):
|
|
361
|
+
resolved = resolve_dev_primitive(project, tree)
|
|
362
|
+
if resolved is not None and _is_renderer_stub(project / tree / resolved):
|
|
363
|
+
return True
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def dev_primitive_or_default(project: Path, tree: str | None) -> str:
|
|
368
|
+
"""Total form of :func:`resolve_dev_primitive` for callers that need a name.
|
|
369
|
+
|
|
370
|
+
A prompt string — and a path this module has to probe *somewhere* to report on —
|
|
371
|
+
always has to name something, and the preflight has already refused the
|
|
372
|
+
unresolvable cases before any session is spawned. So an unresolvable tree (and a
|
|
373
|
+
None tree, which is what an adapter with no profile reports) falls back to the
|
|
374
|
+
legacy name rather than raising into prompt construction."""
|
|
375
|
+
if tree is None:
|
|
376
|
+
return DEV_PRIMITIVE_LEGACY
|
|
377
|
+
return resolve_dev_primitive(project, tree) or DEV_PRIMITIVE_LEGACY
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class ReviewResolution(NamedTuple):
|
|
381
|
+
"""Which skills the installed dev primitive's review step actually invokes.
|
|
382
|
+
|
|
383
|
+
``source`` is the file it was read from (for the finding's detail).
|
|
384
|
+
``required`` maps each invoked skill to the review-layer ids invoking it —
|
|
385
|
+
empty ids for the pre-consolidation shape, which has no layers. ``advisory``
|
|
386
|
+
is the same shape for references we cannot confirm will run (see
|
|
387
|
+
:data:`_SKILL_REF_RE` and ``when``-gated layers): missing ones warn, never
|
|
388
|
+
fail. ``active_layers`` names every layer with a non-empty ``instruction`` —
|
|
389
|
+
all of them disabled is a run that HALTs. ``unreadable`` names override files
|
|
390
|
+
that failed to parse.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
source: str
|
|
394
|
+
required: dict[str, tuple[str, ...]]
|
|
395
|
+
advisory: dict[str, tuple[str, ...]]
|
|
396
|
+
layer_driven: bool
|
|
397
|
+
active_layers: tuple[str, ...]
|
|
398
|
+
unreadable: tuple[str, ...]
|
|
399
|
+
|
|
400
|
+
def skills(self) -> tuple[str, ...]:
|
|
401
|
+
"""Every skill this review step may invoke, required or advisory."""
|
|
402
|
+
return tuple(dict.fromkeys((*self.required, *self.advisory)))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _read_toml(path: Path) -> dict[str, Any] | None:
|
|
406
|
+
"""Parse a TOML file, or None if it is absent/unreadable/malformed."""
|
|
407
|
+
try:
|
|
408
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
|
409
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _layers_of(data: Any) -> list[Any]:
|
|
414
|
+
"""The ``[[workflow.review_layers]]`` array, or [] for any other shape.
|
|
415
|
+
|
|
416
|
+
Every hop is type-guarded independently: `workflow` is only a table by
|
|
417
|
+
convention, and a syntactically valid `workflow = "..."` would otherwise
|
|
418
|
+
raise AttributeError straight out of the preflight — crashing validate, run,
|
|
419
|
+
resume and sweep on a file whose only sin is being misconfigured.
|
|
420
|
+
"""
|
|
421
|
+
workflow = data.get("workflow") if isinstance(data, dict) else None
|
|
422
|
+
layers = workflow.get("review_layers") if isinstance(workflow, dict) else None
|
|
423
|
+
return list(layers) if isinstance(layers, list) else []
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# FROID's own merge keys for arrays of tables, in detection precedence order.
|
|
427
|
+
_KEYED_MERGE_FIELDS = ("code", "id")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _keyed_merge_field(items: list[Any]) -> str | None:
|
|
431
|
+
"""The key an array of tables merges on, or None to append.
|
|
432
|
+
|
|
433
|
+
Port of `_detect_keyed_merge_field` in Froid Plane
|
|
434
|
+
`src/scripts/resolve_customization.py`. EVERY item — base and override
|
|
435
|
+
combined — must be a table carrying the same key, and `code` is checked
|
|
436
|
+
before `id`; a mixed or key-less array falls through to append. Guessing
|
|
437
|
+
differently here means the preflight requires a different skill set than the
|
|
438
|
+
resolver the run actually uses.
|
|
439
|
+
"""
|
|
440
|
+
if not items or not all(isinstance(item, dict) for item in items):
|
|
441
|
+
return None
|
|
442
|
+
for candidate in _KEYED_MERGE_FIELDS:
|
|
443
|
+
if all(item.get(candidate) is not None for item in items):
|
|
444
|
+
return candidate
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _merge_layer_arrays(base: list[Any], override: list[Any]) -> list[Any]:
|
|
449
|
+
"""Merge two arrays of tables the way FROID's resolver does.
|
|
450
|
+
|
|
451
|
+
Port of `_merge_arrays`/`_merge_by_key` in Froid Plane
|
|
452
|
+
`src/scripts/resolve_customization.py`: keyed merge when the combined array
|
|
453
|
+
opts into one (matching keys replace in place, new keys append), plain
|
|
454
|
+
concatenation otherwise.
|
|
455
|
+
"""
|
|
456
|
+
key = _keyed_merge_field(base + override)
|
|
457
|
+
if key is None:
|
|
458
|
+
return base + override
|
|
459
|
+
result: list[Any] = []
|
|
460
|
+
index_by_key: dict[Any, int] = {}
|
|
461
|
+
for item in base:
|
|
462
|
+
if not isinstance(item, dict):
|
|
463
|
+
continue
|
|
464
|
+
if item.get(key) is not None:
|
|
465
|
+
index_by_key[item[key]] = len(result)
|
|
466
|
+
result.append(dict(item))
|
|
467
|
+
for item in override:
|
|
468
|
+
if not isinstance(item, dict):
|
|
469
|
+
result.append(item)
|
|
470
|
+
continue
|
|
471
|
+
item_key = item.get(key)
|
|
472
|
+
if item_key is not None and item_key in index_by_key:
|
|
473
|
+
result[index_by_key[item_key]] = dict(item)
|
|
474
|
+
else:
|
|
475
|
+
if item_key is not None:
|
|
476
|
+
index_by_key[item_key] = len(result)
|
|
477
|
+
result.append(dict(item))
|
|
478
|
+
return result
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _merged_review_layers(
|
|
482
|
+
project: Path, tree: str, skill: str
|
|
483
|
+
) -> tuple[list[Any], tuple[str, ...]] | None:
|
|
484
|
+
"""The skill's shipped review layers with project overrides applied.
|
|
485
|
+
|
|
486
|
+
``skill`` is the resolved dev-primitive name — the shipped config and its
|
|
487
|
+
overrides are read from the SAME era, because that is what the run does.
|
|
488
|
+
|
|
489
|
+
Returns ``(layers, unreadable_override_paths)``, or None when the skill's OWN
|
|
490
|
+
customize.toml is absent or unparseable — the one case that genuinely means
|
|
491
|
+
"shape unknown", so the caller falls back to the static catalog.
|
|
492
|
+
|
|
493
|
+
An unparseable OVERRIDE is not that case: FROID's resolver warns and treats it
|
|
494
|
+
as empty, still resolving every other layer. Matching that keeps the preflight
|
|
495
|
+
agreeing with the run; the broken file is reported separately as a warning.
|
|
496
|
+
"""
|
|
497
|
+
data = _read_toml(project / tree / skill / "customize.toml")
|
|
498
|
+
if data is None:
|
|
499
|
+
return None
|
|
500
|
+
layers = _layers_of(data)
|
|
501
|
+
unreadable: list[str] = []
|
|
502
|
+
for rel in _customize_overrides(skill):
|
|
503
|
+
override = project / rel
|
|
504
|
+
if not override.is_file():
|
|
505
|
+
continue
|
|
506
|
+
extra = _read_toml(override)
|
|
507
|
+
if extra is None:
|
|
508
|
+
unreadable.append(rel.as_posix())
|
|
509
|
+
continue
|
|
510
|
+
layers = _merge_layer_arrays(layers, _layers_of(extra))
|
|
511
|
+
return layers, tuple(unreadable)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _layer_id(layer: dict[str, Any], index: int) -> str:
|
|
515
|
+
"""A layer's id for reporting — positional when it declares none."""
|
|
516
|
+
lid = layer.get("id")
|
|
517
|
+
return lid if isinstance(lid, str) and lid else f"#{index + 1}"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def resolve_review_layers(project: Path, tree: str) -> ReviewResolution | None:
|
|
521
|
+
"""Read the review skills this project will really invoke, or None if unknown.
|
|
522
|
+
|
|
523
|
+
The primitive's installed NAME is resolved from disk here rather than taken as
|
|
524
|
+
an argument (:func:`dev_primitive_or_default`): both call sites — the preflight
|
|
525
|
+
and worktree provisioning — hold exactly ``(project, tree)`` and would otherwise
|
|
526
|
+
each compute the same value to hand back. On a renamed project the legacy paths
|
|
527
|
+
are simply absent, so without this the resolution silently returns None and the
|
|
528
|
+
caller degrades to the static catalog — quietly seeding a worktree with the
|
|
529
|
+
wrong reviewers instead of the ones this project configured.
|
|
530
|
+
|
|
531
|
+
Post-consolidation the primitive is layer-driven: each
|
|
532
|
+
``[[workflow.review_layers]]`` entry carries its whole execution recipe, and a
|
|
533
|
+
layer that runs a skill names it inline (an empty ``instruction`` disables the
|
|
534
|
+
layer, and a layer may legitimately name no skill at all — `intent-alignment`
|
|
535
|
+
is a self-contained prompt). Pre-consolidation, ``step-04-review.md`` names its
|
|
536
|
+
reviewers directly instead.
|
|
537
|
+
|
|
538
|
+
Reading whichever is installed keeps the preflight honest as upstream moves:
|
|
539
|
+
a project whose configured layers invoke a skill it does not have is otherwise
|
|
540
|
+
green here and broken on every dev run (#260). None means the shape could not
|
|
541
|
+
be determined, so the caller falls back to the static catalog.
|
|
542
|
+
|
|
543
|
+
A ``when``-gated layer contributes ADVISORY requirements only: step-04 skips
|
|
544
|
+
every layer whose condition does not hold, and that condition is evaluated by
|
|
545
|
+
the model in run context — undecidable here, so hard-requiring its skill would
|
|
546
|
+
be a false FAIL.
|
|
547
|
+
"""
|
|
548
|
+
# `primitive`, not `skill`: the layer loop below binds `skill` to each INVOKED
|
|
549
|
+
# review skill, and the step-04 fallback after it reads this name as a directory.
|
|
550
|
+
# Sharing one name is safe only while the layer branch returns first — a
|
|
551
|
+
# shadowing this function should not be one edit away from.
|
|
552
|
+
primitive = dev_primitive_or_default(project, tree)
|
|
553
|
+
merged = _merged_review_layers(project, tree, primitive)
|
|
554
|
+
if merged is None:
|
|
555
|
+
return None
|
|
556
|
+
layers, unreadable = merged
|
|
557
|
+
if layers:
|
|
558
|
+
required: dict[str, list[str]] = {}
|
|
559
|
+
advisory: dict[str, list[str]] = {}
|
|
560
|
+
active: list[str] = []
|
|
561
|
+
for index, layer in enumerate(layers):
|
|
562
|
+
if not isinstance(layer, dict):
|
|
563
|
+
continue
|
|
564
|
+
instruction = layer.get("instruction")
|
|
565
|
+
if not isinstance(instruction, str) or not instruction.strip():
|
|
566
|
+
continue
|
|
567
|
+
layer_id = _layer_id(layer, index)
|
|
568
|
+
active.append(layer_id)
|
|
569
|
+
when = layer.get("when")
|
|
570
|
+
gated = isinstance(when, str) and bool(when.strip())
|
|
571
|
+
invoked = _INVOKE_SKILL_RE.findall(instruction)
|
|
572
|
+
# A gated layer's invocations are advisory; so is any froid-* token we
|
|
573
|
+
# can't tie to an invocation phrasing, in either kind of layer.
|
|
574
|
+
hard = [] if gated else invoked
|
|
575
|
+
soft = [s for s in _SKILL_REF_RE.findall(instruction) if gated or s not in invoked]
|
|
576
|
+
for bucket, skills in ((required, hard), (advisory, soft)):
|
|
577
|
+
for skill in skills:
|
|
578
|
+
ids = bucket.setdefault(skill, [])
|
|
579
|
+
if layer_id not in ids:
|
|
580
|
+
ids.append(layer_id)
|
|
581
|
+
return ReviewResolution(
|
|
582
|
+
"customize.toml",
|
|
583
|
+
{s: tuple(i) for s, i in required.items()},
|
|
584
|
+
# never warn about a skill another layer already hard-requires
|
|
585
|
+
{s: tuple(i) for s, i in advisory.items() if s not in required},
|
|
586
|
+
True,
|
|
587
|
+
tuple(active),
|
|
588
|
+
unreadable,
|
|
589
|
+
)
|
|
590
|
+
try:
|
|
591
|
+
step04 = (project / tree / primitive / "step-04-review.md").read_text(encoding="utf-8")
|
|
592
|
+
except (OSError, UnicodeDecodeError):
|
|
593
|
+
return None
|
|
594
|
+
# step-04 is a whole prose document rather than a self-contained execution
|
|
595
|
+
# recipe, so only the invocation phrasing counts here — a backticked mention
|
|
596
|
+
# anywhere in it is not evidence of a handoff.
|
|
597
|
+
named = dict.fromkeys(_INVOKE_SKILL_RE.findall(step04), ())
|
|
598
|
+
if not named:
|
|
599
|
+
return None
|
|
600
|
+
return ReviewResolution("step-04-review.md", dict(named), {}, False, (), unreadable)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# Stories mode (folder+id dispatch, Froid Plane #2549) needs a *newer* dev primitive
|
|
604
|
+
# than sprint mode: one whose step-01 routes a spec-folder + story-id invocation.
|
|
605
|
+
# File existence (missing_base_skills) can't tell the two skill versions apart, so
|
|
606
|
+
# a content probe confirms the merged dispatch protocol is present. This literal is
|
|
607
|
+
# stable prose in the merged step-01 ("this is a **folder+id dispatch**").
|
|
608
|
+
# STORIES_PROBE_SKILL names the FALLBACK era only — the probe runs against the skill
|
|
609
|
+
# resolve_dev_primitive picked for that tree, so a froid-build-auto install is probed
|
|
610
|
+
# under its own name.
|
|
611
|
+
STORIES_PROBE_SKILL = DEV_PRIMITIVE_LEGACY
|
|
612
|
+
STORIES_PROBE_FILE = "step-01-clarify-and-route.md"
|
|
613
|
+
STORIES_PROBE_TEXT = "folder+id dispatch"
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def missing_stories_support(project: Path, trees: Sequence[str]) -> list[Finding]:
|
|
617
|
+
"""Problems for stories mode's stricter dev-primitive requirement.
|
|
618
|
+
|
|
619
|
+
Sprint mode drives any dev primitive; stories mode needs the folder+id
|
|
620
|
+
dispatch flow, which older skill versions lack. For every ``trees`` entry —
|
|
621
|
+
callers pass the :data:`DEV_PRIMITIVE_ROLES` trees, since only a dev or review
|
|
622
|
+
session ever dispatches one of these — confirm
|
|
623
|
+
``<resolved-primitive>/step-01-clarify-and-route.md`` exists and carries the
|
|
624
|
+
dispatch-protocol marker. Returns one problem :class:`Finding` per tree lacking
|
|
625
|
+
it (empty = OK). Callers gate this on stories mode only — sprint-mode runs must
|
|
626
|
+
not require the newer skill.
|
|
627
|
+
|
|
628
|
+
The two failures are separate check ids because they are separate conditions
|
|
629
|
+
with separate remediations: ``-missing`` is a half install (reinstall the
|
|
630
|
+
module), ``-stale`` is an install that is simply too old (update it)."""
|
|
631
|
+
problems: list[Finding] = []
|
|
632
|
+
for tree in dict.fromkeys(trees):
|
|
633
|
+
skill = dev_primitive_or_default(project, tree)
|
|
634
|
+
probe = project / tree / skill / STORIES_PROBE_FILE
|
|
635
|
+
detail = {"tree": tree, "skill": skill, "file": STORIES_PROBE_FILE}
|
|
636
|
+
try:
|
|
637
|
+
text = probe.read_text(encoding="utf-8")
|
|
638
|
+
except (OSError, UnicodeDecodeError):
|
|
639
|
+
# OSError = the probe file is absent/unreadable; UnicodeDecodeError = it
|
|
640
|
+
# exists but is binary/non-UTF-8 (a corrupted skill tree). Either way the
|
|
641
|
+
# dispatch-protocol marker can't be confirmed, so report a problem rather
|
|
642
|
+
# than letting the decode error escape and crash the whole preflight.
|
|
643
|
+
problems.append(
|
|
644
|
+
Finding(
|
|
645
|
+
"skills.stories-dispatch-missing",
|
|
646
|
+
"problem",
|
|
647
|
+
f"{tree}/{skill}/{STORIES_PROBE_FILE} not found — stories "
|
|
648
|
+
f"mode needs folder+id dispatch; update the Froid Plane (bmm) module",
|
|
649
|
+
detail,
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
continue
|
|
653
|
+
if STORIES_PROBE_TEXT not in text:
|
|
654
|
+
problems.append(
|
|
655
|
+
Finding(
|
|
656
|
+
"skills.stories-dispatch-stale",
|
|
657
|
+
"problem",
|
|
658
|
+
f"{tree}/{skill} lacks folder+id dispatch (no "
|
|
659
|
+
f"{STORIES_PROBE_TEXT!r} in {STORIES_PROBE_FILE}) — stories mode needs a "
|
|
660
|
+
f"newer {skill}; update the bmm module",
|
|
661
|
+
{**detail, "marker": STORIES_PROBE_TEXT},
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
return problems
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]:
|
|
668
|
+
"""Problems for the upstream skills the orchestrator drives but doesn't bundle.
|
|
669
|
+
|
|
670
|
+
The dev primitive (froid-build-auto, or a complete pre-rename froid-dev-auto) and
|
|
671
|
+
the review layers its step-04 invokes inline are installed by the Froid Plane
|
|
672
|
+
module, not by `froid-loop init`. Each must exist in every ``trees`` entry —
|
|
673
|
+
callers pass the :data:`DEV_PRIMITIVE_ROLES` trees, since only a dev or review
|
|
674
|
+
session ever dispatches one of these — and carry its marker files. Returns one
|
|
675
|
+
:class:`Finding` per missing/incomplete skill; empty list means OK. Run as a
|
|
676
|
+
preflight so a missing skill fails loudly with remediation instead of stalling
|
|
677
|
+
as an `Unknown command` until the run times out.
|
|
678
|
+
|
|
679
|
+
Not every finding is fatal: review layers that are conditional, ambiguously
|
|
680
|
+
phrased, or configured by an unparseable override come back as ``warning``
|
|
681
|
+
(see :func:`_review_findings`). **Callers must branch on severity** — treating
|
|
682
|
+
a non-empty return as failure turns every advisory into a blocked run.
|
|
683
|
+
|
|
684
|
+
The primitive is resolved per tree (:func:`resolve_dev_primitive`) before any
|
|
685
|
+
marker check, so the markers are asserted against the skill this run would
|
|
686
|
+
actually drive. That splits the failures three ways:
|
|
687
|
+
|
|
688
|
+
- ``skills.base-incomplete`` — one resolved, but it is truncated.
|
|
689
|
+
- ``skills.base-shim`` — nothing resolved, yet a legacy-named SKILL.md is there.
|
|
690
|
+
- ``skills.base-missing`` — nothing at all under either name.
|
|
691
|
+
|
|
692
|
+
A truncated *legacy* install is byte-for-byte the same shape as the shim (old
|
|
693
|
+
SKILL.md, absent markers), so it lands on ``base-shim`` rather than
|
|
694
|
+
``base-incomplete``; nothing on disk can tell those two apart, so the message
|
|
695
|
+
names both causes and the single remediation they share. What the ids DO
|
|
696
|
+
separate is what a consumer can act on differently: resolved-but-truncated
|
|
697
|
+
(reinstall that skill) vs nothing-usable-resolved (update the module).
|
|
698
|
+
|
|
699
|
+
The review skills are read from the installed primitive itself
|
|
700
|
+
(:func:`resolve_review_layers`) so the preflight requires what this project
|
|
701
|
+
will really invoke: a tree whose configured layers call the merged
|
|
702
|
+
``froid-review`` needs that skill and not the standalone hunters, and a tree on
|
|
703
|
+
the pre-consolidation step-04 needs the two hunters it names. When the shape
|
|
704
|
+
can't be read we fall back to the static catalog, with a present
|
|
705
|
+
``froid-review`` satisfying the hunters. Everything is per tree, since a project
|
|
706
|
+
can have a post-consolidation `.claude` tree and a pre-merge `.agents` one side
|
|
707
|
+
by side — and, across the rename, a different primitive era in each.
|
|
708
|
+
|
|
709
|
+
``skills.base-incomplete`` carries ``missing_markers`` as a list — the message
|
|
710
|
+
joins it with ", " for the human line, which a consumer would otherwise have to
|
|
711
|
+
split back apart on a separator the message is free to change.
|
|
712
|
+
|
|
713
|
+
A resolved renderer stub is also checked at the project-global and
|
|
714
|
+
skill-relative surfaces whose absence deterministically HALTs before the
|
|
715
|
+
workflow can write its contract:
|
|
716
|
+
|
|
717
|
+
- ``skills.dev-renderer``: a required project-relative renderer script-unit
|
|
718
|
+
member is absent (``missing_scripts`` detail, per tree);
|
|
719
|
+
- ``skills.dev-renderer-config``: the project-global required config is absent
|
|
720
|
+
(emitted once even when multiple trees resolve a stub);
|
|
721
|
+
- ``skills.dev-renderer-sources``: the skill-relative workflow entry or a
|
|
722
|
+
declared snapshot source is absent (``missing_sources`` detail, per tree).
|
|
723
|
+
|
|
724
|
+
These are independent of the ordinary marker check: a damaged primitive may
|
|
725
|
+
need both remediations. All are content-gated on the installed SKILL.md, so an
|
|
726
|
+
inline pre-renderer install sees byte-stable behavior.
|
|
727
|
+
"""
|
|
728
|
+
problems: list[Finding] = []
|
|
729
|
+
resolved_stub = False
|
|
730
|
+
for tree in dict.fromkeys(trees):
|
|
731
|
+
resolved = resolve_dev_primitive(project, tree)
|
|
732
|
+
if resolved is None and _is_dev_primitive_shim(project, tree):
|
|
733
|
+
legacy_dir = project / tree / DEV_PRIMITIVE_LEGACY
|
|
734
|
+
absent = [m for m in DEV_PRIMITIVE_MARKERS if not (legacy_dir / m).is_file()]
|
|
735
|
+
problems.append(
|
|
736
|
+
Finding(
|
|
737
|
+
"skills.base-shim",
|
|
738
|
+
"problem",
|
|
739
|
+
f"{tree}/{DEV_PRIMITIVE_LEGACY} is unusable (missing "
|
|
740
|
+
f"{', '.join(absent)}) and {DEV_PRIMITIVE_NEW} is not installed — "
|
|
741
|
+
f"most likely the forwarding shim the Froid Plane's rename left "
|
|
742
|
+
f"behind, otherwise a truncated install; update the bmm module. The "
|
|
743
|
+
f"shim's migration prompt is interactive and would HALT an unattended "
|
|
744
|
+
f"session without writing anything to disk",
|
|
745
|
+
{
|
|
746
|
+
"tree": tree,
|
|
747
|
+
"skill": DEV_PRIMITIVE_LEGACY,
|
|
748
|
+
"expected": DEV_PRIMITIVE_NEW,
|
|
749
|
+
"missing_markers": absent,
|
|
750
|
+
},
|
|
751
|
+
)
|
|
752
|
+
)
|
|
753
|
+
elif resolved is None:
|
|
754
|
+
problems.append(
|
|
755
|
+
Finding(
|
|
756
|
+
"skills.base-missing",
|
|
757
|
+
"problem",
|
|
758
|
+
f"{tree}/{DEV_PRIMITIVE_NEW} not found — the orchestrator drives this "
|
|
759
|
+
f"upstream dev primitive directly; it ships with the bmm module "
|
|
760
|
+
f"(Froid Plane >= 6.10.0); install or update bmm in this project "
|
|
761
|
+
f"(older installs name it {DEV_PRIMITIVE_LEGACY})",
|
|
762
|
+
{"tree": tree, "skill": DEV_PRIMITIVE_NEW},
|
|
763
|
+
)
|
|
764
|
+
)
|
|
765
|
+
else:
|
|
766
|
+
skill_dir = project / tree / resolved
|
|
767
|
+
absent = [m for m in DEV_PRIMITIVE_MARKERS if not (skill_dir / m).is_file()]
|
|
768
|
+
if absent:
|
|
769
|
+
problems.append(
|
|
770
|
+
Finding(
|
|
771
|
+
"skills.base-incomplete",
|
|
772
|
+
"problem",
|
|
773
|
+
f"{tree}/{resolved} is incomplete (missing "
|
|
774
|
+
f"{', '.join(absent)}) — reinstall it from the bmm module",
|
|
775
|
+
{"tree": tree, "skill": resolved, "missing_markers": absent},
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
if _is_renderer_stub(skill_dir):
|
|
779
|
+
resolved_stub = True
|
|
780
|
+
absent_scripts = [
|
|
781
|
+
rel
|
|
782
|
+
for rel in RENDERER_SCRIPT_UNIT_REL
|
|
783
|
+
if _renderer_unit_required(project, rel) and not (project / rel).is_file()
|
|
784
|
+
]
|
|
785
|
+
if absent_scripts:
|
|
786
|
+
problems.append(
|
|
787
|
+
Finding(
|
|
788
|
+
"skills.dev-renderer",
|
|
789
|
+
"problem",
|
|
790
|
+
f"{tree}/{resolved}/SKILL.md renders via "
|
|
791
|
+
f"{RENDERER_SCRIPT_MARKER} but the renderer script unit is "
|
|
792
|
+
f"incomplete (missing {', '.join(absent_scripts)}) — the "
|
|
793
|
+
"session would HALT without writing a spec; reinstall the "
|
|
794
|
+
"Froid Plane (bmm) module",
|
|
795
|
+
{
|
|
796
|
+
"tree": tree,
|
|
797
|
+
"skill": resolved,
|
|
798
|
+
"missing_scripts": absent_scripts,
|
|
799
|
+
},
|
|
800
|
+
)
|
|
801
|
+
)
|
|
802
|
+
absent_sources = _absent_renderer_sources(skill_dir)
|
|
803
|
+
if absent_sources:
|
|
804
|
+
problems.append(
|
|
805
|
+
Finding(
|
|
806
|
+
"skills.dev-renderer-sources",
|
|
807
|
+
"problem",
|
|
808
|
+
f"{tree}/{resolved} renders via {RENDERER_SCRIPT_MARKER} but its "
|
|
809
|
+
f"render sources are incomplete (unresolved: "
|
|
810
|
+
f"{', '.join(absent_sources)}) — the session would HALT without "
|
|
811
|
+
"writing a spec; reinstall the Froid Plane (bmm) module",
|
|
812
|
+
{
|
|
813
|
+
"tree": tree,
|
|
814
|
+
"skill": resolved,
|
|
815
|
+
"missing_sources": absent_sources,
|
|
816
|
+
},
|
|
817
|
+
)
|
|
818
|
+
)
|
|
819
|
+
problems.extend(_review_findings(project, tree))
|
|
820
|
+
if resolved_stub and not (project / CENTRAL_CONFIG_REL).is_file():
|
|
821
|
+
problems.append(
|
|
822
|
+
Finding(
|
|
823
|
+
"skills.dev-renderer-config",
|
|
824
|
+
"problem",
|
|
825
|
+
f"the dev primitive renders via {RENDERER_SCRIPT_MARKER} but "
|
|
826
|
+
f"{CENTRAL_CONFIG_REL} is missing — the renderer requires that layer "
|
|
827
|
+
"and would HALT without writing a spec; reinstall the Froid Plane "
|
|
828
|
+
"(bmm) module",
|
|
829
|
+
{"config": CENTRAL_CONFIG_REL},
|
|
830
|
+
)
|
|
831
|
+
)
|
|
832
|
+
return problems
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def dev_primitive_warnings(project: Path, trees: Sequence[str]) -> list[Finding]:
|
|
836
|
+
"""Advisory findings about a resolved dev primitive — validate-only, never a gate.
|
|
837
|
+
|
|
838
|
+
One condition, and it is genuinely survivable — which is what keeps it out of
|
|
839
|
+
:func:`missing_base_skills`:
|
|
840
|
+
|
|
841
|
+
- ``skills.customize-legacy``: at least one tree resolved to the NEW name while a
|
|
842
|
+
customization override still sits under the OLD one with no counterpart, i.e.
|
|
843
|
+
the rename silently orphaned it. Emitted once per project (the override files
|
|
844
|
+
are project-global, not per tree). The session still runs; it just runs
|
|
845
|
+
unstyled, so naming it is an operator heads-up rather than a gate.
|
|
846
|
+
|
|
847
|
+
⚠️ The remediation is COPY, not rename, whenever another active tree still
|
|
848
|
+
resolves to the legacy primitive. A project can sit mid-upgrade with a different
|
|
849
|
+
era in each tree (`.claude/skills` on the new name, `.agents/skills` still on a
|
|
850
|
+
marker-complete old one), and each tree resolves its overrides under its OWN
|
|
851
|
+
era — so the legacy file is orphaned for the new tree and LIVE for the legacy
|
|
852
|
+
one. Telling that operator to rename it moves the customization from one tree to
|
|
853
|
+
the other instead of fixing anything. Suppressing the finding instead would be
|
|
854
|
+
the opposite error: the new tree really is running unstyled, which is precisely
|
|
855
|
+
the silent degradation this warning exists to surface.
|
|
856
|
+
|
|
857
|
+
Deliberately a warning and not a problem. :func:`missing_base_skills` feeds a
|
|
858
|
+
gate with no severity filter and no ``--force``: a false FAIL there pauses every
|
|
859
|
+
run behind a remediation nobody can apply, so on these checks a false green is
|
|
860
|
+
the safe direction. The orphaned file's layers really are inert for a new-era
|
|
861
|
+
tree — upstream's resolver keys on the skill dir — so the honest response is to
|
|
862
|
+
say so and name the rename, not to block.
|
|
863
|
+
|
|
864
|
+
Returns [] when nothing resolves — :func:`missing_base_skills` owns that story.
|
|
865
|
+
"""
|
|
866
|
+
findings: list[Finding] = []
|
|
867
|
+
resolved = {tree: resolve_dev_primitive(project, tree) for tree in dict.fromkeys(trees)}
|
|
868
|
+
new_trees = [tree for tree, name in resolved.items() if name == DEV_PRIMITIVE_NEW]
|
|
869
|
+
legacy_trees = [tree for tree, name in resolved.items() if name == DEV_PRIMITIVE_LEGACY]
|
|
870
|
+
if new_trees:
|
|
871
|
+
orphaned = [
|
|
872
|
+
(CUSTOMIZE_DIR / f"{DEV_PRIMITIVE_LEGACY}{suffix}").as_posix()
|
|
873
|
+
for suffix in (".toml", ".user.toml")
|
|
874
|
+
if (project / CUSTOMIZE_DIR / f"{DEV_PRIMITIVE_LEGACY}{suffix}").is_file()
|
|
875
|
+
and not (project / CUSTOMIZE_DIR / f"{DEV_PRIMITIVE_NEW}{suffix}").is_file()
|
|
876
|
+
]
|
|
877
|
+
if orphaned and legacy_trees:
|
|
878
|
+
# Mixed-era project: the file is live for the legacy tree, so name the
|
|
879
|
+
# tree it still styles and say copy. `legacy_trees` rides in `detail`
|
|
880
|
+
# only on this branch, keeping the all-new detail dict unchanged.
|
|
881
|
+
findings.append(
|
|
882
|
+
Finding(
|
|
883
|
+
"skills.customize-legacy",
|
|
884
|
+
"warning",
|
|
885
|
+
f"{', '.join(orphaned)} does not apply in "
|
|
886
|
+
f"{', '.join(new_trees)} (resolved {DEV_PRIMITIVE_NEW}) but still "
|
|
887
|
+
f"applies in {', '.join(legacy_trees)} — COPY the override file(s) "
|
|
888
|
+
f"to the {DEV_PRIMITIVE_NEW} name; renaming would drop the "
|
|
889
|
+
f"customization from {', '.join(legacy_trees)}",
|
|
890
|
+
{
|
|
891
|
+
"files": orphaned,
|
|
892
|
+
"skill": DEV_PRIMITIVE_NEW,
|
|
893
|
+
"legacy_trees": legacy_trees,
|
|
894
|
+
},
|
|
895
|
+
)
|
|
896
|
+
)
|
|
897
|
+
elif orphaned:
|
|
898
|
+
findings.append(
|
|
899
|
+
Finding(
|
|
900
|
+
"skills.customize-legacy",
|
|
901
|
+
"warning",
|
|
902
|
+
f"{', '.join(orphaned)} no longer applies — the dev primitive is now "
|
|
903
|
+
f"{DEV_PRIMITIVE_NEW}; rename the override file(s) to match",
|
|
904
|
+
{"files": orphaned, "skill": DEV_PRIMITIVE_NEW},
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
return findings
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _where_clause(layer_ids: Sequence[str]) -> str:
|
|
911
|
+
"""How a finding's message names the layers that reach for a skill."""
|
|
912
|
+
if len(layer_ids) > 1:
|
|
913
|
+
return f"review layers {', '.join(layer_ids)} invoke"
|
|
914
|
+
if layer_ids:
|
|
915
|
+
return f"review layer {layer_ids[0]} invokes"
|
|
916
|
+
return "review step invokes"
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _review_findings(project: Path, tree: str) -> list[Finding]:
|
|
920
|
+
"""Findings for the review skills this tree's dev primitive invokes.
|
|
921
|
+
|
|
922
|
+
Problems block; warnings never do. A skill is only a problem when the
|
|
923
|
+
installed config says this run WILL invoke it — anything conditional or
|
|
924
|
+
ambiguous warns instead, because a false FAIL here is #260 all over again.
|
|
925
|
+
Callers must therefore honour severity rather than treating any finding as
|
|
926
|
+
fatal.
|
|
927
|
+
|
|
928
|
+
The primitive's name is resolved here for the MESSAGES — which tell an operator
|
|
929
|
+
which skill dir and which override file to go and fix, so a hardcoded era would
|
|
930
|
+
send them to a path that does not exist on a renamed project. The static-catalog
|
|
931
|
+
fallback below has no :class:`ReviewResolution` to carry the name, so this
|
|
932
|
+
resolves independently rather than widening that tuple.
|
|
933
|
+
"""
|
|
934
|
+
primitive = dev_primitive_or_default(project, tree)
|
|
935
|
+
resolved = resolve_review_layers(project, tree)
|
|
936
|
+
if resolved is None:
|
|
937
|
+
# Unknown shape: keep the long-standing static requirement, which both real
|
|
938
|
+
# topologies satisfy — the two hunters, or the merged reviewer instead.
|
|
939
|
+
if (project / tree / MERGED_REVIEW_SKILL / "SKILL.md").is_file():
|
|
940
|
+
return []
|
|
941
|
+
return [
|
|
942
|
+
Finding(
|
|
943
|
+
"skills.base-missing",
|
|
944
|
+
"problem",
|
|
945
|
+
f"{tree}/{skill} not found — this review layer ships with the bmm module "
|
|
946
|
+
f"(Froid Plane >= 6.10.0), as does the consolidated {MERGED_REVIEW_SKILL} "
|
|
947
|
+
f"skill that supersedes it in newer releases; install or update bmm in "
|
|
948
|
+
f"this project",
|
|
949
|
+
{"tree": tree, "skill": skill},
|
|
950
|
+
)
|
|
951
|
+
for skill in sorted(_REVIEW_LAYER_SKILLS)
|
|
952
|
+
if not (project / tree / skill / "SKILL.md").is_file()
|
|
953
|
+
]
|
|
954
|
+
findings: list[Finding] = []
|
|
955
|
+
for rel in resolved.unreadable:
|
|
956
|
+
findings.append(
|
|
957
|
+
Finding(
|
|
958
|
+
"skills.customize-unreadable",
|
|
959
|
+
"warning",
|
|
960
|
+
f"{rel} could not be parsed as TOML — the run's resolver skips a broken "
|
|
961
|
+
f"override layer, so this project's review layers resolve without it; "
|
|
962
|
+
f"fix the file or remove it",
|
|
963
|
+
{"tree": tree, "file": rel},
|
|
964
|
+
)
|
|
965
|
+
)
|
|
966
|
+
if resolved.layer_driven and not resolved.active_layers:
|
|
967
|
+
findings.append(
|
|
968
|
+
Finding(
|
|
969
|
+
"skills.review-layers-empty",
|
|
970
|
+
"problem",
|
|
971
|
+
f"{tree}/{primitive} has no enabled review layer (every "
|
|
972
|
+
f"`instruction` is empty) — every dev run would HALT blocked with "
|
|
973
|
+
f"'no active review layers'; re-enable a layer in "
|
|
974
|
+
f"{_customize_overrides(primitive)[0].as_posix()}",
|
|
975
|
+
{"tree": tree, "source": resolved.source},
|
|
976
|
+
)
|
|
977
|
+
)
|
|
978
|
+
for skill, layer_ids in sorted(resolved.required.items()):
|
|
979
|
+
if (project / tree / skill / "SKILL.md").is_file():
|
|
980
|
+
continue
|
|
981
|
+
findings.append(
|
|
982
|
+
Finding(
|
|
983
|
+
"skills.review-layer-missing",
|
|
984
|
+
"problem",
|
|
985
|
+
f"{tree}/{skill} not found — {tree}/{primitive}'s "
|
|
986
|
+
f"{_where_clause(layer_ids)} it ({resolved.source}), so every dev run's "
|
|
987
|
+
f"review would fail; install or update the bmm module so this project's "
|
|
988
|
+
f"review layers resolve",
|
|
989
|
+
{
|
|
990
|
+
"tree": tree,
|
|
991
|
+
"skill": skill,
|
|
992
|
+
"layers": list(layer_ids),
|
|
993
|
+
"source": resolved.source,
|
|
994
|
+
},
|
|
995
|
+
)
|
|
996
|
+
)
|
|
997
|
+
for skill, layer_ids in sorted(resolved.advisory.items()):
|
|
998
|
+
if (project / tree / skill / "SKILL.md").is_file():
|
|
999
|
+
continue
|
|
1000
|
+
findings.append(
|
|
1001
|
+
Finding(
|
|
1002
|
+
"skills.review-layer-unresolved",
|
|
1003
|
+
"warning",
|
|
1004
|
+
f"{tree}/{skill} not found — {tree}/{primitive}'s "
|
|
1005
|
+
f"{_where_clause(layer_ids)} it conditionally, or names it in prose this "
|
|
1006
|
+
f"check cannot confirm is a handoff ({resolved.source}); install it if "
|
|
1007
|
+
f"that layer is meant to run",
|
|
1008
|
+
{
|
|
1009
|
+
"tree": tree,
|
|
1010
|
+
"skill": skill,
|
|
1011
|
+
"layers": list(layer_ids),
|
|
1012
|
+
"source": resolved.source,
|
|
1013
|
+
},
|
|
1014
|
+
)
|
|
1015
|
+
)
|
|
1016
|
+
return findings
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def hook_script_current(project: Path) -> bool | None:
|
|
1020
|
+
"""Does the project's installed relay match the one this wheel would write?
|
|
1021
|
+
|
|
1022
|
+
``True`` yes, ``False`` stale (or otherwise divergent), ``None`` unknowable —
|
|
1023
|
+
the installed copy or the packaged source could not be read as text. The
|
|
1024
|
+
unknown arm is a third state and not a coerced ``False`` on purpose: the sole
|
|
1025
|
+
caller (``cmd_validate``'s ``hooks.relay-stale``) reports what it knows, and
|
|
1026
|
+
"I could not look" is not "your relay is out of date".
|
|
1027
|
+
|
|
1028
|
+
Lives here, beside :func:`install_into`'s write of the same two paths, so the
|
|
1029
|
+
reader and the writer of the relay stay in one module and one reviewer's view
|
|
1030
|
+
— a comparison that resolved the source differently from the writer would
|
|
1031
|
+
answer a different question.
|
|
1032
|
+
|
|
1033
|
+
Compared as TEXT read with universal newlines, not as raw bytes. That is
|
|
1034
|
+
precisely the round trip ``install_into`` performs (``read_text`` then
|
|
1035
|
+
``write_text``), and ``write_text`` translates ``\\n`` to ``os.linesep`` — so
|
|
1036
|
+
on Windows every freshly-installed relay differs from the packaged source
|
|
1037
|
+
byte-for-byte while being exactly what ``init`` writes. A byte compare would
|
|
1038
|
+
call those installs permanently stale.
|
|
1039
|
+
"""
|
|
1040
|
+
try:
|
|
1041
|
+
installed = (project / HOOK_SCRIPT_REL).read_text(encoding="utf-8")
|
|
1042
|
+
packaged = (
|
|
1043
|
+
resources.files("froid_loop.data")
|
|
1044
|
+
.joinpath("froid_loop_hook.py")
|
|
1045
|
+
.read_text(encoding="utf-8")
|
|
1046
|
+
)
|
|
1047
|
+
except (OSError, ValueError):
|
|
1048
|
+
# ValueError covers UnicodeDecodeError (a relay overwritten with non-UTF-8
|
|
1049
|
+
# bytes); OSError covers missing/unreadable on either side. Observation
|
|
1050
|
+
# degrades — the missing and unreadable cases already have their own
|
|
1051
|
+
# finding (`hooks.relay-present`), and a packaged source this process
|
|
1052
|
+
# cannot read is a broken wheel, not a stale project.
|
|
1053
|
+
return None
|
|
1054
|
+
return installed == packaged
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _hook_command(project: Path, profile: CLIProfile, canonical_event: str) -> str:
|
|
1058
|
+
host = get_process_host()
|
|
1059
|
+
interp = host.hook_interpreter()
|
|
1060
|
+
if profile.hooks.dialect == "claude-settings-json":
|
|
1061
|
+
return f'{interp} "$CLAUDE_PROJECT_DIR"/{HOOK_SCRIPT_REL} {canonical_event}'
|
|
1062
|
+
# Codex/Gemini expose no $CLAUDE_PROJECT_DIR equivalent to hook commands;
|
|
1063
|
+
# bake the absolute path at init time.
|
|
1064
|
+
return f"{interp} {host.shell_quote(str(project / HOOK_SCRIPT_REL))} {canonical_event}"
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _hook_entry(dialect: str, command: str) -> dict:
|
|
1068
|
+
handler: dict = {"type": "command", "command": command}
|
|
1069
|
+
if dialect == "gemini-settings-json":
|
|
1070
|
+
handler["timeout"] = GEMINI_HOOK_TIMEOUT_MS # Gemini timeouts are milliseconds
|
|
1071
|
+
return {"matcher": "", "hooks": [handler]}
|
|
1072
|
+
if dialect == "copilot-settings-json":
|
|
1073
|
+
handler["timeoutSec"] = COPILOT_HOOK_TIMEOUT_SEC # Copilot timeouts are seconds
|
|
1074
|
+
return handler # Copilot stores the handler directly in the event list
|
|
1075
|
+
if dialect == "antigravity-hooks-json":
|
|
1076
|
+
handler["timeout"] = ANTIGRAVITY_HOOK_TIMEOUT_SEC # agy timeouts are seconds
|
|
1077
|
+
# agy's Stop event value is a flat list of handler objects — the handler
|
|
1078
|
+
# sits directly in the event list, with no matcher/hooks wrapper (unlike
|
|
1079
|
+
# gemini's grouped shape).
|
|
1080
|
+
return handler
|
|
1081
|
+
# claude-settings-json and codex-hooks-json share the schema
|
|
1082
|
+
return {"hooks": [handler]}
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def hook_event_container(config: dict, dialect: str) -> dict:
|
|
1086
|
+
"""The `native event -> handlers` map inside a parsed hook config.
|
|
1087
|
+
|
|
1088
|
+
Most dialects nest it under "hooks". agy instead keys the file by hook GROUP
|
|
1089
|
+
name at the top level, so our relay lives under ANTIGRAVITY_HOOK_GROUP —
|
|
1090
|
+
reading "hooks" there yields {} and reports a correctly-installed relay as
|
|
1091
|
+
unregistered (issue #159). Every reader must go through this, or it drifts.
|
|
1092
|
+
"""
|
|
1093
|
+
if dialect == "antigravity-hooks-json":
|
|
1094
|
+
container = config.get(ANTIGRAVITY_HOOK_GROUP, {})
|
|
1095
|
+
else:
|
|
1096
|
+
container = config.get("hooks", {})
|
|
1097
|
+
return container if isinstance(container, dict) else {}
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _relay_in_handlers(handlers) -> bool:
|
|
1101
|
+
"""True if any handler in a native-event list carries the relay command."""
|
|
1102
|
+
return RELAY_MARKER in json.dumps(handlers)
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _managed_hook_in_handlers(handlers) -> bool:
|
|
1106
|
+
"""merge_hooks' dedup: a relay OR probe-capture command is already present."""
|
|
1107
|
+
dumped = json.dumps(handlers)
|
|
1108
|
+
return RELAY_MARKER in dumped or PROBE_MARKER in dumped
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def strip_relay_hooks(config: dict, dialect: str) -> bool:
|
|
1112
|
+
"""Drop every relay registration from a parsed hook config. True if any went.
|
|
1113
|
+
|
|
1114
|
+
The inverse of :func:`merge_hooks`, for the one caller that needs its own
|
|
1115
|
+
registration to be authoritative rather than additive: a worktree seeded with
|
|
1116
|
+
the main repo's hook config (``provision_worktree``). That config already
|
|
1117
|
+
carries a relay command written for the main repo — `$CLAUDE_PROJECT_DIR`-relative
|
|
1118
|
+
for the claude dialect, which resolves inside the worktree, where no relay
|
|
1119
|
+
exists. `merge_hooks` will not replace it, since `_managed_hook_in_handlers`
|
|
1120
|
+
reports the event as already registered, so the stale command has to go first.
|
|
1121
|
+
|
|
1122
|
+
Only RELAY_MARKER commands are removed, at command granularity: a matcher
|
|
1123
|
+
entry whose nested list holds a project command beside the relay keeps the
|
|
1124
|
+
entry and loses only the relay command. A probe-capture hook is a deliberate,
|
|
1125
|
+
temporary registration that no worktree seeding produces, and is left alone.
|
|
1126
|
+
Empty event lists are dropped; an empty container is left in place for
|
|
1127
|
+
`merge_hooks` to refill.
|
|
1128
|
+
"""
|
|
1129
|
+
container = hook_event_container(config, dialect)
|
|
1130
|
+
removed = False
|
|
1131
|
+
for native_event in list(container):
|
|
1132
|
+
handlers = container.get(native_event)
|
|
1133
|
+
if not isinstance(handlers, list):
|
|
1134
|
+
continue
|
|
1135
|
+
kept = []
|
|
1136
|
+
for handler in handlers:
|
|
1137
|
+
if RELAY_MARKER not in json.dumps(handler):
|
|
1138
|
+
kept.append(handler)
|
|
1139
|
+
continue
|
|
1140
|
+
# claude/codex/gemini wrap commands in a nested "hooks" list, and a
|
|
1141
|
+
# user may have added their own command beside the relay inside ONE
|
|
1142
|
+
# matcher entry — strip inside the list so theirs survives. copilot
|
|
1143
|
+
# and agy store the command dict flat in the event list, so a marker
|
|
1144
|
+
# match means the entry IS the relay and it drops whole.
|
|
1145
|
+
nested = handler.get("hooks") if isinstance(handler, dict) else None
|
|
1146
|
+
if isinstance(nested, list):
|
|
1147
|
+
surviving = [c for c in nested if RELAY_MARKER not in json.dumps(c)]
|
|
1148
|
+
if surviving:
|
|
1149
|
+
if len(surviving) != len(nested):
|
|
1150
|
+
handler["hooks"] = surviving
|
|
1151
|
+
removed = True
|
|
1152
|
+
kept.append(handler)
|
|
1153
|
+
continue
|
|
1154
|
+
removed = True
|
|
1155
|
+
if len(kept) != len(handlers):
|
|
1156
|
+
if kept:
|
|
1157
|
+
container[native_event] = kept
|
|
1158
|
+
else:
|
|
1159
|
+
del container[native_event]
|
|
1160
|
+
return removed
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def relay_registered(config: dict, dialect: str, events: Iterable[str]) -> bool:
|
|
1164
|
+
"""True if the froid-loop relay is registered for any of `events`."""
|
|
1165
|
+
container = hook_event_container(config, dialect)
|
|
1166
|
+
return any(_relay_in_handlers(container.get(event, [])) for event in events)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def merge_hooks(config: dict, registrations: dict[str, str], dialect: str) -> tuple[dict, bool]:
|
|
1170
|
+
"""Add relay registrations (native event -> command) to a hook config dict."""
|
|
1171
|
+
changed = False
|
|
1172
|
+
if dialect == "antigravity-hooks-json":
|
|
1173
|
+
# agy keys .agents/hooks.json by hook NAME at the top level (no "hooks"
|
|
1174
|
+
# wrapper); register every handler under one ANTIGRAVITY_HOOK_GROUP group.
|
|
1175
|
+
# Other named groups (user/plugin hooks) sit alongside and are preserved.
|
|
1176
|
+
group = config.setdefault(ANTIGRAVITY_HOOK_GROUP, {})
|
|
1177
|
+
if not isinstance(group, dict):
|
|
1178
|
+
raise ProfileError(
|
|
1179
|
+
f"{ANTIGRAVITY_HOOK_GROUP!r} in the hooks file is not a table; "
|
|
1180
|
+
"fix or remove it before registering the Stop hook"
|
|
1181
|
+
)
|
|
1182
|
+
for native_event, command in registrations.items():
|
|
1183
|
+
handlers = group.setdefault(native_event, [])
|
|
1184
|
+
if not isinstance(handlers, list):
|
|
1185
|
+
raise ProfileError(
|
|
1186
|
+
f"hook event {native_event!r} under {ANTIGRAVITY_HOOK_GROUP!r} "
|
|
1187
|
+
"is not a list; fix the hooks file before re-running init"
|
|
1188
|
+
)
|
|
1189
|
+
if not _managed_hook_in_handlers(handlers):
|
|
1190
|
+
handlers.append(_hook_entry(dialect, command))
|
|
1191
|
+
changed = True
|
|
1192
|
+
return config, changed
|
|
1193
|
+
if dialect == "copilot-settings-json":
|
|
1194
|
+
config.setdefault("version", 1) # Copilot hook configs are versioned
|
|
1195
|
+
hooks = config.setdefault("hooks", {})
|
|
1196
|
+
for native_event, command in registrations.items():
|
|
1197
|
+
matchers = hooks.setdefault(native_event, [])
|
|
1198
|
+
# claude/codex/gemini nest handlers under "hooks"; copilot stores the
|
|
1199
|
+
# handler dict directly in the event list — the serialized scan covers
|
|
1200
|
+
# both shapes so a re-run stays idempotent for every dialect.
|
|
1201
|
+
if not _managed_hook_in_handlers(matchers):
|
|
1202
|
+
matchers.append(_hook_entry(dialect, command))
|
|
1203
|
+
changed = True
|
|
1204
|
+
return config, changed
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _confined_to(target: Path, root: Path) -> bool:
|
|
1208
|
+
"""True if ``target`` resolves *strictly below* ``root``.
|
|
1209
|
+
|
|
1210
|
+
The profile/manifest guards (``names_tree_root``/``is_absolute_path``/
|
|
1211
|
+
``has_parent_ref``) are lexical and run at load, so they cannot see a *link*:
|
|
1212
|
+
a `skill_tree` or `hooks.config_path` naming a perfectly project-relative
|
|
1213
|
+
directory that happens to be a symlink out of the tree passes all three. This
|
|
1214
|
+
is the resolve-time backstop the worktree side already had inline
|
|
1215
|
+
(``worktree_flow`` compares resolved-vs-raw before writing); the ``init`` path
|
|
1216
|
+
reached mkdir/rmtree/write with no such check. Refuses on OSError rather than
|
|
1217
|
+
degrading: a slot that cannot be inspected is not safe to write through.
|
|
1218
|
+
|
|
1219
|
+
Strictly below, not merely inside, because ``is_relative_to`` is true for
|
|
1220
|
+
equal paths — the same "a path is relative to itself" hole this guard family
|
|
1221
|
+
exists to close in `provision_worktree`'s seed loop. `skill_tree = "skills"`
|
|
1222
|
+
where `project/skills` links back to `project` resolves to the root itself, and
|
|
1223
|
+
an equality-inclusive check would hand `_copy_skills` the project root: the
|
|
1224
|
+
bundled skill dirs land at top level, and `--force-skills` `rmtree`s any root
|
|
1225
|
+
directory whose name collides with one of them."""
|
|
1226
|
+
try:
|
|
1227
|
+
resolved, base = target.resolve(), root.resolve()
|
|
1228
|
+
return resolved != base and resolved.is_relative_to(base)
|
|
1229
|
+
except (OSError, RuntimeError):
|
|
1230
|
+
return False
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _register_hooks(project: Path, profile: CLIProfile) -> int:
|
|
1234
|
+
if profile.hookless:
|
|
1235
|
+
print(f" no hooks needed ({profile.name}): HTTP/SSE transport")
|
|
1236
|
+
return 0
|
|
1237
|
+
config_path = project / profile.hooks.config_path
|
|
1238
|
+
if not _confined_to(config_path, project):
|
|
1239
|
+
print(f"FAIL: hooks config_path escapes the project ({profile.name}): {config_path}")
|
|
1240
|
+
return 1
|
|
1241
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1242
|
+
config: dict = {}
|
|
1243
|
+
if config_path.is_file():
|
|
1244
|
+
try:
|
|
1245
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
1246
|
+
except json.JSONDecodeError:
|
|
1247
|
+
print(f"FAIL: {config_path} is not valid JSON; fix it and re-run init")
|
|
1248
|
+
return 1
|
|
1249
|
+
registrations = {
|
|
1250
|
+
native: _hook_command(project, profile, canonical)
|
|
1251
|
+
for native, canonical in profile.hooks.events.items()
|
|
1252
|
+
}
|
|
1253
|
+
config, changed = merge_hooks(config, registrations, profile.hooks.dialect)
|
|
1254
|
+
if changed:
|
|
1255
|
+
# atomic_write_text, never write_text (#379), the same rule
|
|
1256
|
+
# `_worktree_local_exclude` states for its bytes sibling. This is a
|
|
1257
|
+
# read-modify-REWRITE of a file `init` does not own: the parse above kept
|
|
1258
|
+
# the operator's permission allowlist, env, MCP entries and their own
|
|
1259
|
+
# hooks, and every one of them is re-serialized here. `"w"` TRUNCATES
|
|
1260
|
+
# before writing, so a short write (ENOSPC, a full quota) publishes a
|
|
1261
|
+
# PREFIX of that JSON — and unlike the ledgers, this failure is loud in
|
|
1262
|
+
# the worst way: the next `init` reads it back, fails json.loads, and
|
|
1263
|
+
# prints "is not valid JSON; fix it and re-run init" (above) at a human
|
|
1264
|
+
# whose file this tool just shredded. The helper leaves the original
|
|
1265
|
+
# untouched on any raise. follow_symlinks stays at the default, matching
|
|
1266
|
+
# the `write_text` it replaces: `_confined_to` above resolves the path and
|
|
1267
|
+
# refuses anything landing outside the project, so the only links reaching
|
|
1268
|
+
# this write point back INSIDE it — an in-repo indirection the operator
|
|
1269
|
+
# arranged, which a name-replacement would orphan on the first init. That
|
|
1270
|
+
# rules out the confined writers too: they are no-follow by construction,
|
|
1271
|
+
# so this site takes the #597 flag and nothing else.
|
|
1272
|
+
# `require_writable_target=True` is that flag. `os.replace` needs write
|
|
1273
|
+
# permission on the parent DIRECTORY, never on the entry it replaces, so a
|
|
1274
|
+
# settings.json the operator had marked read-only was rewritten anyway and,
|
|
1275
|
+
# because the mode is inherited, came back reading `0444` with nothing in
|
|
1276
|
+
# the permission bits to record it. This file is the operator's own — the
|
|
1277
|
+
# parse above kept their allowlist, env and MCP entries — so a read-only one
|
|
1278
|
+
# is a `PermissionError`, which is what the `write_text` this replaced
|
|
1279
|
+
# raised (#597).
|
|
1280
|
+
atomic_write_text(
|
|
1281
|
+
config_path, json.dumps(config, indent=2) + "\n", require_writable_target=True
|
|
1282
|
+
)
|
|
1283
|
+
print(f" hooks registered ({profile.name}): {config_path}")
|
|
1284
|
+
else:
|
|
1285
|
+
print(f" hooks already registered ({profile.name})")
|
|
1286
|
+
return 0
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _walk_traversable_files(
|
|
1290
|
+
src,
|
|
1291
|
+
rel: str = "",
|
|
1292
|
+
_seen: frozenset[str] = frozenset(),
|
|
1293
|
+
_should_descend=None,
|
|
1294
|
+
_visit_dir=None,
|
|
1295
|
+
_suppress_errors: bool = True,
|
|
1296
|
+
) -> Iterator[tuple[str, Traversable]]:
|
|
1297
|
+
"""Yield the leaves of a Traversable tree as deterministic POSIX rels.
|
|
1298
|
+
|
|
1299
|
+
``iterdir`` recursion deliberately descends symlinked source directories, unlike
|
|
1300
|
+
the renderer's ``rglob`` enumeration in :func:`_absent_renderer_sources`. A
|
|
1301
|
+
branch-local real-path set terminates cycles without dropping a second sibling
|
|
1302
|
+
that points at the same shared tree.
|
|
1303
|
+
|
|
1304
|
+
A directory the filesystem refuses to list is yielded as a leaf. That lets a
|
|
1305
|
+
copier decline content it cannot confirm while a result-side completeness check
|
|
1306
|
+
can still name the unreadable rel. Wheel Traversables have no real-path cycle leg.
|
|
1307
|
+
|
|
1308
|
+
``_should_descend`` and ``_visit_dir`` are private copier plumbing. The first may
|
|
1309
|
+
prune before ``iterdir`` (preserving no-clobber when a destination file stands
|
|
1310
|
+
where the source has a directory); the second sees every successfully enumerated
|
|
1311
|
+
directory, including empty ones. Keeping materialization after enumeration means
|
|
1312
|
+
an unreadable source directory never leaves an empty destination behind.
|
|
1313
|
+
"""
|
|
1314
|
+
if _is_dir(src):
|
|
1315
|
+
try:
|
|
1316
|
+
real = str(src.resolve()) if isinstance(src, Path) else None
|
|
1317
|
+
except (OSError, RuntimeError):
|
|
1318
|
+
if not _suppress_errors:
|
|
1319
|
+
raise
|
|
1320
|
+
yield rel, src
|
|
1321
|
+
return
|
|
1322
|
+
if real is not None and real in _seen:
|
|
1323
|
+
return
|
|
1324
|
+
if _should_descend is not None and not _should_descend(rel, src):
|
|
1325
|
+
return
|
|
1326
|
+
try:
|
|
1327
|
+
children = sorted(src.iterdir(), key=lambda entry: entry.name)
|
|
1328
|
+
except OSError:
|
|
1329
|
+
if not _suppress_errors:
|
|
1330
|
+
raise
|
|
1331
|
+
yield rel, src
|
|
1332
|
+
return
|
|
1333
|
+
if _visit_dir is not None and not _visit_dir(rel, src):
|
|
1334
|
+
return
|
|
1335
|
+
inner = _seen if real is None else _seen | {real}
|
|
1336
|
+
for child in children:
|
|
1337
|
+
child_rel = f"{rel}/{child.name}" if rel else child.name
|
|
1338
|
+
yield from _walk_traversable_files(
|
|
1339
|
+
child,
|
|
1340
|
+
child_rel,
|
|
1341
|
+
inner,
|
|
1342
|
+
_should_descend,
|
|
1343
|
+
_visit_dir,
|
|
1344
|
+
_suppress_errors,
|
|
1345
|
+
)
|
|
1346
|
+
return
|
|
1347
|
+
yield rel, src
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _is_file(path) -> bool:
|
|
1351
|
+
"""Answer whether ``path`` is a usable file, total over filesystem faults.
|
|
1352
|
+
|
|
1353
|
+
A refused probe and an absent/non-file path all mean the same thing to a reader
|
|
1354
|
+
or copier: there are no bytes it can promise to consume. Python <=3.13 raises on
|
|
1355
|
+
an entry below an unsearchable parent while 3.14 answers false; both fold to false
|
|
1356
|
+
here. The directory reading is deliberately different — see :func:`_is_dir`.
|
|
1357
|
+
"""
|
|
1358
|
+
try:
|
|
1359
|
+
return path.is_file()
|
|
1360
|
+
except OSError:
|
|
1361
|
+
return False
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _is_dir(path) -> bool:
|
|
1365
|
+
"""Answer whether a walk may have unseen content below ``path``.
|
|
1366
|
+
|
|
1367
|
+
Refusal is true, not false: an unreadable source must reach the walk as a named
|
|
1368
|
+
leaf instead of collapsing into absence. Python 3.14 made ``Path.is_dir`` return
|
|
1369
|
+
false for faults older interpreters raise, so a false is re-asked with ``stat``.
|
|
1370
|
+
"""
|
|
1371
|
+
try:
|
|
1372
|
+
if path.is_dir():
|
|
1373
|
+
return True
|
|
1374
|
+
except OSError:
|
|
1375
|
+
return True
|
|
1376
|
+
return _probe_refused(path)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
# pathlib's version-dependent private ignored-error sets, copied here so absence and
|
|
1380
|
+
# refusal retain the same meaning on every supported interpreter and platform.
|
|
1381
|
+
_ABSENCE_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP)
|
|
1382
|
+
_ABSENCE_WINERRORS = (
|
|
1383
|
+
21, # ERROR_NOT_READY: drive exists but is not accessible
|
|
1384
|
+
123, # ERROR_INVALID_NAME
|
|
1385
|
+
1921, # ERROR_CANT_RESOLVE_FILENAME: broken self-referential symlink
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _probe_refused(path) -> bool:
|
|
1390
|
+
"""True when a false pathlib convenience probe actually means I/O refusal."""
|
|
1391
|
+
if not isinstance(path, Path):
|
|
1392
|
+
# A wheel Traversable has no stat() method and no permission mode to recover.
|
|
1393
|
+
return False
|
|
1394
|
+
try:
|
|
1395
|
+
path.stat()
|
|
1396
|
+
except OSError as exc:
|
|
1397
|
+
return (
|
|
1398
|
+
exc.errno not in _ABSENCE_ERRNOS
|
|
1399
|
+
and getattr(exc, "winerror", None) not in _ABSENCE_WINERRORS
|
|
1400
|
+
)
|
|
1401
|
+
except ValueError:
|
|
1402
|
+
# Embedded-null and otherwise invalid paths are absent, not refused.
|
|
1403
|
+
return False
|
|
1404
|
+
return False
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def _occupied(path: Path) -> bool:
|
|
1408
|
+
"""Whether a destination slot is taken, dangling symlinks included."""
|
|
1409
|
+
try:
|
|
1410
|
+
return path.exists() or path.is_symlink()
|
|
1411
|
+
except OSError:
|
|
1412
|
+
# A slot that cannot even be inspected is not safe to write through.
|
|
1413
|
+
return True
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _copy_traversable(
|
|
1417
|
+
src,
|
|
1418
|
+
dst: Path,
|
|
1419
|
+
*,
|
|
1420
|
+
skip_existing: bool = False,
|
|
1421
|
+
worktree: Path | None = None,
|
|
1422
|
+
repo_root: Path | None = None,
|
|
1423
|
+
copied_paths: list[Path] | None = None,
|
|
1424
|
+
) -> bool:
|
|
1425
|
+
"""Recursively copy a Traversable tree, optionally confined to a worktree.
|
|
1426
|
+
|
|
1427
|
+
``skip_existing`` remains the install helper's opt-in per-file no-clobber mode.
|
|
1428
|
+
Supplying ``worktree`` makes no-clobber mandatory and adds destination
|
|
1429
|
+
containment plus per-entry OSError degradation: provisioning never escapes the
|
|
1430
|
+
worktree through a link or crashes the run because one entry cannot be read.
|
|
1431
|
+
``repo_root`` additionally refuses real source entries resolving outside the main
|
|
1432
|
+
checkout. Wheel Traversables have no source containment leg.
|
|
1433
|
+
|
|
1434
|
+
The shared walk classifies every source leaf through :func:`_is_file`; FIFOs,
|
|
1435
|
+
dangling links, and refused entries therefore have no copy/read path. Directory
|
|
1436
|
+
visits preserve main's existing empty-directory behavior and its boolean result:
|
|
1437
|
+
true means at least one directory or file actually landed, false means total no-op.
|
|
1438
|
+
|
|
1439
|
+
``copied_paths``, when supplied, receives every destination path that actually
|
|
1440
|
+
landed — each file written and each directory this call created — in walk order.
|
|
1441
|
+
The boolean answers the whole call, which under ``skip_existing`` is only ever
|
|
1442
|
+
"at least one descendant landed"; a caller that must know whether ONE named path
|
|
1443
|
+
landed cannot recover that from the boolean, and must not infer it from the
|
|
1444
|
+
entry's presence in a copied-something ledger either, because the no-clobber legs
|
|
1445
|
+
above skip occupied destinations one at a time and silently. Recording per path is
|
|
1446
|
+
what makes that question answerable: the returned boolean is exactly
|
|
1447
|
+
``bool(appended entries)``, and membership is exact rather than parent-scoped
|
|
1448
|
+
(#592). Appends only — the caller owns the list and may share one across calls.
|
|
1449
|
+
"""
|
|
1450
|
+
copied = False
|
|
1451
|
+
no_clobber = skip_existing or worktree is not None
|
|
1452
|
+
|
|
1453
|
+
def source_contained(entry) -> bool:
|
|
1454
|
+
if repo_root is None or not isinstance(entry, Path):
|
|
1455
|
+
return True
|
|
1456
|
+
try:
|
|
1457
|
+
return entry.resolve().is_relative_to(repo_root)
|
|
1458
|
+
except (OSError, RuntimeError):
|
|
1459
|
+
return False
|
|
1460
|
+
|
|
1461
|
+
def target_for(rel: str) -> Path:
|
|
1462
|
+
return dst.joinpath(*rel.split("/"))
|
|
1463
|
+
|
|
1464
|
+
def target_contained(target: Path) -> bool:
|
|
1465
|
+
if worktree is None:
|
|
1466
|
+
return True
|
|
1467
|
+
try:
|
|
1468
|
+
return target.resolve().is_relative_to(worktree)
|
|
1469
|
+
except (OSError, RuntimeError):
|
|
1470
|
+
return False
|
|
1471
|
+
|
|
1472
|
+
def should_descend(rel: str, entry) -> bool:
|
|
1473
|
+
target = target_for(rel)
|
|
1474
|
+
if not source_contained(entry) or not target_contained(target):
|
|
1475
|
+
return False
|
|
1476
|
+
if no_clobber and _occupied(target) and not _is_dir(target):
|
|
1477
|
+
# A file or dangling link sits where the source has a directory. It wins;
|
|
1478
|
+
# mkdir must never replace it or write through its target.
|
|
1479
|
+
return False
|
|
1480
|
+
return True
|
|
1481
|
+
|
|
1482
|
+
def visit_dir(rel: str, entry) -> bool:
|
|
1483
|
+
nonlocal copied
|
|
1484
|
+
# Recheck after enumeration: iterdir may block while a source or destination
|
|
1485
|
+
# component is replaced, so the pre-walk containment result can go stale.
|
|
1486
|
+
if not should_descend(rel, entry):
|
|
1487
|
+
return False
|
|
1488
|
+
target = target_for(rel)
|
|
1489
|
+
existed = _occupied(target)
|
|
1490
|
+
try:
|
|
1491
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
1492
|
+
except OSError:
|
|
1493
|
+
if worktree is None:
|
|
1494
|
+
raise
|
|
1495
|
+
return False
|
|
1496
|
+
if not existed:
|
|
1497
|
+
copied = True
|
|
1498
|
+
if copied_paths is not None:
|
|
1499
|
+
copied_paths.append(target)
|
|
1500
|
+
return True
|
|
1501
|
+
|
|
1502
|
+
for rel, child in _walk_traversable_files(
|
|
1503
|
+
src,
|
|
1504
|
+
_should_descend=should_descend,
|
|
1505
|
+
_visit_dir=visit_dir,
|
|
1506
|
+
_suppress_errors=worktree is not None,
|
|
1507
|
+
):
|
|
1508
|
+
if not _is_file(child) or not source_contained(child):
|
|
1509
|
+
continue
|
|
1510
|
+
target = target_for(rel)
|
|
1511
|
+
if not target_contained(target) or (no_clobber and _occupied(target)):
|
|
1512
|
+
continue
|
|
1513
|
+
try:
|
|
1514
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1515
|
+
if isinstance(child, Path):
|
|
1516
|
+
# Preserve modes for filesystem sources (notably vendor/bin/*).
|
|
1517
|
+
shutil.copy2(child, target)
|
|
1518
|
+
else:
|
|
1519
|
+
# Zip-imported Traversables expose no stat: content only.
|
|
1520
|
+
target.write_bytes(child.read_bytes())
|
|
1521
|
+
except OSError:
|
|
1522
|
+
if worktree is None:
|
|
1523
|
+
raise
|
|
1524
|
+
# `copy2` is `copyfile` FOLLOWED BY `copystat`, so a destination
|
|
1525
|
+
# filesystem that refuses the utime/chmod raises with the bytes already
|
|
1526
|
+
# fully written (measured). Reading that as "nothing happened"
|
|
1527
|
+
# under-reports twice: the entry is journaled as a no-op seed and loses
|
|
1528
|
+
# its `git add -A` shield, and the file's provenance goes missing — so an
|
|
1529
|
+
# unparseable config seeding really did supply would be blamed on the
|
|
1530
|
+
# branch (#592). Deleting the survivor instead would be worse: the
|
|
1531
|
+
# content is correct and only its metadata was refused, and dropping it
|
|
1532
|
+
# is the absent-config stall (#471). Count whatever survived — the
|
|
1533
|
+
# no-clobber leg above proved this slot was empty, so anything here now
|
|
1534
|
+
# is ours. A partial `write_bytes` on the zip leg counts too: the bytes
|
|
1535
|
+
# came from that source, and a re-arm re-seeds them whole.
|
|
1536
|
+
if not _occupied(target):
|
|
1537
|
+
continue
|
|
1538
|
+
copied = True
|
|
1539
|
+
if copied_paths is not None:
|
|
1540
|
+
copied_paths.append(target)
|
|
1541
|
+
return copied
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
# The shield's gate is the PROJECT support floor, `verify.GIT_FLOOR`, not a floor of
|
|
1545
|
+
# its own. `extensions.worktreeConfig` and `git config --worktree` — the two things
|
|
1546
|
+
# this shield is built out of — arrived in git 2.20 (git-worktree(1)), and that fact
|
|
1547
|
+
# is still true; it simply stopped being the threshold. Keeping a second, lower
|
|
1548
|
+
# number here is what used to force this module to write 2.20-era code, and it also
|
|
1549
|
+
# read as the project's supported range from the outside (verify.py cited it as one).
|
|
1550
|
+
# One floor, named once, in the module that owns the git chokepoint.
|
|
1551
|
+
|
|
1552
|
+
# The shield's mutual exclusion, held in the repository's COMMON dir so every
|
|
1553
|
+
# worktree of a repo contends on one file — a per-worktree gitdir would give each
|
|
1554
|
+
# concurrent run its own lock and exclude nothing. A dedicated file, never config
|
|
1555
|
+
# or the exclude itself, per `file_lock`'s contract: the lock rides an open fd's
|
|
1556
|
+
# inode, so anything written through `atomic_replace` swaps that inode out from
|
|
1557
|
+
# under later acquirers. Zero bytes, and inside `.git` — never in the working tree,
|
|
1558
|
+
# so it is not something the operator's `git add -A` can see.
|
|
1559
|
+
_SHIELD_LOCK_NAME = "froid-loop-shield.lock"
|
|
1560
|
+
|
|
1561
|
+
# THE ALTERNATIVE ARCHITECTURE, EVALUATED AND REJECTED — recorded here the way the
|
|
1562
|
+
# `--includes` note below is, so a later reader does not re-propose it as a
|
|
1563
|
+
# simplification of the whole block.
|
|
1564
|
+
#
|
|
1565
|
+
# Every finding in this family (an empty seed plus an activation that shadows what
|
|
1566
|
+
# the seed failed to copy) exists because the shield SHADOWS a config key. One
|
|
1567
|
+
# design dissolves the class outright: drop `core.excludesFile` entirely and instead
|
|
1568
|
+
# unstage the tool paths in the orchestrator's own commit path — `git reset --
|
|
1569
|
+
# <paths>` after the `add -A` at `verify.py:1891` and `:1932`. No shadow, so no
|
|
1570
|
+
# seed, no `_shield_inherited_excludes`, no permanent `extensions.worktreeConfig`,
|
|
1571
|
+
# no version floor. It is MECHANICALLY REACHABLE: those adds are the orchestrator's
|
|
1572
|
+
# own, not an LLM's (no skill in `data/skills/` runs git).
|
|
1573
|
+
#
|
|
1574
|
+
# Rejected on the merits, for two reasons that are about correctness rather than
|
|
1575
|
+
# scope:
|
|
1576
|
+
#
|
|
1577
|
+
# - It converts AMBIENT protection into CALL-SITE protection. An ignore binds
|
|
1578
|
+
# whoever runs the add; a reset covers only the call sites we remember to patch.
|
|
1579
|
+
# These adds run around disposable coding-CLI sessions that have shell access and
|
|
1580
|
+
# can stage and commit on their own initiative, so the shield has to hold for a
|
|
1581
|
+
# `git add -A` froid-loop never issued. That actor is the one we cannot constrain,
|
|
1582
|
+
# and the reset design is the one that stops protecting against it.
|
|
1583
|
+
# - It leaves the tool files permanently UNTRACKED-VISIBLE in the worktree. Every
|
|
1584
|
+
# `git status` a session runs would list the skill trees, the per-CLI hook config
|
|
1585
|
+
# and the seeded configs as untracked — inviting exactly the commit the shield
|
|
1586
|
+
# exists to prevent, from exactly that actor.
|
|
1587
|
+
#
|
|
1588
|
+
# The shadow is also structural, not an artifact of this implementation, so there is
|
|
1589
|
+
# no third way to look for: gitignore(5) documents four pattern sources and the
|
|
1590
|
+
# per-repo one is `$GIT_COMMON_DIR/info/exclude` — the COMMON dir, shared by every
|
|
1591
|
+
# worktree. There is no per-worktree exclude file, `core.excludesFile` is singular,
|
|
1592
|
+
# and git never concatenates the key across scopes. Checked against current sources
|
|
1593
|
+
# at git 2.55.0, the newest release.
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def _shield_shared_config(worktree: Path, shared: str, key: str, *opts: str) -> bytes | None:
|
|
1597
|
+
"""`key`'s raw value in the SHARED config, or None when it is definitely ABSENT.
|
|
1598
|
+
|
|
1599
|
+
Raises `GitError` when git did not answer. **rc 1 is the only non-zero rc that
|
|
1600
|
+
means "no such key"** (#384) — reading any other non-zero rc as an absence
|
|
1601
|
+
degrades the `core.bare` / `core.worktree` gates OPEN, and what they guard is
|
|
1602
|
+
irreversible: enabling `extensions.worktreeConfig` drops the exception that
|
|
1603
|
+
confined those keys to the main worktree, after which a genuine `core.worktree`
|
|
1604
|
+
starts applying to every linked worktree.
|
|
1605
|
+
|
|
1606
|
+
The rc mapping, stable across the supported range, `GIT_FLOOR` to current git:
|
|
1607
|
+
|
|
1608
|
+
absent key ......................... 1 malformed config file ..... 128
|
|
1609
|
+
missing file ....................... 1 --type=bool on a non-bool . 128
|
|
1610
|
+
`--file` naming a directory ........ 1 key present ................ 0
|
|
1611
|
+
unreadable file (EACCES, non-root) . 1 empty or valueless key ..... 0
|
|
1612
|
+
|
|
1613
|
+
git-config(1) prefaces its own list with "Some exit codes are:", i.e. the list is
|
|
1614
|
+
not closed, and it documents a malformed file as ret=3 while git actually answers
|
|
1615
|
+
128 — so a `{0,1}`-closed reading is wrong on git's documentation AND on its
|
|
1616
|
+
behavior. Hence a funnel rather than a per-rc taxonomy.
|
|
1617
|
+
|
|
1618
|
+
THE RESIDUAL THIS CANNOT CLOSE: a momentarily missing or unreadable `.git/config`
|
|
1619
|
+
answers **rc 1**, byte-identical to "no such key" — all four rc-1 rows above are
|
|
1620
|
+
the same answer from here. git exposes no way to separate them, so this is a
|
|
1621
|
+
bound on the interface rather than a defect. Deliberately NOT narrowed with a
|
|
1622
|
+
pre-`--get` stat: that is TOCTOU, which shrinks the window instead of closing it,
|
|
1623
|
+
adds a syscall to every probe, and trades a very rare silent-wrong for a less
|
|
1624
|
+
rare noisy-degrade. Bounded in practice because git rewrites config atomically
|
|
1625
|
+
(lock + rename), so only a non-git tool truncating in place can produce it.
|
|
1626
|
+
|
|
1627
|
+
Returns the value RAW and undecoded — `core.worktree` is a path — so callers test
|
|
1628
|
+
presence with `is not None`, never truthiness. See the call sites for why.
|
|
1629
|
+
"""
|
|
1630
|
+
answer = git_bytes(worktree, "config", "--file", shared, *opts, "--get", key)
|
|
1631
|
+
if answer.returncode == 0:
|
|
1632
|
+
return answer.stdout
|
|
1633
|
+
if answer.returncode == 1:
|
|
1634
|
+
return None
|
|
1635
|
+
detail = os.fsdecode(answer.stderr).strip() or f"git exited {answer.returncode}"
|
|
1636
|
+
raise GitError(f"git could not read {key} from the shared config: {detail}")
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
_SHARED_REPOSITORY_OCTAL = re.compile(r"[0-7]+")
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _shared_repository_is_private(value: str) -> bool:
|
|
1643
|
+
"""Does `core.sharedRepository = value` leave the repository private to its owner?
|
|
1644
|
+
|
|
1645
|
+
git's parse, mirrored (`setup.c::git_config_perm`): the keyword `umask` and the
|
|
1646
|
+
false booleans are private, `group`/`all`/`world`/`everybody` and the true
|
|
1647
|
+
booleans are not, and an octal value is a FILEMODE with the legacy 0/1/2
|
|
1648
|
+
special-cased ahead of it.
|
|
1649
|
+
|
|
1650
|
+
For a filemode git masks the value with 0666, so private is `& 0066 == 0` — NOT
|
|
1651
|
+
"no execute bits" and NOT "equals 0600". `0711` IS private, because the mask
|
|
1652
|
+
discards the execute bits; `0755` is NOT, because its group/other READ bits
|
|
1653
|
+
survive it. Those two cases are why this is a mask test and not a literal set.
|
|
1654
|
+
|
|
1655
|
+
`(mode & 0600) != 0600` is git's own `die()` condition, so such a value reaches
|
|
1656
|
+
us only in a repository git already refuses to operate on. It stays off the
|
|
1657
|
+
accept side with every other value git rejects.
|
|
1658
|
+
|
|
1659
|
+
THE PATTERN IS DELIBERATELY STRICTER THAN `strtol`, in the refusing direction.
|
|
1660
|
+
git accepts leading whitespace and a leading `+` (`+0600` is private to git)
|
|
1661
|
+
which `[0-7]+` refuses — a false refusal, which this gate is contracted to
|
|
1662
|
+
prefer. Strictness in the other direction is the load-bearing half: Python's
|
|
1663
|
+
`int(value, 8)` accepts `0o600` and `0_600`, which git REJECTS, so parsing with
|
|
1664
|
+
Python's own rules would accept a repository git cannot open.
|
|
1665
|
+
|
|
1666
|
+
THE EMPTY STRING IS EXCLUDED ON PURPOSE, and the `+` quantifier is what excludes
|
|
1667
|
+
it. `strtol("")` converts nothing, leaves `*end == 0` and yields 0, so git reads
|
|
1668
|
+
an explicitly empty value as PERM_UMASK, i.e. private. It is refused anyway,
|
|
1669
|
+
because `--get` answers an empty value and a VALUELESS `sharedRepository` line
|
|
1670
|
+
(PERM_GROUP, shared) with the same rc 0 and the same lone newline. What is
|
|
1671
|
+
refused there is the ambiguity, not the value — see the caller.
|
|
1672
|
+
"""
|
|
1673
|
+
if value == "umask" or value.lower() in ("false", "no", "off"):
|
|
1674
|
+
return True
|
|
1675
|
+
if not _SHARED_REPOSITORY_OCTAL.fullmatch(value):
|
|
1676
|
+
return False
|
|
1677
|
+
mode = int(value, 8)
|
|
1678
|
+
if mode == 0: # PERM_UMASK
|
|
1679
|
+
return True
|
|
1680
|
+
# The legacy 1 (PERM_GROUP) and 2 (PERM_EVERYBODY) need no special case here,
|
|
1681
|
+
# though git special-cases them ahead of its filemode branch: neither satisfies
|
|
1682
|
+
# `& 0600`, so both land on the refusal this line already returns. A
|
|
1683
|
+
# `mode in (1, 2)` arm would be indistinguishable from dead code (#384).
|
|
1684
|
+
return mode & 0o600 == 0o600 and mode & 0o066 == 0
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def _shield_shared_repository(worktree: Path, common_dir: Path) -> str | None:
|
|
1688
|
+
"""Refuse a repository configured to be SHARED BETWEEN OS USERS, else None.
|
|
1689
|
+
|
|
1690
|
+
A repository shared between OS users is not a supported configuration
|
|
1691
|
+
(maintainer decision, #384): the shield creates files of its own inside `.git`,
|
|
1692
|
+
and making those work across OS users is out of scope. Refusing up front fails
|
|
1693
|
+
LOUDLY and identically for every user — journaled and notified — rather than
|
|
1694
|
+
behaving differently depending on who provisioned first.
|
|
1695
|
+
|
|
1696
|
+
The gate runs ABOVE the shield's lock, so nothing is created in a shared
|
|
1697
|
+
repository at all. That is safe here and would not be for
|
|
1698
|
+
`extensions.worktreeConfig`: this key is static configuration the tool never
|
|
1699
|
+
writes, so there is no shared mutable state to serialize. It adds no version
|
|
1700
|
+
floor either — a plain `--get` with no `--type=`. `--type=` was itself a reason to
|
|
1701
|
+
keep the version gate above the probes when the gate was 2.20; at `GIT_FLOOR` it
|
|
1702
|
+
is far below the floor, and the gate leads for its unreadable-answer arm instead
|
|
1703
|
+
(see `_shield_enable_worktree_config`).
|
|
1704
|
+
|
|
1705
|
+
AN ALLOWLIST, NOT AN ENUMERATION OF SHARED SHAPES: git accepts keywords,
|
|
1706
|
+
booleans, the legacy 0/1/2 and bare octal modes, so enumerating the shared ones
|
|
1707
|
+
leaves every value git adds later — and every value git rejects — silently
|
|
1708
|
+
opening the gate. Values git rejects really do reach here, since `rev-parse
|
|
1709
|
+
--absolute-git-dir` answers rc 0 for every one of them, `banana` included; the
|
|
1710
|
+
"it would have fatalled earlier" reasoning that fits other probes in this file
|
|
1711
|
+
does not hold. They fall off the allowlist deliberately: such a repository is
|
|
1712
|
+
already unusable (`git status` exits 128), so refusing costs nothing while
|
|
1713
|
+
guessing at a malformed value would.
|
|
1714
|
+
|
|
1715
|
+
AN EMPTY VALUE AND A VALUELESS `sharedRepository` LINE ARE INDISTINGUISHABLE AND
|
|
1716
|
+
MEAN OPPOSITE THINGS: explicitly empty is PERM_UMASK (private), a valueless line
|
|
1717
|
+
is PERM_GROUP (shared), and `--get` answers both rc 0 with a lone b"\\n". Nothing
|
|
1718
|
+
separates them, so the empty answer REFUSES — a false refusal is a reported skip,
|
|
1719
|
+
a false accept is the bug this gate prevents.
|
|
1720
|
+
|
|
1721
|
+
`removesuffix("\\n")`, NEVER `.strip()`: `--get` returns edge whitespace VERBATIM
|
|
1722
|
+
(a quoted `" umask"` comes back as b" umask\\n"), so stripping would widen a value
|
|
1723
|
+
git itself rejects straight into the accept set below. The case handling is
|
|
1724
|
+
asymmetric because git's is — keywords compared with strcmp, booleans with
|
|
1725
|
+
strcasecmp — so `FALSE` and `Off` are accepted while `UMASK` and `GROUP` are
|
|
1726
|
+
values git REJECTS.
|
|
1727
|
+
|
|
1728
|
+
SCOPE, NARROWER THAN GIT'S OWN RESOLUTION: the read names the repository's
|
|
1729
|
+
SHARED CONFIG. git honors `core.sharedRepository` from a GLOBAL config too, so
|
|
1730
|
+
an operator's global setting is not consulted here and such a repository is
|
|
1731
|
+
still shielded — deliberately, since a global preference is not a statement that
|
|
1732
|
+
THIS repository is shared. What is refused is a repository CONFIGURED as shared,
|
|
1733
|
+
the shape `git init --shared` writes (`--shared=group` records the legacy
|
|
1734
|
+
`sharedrepository = 1`).
|
|
1735
|
+
|
|
1736
|
+
WORKTREE SCOPE is unread too, and git DOES honor `core.sharedRepository` from a
|
|
1737
|
+
linked worktree's own `config.worktree`, which the `--file` read below answers
|
|
1738
|
+
rc 1 for. What makes that unreachable is the provisioning flow rather than
|
|
1739
|
+
anything here: the shield only ever runs on a worktree this process just created
|
|
1740
|
+
(`workspace.open_unit_workspace` -> `verify.worktree_add` ->
|
|
1741
|
+
`worktree_flow.provision_worktree`), `git worktree add` creates the admin dir
|
|
1742
|
+
with NO `config.worktree`, a re-mount prunes `.git/worktrees/<id>` whole, the
|
|
1743
|
+
MAIN worktree's `.git/config.worktree` does not reach a linked one, and the
|
|
1744
|
+
tree's only worktree-scoped write is the shield's own `core.excludesFile`. So
|
|
1745
|
+
the value has no writer and no window. If a re-provision path onto an EXISTING
|
|
1746
|
+
worktree is ever added, or anything upstream starts writing the unit worktree's
|
|
1747
|
+
config, this read must grow a second `--file <git_dir>/config.worktree` probe in
|
|
1748
|
+
the same change.
|
|
1749
|
+
"""
|
|
1750
|
+
raw = _shield_shared_config(worktree, str(common_dir / "config"), "core.sharedRepository")
|
|
1751
|
+
if raw is None:
|
|
1752
|
+
return None
|
|
1753
|
+
value = os.fsdecode(raw).removesuffix("\n")
|
|
1754
|
+
if _shared_repository_is_private(value):
|
|
1755
|
+
return None
|
|
1756
|
+
# {value!r}: operator-authored text going into a journaled reason, and repr
|
|
1757
|
+
# neutralizes a newline in it — as the version gate above does.
|
|
1758
|
+
return (
|
|
1759
|
+
f"skipped the git-add shield ({worktree}): the repository's shared config sets "
|
|
1760
|
+
f"core.sharedRepository = {value!r}, and a repository shared between OS users is "
|
|
1761
|
+
"not a supported configuration — the provisioned tool files are not shielded "
|
|
1762
|
+
"from the unit's `git add -A`"
|
|
1763
|
+
)
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def _shield_enable_worktree_config(worktree: Path, common_dir: Path) -> tuple[str | None, bool]:
|
|
1767
|
+
"""May `git config --worktree` be made writable in this repo? `(reason, needs_enable)`.
|
|
1768
|
+
|
|
1769
|
+
PROBE ONLY — it does not write. `needs_enable` is True when every gate passed
|
|
1770
|
+
and the caller must still set `extensions.worktreeConfig` itself; False when the
|
|
1771
|
+
repo already carries it (or when `reason` is set and nothing may be written).
|
|
1772
|
+
|
|
1773
|
+
Answering is split from writing because enabling the extension is a PERMANENT
|
|
1774
|
+
repo-format change: performed here it precedes the seed, the write and the
|
|
1775
|
+
activation, so any degrade below it left the repo marked for a shield that never
|
|
1776
|
+
applied. The caller enables it immediately before the activation and rolls the
|
|
1777
|
+
flag back when the shield then fails to hold — including when the ENABLE ITSELF
|
|
1778
|
+
fails in a way that leaves the outcome unknown. The flag outlives a failed
|
|
1779
|
+
shield only where the rollback was DECLINED because a sibling worktree depends
|
|
1780
|
+
on it, or could not be made at all; the reason says which.
|
|
1781
|
+
|
|
1782
|
+
`extensions.worktreeConfig` is what makes a per-worktree config file exist at
|
|
1783
|
+
all; without it `git config --worktree` either refuses outright (a repo with
|
|
1784
|
+
linked worktrees) or silently writes the SHARED config.
|
|
1785
|
+
|
|
1786
|
+
Enabling it is refused in the two shapes git-worktree(1) (CONFIGURATION FILE)
|
|
1787
|
+
calls out: with `core.bare = true` or `core.worktree` in the shared config,
|
|
1788
|
+
enabling drops the exception that confines those keys to the main worktree, so
|
|
1789
|
+
they would start applying to every worktree. Git's remedy is a repo-layout edit
|
|
1790
|
+
the installer will not make behind an operator's back, so the shield degrades
|
|
1791
|
+
instead.
|
|
1792
|
+
|
|
1793
|
+
It is refused a THIRD way, which is this project's rather than git's: an
|
|
1794
|
+
`extensions.worktreeConfig` already PRESENT but not `true` is an operator's
|
|
1795
|
+
explicit disable, and enabling over it would rewrite that declaration
|
|
1796
|
+
permanently — the same discipline as above, applied to the flag itself (#396).
|
|
1797
|
+
|
|
1798
|
+
The version gate and the refusal probes STAY HERE, first. The gate is the
|
|
1799
|
+
PROJECT support floor (`verify.GIT_FLOOR`), not a capability threshold of this
|
|
1800
|
+
shield's own: `extensions.worktreeConfig` and `git config --worktree` arrived in
|
|
1801
|
+
git 2.20 and `--type=` in 2.18, all well below the floor, so on any supported git
|
|
1802
|
+
every probe below can answer. Refusing an unsupported git is therefore a POLICY
|
|
1803
|
+
decision, not a capability one — the enable is a permanent repo-format change,
|
|
1804
|
+
and git-worktree(1) says older git refuses a repository carrying the extension,
|
|
1805
|
+
so a git this project neither tests nor supports is exactly the one to withhold
|
|
1806
|
+
that write from. `_shield_shared_config`'s raise on any rc but 0 or 1 is defence
|
|
1807
|
+
in depth BEHIND this gate, not licence to relax it. The caller's `--type=path`
|
|
1808
|
+
excludesFile read rests on it too.
|
|
1809
|
+
|
|
1810
|
+
The run, sweep and resume entrypoints already refuse an under-floor git outright
|
|
1811
|
+
(`cli._reject_under_floor_git`), so no supported path reaches this gate with one.
|
|
1812
|
+
It stays because its OTHER arm still fires on a perfectly current host: a git
|
|
1813
|
+
that cannot be spawned, times out, or answers unparseably reads as below the
|
|
1814
|
+
floor, and none of those may authorize the write.
|
|
1815
|
+
"""
|
|
1816
|
+
# Polarity note: this gate reads an unreadable answer as REFUSE, while the funnel
|
|
1817
|
+
# below reads a bad rc as "git did not answer" and raises. Both fail closed, by
|
|
1818
|
+
# opposite-looking means, and unifying them would reverse one — an unanswerable
|
|
1819
|
+
# `git version` must refuse here, not degrade into a question about a key. Nor is
|
|
1820
|
+
# it a repo-config check: `git version` does no repository setup, so it exits 0
|
|
1821
|
+
# where `rev-parse` fatals 128 on a malformed `.git/config`. `git_below_floor`
|
|
1822
|
+
# folds all three unreadable shapes (bad rc, unparseable text, empty text) into
|
|
1823
|
+
# the one refusal this needs.
|
|
1824
|
+
if (found := git_below_floor(worktree)) is not None:
|
|
1825
|
+
return (
|
|
1826
|
+
f"skipped the git-add shield ({worktree}): froid-loop supports git "
|
|
1827
|
+
f"{git_floor_text()} and newer, but git answered {found!r} — the shield "
|
|
1828
|
+
"enables extensions.worktreeConfig, a permanent repo-format change, and "
|
|
1829
|
+
"that is not made on an unsupported git"
|
|
1830
|
+
), False
|
|
1831
|
+
# EVERY read below names the SHARED config as a file rather than going by scope
|
|
1832
|
+
# (`--local` resolves to it today; naming it keeps the checks honest if that
|
|
1833
|
+
# stops being true). Load-bearing for the first of them: `extensions.*` is
|
|
1834
|
+
# repo-format state git honors only from the repo's own config, so a
|
|
1835
|
+
# `worktreeConfig = true` in `~/.gitconfig` answering an unscoped read would be a
|
|
1836
|
+
# false "already enabled" — the write below skipped, the activation left to fatal
|
|
1837
|
+
# for want of the extension.
|
|
1838
|
+
#
|
|
1839
|
+
# And NO `--includes` on any of the three, which is the asymmetry a later reader
|
|
1840
|
+
# will want to "fix" — `--file` without it does miss a value an `include.path`
|
|
1841
|
+
# supplies. Adding it breaks both gates, because git's worktree SETUP reads
|
|
1842
|
+
# `core.bare` / `core.worktree` from the common config LITERALLY: it goes through
|
|
1843
|
+
# `git_config_from_file`, which does not respect includes (config.h).
|
|
1844
|
+
#
|
|
1845
|
+
# - The REFUSAL probes would start refusing repos git is perfectly happy to
|
|
1846
|
+
# shield, leaving the provisioned tool files unshielded: include-supplied
|
|
1847
|
+
# values of either key do nothing, while literal ones move a linked worktree's
|
|
1848
|
+
# toplevel to the main checkout or fatal every command. (`core.bare` only
|
|
1849
|
+
# because `is_bare_repository()` also requires no work tree, which every
|
|
1850
|
+
# worktree here has — its inertness is a conjunction, not an absence.)
|
|
1851
|
+
# - The ENABLED probe is worse: an include-supplied `extensions.worktreeConfig`
|
|
1852
|
+
# is not honored either (`git config --worktree` still fatals), because the
|
|
1853
|
+
# `config.worktree` read is gated on the flag harvested by that same
|
|
1854
|
+
# include-blind setup read — so `--includes` there is the same false "already
|
|
1855
|
+
# enabled", and nothing is shielded.
|
|
1856
|
+
#
|
|
1857
|
+
# All of that is undocumented IMPLEMENTATION DETAIL, not a compatibility
|
|
1858
|
+
# contract: git has converted reads to respect includes before (2.39, protected
|
|
1859
|
+
# config). Flag availability was never the question: `--includes` dates to git
|
|
1860
|
+
# 1.7.10, far below the floor the gate above enforces.
|
|
1861
|
+
shared = str(common_dir / "config")
|
|
1862
|
+
carried = _shield_shared_config(worktree, shared, "extensions.worktreeConfig", "--type=bool")
|
|
1863
|
+
if carried is not None and os.fsdecode(carried).strip() == "true":
|
|
1864
|
+
return None, False # already carried: nothing for the caller to write
|
|
1865
|
+
if carried is not None:
|
|
1866
|
+
# Present but NOT true: enabling over an operator's explicit disable is a stronger
|
|
1867
|
+
# intervention than enabling from ABSENT, and it is the SUCCESS path that does the
|
|
1868
|
+
# lasting damage — rewriting that declaration to `true` forever, unjournaled. The
|
|
1869
|
+
# shield degrades instead, which also puts #396's rollback deletion out of reach.
|
|
1870
|
+
#
|
|
1871
|
+
# `--type=bool` normalized the spelling away (`off`/`no`/`0`/`FALSE` all read back
|
|
1872
|
+
# `false`, measured at 2.34.1 and 2.55.0), so re-read it RAW for the reason and
|
|
1873
|
+
# neutralize it as the sharedRepository arm above does. A GitError from that read
|
|
1874
|
+
# propagates — the caller's tail degrades, and still nothing is enabled. `carried`
|
|
1875
|
+
# stands in only if the key stops existing between the two reads.
|
|
1876
|
+
raw = _shield_shared_config(worktree, shared, "extensions.worktreeConfig")
|
|
1877
|
+
value = os.fsdecode(carried if raw is None else raw).removesuffix("\n")
|
|
1878
|
+
return (
|
|
1879
|
+
f"skipped the git-add shield ({worktree}): the repository's shared config sets "
|
|
1880
|
+
f"extensions.worktreeConfig = {value!r}, explicitly disabling it, and the shield "
|
|
1881
|
+
"will not override an operator's declaration — the provisioned tool files are "
|
|
1882
|
+
"not shielded from the unit's `git add -A`"
|
|
1883
|
+
), False
|
|
1884
|
+
bare = _shield_shared_config(worktree, shared, "core.bare", "--type=bool")
|
|
1885
|
+
if bare is not None and os.fsdecode(bare).strip() == "true":
|
|
1886
|
+
refused = "core.bare = true"
|
|
1887
|
+
elif _shield_shared_config(worktree, shared, "core.worktree") is not None:
|
|
1888
|
+
# `is not None`, NEVER truthiness: any value refuses, including an empty one.
|
|
1889
|
+
# `core.worktree = ""` and a valueless `worktree` line both answer rc 0 with a
|
|
1890
|
+
# lone b"\n", so truthiness holds today — but one `.strip()` makes it b"",
|
|
1891
|
+
# which is falsy, re-opening this gate for a key that IS set. The value is
|
|
1892
|
+
# never decoded or inspected: this key is a path and its mere PRESENCE refuses,
|
|
1893
|
+
# unlike `core.bare`, where only a true value disqualifies.
|
|
1894
|
+
refused = "core.worktree"
|
|
1895
|
+
else:
|
|
1896
|
+
# Cleared, not enabled. The write itself is the caller's, deferred to the
|
|
1897
|
+
# last moment before the activation (see the docstring).
|
|
1898
|
+
return None, True
|
|
1899
|
+
return (
|
|
1900
|
+
f"skipped the git-add shield ({worktree}): the repository's shared config sets "
|
|
1901
|
+
f"{refused}, which git requires moving into the main worktree's config.worktree "
|
|
1902
|
+
"before extensions.worktreeConfig may be enabled"
|
|
1903
|
+
), False
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
def _shield_undo_extension(worktree: Path, git_dir: Path, common_dir: Path) -> str:
|
|
1907
|
+
"""Undo the `extensions.worktreeConfig` THIS provisioning just enabled.
|
|
1908
|
+
|
|
1909
|
+
Returns `""` when the repository is back in the state it was found in, or a
|
|
1910
|
+
clause naming the fault otherwise — never raises: every caller is already
|
|
1911
|
+
reporting a degrade, and this says what it could not undo.
|
|
1912
|
+
|
|
1913
|
+
Callers MUST gate this on having enabled the flag themselves, or on not having
|
|
1914
|
+
been able to FIND OUT whether they did — which is what the enable's own raise
|
|
1915
|
+
means. Unsetting one the repository already carried would stop git reading the
|
|
1916
|
+
`config.worktree` files other worktrees may depend on, so the rollback is
|
|
1917
|
+
conditional, never unconditional cleanup.
|
|
1918
|
+
|
|
1919
|
+
That caller-side gate is necessary and NOT sufficient (#384): `needs_enable`
|
|
1920
|
+
records what the PROBE saw, and the enable is an idempotent rc-0 no-op against a
|
|
1921
|
+
flag already `true`, so two concurrent provisionings that both probed an absent
|
|
1922
|
+
flag both believe they own it, and whichever one's activation fails would unset a
|
|
1923
|
+
flag the other's LIVE shield depends on — leaving its tool files stageable
|
|
1924
|
+
mid-run. The caller serializes the transaction under a repository lock, but a lock
|
|
1925
|
+
binds only its holders: an operator, an older froid-loop, or `git sparse-checkout`
|
|
1926
|
+
can set the same flag outside it.
|
|
1927
|
+
|
|
1928
|
+
EXCLUDING OUR OWN `git_dir` IS LOAD-BEARING: a `config.worktree` there is the
|
|
1929
|
+
half-written product of the very activation whose failure prompted this rollback
|
|
1930
|
+
(a timeout can leave one behind), so counting it as a dependent would suppress
|
|
1931
|
+
the rollback.
|
|
1932
|
+
|
|
1933
|
+
WHAT IT DOES NOT COVER: a non-lock-taking party between its own enable and its
|
|
1934
|
+
own activation owns the flag while having written nothing yet, so the scan cannot
|
|
1935
|
+
see it and this rollback unsets it. Bounded — its `git config --worktree` then
|
|
1936
|
+
fatals for want of the extension (rc 128 — a sibling exists by construction here;
|
|
1937
|
+
in a single-worktree repo `--worktree` instead exits 0 and writes the SHARED
|
|
1938
|
+
config), so its shield degrades with a REPORTED reason rather than going silently
|
|
1939
|
+
inert. The opposite error is accepted too: a stale admin dir git has not pruned yet
|
|
1940
|
+
counts as a dependent, costing a flag left enabled and nothing else.
|
|
1941
|
+
"""
|
|
1942
|
+
try:
|
|
1943
|
+
# The main worktree's own, then every SIBLING's — never ours (above).
|
|
1944
|
+
others = [common_dir / "config.worktree"]
|
|
1945
|
+
others += [
|
|
1946
|
+
d / "config.worktree"
|
|
1947
|
+
for d in (common_dir / "worktrees").iterdir()
|
|
1948
|
+
if d.resolve() != git_dir
|
|
1949
|
+
]
|
|
1950
|
+
dependent = next((str(p) for p in others if p.exists()), None)
|
|
1951
|
+
except (OSError, RuntimeError, UnicodeError) as e:
|
|
1952
|
+
# Conservative on purpose, and the asymmetry is the argument: skipping
|
|
1953
|
+
# leaves a reported flag this call did not earn, while unsetting one that IS
|
|
1954
|
+
# depended on silently un-shields a live worktree. So an unreadable scan
|
|
1955
|
+
# counts as a dependent.
|
|
1956
|
+
#
|
|
1957
|
+
# The tuple mirrors the caller's tail minus `GitError` (nothing here spawns
|
|
1958
|
+
# git), and the members beyond `OSError` are what keeps the "never raises"
|
|
1959
|
+
# promise true: on the 3.11/3.12 floor `Path.resolve()` raises `RuntimeError`
|
|
1960
|
+
# for a symlink loop rather than `OSError`, and `UnicodeError` covers the
|
|
1961
|
+
# `fsdecode` of a worktree admin-dir name on Windows.
|
|
1962
|
+
dependent = f"the scan for sibling worktrees failed ({e})"
|
|
1963
|
+
if dependent is not None:
|
|
1964
|
+
return (
|
|
1965
|
+
"; extensions.worktreeConfig was deliberately LEFT enabled — "
|
|
1966
|
+
f"{dependent} exists, so another worktree's shield depends "
|
|
1967
|
+
"on the flag and unsetting it would stop git reading that file"
|
|
1968
|
+
)
|
|
1969
|
+
try:
|
|
1970
|
+
# `--unset-all`, and rc 5 counts as SUCCESS: an uncertain ENABLE routed here
|
|
1971
|
+
# makes an absent flag reachable, and `--unset` of an absent key exits 5 —
|
|
1972
|
+
# reporting that as a failed rollback would be false.
|
|
1973
|
+
#
|
|
1974
|
+
# `--unset` alone could not simply treat 5 as success, because git-config(1)
|
|
1975
|
+
# gives that code TWO meanings — the key did not exist, or MULTIPLE LINES
|
|
1976
|
+
# matched — so a doubled key that SURVIVED would report a clean rollback.
|
|
1977
|
+
# Stable across the supported range to current git: `--unset` against a doubled key
|
|
1978
|
+
# exits 5 and removes NOTHING, while `--unset-all` exits 0 and removes both
|
|
1979
|
+
# lines, collapsing rc 5 to the single meaning "no line matched".
|
|
1980
|
+
undone = git_bytes(worktree, "config", "--unset-all", "extensions.worktreeConfig")
|
|
1981
|
+
if undone.returncode in (0, 5):
|
|
1982
|
+
return ""
|
|
1983
|
+
detail = os.fsdecode(undone.stderr).strip() or f"git exited {undone.returncode}"
|
|
1984
|
+
except (GitError, UnicodeError) as e:
|
|
1985
|
+
# the rollback's OWN git can time out or fail to spawn: a read-only `.git` or
|
|
1986
|
+
# a dead git fails this unset for the same reason it failed the activation.
|
|
1987
|
+
#
|
|
1988
|
+
# `UnicodeError` is the `fsdecode` of git's stderr one line up (#394): Windows
|
|
1989
|
+
# decodes utf-8/surrogatepass, which REJECTS a lone invalid byte, so without it
|
|
1990
|
+
# a codec fault escapes a function contracted never to raise (POSIX decodes
|
|
1991
|
+
# with surrogateescape and never raises). `OSError`/`RuntimeError` are
|
|
1992
|
+
# deliberately absent, so this is NOT a copy of the sibling scan's tuple above:
|
|
1993
|
+
# nothing in this block resolves a path, which is what those two are there for.
|
|
1994
|
+
detail = str(e)
|
|
1995
|
+
# Both clauses HEDGE whether this shield set the flag, and must: reached from the
|
|
1996
|
+
# enable's own raise, a spawn failure can kill the enable and this unset alike,
|
|
1997
|
+
# having written nothing at all. No precision is lost — `needs_enable` is
|
|
1998
|
+
# probe-derived and cannot tell "we enabled it" from "we both thought we did".
|
|
1999
|
+
return (
|
|
2000
|
+
"; extensions.worktreeConfig could NOT be "
|
|
2001
|
+
f"rolled back ({detail}) — if this shield set the flag, the repository keeps "
|
|
2002
|
+
"a permanent format change that shields nothing"
|
|
2003
|
+
)
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
# The Git for Windows FORK patches `xdg_config_home_for` (`path.c`) to prefer
|
|
2007
|
+
# `%APPDATA%/Git/<file>` over the `$HOME/.config/git/<file>` upstream computes,
|
|
2008
|
+
# from this version onward (absent at 2.45, absent upstream at every version).
|
|
2009
|
+
# `_shield_home_git_ignore` gates on it (#403).
|
|
2010
|
+
_APPDATA_IGNORE_GIT = (2, 46)
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
def _shield_file_exists(candidate: Path) -> bool:
|
|
2014
|
+
"""git's OWN existence predicate, which is `lstat(f, &sb) == 0` (`dir.c`).
|
|
2015
|
+
|
|
2016
|
+
Deliberately NOT `Path.is_file()`. `lstat` succeeds on a DIRECTORY and on a BROKEN
|
|
2017
|
+
SYMLINK, so `xdg_config_home_for` selects those exactly as it selects a regular
|
|
2018
|
+
file, and a narrower test here would reject what the fork accepts — sending this
|
|
2019
|
+
module down the `$HOME` arm git is NOT reading, which is #403's over-ignore
|
|
2020
|
+
direction rather than a safe fall-through.
|
|
2021
|
+
|
|
2022
|
+
Selecting is not the same as being able to MIRROR, and the two non-regular shapes
|
|
2023
|
+
part company right there — the caller distinguishes them:
|
|
2024
|
+
|
|
2025
|
+
- a BROKEN SYMLINK is dropped by git's own `access_or_warn(..., R_OK)` gate, whose
|
|
2026
|
+
`ENOENT` counts as an ignorable missing file, so git loads no patterns and runs
|
|
2027
|
+
on. The caller's `is_file()` seeds nothing, which mirrors that exactly.
|
|
2028
|
+
- a DIRECTORY passes that same `access(R_OK)` gate, so git goes on to
|
|
2029
|
+
`add_patterns_from_file_1` and `die("cannot use %s as an exclude file")`. Git
|
|
2030
|
+
does not run at all, and an empty seed would model a FATAL as a permissive
|
|
2031
|
+
success — see `_shield_home_git_ignore`, which refuses that shape.
|
|
2032
|
+
|
|
2033
|
+
Swallows `OSError` alone — a candidate this process cannot stat is one the shield
|
|
2034
|
+
must treat as absent, exactly as `file_exists` reports a failed `lstat`.
|
|
2035
|
+
"""
|
|
2036
|
+
try:
|
|
2037
|
+
os.lstat(candidate)
|
|
2038
|
+
except OSError:
|
|
2039
|
+
return False
|
|
2040
|
+
return True
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
def _shield_home_git_ignore(worktree: Path) -> Path:
|
|
2044
|
+
"""`$HOME/.config/git/ignore` — git's XDG fallback — asked of GIT, not of Python.
|
|
2045
|
+
|
|
2046
|
+
`Path.home()` is the obvious spelling and it is WRONG on Windows, the one
|
|
2047
|
+
platform where git's home and Python's are derived differently. Git locates this
|
|
2048
|
+
fallback with `getenv("HOME")` everywhere (`path.c::xdg_config_home`), but on
|
|
2049
|
+
Windows `compat/mingw.c::setup_windows_environment()` DERIVES `HOME` inside the
|
|
2050
|
+
git process first, preferring `HOMEDRIVE`+`HOMEPATH` (only when that names a real
|
|
2051
|
+
directory) over `USERPROFILE`; Python's `ntpath.expanduser` has the OPPOSITE
|
|
2052
|
+
precedence and never consults `HOME` at all. Git Bash and MSYS2 set `HOME`; a
|
|
2053
|
+
domain-joined machine's `HOMEDRIVE`+`HOMEPATH` may be a network home share.
|
|
2054
|
+
Either makes the two disagree.
|
|
2055
|
+
|
|
2056
|
+
The miss is SILENT, and it is #384's own harm through the platform split: the
|
|
2057
|
+
wrong path is simply not a file, the seed comes back empty, and the caller's
|
|
2058
|
+
activation then SHADOWS the global ignore file git really reads.
|
|
2059
|
+
|
|
2060
|
+
So ask git. `--type=path` interpolates a leading `~/` through the same
|
|
2061
|
+
`getenv("HOME")` git uses for the fallback itself, so the answer is correct by
|
|
2062
|
+
construction on every platform and names no environment variable. `-c` is COMMAND
|
|
2063
|
+
scope, the most specific git has, so the probe's value cannot be outranked by the
|
|
2064
|
+
operator's config. The answer comes back NUL-terminated at rc 0; with `HOME`
|
|
2065
|
+
unset git exits 128 and applies no fallback at all.
|
|
2066
|
+
|
|
2067
|
+
That `$HOME` answer is the whole of the fallback UPSTREAM, and is not on **Git
|
|
2068
|
+
for Windows >= 2.46** (#403). The fork patches `xdg_config_home_for`
|
|
2069
|
+
(`git-for-windows/git`, `path.c`) to prefer `%APPDATA%/Git/<file>` whenever that
|
|
2070
|
+
file EXISTS, warning that it ignored the `$HOME` one when both are there. Counted
|
|
2071
|
+
per tag: present at 2.46.0.windows.1 and 2.55.0.windows.3, absent at
|
|
2072
|
+
2.45.0.windows.1 and 2.20.0.windows.1, absent from upstream `git/git` entirely.
|
|
2073
|
+
PROVENANCE: source-read through #403, **NOT measured on a Windows machine** — no
|
|
2074
|
+
runtime observation of Git for Windows was available, and Windows CI cannot supply
|
|
2075
|
+
one either (the runners carry no `%APPDATA%\\Git\\ignore`, so they can show only
|
|
2076
|
+
that nothing broke).
|
|
2077
|
+
|
|
2078
|
+
The APPDATA arm below closes a harm that ran in BOTH directions, each silent.
|
|
2079
|
+
APPDATA file only: this returned a `$HOME` path that is typically not a file, the
|
|
2080
|
+
seed came back empty with `reason is None`, and the caller then activated a
|
|
2081
|
+
worktree-scoped `core.excludesFile` SHADOWING the file git really reads — so
|
|
2082
|
+
everything the operator globally ignores became visible to `git add -A` and swept
|
|
2083
|
+
into the story commit. BOTH files present: git uses the APPDATA one and says so,
|
|
2084
|
+
while this seeded the `$HOME` one — copying patterns git is not applying, so the
|
|
2085
|
+
worktree OVER-ignored and session-created files went silently missing instead.
|
|
2086
|
+
|
|
2087
|
+
Raises `GitError` on any non-zero rc, INCLUDING the `HOME`-unset one. Proceeding
|
|
2088
|
+
would be a guess, and a guess here is silent: the caller seeds nothing and
|
|
2089
|
+
activates over whatever git does read. git's message is deliberately NOT matched
|
|
2090
|
+
to tell "no HOME" apart from a transient fault, because that wording is
|
|
2091
|
+
version-dependent. A `HOME`-less environment therefore skips the shield with a
|
|
2092
|
+
reported reason; only a definite absent may be silent in this caller.
|
|
2093
|
+
"""
|
|
2094
|
+
# Cheap-first, and every arm that does not MATCH falls through to the `$HOME`
|
|
2095
|
+
# probe below — no APPDATA, no such file, an unanswerable `git version`, upstream
|
|
2096
|
+
# git, a fork below 2.46. That fall-through is the conservative direction: it is
|
|
2097
|
+
# exactly the pre-fix behavior, and a git too dead to report its version is not
|
|
2098
|
+
# absolved by it, because the probe below raises its own `GitError` on the same
|
|
2099
|
+
# git and the caller degrades with a reason.
|
|
2100
|
+
appdata = os.environ.get("APPDATA")
|
|
2101
|
+
if appdata:
|
|
2102
|
+
candidate = Path(appdata) / "Git" / "ignore"
|
|
2103
|
+
# LOAD-BEARING, and it mirrors the fork's own `file_exists` precondition
|
|
2104
|
+
# EXACTLY rather than approximately — see `_shield_file_exists` for why an
|
|
2105
|
+
# `is_file()` here would reject a directory and a broken symlink that git
|
|
2106
|
+
# itself selects. It is also what keeps the cost at one `lstat` on every other
|
|
2107
|
+
# platform: only a candidate that exists is worth the extra spawn below.
|
|
2108
|
+
if _shield_file_exists(candidate):
|
|
2109
|
+
# Gated on the FORK STRING, deliberately NOT `sys.platform` (#403). The
|
|
2110
|
+
# preference is a patch carried by one FORK, not a property of the OS:
|
|
2111
|
+
# Cygwin, MSYS2 and WSL gits run on Windows hardware without it, and a
|
|
2112
|
+
# `win32` test would hand them the wrong file. This module has no
|
|
2113
|
+
# `sys.platform` branch anywhere else, and asking git what it is keeps the
|
|
2114
|
+
# tests honest — they fake a version string, never a platform.
|
|
2115
|
+
version = git_bytes(worktree, "version")
|
|
2116
|
+
if version.returncode == 0:
|
|
2117
|
+
reported = os.fsdecode(version.stdout)
|
|
2118
|
+
if ".windows." in reported and git_version_at_least(reported, _APPDATA_IGNORE_GIT):
|
|
2119
|
+
if candidate.is_dir():
|
|
2120
|
+
# SELECTED by git and then UNUSABLE by it: `access(R_OK)`
|
|
2121
|
+
# succeeds on a readable directory, so git reaches
|
|
2122
|
+
# `add_patterns_from_file_1` and dies ("cannot use %s as an
|
|
2123
|
+
# exclude file"). Returning it would seed nothing — and an
|
|
2124
|
+
# empty seed here is not the faithful mirror it is for a broken
|
|
2125
|
+
# symlink, it is a FATAL rendered as a permissive success:
|
|
2126
|
+
# activating a worktree-scoped `core.excludesFile` SHADOWS the
|
|
2127
|
+
# broken path, so the unit's `git add -A` would run happily
|
|
2128
|
+
# where the operator's own git refuses to run at all, and the
|
|
2129
|
+
# misconfiguration would never surface.
|
|
2130
|
+
#
|
|
2131
|
+
# `is_dir()` FOLLOWS the link deliberately: a symlink to a
|
|
2132
|
+
# directory is the same fatal. A broken one is not a directory
|
|
2133
|
+
# and falls through to be seeded as empty, which is what git
|
|
2134
|
+
# does with it.
|
|
2135
|
+
raise GitError(
|
|
2136
|
+
f"git's global ignore path is a directory ({candidate}) — "
|
|
2137
|
+
"git for Windows selects it and then cannot read it"
|
|
2138
|
+
)
|
|
2139
|
+
return candidate
|
|
2140
|
+
key = "froidloop.xdghomeprobe"
|
|
2141
|
+
probe = git_bytes(
|
|
2142
|
+
worktree, "-c", f"{key}=~/.config/git/ignore", "config", "-z", "--type=path", "--get", key
|
|
2143
|
+
)
|
|
2144
|
+
if probe.returncode != 0:
|
|
2145
|
+
detail = os.fsdecode(probe.stderr).strip() or f"git exited {probe.returncode}"
|
|
2146
|
+
raise GitError(f"git could not resolve its own home directory: {detail}")
|
|
2147
|
+
return Path(os.fsdecode(probe.stdout.split(b"\0", 1)[0]))
|
|
2148
|
+
|
|
2149
|
+
|
|
2150
|
+
def _shield_inherited_excludes(worktree: Path) -> bytes:
|
|
2151
|
+
"""The BYTES of whatever `core.excludesFile` this worktree resolves to now.
|
|
2152
|
+
|
|
2153
|
+
A worktree-scoped `core.excludesFile` SHADOWS the operator's own: git reads the
|
|
2154
|
+
key from the most specific scope that sets it, never concatenating scopes. Not
|
|
2155
|
+
carrying their patterns over would un-ignore, inside the worktree, everything
|
|
2156
|
+
they ignore globally — and `git add -A` would commit it. The caller seeds once,
|
|
2157
|
+
at creation, so the copy never fights later edits to the private file.
|
|
2158
|
+
|
|
2159
|
+
BYTES, never decoded text: exclude entries are path patterns, POSIX paths are
|
|
2160
|
+
arbitrary bytes, and one non-UTF-8 byte collapsed a `read_text` seed to empty —
|
|
2161
|
+
after which the activation SHADOWED what it failed to copy. Copying verbatim
|
|
2162
|
+
also keeps every pattern intact for the caller's dedupe; git reads the result
|
|
2163
|
+
either way, stripping a trailing `\\r` from an exclude line and skipping a
|
|
2164
|
+
UTF-8 BOM.
|
|
2165
|
+
|
|
2166
|
+
FOUR outcomes (#384). The first two are silent; the other two RAISE, and the
|
|
2167
|
+
caller's tail turns that into the degrade reason that skips the activation.
|
|
2168
|
+
|
|
2169
|
+
- ABSENT is silent, empty, and the ONLY outcome that falls back: `--get` of an
|
|
2170
|
+
unset key exits 1, and `is_file()` false says the fallback file is not there.
|
|
2171
|
+
Reaching that fallback can itself fail — `_shield_home_git_ignore` raises, and
|
|
2172
|
+
an unresolvable home is UNKNOWN, not absent.
|
|
2173
|
+
- DISABLED is silent and empty too, and is NOT absent. An explicitly EMPTY
|
|
2174
|
+
`core.excludesFile` means "no excludes file at all", and git honors that
|
|
2175
|
+
literally: `-z --get` answers rc 0 with a lone NUL, git loads no patterns, and
|
|
2176
|
+
does NOT reach for XDG (`dir.c` guards that fallback on a NULL `excludes_file`;
|
|
2177
|
+
an empty value is not NULL — unchanged across the supported range to master).
|
|
2178
|
+
Reading it as unset instead seeds and activates the XDG file's patterns —
|
|
2179
|
+
OVER-ignoring, so files the operator deliberately stopped ignoring go silently
|
|
2180
|
+
missing from `git add -A`.
|
|
2181
|
+
- PRESENT BUT UNREADABLE propagates: a read `OSError` (EACCES, EIO) reaches the
|
|
2182
|
+
caller's degrade arm — activating over patterns that could not be copied
|
|
2183
|
+
shadows them exactly as an empty seed did.
|
|
2184
|
+
- UNKNOWN propagates too, in TWO shapes: a raised `GitError`, and any returncode
|
|
2185
|
+
that is neither 0 nor 1. A fault means we did not FIND OUT; swallowing it maps
|
|
2186
|
+
UNKNOWN onto ABSENT and activates a key shadowing the file it failed to read.
|
|
2187
|
+
"""
|
|
2188
|
+
# -z, not a bare read plus `.strip()`. `--type=path --get` returns leading and
|
|
2189
|
+
# trailing whitespace VERBATIM, because git writes such a value quoted and it
|
|
2190
|
+
# round-trips. So an operator whose excludesFile ended in a space had the strip
|
|
2191
|
+
# mangle the path, `is_file()` read absent, the seed come back empty, and the
|
|
2192
|
+
# activation shadow the file it never copied. `-z` terminates the value with NUL
|
|
2193
|
+
# instead.
|
|
2194
|
+
#
|
|
2195
|
+
# The answers below hold at `GIT_FLOOR` (the version refusal in
|
|
2196
|
+
# `_shield_enable_worktree_config` has already run, so the floor is the oldest git
|
|
2197
|
+
# that reaches this line) and at 2.55: `-z` expands `~`, gives an unset key rc 1 +
|
|
2198
|
+
# empty stdout, an empty value a lone NUL at rc 0, a multi-valued key the LAST
|
|
2199
|
+
# value + NUL at rc 0, and a relative value verbatim.
|
|
2200
|
+
answer = git_bytes(worktree, "config", "-z", "--type=path", "--get", "core.excludesFile")
|
|
2201
|
+
raw = answer.stdout.split(b"\0", 1)[0]
|
|
2202
|
+
if answer.returncode == 0:
|
|
2203
|
+
if not raw:
|
|
2204
|
+
# DISABLED: an explicit "no excludes file", so there is nothing to copy
|
|
2205
|
+
# and NO fallback to reach for — see the docstring. The arm is inert on
|
|
2206
|
+
# its own (`Path(os.fsdecode(b""))` is `.`, which reads `is_file()` false),
|
|
2207
|
+
# so what separates DISABLED from ABSENT is the rc-FIRST condition above,
|
|
2208
|
+
# and restoring `and raw` on it is the only faithful ablation.
|
|
2209
|
+
return b""
|
|
2210
|
+
source = Path(os.fsdecode(raw))
|
|
2211
|
+
if not source.is_absolute():
|
|
2212
|
+
# `--type=path` expands `~` and stops there: a RELATIVE value comes back
|
|
2213
|
+
# verbatim. Git resolves such a value against the worktree's TOP LEVEL —
|
|
2214
|
+
# observed rather than specified (it falls out of git's setup chdir), and
|
|
2215
|
+
# it holds here because every git call goes through `_run_git`'s
|
|
2216
|
+
# `git -C <worktree>`. Python would resolve it against this process's cwd,
|
|
2217
|
+
# wherever the orchestrator was launched — the wrong file or, far more
|
|
2218
|
+
# often, none, and `is_file()` false is SILENT: the operator's patterns
|
|
2219
|
+
# would not be carried and the activation below would shadow them anyway.
|
|
2220
|
+
source = worktree / source
|
|
2221
|
+
elif answer.returncode != 1:
|
|
2222
|
+
# UNKNOWN, through git's OTHER failure shape. rc 1 is the ONLY non-zero rc
|
|
2223
|
+
# that means "no such key"; every other one is git saying it could not answer,
|
|
2224
|
+
# and routing those to the fallback below is the same UNKNOWN-as-ABSENT
|
|
2225
|
+
# mistake the raised shape is caught for.
|
|
2226
|
+
#
|
|
2227
|
+
# No STATIC config value reaches this branch: a `core.excludesFile` of
|
|
2228
|
+
# `~nosuchuser/ignore` does make THIS read exit 128, but git expands that same
|
|
2229
|
+
# value while parsing core config for any command that sets the repository up,
|
|
2230
|
+
# so `rev-parse --absolute-git-dir` in the caller fatals identically and the
|
|
2231
|
+
# shield SILENTLY SKIPS above this line. What occupies this branch is a fault
|
|
2232
|
+
# arriving BETWEEN that rev-parse and this read, and the caller sets that
|
|
2233
|
+
# window's width: the rev-parse runs before the repo-scoped lock, this read
|
|
2234
|
+
# after it, and POSIX `flock` blocks INDEFINITELY, so a run waiting on a
|
|
2235
|
+
# sibling worktree's shield sits in it for the sibling's whole git
|
|
2236
|
+
# transaction. Such a fault can CLEAR while an activation performed over it
|
|
2237
|
+
# lasts as long as the worktree.
|
|
2238
|
+
#
|
|
2239
|
+
# A FUNNEL rather than a per-rc taxonomy: git-config(1) allocates several
|
|
2240
|
+
# failure codes, and anything that is not "answered" or "no such key" is not
|
|
2241
|
+
# an answer.
|
|
2242
|
+
detail = os.fsdecode(answer.stderr).strip() or f"git exited {answer.returncode}"
|
|
2243
|
+
raise GitError(f"git could not resolve core.excludesFile: {detail}")
|
|
2244
|
+
else:
|
|
2245
|
+
# ABSENT (rc 1, an unset key): git's documented fallback (gitignore(5)), and
|
|
2246
|
+
# the ONLY outcome that gets one.
|
|
2247
|
+
#
|
|
2248
|
+
# `XDG_CONFIG_HOME` this process may read for itself: git reads the same
|
|
2249
|
+
# variable from the same environment (`_run_git` passes `os.environ`
|
|
2250
|
+
# through), and set-but-empty counts as unset for BOTH — git's guard is
|
|
2251
|
+
# `if (config_home && *config_home)`, the falsy check below is the same
|
|
2252
|
+
# test. The `$HOME` limb is the one Python cannot answer; see
|
|
2253
|
+
# `_shield_home_git_ignore` for why it is asked of git instead.
|
|
2254
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
2255
|
+
source = Path(xdg) / "git" / "ignore" if xdg else _shield_home_git_ignore(worktree)
|
|
2256
|
+
if not source.is_absolute():
|
|
2257
|
+
# THE SAME DEFECT AS THE RELATIVE `core.excludesFile` ABOVE: this branch
|
|
2258
|
+
# reproduces git's fallback, so it must reproduce git's RESOLUTION too. A
|
|
2259
|
+
# relative XDG_CONFIG_HOME is invalid per the XDG base-directory spec, but
|
|
2260
|
+
# git does not ignore it: it HONORS the value and resolves it against the
|
|
2261
|
+
# worktree's TOP LEVEL, while Python would resolve it against the
|
|
2262
|
+
# orchestrator's launch cwd. The miss is SILENT — `is_file()` false, an
|
|
2263
|
+
# empty seed, and then the activation shadows the file git really reads.
|
|
2264
|
+
source = worktree / source
|
|
2265
|
+
# `is_file()` is the ABSENT test and swallows its own OSError; `read_bytes`
|
|
2266
|
+
# on a file that exists but cannot be read raises, deliberately (docstring).
|
|
2267
|
+
return source.read_bytes() if source.is_file() else b""
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def _shield_verify_activation(worktree: Path, exclude: Path) -> str | None:
|
|
2271
|
+
"""Why the just-written `core.excludesFile` is NOT the one git reads, or None.
|
|
2272
|
+
|
|
2273
|
+
A successful `git config --worktree core.excludesFile` proves the value was
|
|
2274
|
+
WRITTEN, not that it is in FORCE. git resolves the key from the most specific
|
|
2275
|
+
scope that carries it, and `command` — a scope fed by the ENVIRONMENT the
|
|
2276
|
+
orchestrator was launched with — outranks `worktree`. So an operator carrying an
|
|
2277
|
+
ambient `core.excludesFile` gets a shield that reports success and whose private
|
|
2278
|
+
file is never read: the provisioned tool files stay stageable by the unit's
|
|
2279
|
+
`git add -A`, with no degrade reason to journal. The check has git NAME the winning
|
|
2280
|
+
scope rather than leaving it inferred from the value that came back.
|
|
2281
|
+
|
|
2282
|
+
So the post-condition is asked of git. Detecting the ORIGIN instead —
|
|
2283
|
+
`GIT_CONFIG_COUNT` and friends in `os.environ` — is the enumeration this function
|
|
2284
|
+
exists to avoid, and `--show-scope` strengthens that argument rather than retiring
|
|
2285
|
+
it: nothing here reads `os.environ`, and git folds the whole channel family into
|
|
2286
|
+
the single token `command` on the same single call, so every channel — and whatever
|
|
2287
|
+
git adds next — arrives already LABELED, and the degrade reason names the family
|
|
2288
|
+
without this code enumerating it. The evidence for why enumerating was never viable
|
|
2289
|
+
stands: `GIT_CONFIG_PARAMETERS` is carried in two mutually incompatible encodings
|
|
2290
|
+
across the supported range, `GIT_CONFIG_COUNT` is one more channel to remember (it
|
|
2291
|
+
sits below `GIT_FLOOR`, so it is always present and always another thing to read),
|
|
2292
|
+
and a `git -c` on a session's own command line never appears in our environment at
|
|
2293
|
+
all. Asking git what it RESOLVED costs one call and covers all of them.
|
|
2294
|
+
|
|
2295
|
+
`--show-scope` is git 2.26, which was above the old 2.20 gate this shield used to
|
|
2296
|
+
carry; at `GIT_FLOOR` it is present on every supported git, which is what unblocked
|
|
2297
|
+
it (#692). Under `-z` the answer is `scope NUL value NUL` — measured at BOTH ends of
|
|
2298
|
+
the supported range, git 2.34.1 (the floor itself) and git 2.55.0, where the flag
|
|
2299
|
+
also leaves the rc taxonomy alone: an absent key is still rc 1 with or without it.
|
|
2300
|
+
The scope tokens git documents are `system`, `global`, `local`, `worktree` and
|
|
2301
|
+
`command`. The scope refines the MESSAGE and nothing else: a byte-identical value
|
|
2302
|
+
returns None whatever scope supplied it, since the post-condition is that git reads
|
|
2303
|
+
the file we wrote and provenance is not a fault; every mismatch degrades; the scope
|
|
2304
|
+
only decides what the reason tells the operator to go looking for.
|
|
2305
|
+
|
|
2306
|
+
The read shape is the seed read's: `-z` because a legal POSIX path may carry edge
|
|
2307
|
+
whitespace, `--type=path` because that is how git itself resolves the key. Any
|
|
2308
|
+
non-zero rc is a fault here, not an ABSENT answer — this call asks about a key we
|
|
2309
|
+
have just written, so "there is no such key" is not good news about it. That is a
|
|
2310
|
+
DIFFERENT taxonomy from `_shield_inherited_excludes`, which reads a key the
|
|
2311
|
+
operator may never have set; the two must not be unified. rc 0 carries a fault of
|
|
2312
|
+
its own now: a well-formed `-z --show-scope` answer always holds the seam NUL
|
|
2313
|
+
between scope and value, so an answer without one is not an answer and degrades
|
|
2314
|
+
fail-closed rather than being parsed as a scope. Whatever shape an unmeasured git
|
|
2315
|
+
might return lands there or in the unknown-token branch, and both degrade. The two
|
|
2316
|
+
reads are also no longer byte-identical in ARGV — `--show-scope` is on this one
|
|
2317
|
+
alone, retiring a trap the tests documented — and the seed read must NOT grow it,
|
|
2318
|
+
since rc 1 there means ABSENT.
|
|
2319
|
+
|
|
2320
|
+
The comparison is byte-exact and stays that way: `git config` round-trips a path
|
|
2321
|
+
verbatim through every hazard this shield has been burned by — edge whitespace,
|
|
2322
|
+
an embedded newline, a non-UTF-8 byte, an interior `~` — so loosening it would
|
|
2323
|
+
buy nothing and could only mask a real mismatch.
|
|
2324
|
+
"""
|
|
2325
|
+
resolved = git_bytes(
|
|
2326
|
+
worktree, "config", "-z", "--show-scope", "--type=path", "--get", "core.excludesFile"
|
|
2327
|
+
)
|
|
2328
|
+
if resolved.returncode != 0:
|
|
2329
|
+
detail = os.fsdecode(resolved.stderr).strip() or f"git exited {resolved.returncode}"
|
|
2330
|
+
return f"git would not confirm which excludes file now applies: {detail}"
|
|
2331
|
+
scope, sep, rest = resolved.stdout.partition(b"\0")
|
|
2332
|
+
if not sep:
|
|
2333
|
+
return (
|
|
2334
|
+
"git answered the activation check without naming a scope "
|
|
2335
|
+
f"({resolved.stdout!r}), so which excludes file applies is unconfirmed"
|
|
2336
|
+
)
|
|
2337
|
+
effective = rest.split(b"\0", 1)[0]
|
|
2338
|
+
if effective == os.fsencode(str(exclude)):
|
|
2339
|
+
return None
|
|
2340
|
+
shown = os.fsdecode(effective)
|
|
2341
|
+
if scope == b"command":
|
|
2342
|
+
return (
|
|
2343
|
+
"the write succeeded but an ambient command-scope override — a `git -c` "
|
|
2344
|
+
"this process was launched inside of, GIT_CONFIG_PARAMETERS, or "
|
|
2345
|
+
f"GIT_CONFIG_COUNT — outranks it, so git reads {shown!r} instead and the "
|
|
2346
|
+
"shield's patterns never apply"
|
|
2347
|
+
)
|
|
2348
|
+
if scope == b"worktree":
|
|
2349
|
+
return (
|
|
2350
|
+
"the write succeeded but worktree scope answers a different value, so git "
|
|
2351
|
+
f"reads {shown!r} instead of the path just written and the shield's "
|
|
2352
|
+
"patterns never apply"
|
|
2353
|
+
)
|
|
2354
|
+
if scope in (b"local", b"global", b"system"):
|
|
2355
|
+
return (
|
|
2356
|
+
"the write succeeded but git still resolves core.excludesFile from "
|
|
2357
|
+
f"{os.fsdecode(scope)} scope — the worktree-scoped write is not in force "
|
|
2358
|
+
f"at all, so git reads {shown!r} and the shield's patterns never apply"
|
|
2359
|
+
)
|
|
2360
|
+
return (
|
|
2361
|
+
f"the write succeeded but a scope this code does not know, {os.fsdecode(scope)!r}, "
|
|
2362
|
+
f"outranks it, so git reads {shown!r} instead and the shield's patterns never apply"
|
|
2363
|
+
)
|
|
2364
|
+
|
|
2365
|
+
|
|
2366
|
+
def _worktree_local_exclude(worktree: Path, patterns: Sequence[str]) -> str | None:
|
|
2367
|
+
"""Shield the just-provisioned tool files from the unit's `git add -A`, in a
|
|
2368
|
+
private exclude scoped to THIS worktree.
|
|
2369
|
+
|
|
2370
|
+
SCOPE — the patterns land in `<worktree gitdir>/info/exclude`, activated by
|
|
2371
|
+
`git config --worktree core.excludesFile`. Git's only per-repo exclude is
|
|
2372
|
+
`$GIT_COMMON_DIR/info/exclude`, so the private file is inert without that key,
|
|
2373
|
+
which applies it to this worktree alone — main checkout and siblings untouched.
|
|
2374
|
+
|
|
2375
|
+
LIFETIME — `git worktree remove`/`prune` deletes the per-worktree gitdir, taking
|
|
2376
|
+
the exclude and the `config.worktree` pointing at it: the shield expires with
|
|
2377
|
+
what it shields.
|
|
2378
|
+
|
|
2379
|
+
The repository-wide `.git/info/exclude` is NEVER written again on any path
|
|
2380
|
+
(#384): a refusal SKIPS the shield rather than falling back to it.
|
|
2381
|
+
|
|
2382
|
+
PRICE — `extensions.worktreeConfig` is repo-wide format state a successful shield
|
|
2383
|
+
leaves permanently enabled, and git older than 2.20 refuses to access a repository
|
|
2384
|
+
carrying it (git-worktree(1)). It is enabled at the LAST possible moment,
|
|
2385
|
+
immediately before the activation. The ACTIVATION rolls it back on a non-zero rc,
|
|
2386
|
+
on a raise, and on a write that succeeded without taking effect
|
|
2387
|
+
(`_shield_verify_activation`); the ENABLE only on the raise, since an rc means git
|
|
2388
|
+
declined to write and an unset would remove a concurrent writer's flag. Both are
|
|
2389
|
+
gated on this call having set the flag and no sibling worktree depending on it
|
|
2390
|
+
(`_shield_undo_extension`).
|
|
2391
|
+
|
|
2392
|
+
Every git call goes through `verify.git_bytes`: the returncode is an ANSWER, not
|
|
2393
|
+
a raise, and stdout arrives as BYTES.
|
|
2394
|
+
|
|
2395
|
+
Best-effort in two arms, separate because a fault means the opposite in each:
|
|
2396
|
+
|
|
2397
|
+
- git unqueryable BEFORE GIT HAS ANSWERED AT ALL (not a repo, git missing, a
|
|
2398
|
+
timeout or spawn failure on the FIRST `rev-parse` — both `GitError`) is an
|
|
2399
|
+
EXPECTED skip: returns None silently. That scope is the whole of it, and
|
|
2400
|
+
nothing is DECODED in this arm — git's stdout stays bytes until the tail
|
|
2401
|
+
below, because a decode fault is a degrade, not this arm's silent skip (#374).
|
|
2402
|
+
- anything AFTER git answered degrades to a returned reason string; only a
|
|
2403
|
+
definite ABSENT answer stays silent. Silence is not cosmetic: without the
|
|
2404
|
+
exclude the unit's `git add -A` commits the tool files just provisioned.
|
|
2405
|
+
|
|
2406
|
+
The exclude PAYLOAD is bytes end-to-end, so the tail's `UnicodeError` has two
|
|
2407
|
+
sources: `os.fsdecode` of git's stdout, Windows-only (#374/#377), and
|
|
2408
|
+
`os.fsencode` of a pattern carrying a surrogate POSIX surrogateescape never
|
|
2409
|
+
produced.
|
|
2410
|
+
"""
|
|
2411
|
+
# Callers pass POSIX-slash patterns (glob rels via as_posix; config strings as
|
|
2412
|
+
# authored); git's exclude is POSIX-slash on every platform, so nothing to fix here.
|
|
2413
|
+
try:
|
|
2414
|
+
# ONE path per call, never both in one answer. `rev-parse` separates its
|
|
2415
|
+
# answers with a newline, a legal BYTE IN A POSIX PATH: asking for both at once
|
|
2416
|
+
# mis-split a repo directory carrying one, degrading the shield away *after*
|
|
2417
|
+
# provisioning had copied the very files it exists to hide. No NUL-delimited
|
|
2418
|
+
# mode to reach for instead — `rev-parse` has no `-z`. A path ENDING in a
|
|
2419
|
+
# newline is beyond this parse either way.
|
|
2420
|
+
answered = git_bytes(worktree, "rev-parse", "--absolute-git-dir")
|
|
2421
|
+
if answered.returncode != 0:
|
|
2422
|
+
# Not a repo (rc 128), the expected skip — NOT "a git too old for
|
|
2423
|
+
# `--absolute-git-dir`": rev-parse ECHOES an option it does not know and
|
|
2424
|
+
# exits 0, so a git predating the flag lands in the version refusal in
|
|
2425
|
+
# `_shield_enable_worktree_config` — a degrade, not this silent skip.
|
|
2426
|
+
return None
|
|
2427
|
+
except GitError:
|
|
2428
|
+
# GitError alone is the whole taxonomy here: this `try` wraps ONE git call, and
|
|
2429
|
+
# the chokepoint raises a timeout as GitError and a spawn failure as its
|
|
2430
|
+
# GitSpawnError subclass — the translated form of the `OSError` this arm once
|
|
2431
|
+
# caught.
|
|
2432
|
+
return None
|
|
2433
|
+
try:
|
|
2434
|
+
# The COMMON-DIR probe belongs in THIS try, not the silent one above: once
|
|
2435
|
+
# `--absolute-git-dir` has answered, git has identified the repository, so every
|
|
2436
|
+
# later fault degrades with a reason instead of skipping the shield silently. No
|
|
2437
|
+
# `except` of its own: a non-zero rc returns the reason below, a raise lands in
|
|
2438
|
+
# this try's tail — which also catches the `UnicodeError` the `fsdecode` of
|
|
2439
|
+
# git's stderr can raise on Windows, hence that read sits inside the guard.
|
|
2440
|
+
# The sibling decode in `_shield_undo_extension`'s rollback carries that same
|
|
2441
|
+
# guard, for the same reason (#394).
|
|
2442
|
+
shared_answer = git_bytes(worktree, "rev-parse", "--git-common-dir")
|
|
2443
|
+
if shared_answer.returncode != 0:
|
|
2444
|
+
detail = (
|
|
2445
|
+
os.fsdecode(shared_answer.stderr).strip()
|
|
2446
|
+
or f"git exited {shared_answer.returncode}"
|
|
2447
|
+
)
|
|
2448
|
+
return (
|
|
2449
|
+
f"skipped the git-add shield ({worktree}): git identified the "
|
|
2450
|
+
f"repository but could not name its common dir: {detail} — the "
|
|
2451
|
+
"provisioned tool files are not shielded from the unit's `git add -A`"
|
|
2452
|
+
)
|
|
2453
|
+
# fsdecode, so a non-UTF-8 repo path round-trips back to the filesystem instead
|
|
2454
|
+
# of faulting; it can still raise on Windows (utf-8/surrogatepass rejects a lone
|
|
2455
|
+
# invalid byte), which is why it sits inside this try.
|
|
2456
|
+
#
|
|
2457
|
+
# removesuffix("\n"), NOT strip() — rev-parse terminates its answer with one
|
|
2458
|
+
# newline and every other byte belongs to the path. A BARE repo's common dir IS
|
|
2459
|
+
# the repository directory, so one at `…/common ` comes back with its trailing
|
|
2460
|
+
# space (`--absolute-git-dir` stays safe: git sanitizes the admin id). Stripping
|
|
2461
|
+
# pointed every later step at `…/common`, which does not exist, where `--file
|
|
2462
|
+
# <that>/config` reads rc 1, i.e. ABSENT — so the `core.bare = true` refusal was
|
|
2463
|
+
# MISSED and the shield made its permanent format change on a bare repository.
|
|
2464
|
+
#
|
|
2465
|
+
# CRLF is deliberately not absorbed: git writes LF here on every platform, and a
|
|
2466
|
+
# path legitimately ending in `\r` is indistinguishable from a CRLF terminator.
|
|
2467
|
+
raw_git_dir = os.fsdecode(answered.stdout).removesuffix("\n")
|
|
2468
|
+
raw_common = os.fsdecode(shared_answer.stdout).removesuffix("\n")
|
|
2469
|
+
if not raw_git_dir or not raw_common:
|
|
2470
|
+
# Defensive, and deliberately a REASON rather than a silent skip: git
|
|
2471
|
+
# answered. Unreachable with real git — an rc 0 from `rev-parse` always
|
|
2472
|
+
# prints a path.
|
|
2473
|
+
return (
|
|
2474
|
+
f"could not read this worktree's git dirs ({worktree}): "
|
|
2475
|
+
f"{raw_git_dir!r}, {raw_common!r}"
|
|
2476
|
+
)
|
|
2477
|
+
git_dir = Path(raw_git_dir)
|
|
2478
|
+
common_dir = Path(raw_common)
|
|
2479
|
+
if not common_dir.is_absolute():
|
|
2480
|
+
# a PLAIN checkout answers with a relative ".git"; a linked worktree
|
|
2481
|
+
# answers with the main repo's absolute .git.
|
|
2482
|
+
common_dir = worktree / common_dir
|
|
2483
|
+
# resolve BOTH before comparing: --absolute-git-dir is absolute but not
|
|
2484
|
+
# canonical, so a symlinked repo path would otherwise read as two
|
|
2485
|
+
# different dirs and a main checkout would pass for a linked worktree.
|
|
2486
|
+
git_dir, common_dir = git_dir.resolve(), common_dir.resolve()
|
|
2487
|
+
if git_dir == common_dir:
|
|
2488
|
+
# The main checkout itself: its gitdir IS the common dir, so the only
|
|
2489
|
+
# exclude file to write would be the shared one — the #384 bug.
|
|
2490
|
+
return (
|
|
2491
|
+
f"skipped the git-add shield ({worktree}): not a linked worktree, and the "
|
|
2492
|
+
"shield never writes the repository-wide exclude (#384)"
|
|
2493
|
+
)
|
|
2494
|
+
# ABOVE THE LOCK DELIBERATELY: a repository shared between OS users is refused
|
|
2495
|
+
# before anything is created in it, so every user gets the same reason rather
|
|
2496
|
+
# than the second one meeting a lock file the first left behind. Safe there
|
|
2497
|
+
# because `core.sharedRepository` is static config the tool never writes — no
|
|
2498
|
+
# shared mutable state to serialize. A `GitError` from the read hits this tail.
|
|
2499
|
+
shared_repository = _shield_shared_repository(worktree, common_dir)
|
|
2500
|
+
if shared_repository is not None:
|
|
2501
|
+
return shared_repository
|
|
2502
|
+
# EVERYTHING BELOW IS ONE TRANSACTION, serialized per repository.
|
|
2503
|
+
# `_shield_enable_worktree_config` only PROBES the flag, so two concurrent
|
|
2504
|
+
# provisionings both read it absent, both believe they enabled it, and whichever
|
|
2505
|
+
# one's activation fails rolls the flag back out from under the other's LIVE
|
|
2506
|
+
# shield — its `core.excludesFile` goes inert and its shielded tool files become
|
|
2507
|
+
# stageable again mid-run. That flag is the one piece of state two runs share;
|
|
2508
|
+
# every other per-run name is keyed on the run id. Concurrent runs in one repo
|
|
2509
|
+
# are permitted by design — `cmd_run` has no liveness check and the TUI offers
|
|
2510
|
+
# "launch anyway".
|
|
2511
|
+
#
|
|
2512
|
+
# The span cannot be narrowed to those writes: the enable must stay immediately
|
|
2513
|
+
# above the activation, the private exclude must exist before it, and the
|
|
2514
|
+
# version + `core.bare`/`core.worktree` gates inside the probe must stay above
|
|
2515
|
+
# the seed read — the version gate for its unreadable-answer arm (see
|
|
2516
|
+
# `_shield_enable_worktree_config`), the other two because the seed read must
|
|
2517
|
+
# not run against a repo they would refuse. So the file write sits inside the
|
|
2518
|
+
# locked span.
|
|
2519
|
+
#
|
|
2520
|
+
# The acquisition's own `OSError` is caught HERE only so the operator is told
|
|
2521
|
+
# which step failed: POSIX `flock` blocks indefinitely, but `msvcrt.locking`
|
|
2522
|
+
# bounds the wait at ~10 s and then raises, so Windows contention is a real,
|
|
2523
|
+
# reportable outcome.
|
|
2524
|
+
held = ExitStack()
|
|
2525
|
+
try:
|
|
2526
|
+
held.enter_context(file_lock(common_dir / _SHIELD_LOCK_NAME))
|
|
2527
|
+
except OSError as e:
|
|
2528
|
+
return (
|
|
2529
|
+
f"skipped the git-add shield ({worktree}): could not take this "
|
|
2530
|
+
f"repository's shield lock ({e}) — another froid-loop run may hold it"
|
|
2531
|
+
)
|
|
2532
|
+
with held:
|
|
2533
|
+
refusal, needs_enable = _shield_enable_worktree_config(worktree, common_dir)
|
|
2534
|
+
if refusal is not None:
|
|
2535
|
+
return refusal
|
|
2536
|
+
exclude = git_dir / "info" / "exclude"
|
|
2537
|
+
exclude.parent.mkdir(parents=True, exist_ok=True)
|
|
2538
|
+
# Zero bytes is an INTERRUPTED creation (a kill or ENOSPC between the
|
|
2539
|
+
# `touch()` and the fill below), not an initialized exclude: read as
|
|
2540
|
+
# authoritative it skips the seed below and then points a shadowing
|
|
2541
|
+
# `core.excludesFile` at a file missing the operator's own excludes. Sized
|
|
2542
|
+
# rather than unlinked on the way out, because no handler runs on a KILL.
|
|
2543
|
+
existed = exclude.is_file() and exclude.stat().st_size > 0
|
|
2544
|
+
# On creation the operator's own excludes are copied in, because the
|
|
2545
|
+
# activation below shadows them (see _shield_inherited_excludes); on a
|
|
2546
|
+
# re-provision a non-empty file is authoritative, so the two are idempotent.
|
|
2547
|
+
#
|
|
2548
|
+
# A COPY FAULT SKIPS THE ACTIVATION: `_shield_inherited_excludes` raises on
|
|
2549
|
+
# anything but a definite ANSWER — an unreadable file, or a git that will
|
|
2550
|
+
# not say which file applies — and the raise lands in this function's tail,
|
|
2551
|
+
# which returns a reason. It raises rather than returning empty
|
|
2552
|
+
# because an empty seed plus the activation below is not a lost seed but
|
|
2553
|
+
# the operator's global ignores SHADOWED inside this worktree, with `git
|
|
2554
|
+
# add -A` staging what they told git to ignore. The raise is above
|
|
2555
|
+
# `exclude.touch()`, so no placeholder is left behind.
|
|
2556
|
+
#
|
|
2557
|
+
# BYTES, never decoded text: an exclude holds path patterns, POSIX paths
|
|
2558
|
+
# are arbitrary bytes, and an operator's own file may be in any legacy
|
|
2559
|
+
# 8-bit encoding. `str.splitlines()` would also break on \x0b, \x0c, \x1c,
|
|
2560
|
+
# \x1d, \x1e and \x85, none of which git treats as a line boundary, so a
|
|
2561
|
+
# legitimate pattern carrying one fragments into wrong dedupe keys.
|
|
2562
|
+
#
|
|
2563
|
+
# SPLIT THE WAY GIT DOES (#472): \n boundaries, with exactly ONE trailing
|
|
2564
|
+
# \r trimmed per line. `bytes.splitlines()` is CLOSE but not identical —
|
|
2565
|
+
# it also breaks on a LONE \r, which git treats as ordinary content
|
|
2566
|
+
# (measured, 2.55.0: `/hidden\rjunk` ignores nothing, while `/hidden\r\n`
|
|
2567
|
+
# ignores `hidden` and `/hidden\r\r\n` does not). That difference is not
|
|
2568
|
+
# cosmetic here: an operator line carrying an embedded \r fragments, and a
|
|
2569
|
+
# fragment byte-equal to a wanted pattern reads as ALREADY PRESENT. Where
|
|
2570
|
+
# that fragment sits after the last negation the settled rule below skips
|
|
2571
|
+
# the append — the shield then writes a file that does not shield, with no
|
|
2572
|
+
# degrade reason, because nothing failed. The mirror direction is benign
|
|
2573
|
+
# (a fragmented key only ever costs a duplicate append; last match wins),
|
|
2574
|
+
# so this split is chosen for the SKIP direction alone.
|
|
2575
|
+
#
|
|
2576
|
+
# A trailing b"" (the file ended in \n) rides along unfiltered: no wanted
|
|
2577
|
+
# pattern is empty and b"" does not start with b"!", so it is inert in both
|
|
2578
|
+
# consumers below.
|
|
2579
|
+
existing = exclude.read_bytes() if existed else _shield_inherited_excludes(worktree)
|
|
2580
|
+
lines = [ln.removesuffix(b"\r") for ln in existing.split(b"\n")]
|
|
2581
|
+
# PRESENT IS NOT THE SAME AS EFFECTIVE (#384). gitignore is LAST MATCH
|
|
2582
|
+
# WINS, so a pattern this file already contains can be cancelled by a `!`
|
|
2583
|
+
# line below it, and a plain set-membership dedupe then declined to append
|
|
2584
|
+
# the shield's own pattern — leaving the provisioned tool files stageable
|
|
2585
|
+
# with NO degrade reason at all, because nothing failed. So a pattern may
|
|
2586
|
+
# be skipped only where it is GUARANTEED effective: it sits after the LAST
|
|
2587
|
+
# negation. No later line can negate it, so the last pattern matching any
|
|
2588
|
+
# path it covers is either this one or a positive below it, and both
|
|
2589
|
+
# ignore. Anything earlier is appended, which is always safe — the appended
|
|
2590
|
+
# copy is last, so it decides.
|
|
2591
|
+
#
|
|
2592
|
+
# Deliberately CONSERVATIVE rather than exact: the only negation that
|
|
2593
|
+
# actually defeats the shield re-includes the pattern's own directory
|
|
2594
|
+
# (`!.claude/skills` does it too — it need not repeat the positive's
|
|
2595
|
+
# spelling), while `!*.md` below it does not, since git never descends into
|
|
2596
|
+
# an excluded directory, and neither does `!/.claude`, since re-including a
|
|
2597
|
+
# parent leaves the child matched. Telling those apart is git's matcher, so
|
|
2598
|
+
# this appends a duplicate in the harmless cases instead of guessing.
|
|
2599
|
+
#
|
|
2600
|
+
# `startswith(b"!")` with NO lstrip is git-correct twice over: git does not
|
|
2601
|
+
# strip leading whitespace from a pattern, and `\!` escapes a literal
|
|
2602
|
+
# leading `!` — both then read as positives here, as they do in git.
|
|
2603
|
+
last_negation = max(
|
|
2604
|
+
(i for i, line in enumerate(lines) if line.startswith(b"!")), default=-1
|
|
2605
|
+
)
|
|
2606
|
+
settled = set(lines[last_negation + 1 :])
|
|
2607
|
+
# fsencode, the inverse of the fsdecode above: a pattern derived from a real
|
|
2608
|
+
# filename round-trips to its exact original bytes. Both sides of the `in`
|
|
2609
|
+
# MUST be bytes — with `patterns` left as str every one of them reads as
|
|
2610
|
+
# absent and gets re-appended on every re-provision.
|
|
2611
|
+
wanted = [os.fsencode(p) for p in patterns]
|
|
2612
|
+
new = [p for p in wanted if p not in settled]
|
|
2613
|
+
# `not existed` keeps a re-provision that adds no pattern from leaving the
|
|
2614
|
+
# config below pointing at a file that was never created.
|
|
2615
|
+
if new or not existed:
|
|
2616
|
+
# b"\n", not "\n": `bytes.endswith(str)` is a TypeError, and TypeError is
|
|
2617
|
+
# not in the tail's except tuple — it would escape a function contracted
|
|
2618
|
+
# never to propagate.
|
|
2619
|
+
prefix = existing if not existing or existing.endswith(b"\n") else existing + b"\n"
|
|
2620
|
+
# atomic_write_bytes, never write_bytes onto `exclude` directly (#375):
|
|
2621
|
+
# "wb" TRUNCATES before writing, and this is a read-modify-REWRITE
|
|
2622
|
+
# carrying the operator's own excludes in `prefix`, so a short write
|
|
2623
|
+
# (ENOSPC) left the file truncated mid-content — losing shield lines —
|
|
2624
|
+
# while the degrade reason still said "could not update". A truncation
|
|
2625
|
+
# landing on a bare "/" is inert: git strips it to a zero-length
|
|
2626
|
+
# pattern that matches nothing, so only the LOST lines matter. The helper
|
|
2627
|
+
# rather than a hand-rolled tmp+replace: it fsyncs before the replace
|
|
2628
|
+
# and carries the target's mode over, which a bare replace resets.
|
|
2629
|
+
# #381's interleaving race is moot here — the file is one worktree's
|
|
2630
|
+
# private gitdir, so no two runs share the target — but the atomic
|
|
2631
|
+
# write stays for #375, whose fault needs no second writer.
|
|
2632
|
+
if not existed:
|
|
2633
|
+
# Create it first: O_CREAT under the umask gives the helper
|
|
2634
|
+
# a mode to carry. `atomic_write_bytes` preserves a mode only
|
|
2635
|
+
# when the target already EXISTS (a fresh one gets mkstemp's
|
|
2636
|
+
# private 0600), so dropping this line would narrow every
|
|
2637
|
+
# private exclude's mode — and git treats an exclude it
|
|
2638
|
+
# cannot read as one that is not there: it warns, exits 0,
|
|
2639
|
+
# and the shield silently protects nothing, with no degrade
|
|
2640
|
+
# reason because the write SUCCEEDED. A fault right after
|
|
2641
|
+
# this leaves an empty exclude, which git treats as absent.
|
|
2642
|
+
exclude.touch()
|
|
2643
|
+
atomic_write_bytes(exclude, prefix + b"".join(p + b"\n" for p in new))
|
|
2644
|
+
# The PERMANENT repo-format change, deliberately the last-but-one thing
|
|
2645
|
+
# this function does. `_shield_enable_worktree_config` probed the gates and
|
|
2646
|
+
# reported the write still owed; paying it here is what keeps every degrade
|
|
2647
|
+
# above from charging the operator's repo a format change it carries
|
|
2648
|
+
# forever for a shield that never applied. Two writes can still set the
|
|
2649
|
+
# flag without shielding anything — this enable's own raise and the
|
|
2650
|
+
# activation's failure — and each rolls it back. It cannot move BELOW the
|
|
2651
|
+
# activation either: `git config --worktree` is what the extension unlocks.
|
|
2652
|
+
if needs_enable:
|
|
2653
|
+
# BOTH of the chokepoint's failure shapes classified into one value
|
|
2654
|
+
# before anything is decided: handling only the rc left a raise (a
|
|
2655
|
+
# timeout, a spawn failure) to the tail, which cannot roll anything
|
|
2656
|
+
# back, and `git config` can be killed AFTER renaming the new config
|
|
2657
|
+
# into place — flag set, no shield ever activated.
|
|
2658
|
+
#
|
|
2659
|
+
# THE ROLLBACK FIRES ON THE RAISE ONLY, and that asymmetry is
|
|
2660
|
+
# deliberate: an rc IS an answer — git refused, the lock file was never
|
|
2661
|
+
# renamed into place, there is nothing to undo, and the likeliest cause
|
|
2662
|
+
# is `.git/config.lock` contention with the very concurrent writer whose
|
|
2663
|
+
# flag an `--unset` would then remove. A raise is NOT an answer, and
|
|
2664
|
+
# only the uncertain case may trigger repair; do not "finish" this by
|
|
2665
|
+
# rolling back the rc too. `needs_enable` is True on this branch, so
|
|
2666
|
+
# `_shield_undo_extension`'s caller-side gate holds by construction.
|
|
2667
|
+
rolled_back = ""
|
|
2668
|
+
try:
|
|
2669
|
+
enabled = git_bytes(worktree, "config", "extensions.worktreeConfig", "true")
|
|
2670
|
+
enable_fault = (
|
|
2671
|
+
None
|
|
2672
|
+
if enabled.returncode == 0
|
|
2673
|
+
else os.fsdecode(enabled.stderr).strip()
|
|
2674
|
+
or f"git exited {enabled.returncode}"
|
|
2675
|
+
)
|
|
2676
|
+
except GitError as e:
|
|
2677
|
+
enable_fault = str(e)
|
|
2678
|
+
rolled_back = _shield_undo_extension(worktree, git_dir, common_dir)
|
|
2679
|
+
if enable_fault is not None:
|
|
2680
|
+
return (
|
|
2681
|
+
f"could not enable extensions.worktreeConfig ({worktree}): "
|
|
2682
|
+
f"{enable_fault} — the provisioned tool files are not shielded "
|
|
2683
|
+
f"from the unit's `git add -A`{rolled_back}"
|
|
2684
|
+
)
|
|
2685
|
+
# LAST, after the file it names exists: git reads core.excludesFile lazily,
|
|
2686
|
+
# but a config pointing at a file a fault above left unwritten would be a
|
|
2687
|
+
# shield that silently excludes nothing.
|
|
2688
|
+
#
|
|
2689
|
+
# ONE rollback covering EVERY way this step can fail to leave a working
|
|
2690
|
+
# shield. `git_bytes` fails two ways — a non-zero rc (git refused) and a
|
|
2691
|
+
# RAISE (a timeout, or a spawn failure as its GitSpawnError subclass) — and
|
|
2692
|
+
# a third outcome is not a failure at all: a write that succeeds without
|
|
2693
|
+
# taking effect (`_shield_verify_activation`, below). So the `try` spans the
|
|
2694
|
+
# write AND the verification, every shape converges on one `fault` string
|
|
2695
|
+
# before anything is decided, and rc 0 is no longer an exit from this block.
|
|
2696
|
+
# The rule: confirm the POST-CONDITION rather than enumerate the ways the
|
|
2697
|
+
# call in front of you can go wrong.
|
|
2698
|
+
#
|
|
2699
|
+
# The rollback exists because the enable one line above is a PERMANENT
|
|
2700
|
+
# repo-format change, and the guarantee this code owes (docstring,
|
|
2701
|
+
# CHANGELOG, docs/FEATURES.md) is that the flag outlives a failed shield
|
|
2702
|
+
# only where a rollback was DECLINED or could not be made, and the reason
|
|
2703
|
+
# says which. `needs_enable` gates it — a safety property, not a
|
|
2704
|
+
# micro-optimization, and see `_shield_undo_extension` for the second gate
|
|
2705
|
+
# the callee adds: `needs_enable` records only what the PROBE saw, so it
|
|
2706
|
+
# cannot tell "we enabled it" from "we and a concurrent run both thought
|
|
2707
|
+
# we did".
|
|
2708
|
+
try:
|
|
2709
|
+
activated = git_bytes(
|
|
2710
|
+
worktree, "config", "--worktree", "core.excludesFile", str(exclude)
|
|
2711
|
+
)
|
|
2712
|
+
fault = (
|
|
2713
|
+
None
|
|
2714
|
+
if activated.returncode == 0
|
|
2715
|
+
else os.fsdecode(activated.stderr).strip()
|
|
2716
|
+
or f"git exited {activated.returncode}"
|
|
2717
|
+
)
|
|
2718
|
+
if fault is None:
|
|
2719
|
+
# An rc 0 means the value was WRITTEN, not that it is in
|
|
2720
|
+
# FORCE — `command` scope outranks `worktree` and is fed by
|
|
2721
|
+
# the environment we were launched with. Verified INSIDE this
|
|
2722
|
+
# same `try` and above the one decision below, so the
|
|
2723
|
+
# verification's own failure shapes reach the existing
|
|
2724
|
+
# rollback instead of growing a second one.
|
|
2725
|
+
fault = _shield_verify_activation(worktree, exclude)
|
|
2726
|
+
except GitError as e:
|
|
2727
|
+
# Deliberately NOT left to the tail, which cannot roll the flag back: a
|
|
2728
|
+
# GitError from EITHER call in this block means what a refusal means —
|
|
2729
|
+
# the shield is not in force. Putting this except inside the `try` is
|
|
2730
|
+
# what extends that to the verification's own fault. The tail's GitError
|
|
2731
|
+
# guard stays load-bearing for the seed read above, so this narrows what
|
|
2732
|
+
# reaches it rather than bypassing it.
|
|
2733
|
+
fault = str(e)
|
|
2734
|
+
if fault is not None:
|
|
2735
|
+
if needs_enable:
|
|
2736
|
+
fault += _shield_undo_extension(worktree, git_dir, common_dir)
|
|
2737
|
+
return f"could not activate the worktree git exclude ({worktree}): {fault}"
|
|
2738
|
+
# GitError is every fault a chokepoint git call can raise here — a timeout, or a
|
|
2739
|
+
# spawn failure as its GitSpawnError subclass. OSError and RuntimeError cover the
|
|
2740
|
+
# mkdir and read/write faults plus `.resolve()`'s pre-3.13 symlink loop, which
|
|
2741
|
+
# raises RuntimeError rather than OSError.
|
|
2742
|
+
#
|
|
2743
|
+
# UnicodeError, not UnicodeDecodeError, and NOT for the exclude file — that payload
|
|
2744
|
+
# is bytes end-to-end. What is left is the `os.fsdecode` of git's stdout
|
|
2745
|
+
# (Windows-only) and the `os.fsencode` of a pattern carrying a surrogate POSIX
|
|
2746
|
+
# surrogateescape never produced: ENCODE or DECODE depending on which, hence the
|
|
2747
|
+
# shared base class, and still far short of ValueError-broad.
|
|
2748
|
+
#
|
|
2749
|
+
# TypeError is deliberately absent: a str/bytes mixup on the payload path is a
|
|
2750
|
+
# programming error, and letting it crash beats reporting it as an operator's
|
|
2751
|
+
# degraded shield once per run.
|
|
2752
|
+
except (GitError, OSError, RuntimeError, UnicodeError) as e:
|
|
2753
|
+
return f"could not update the worktree-local git exclude ({worktree}): {e}"
|
|
2754
|
+
return None
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
def _copy_skills(project: Path, trees: Sequence[str], force: bool) -> bool:
|
|
2758
|
+
"""Install the bundled froid-loop-* skills into each project skill tree.
|
|
2759
|
+
|
|
2760
|
+
A skill directory that already exists is skipped unless ``force`` (so the
|
|
2761
|
+
FROID installer's copy or local edits are never clobbered silently). Returns
|
|
2762
|
+
True if any skill was skipped because it already existed.
|
|
2763
|
+
"""
|
|
2764
|
+
skills_root = resources.files("froid_loop.data").joinpath("skills")
|
|
2765
|
+
skipped_any = False
|
|
2766
|
+
for tree in trees:
|
|
2767
|
+
tree_dir = project / tree
|
|
2768
|
+
# This loop rmtree's under `force` and writes unconditionally, and its
|
|
2769
|
+
# `_copy_traversable` call passes neither `worktree` nor `repo_root`, so
|
|
2770
|
+
# both of that helper's containment legs are inert here by construction.
|
|
2771
|
+
# The containment check therefore has to live at this level. Not folded
|
|
2772
|
+
# into `_copy_traversable`'s own guards on purpose: passing `worktree=`
|
|
2773
|
+
# would also force no-clobber and switch per-entry OSError to degrade,
|
|
2774
|
+
# and `init` must fail loudly on a write it cannot complete.
|
|
2775
|
+
#
|
|
2776
|
+
# Raises rather than skipping: a skipped tree leaves the install missing
|
|
2777
|
+
# the skills every later session dispatches, and an `init` that prints
|
|
2778
|
+
# `init complete` and exits 0 over that is an unattended-setup trap. Same
|
|
2779
|
+
# severity as `_register_hooks`' refusal, which already fails the install.
|
|
2780
|
+
if not _confined_to(tree_dir, project):
|
|
2781
|
+
raise ProfileError(f"skill tree does not resolve inside the project: {tree_dir}")
|
|
2782
|
+
installed: list[str] = []
|
|
2783
|
+
skipped: list[str] = []
|
|
2784
|
+
for skill in MODULE_SKILLS:
|
|
2785
|
+
dst = tree_dir / skill
|
|
2786
|
+
if dst.exists() and not force:
|
|
2787
|
+
skipped.append(skill)
|
|
2788
|
+
continue
|
|
2789
|
+
if dst.exists():
|
|
2790
|
+
shutil.rmtree(dst)
|
|
2791
|
+
_copy_traversable(skills_root.joinpath(skill), dst)
|
|
2792
|
+
installed.append(skill)
|
|
2793
|
+
parts: list[str] = []
|
|
2794
|
+
if installed:
|
|
2795
|
+
parts.append(f"installed {', '.join(installed)}")
|
|
2796
|
+
if skipped:
|
|
2797
|
+
parts.append(f"skipped {', '.join(skipped)} (exist)")
|
|
2798
|
+
skipped_any = True
|
|
2799
|
+
print(f" skills -> {tree}/: {'; '.join(parts) if parts else 'nothing to do'}")
|
|
2800
|
+
return skipped_any
|
|
2801
|
+
|
|
2802
|
+
|
|
2803
|
+
def _warn_if_policy_tracked(project: Path) -> None:
|
|
2804
|
+
"""One-time migration hint: a .gitignore entry does not untrack an
|
|
2805
|
+
already-committed policy.toml, so repos initialized before the file was
|
|
2806
|
+
gitignored keep sharing it (and this machine's [mux] backend choice) until
|
|
2807
|
+
the dev runs `git rm --cached` once. Best-effort — not a repo, or no git,
|
|
2808
|
+
means nothing to warn about."""
|
|
2809
|
+
try:
|
|
2810
|
+
tracked = (
|
|
2811
|
+
git_bytes(
|
|
2812
|
+
project,
|
|
2813
|
+
"ls-files",
|
|
2814
|
+
"--error-unmatch",
|
|
2815
|
+
".froid-loop/policy.toml",
|
|
2816
|
+
timeout_s=10, # the pre-#390 bound: a hint must not stall init
|
|
2817
|
+
).returncode
|
|
2818
|
+
== 0
|
|
2819
|
+
)
|
|
2820
|
+
except GitError:
|
|
2821
|
+
return
|
|
2822
|
+
if tracked:
|
|
2823
|
+
print(
|
|
2824
|
+
" note: .froid-loop/policy.toml is tracked by git; run "
|
|
2825
|
+
"`git rm --cached .froid-loop/policy.toml` once to stop sharing it "
|
|
2826
|
+
"(your local copy is kept)"
|
|
2827
|
+
)
|
|
2828
|
+
|
|
2829
|
+
|
|
2830
|
+
def install_into(
|
|
2831
|
+
project: Path,
|
|
2832
|
+
clis: Sequence[str] = ("claude",),
|
|
2833
|
+
*,
|
|
2834
|
+
skills: bool = True,
|
|
2835
|
+
force_skills: bool = False,
|
|
2836
|
+
) -> int:
|
|
2837
|
+
project = project.resolve()
|
|
2838
|
+
try:
|
|
2839
|
+
available = load_profiles(project)
|
|
2840
|
+
profiles = []
|
|
2841
|
+
for name in clis:
|
|
2842
|
+
key = ALIASES.get(name, name)
|
|
2843
|
+
if key not in available:
|
|
2844
|
+
raise ProfileError(
|
|
2845
|
+
f"unknown CLI profile: {name!r} (available: {sorted(available)})"
|
|
2846
|
+
)
|
|
2847
|
+
profiles.append(available[key])
|
|
2848
|
+
except ProfileError as e:
|
|
2849
|
+
print(f"FAIL: {e}")
|
|
2850
|
+
return 1
|
|
2851
|
+
|
|
2852
|
+
froid_loop_dir = project / ".froid-loop"
|
|
2853
|
+
froid_loop_dir.mkdir(parents=True, exist_ok=True)
|
|
2854
|
+
|
|
2855
|
+
# 1. hook relay script (shared by all CLIs)
|
|
2856
|
+
script_target = project / HOOK_SCRIPT_REL
|
|
2857
|
+
script_source = resources.files("froid_loop.data").joinpath("froid_loop_hook.py")
|
|
2858
|
+
script_target.write_text(script_source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
2859
|
+
print(f" hook script: {script_target}")
|
|
2860
|
+
|
|
2861
|
+
# 2. per-CLI hook registration
|
|
2862
|
+
for profile in profiles:
|
|
2863
|
+
if _register_hooks(project, profile) != 0:
|
|
2864
|
+
return 1
|
|
2865
|
+
|
|
2866
|
+
# 3. bundled skills into each CLI's skill tree (deduped: codex+gemini share
|
|
2867
|
+
# .agents/skills)
|
|
2868
|
+
skills_skipped = False
|
|
2869
|
+
if skills:
|
|
2870
|
+
trees = list(dict.fromkeys(p.skill_tree for p in profiles))
|
|
2871
|
+
try:
|
|
2872
|
+
skills_skipped = _copy_skills(project, trees, force_skills)
|
|
2873
|
+
except ProfileError as e:
|
|
2874
|
+
print(f"FAIL: {e}")
|
|
2875
|
+
return 1
|
|
2876
|
+
|
|
2877
|
+
# 4. policy template
|
|
2878
|
+
policy_path = froid_loop_dir / "policy.toml"
|
|
2879
|
+
if policy_path.is_file():
|
|
2880
|
+
print(" policy exists, leaving untouched")
|
|
2881
|
+
else:
|
|
2882
|
+
policy_path.write_text(POLICY_TEMPLATE, encoding="utf-8")
|
|
2883
|
+
print(f" policy written: {policy_path}")
|
|
2884
|
+
|
|
2885
|
+
# 5. gitignore generated/machine-local state: per-run state (.froid-loop/runs/),
|
|
2886
|
+
# the game-engine plugins' rebuildable caches, e.g. the per-worktree Unity
|
|
2887
|
+
# Library (.froid-loop/cache/), and the policy file itself — policy.toml is
|
|
2888
|
+
# per-machine-per-repo (it carries this machine's [mux] backend choice, and
|
|
2889
|
+
# the TUI settings editor rewrites it), so it must never travel to teammates.
|
|
2890
|
+
gitignore = project / ".gitignore"
|
|
2891
|
+
existing = gitignore.read_text(encoding="utf-8") if gitignore.is_file() else ""
|
|
2892
|
+
have = set(existing.splitlines())
|
|
2893
|
+
to_add = [
|
|
2894
|
+
line
|
|
2895
|
+
for line in (
|
|
2896
|
+
".froid-loop/runs/",
|
|
2897
|
+
".froid-loop/cache/",
|
|
2898
|
+
".froid-loop/policy.toml",
|
|
2899
|
+
f"{RENDER_DIR_REL}/",
|
|
2900
|
+
)
|
|
2901
|
+
if line not in have
|
|
2902
|
+
]
|
|
2903
|
+
if to_add:
|
|
2904
|
+
with gitignore.open("a", encoding="utf-8") as f:
|
|
2905
|
+
if existing and not existing.endswith("\n"):
|
|
2906
|
+
f.write("\n")
|
|
2907
|
+
f.write("\n".join(to_add) + "\n")
|
|
2908
|
+
for line in to_add:
|
|
2909
|
+
print(f" gitignored: {line}")
|
|
2910
|
+
_warn_if_policy_tracked(project)
|
|
2911
|
+
|
|
2912
|
+
if skills_skipped:
|
|
2913
|
+
print(" some skills already present; re-run with --force-skills to overwrite")
|
|
2914
|
+
|
|
2915
|
+
print(
|
|
2916
|
+
"init complete. One-time setup before `froid-loop run` — spawned "
|
|
2917
|
+
"sessions cannot answer first-run dialogs, and a pending dialog reads "
|
|
2918
|
+
"as a session timeout:"
|
|
2919
|
+
)
|
|
2920
|
+
for profile in profiles:
|
|
2921
|
+
if profile.first_run_note:
|
|
2922
|
+
print(f" {profile.name}: {profile.first_run_note}")
|
|
2923
|
+
return 0
|
|
2924
|
+
|
|
2925
|
+
|
|
2926
|
+
def __getattr__(name: str):
|
|
2927
|
+
# `provision_worktree` now lives in `worktree_flow` (issue #244 F-9a): the
|
|
2928
|
+
# runtime control loop must not import the installer. It is re-exported here
|
|
2929
|
+
# lazily — a module-level `from .worktree_flow import provision_worktree` would
|
|
2930
|
+
# form an import cycle (worktree_flow imports installer helpers from this
|
|
2931
|
+
# module), so resolve it on first attribute access, once both modules exist.
|
|
2932
|
+
if name == "provision_worktree":
|
|
2933
|
+
from .worktree_flow import provision_worktree
|
|
2934
|
+
|
|
2935
|
+
return provision_worktree
|
|
2936
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|