4"""Build and verify an exact archive of selected candidate-worktree paths.
6The dev-box Ansible role must provision from the checkout that invoked it,
7including unstaged edits and non-ignored new files. Recursively copying source
8directories also copies ignored workstation residue, while ``git archive``
9cannot see dirty or untracked candidate bytes. This helper uses Git only as the
10census: present cached files plus non-ignored untracked files are archived from
11their current worktree bytes.
13Archive metadata is normalized so identical candidate trees produce identical
14bytes on different controllers. Regular files preserve only the current
15worktree's portable executable bit, including unstaged chmod changes; the index
16distinguishes tracked symlinks from regular files. The verifier compares every
17archive member, byte, link target, and normalized mode against an extracted
18tree, including unexpected files.
21from __future__
import annotations
32from dataclasses
import dataclass
33from pathlib
import Path, PurePosixPath
35from git_environment
import LOCAL_GIT_ENVIRONMENT, sanitized_git_environment, trusted_git_executable
40EXECUTABLE_MODE = 0o755
43GIT_REGULAR_MODE =
"100644"
44GIT_EXECUTABLE_MODE =
"100755"
45GIT_SYMLINK_MODE =
"120000"
47GIT_STAGE_FIELD_COUNT = 3
50class ContextError(RuntimeError):
51 """A candidate census, archive, or verification contract failed."""
54@dataclass(frozen=
True)
56 """One normalized archive entry."""
65def _git(root: Path, args: list[str]) -> bytes:
66 """Run one Git query with ``root`` as the only repository selector."""
67 proc = subprocess.run(
68 [trusted_git_executable(), *args],
70 env=sanitized_git_environment(),
74 if proc.returncode != 0:
75 detail = os.fsdecode(proc.stderr).strip()
76 message = f
"git {' '.join(args)} failed: {detail}"
77 raise ContextError(message)
81def _candidate_paths(root: Path, scopes: tuple[str, ...]) -> list[str]:
82 """Return sorted live cached or non-ignored untracked paths in scope."""
85 [
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
"--", *scopes],
88 for record
in raw.split(b
"\0"):
91 rel = os.fsdecode(record)
95 except FileNotFoundError:
98 return sorted(set(paths), key=os.fsencode)
101def _tracked_modes(root: Path, scopes: tuple[str, ...]) -> dict[str, str]:
102 """Return index modes for normal-stage tracked paths in scope."""
103 raw = _git(root, [
"ls-files",
"-z",
"--stage",
"--cached",
"--", *scopes])
104 modes: dict[str, str] = {}
105 for record
in raw.split(b
"\0"):
108 header, separator, raw_path = record.partition(b
"\t")
109 fields = header.split()
110 if not separator
or len(fields) != GIT_STAGE_FIELD_COUNT:
111 message =
"git ls-files --stage returned a malformed record"
112 raise ContextError(message)
113 mode, _object_id, stage = (os.fsdecode(field)
for field
in fields)
114 rel = os.fsdecode(raw_path)
115 if stage != GIT_STAGE_NORMAL:
116 message = f
"cannot archive conflicted index entry: {rel}"
117 raise ContextError(message)
122def _regular_mode(source: Path, git_mode: str |
None) -> int:
123 """Normalize the candidate worktree's current executable mode."""
124 if git_mode
is not None and git_mode
not in (GIT_EXECUTABLE_MODE, GIT_REGULAR_MODE):
125 message = f
"unsupported tracked file mode {git_mode}: {source}"
126 raise ContextError(message)
127 return EXECUTABLE_MODE
if source.stat().st_mode & stat.S_IXUSR
else REGULAR_MODE
130def _file_entry(root: Path, rel: str, git_mode: str |
None) -> Entry:
131 """Read one live worktree path into its normalized representation."""
133 metadata = source.lstat()
134 if stat.S_ISLNK(metadata.st_mode):
135 if git_mode
not in (
None, GIT_SYMLINK_MODE):
136 message = f
"worktree type disagrees with index for {rel}"
137 raise ContextError(message)
138 return Entry(rel,
"symlink", SYMLINK_MODE, link=str(source.readlink()))
139 if stat.S_ISREG(metadata.st_mode):
140 if git_mode == GIT_SYMLINK_MODE:
141 message = f
"worktree type disagrees with index for {rel}"
142 raise ContextError(message)
143 return Entry(rel,
"file", _regular_mode(source, git_mode), data=source.read_bytes())
144 message = f
"unsupported worktree entry type: {rel}"
145 raise ContextError(message)
148def collect_entries(root: Path, scopes: tuple[str, ...]) -> list[Entry]:
149 """Collect normalized directories and candidate file entries."""
150 modes = _tracked_modes(root, scopes)
151 files = [_file_entry(root, rel, modes.get(rel))
for rel
in _candidate_paths(root, scopes)]
152 directories: set[str] = set()
154 parent = PurePosixPath(entry.name).parent
155 while parent != PurePosixPath(
"."):
156 directories.add(parent.as_posix())
157 parent = parent.parent
158 dirs = [Entry(name,
"directory", DIRECTORY_MODE)
for name
in directories]
159 return sorted([*dirs, *files], key=
lambda entry: os.fsencode(entry.name))
162def _tar_info(entry: Entry) -> tarfile.TarInfo:
163 """Create normalized tar metadata for one entry."""
164 info = tarfile.TarInfo(entry.name)
170 info.mode = entry.mode
171 if entry.kind ==
"directory":
172 info.type = tarfile.DIRTYPE
173 elif entry.kind ==
"symlink":
174 info.type = tarfile.SYMTYPE
175 info.linkname = entry.link
177 info.type = tarfile.REGTYPE
178 info.size = len(entry.data)
182def create_archive(root: Path, output: Path, scopes: tuple[str, ...]) -> str:
183 """Write an atomic deterministic tar and return its SHA-256 digest."""
184 entries = collect_entries(root, scopes)
186 message =
"candidate census is empty"
187 raise ContextError(message)
188 output.parent.mkdir(parents=
True, exist_ok=
True)
189 temporary = output.with_name(f
".{output.name}.tmp-{os.getpid()}")
191 with tarfile.open(temporary,
"w", format=tarfile.GNU_FORMAT)
as archive:
192 for entry
in entries:
193 info = _tar_info(entry)
194 payload = io.BytesIO(entry.data)
if entry.kind ==
"file" else None
195 archive.addfile(info, payload)
196 temporary.replace(output)
198 temporary.unlink(missing_ok=
True)
199 return hashlib.sha256(output.read_bytes()).hexdigest()
202def _safe_name(raw_name: str) -> str:
203 """Return a canonical relative member name or reject the archive."""
204 name = raw_name.rstrip(
"/")
205 path = PurePosixPath(name)
206 if not name
or path.is_absolute()
or ".." in path.parts
or path.as_posix() != name:
207 message = f
"unsafe or non-canonical archive member: {raw_name!r}"
208 raise ContextError(message)
212def _archive_entries(archive_path: Path) -> dict[str, Entry]:
213 """Read normalized entries from a trusted candidate archive."""
214 entries: dict[str, Entry] = {}
215 with tarfile.open(archive_path,
"r:")
as archive:
216 for member
in archive.getmembers():
217 name = _safe_name(member.name)
219 message = f
"duplicate archive member: {name}"
220 raise ContextError(message)
222 entry = Entry(name,
"directory", member.mode & 0o777)
224 entry = Entry(name,
"symlink", SYMLINK_MODE, link=member.linkname)
225 elif member.isfile():
226 source = archive.extractfile(member)
228 message = f
"cannot read archive member: {name}"
229 raise ContextError(message)
230 entry = Entry(name,
"file", member.mode & 0o777, data=source.read())
232 message = f
"unsupported archive member type: {name}"
233 raise ContextError(message)
234 entries[name] = entry
236 message =
"archive contains no entries"
237 raise ContextError(message)
241def _tree_entries(directory: Path) -> dict[str, Entry]:
242 """Read every file, directory, and symlink below an extracted root."""
243 entries: dict[str, Entry] = {}
244 if not directory.is_dir():
246 for base, dirs, files
in os.walk(directory, followlinks=
False):
247 base_path = Path(base)
248 names = sorted([*dirs, *files], key=os.fsencode)
250 path = base_path / name
251 rel = path.relative_to(directory).as_posix()
252 metadata = path.lstat()
253 if stat.S_ISLNK(metadata.st_mode):
254 entries[rel] = Entry(rel,
"symlink", SYMLINK_MODE, link=str(path.readlink()))
257 elif stat.S_ISDIR(metadata.st_mode):
258 entries[rel] = Entry(rel,
"directory", metadata.st_mode & 0o777)
259 elif stat.S_ISREG(metadata.st_mode):
260 entries[rel] = Entry(rel,
"file", metadata.st_mode & 0o777, data=path.read_bytes())
262 message = f
"unsupported staged entry type: {rel}"
263 raise ContextError(message)
267def verify_archive(archive_path: Path, directory: Path) -> list[str]:
268 """Return exact archive-versus-directory mismatch descriptions."""
269 expected = _archive_entries(archive_path)
270 actual = _tree_entries(directory)
272 f
"missing: {name}" for name
in sorted(expected.keys() - actual.keys(), key=os.fsencode)
275 f
"unexpected: {name}" for name
in sorted(actual.keys() - expected.keys(), key=os.fsencode)
277 for name
in sorted(expected.keys() & actual.keys(), key=os.fsencode):
278 wanted = expected[name]
280 if wanted.kind != got.kind:
281 findings.append(f
"type mismatch: {name}")
282 elif wanted.mode != got.mode:
283 findings.append(f
"mode mismatch: {name}")
284 elif wanted.kind ==
"file" and wanted.data != got.data:
285 findings.append(f
"content mismatch: {name}")
286 elif wanted.kind ==
"symlink" and wanted.link != got.link:
287 findings.append(f
"link mismatch: {name}")
291def _git_init(root: Path) ->
None:
292 """Initialize the selftest repository with deterministic identity."""
294 _git(root, [
"init",
"-q"])
295 _git(root, [
"config",
"user.email",
"context@example.invalid"])
296 _git(root, [
"config",
"user.name",
"Context Selftest"])
299def _write_fixture(root: Path) ->
None:
300 """Create tracked, dirty, deleted, ignored, untracked, and link inputs."""
301 (root /
"scripts/tool dir").mkdir(parents=
True)
302 (root /
".devcontainer").mkdir()
303 (root /
".gitignore").write_text(
"__pycache__/\n*.pyc\n", encoding=
"ascii")
304 (root /
".devcontainer/Dockerfile").write_text(
"FROM scratch\n", encoding=
"ascii")
305 tracked = root /
"scripts/tracked.sh"
306 tracked.write_text(
"#!/bin/sh\necho index\n", encoding=
"ascii")
307 tracked.chmod(REGULAR_MODE)
308 deleted = root /
"scripts/deleted.py"
309 deleted.write_text(
"old = True\n", encoding=
"ascii")
310 (root /
"scripts/tracked-link").symlink_to(
"tracked.sh")
311 _git(root, [
"add",
".gitignore",
".devcontainer",
"scripts"])
312 _git(root, [
"commit",
"-qm",
"fixture"])
313 tracked.write_text(
"#!/bin/sh\necho worktree\n", encoding=
"ascii")
314 tracked.chmod(EXECUTABLE_MODE)
316 (root /
"scripts/tool dir/new helper.py").write_text(
317 "value = 1\n", encoding=
"ascii"
319 (root /
"scripts/tool dir/__pycache__").mkdir()
320 (root /
"scripts/tool dir/__pycache__/helper.pyc").write_bytes(
323 (root /
"outside.txt").write_text(
"outside\n", encoding=
"ascii")
326def _extract_for_selftest(archive_path: Path, target: Path) ->
None:
327 """Extract a helper-produced archive after validating every member name."""
328 with tarfile.open(archive_path,
"r:")
as archive:
329 for member
in archive.getmembers():
330 destination = target / _safe_name(member.name)
332 destination.mkdir(parents=
True, exist_ok=
True)
333 destination.chmod(member.mode)
335 destination.parent.mkdir(parents=
True, exist_ok=
True)
336 destination.symlink_to(member.linkname)
337 elif member.isfile():
338 source = archive.extractfile(member)
340 message = f
"cannot read archive member: {member.name}"
341 raise ContextError(message)
342 destination.parent.mkdir(parents=
True, exist_ok=
True)
343 destination.write_bytes(source.read())
344 destination.chmod(member.mode)
346 message = f
"unsupported archive member type: {member.name}"
347 raise ContextError(message)
350def _selftest_fixture(root: Path) -> list[str]:
351 """Run the exact create/verify path and return assertion failures."""
354 archive_one = root.parent /
"context one.tar"
355 archive_two = root.parent /
"context two.tar"
356 digest_one = create_archive(root, archive_one, (
".devcontainer",
"scripts"))
357 digest_two = create_archive(root, archive_two, (
".devcontainer",
"scripts"))
358 names = set(_archive_entries(archive_one))
359 failures: list[str] = []
361 ".devcontainer/Dockerfile",
362 "scripts/tracked.sh",
363 "scripts/tracked-link",
364 "scripts/tool dir/new helper.py",
367 "scripts/deleted.py",
368 "scripts/tool dir/__pycache__/helper.pyc",
371 if not required.issubset(names):
372 failures.append(
"tracked, symlink, or spaced untracked input was omitted")
374 failures.append(
"deleted, ignored, or out-of-scope input entered the archive")
375 if digest_one != digest_two
or archive_one.read_bytes() != archive_two.read_bytes():
376 failures.append(
"identical candidate inputs did not produce identical archive bytes")
377 extracted = root.parent /
"extracted"
379 _extract_for_selftest(archive_one, extracted)
380 if verify_archive(archive_one, extracted):
381 failures.append(
"an exact extraction did not verify")
382 if (extracted /
"scripts/tracked.sh").read_text(encoding=
"ascii") != (
383 "#!/bin/sh\necho worktree\n"
385 failures.append(
"tracked worktree bytes were not archived")
386 if (extracted /
"scripts/tracked.sh").stat().st_mode & 0o777 != (
389 failures.append(
"tracked executable mode was not preserved")
390 if not (extracted /
"scripts/tracked-link").is_symlink():
391 failures.append(
"tracked symlink mode was not preserved")
395def _outer_repo_snapshot(root: Path) -> tuple[bytes, bytes, bytes, bytes, bytes]:
396 """Capture the outer repository state a nested fixture must not change."""
398 _git(root, [
"rev-parse",
"HEAD"]),
399 (root /
".git/index").read_bytes(),
400 (root /
".git/config").read_bytes(),
401 _git(root, [
"status",
"--porcelain=v1",
"-z"]),
402 (root /
"outer.txt").read_bytes(),
406def _hook_environment_selftest(base: Path) -> list[str]:
407 """Prove hook-local Git routing cannot escape into an outer repository."""
409 outer = base /
"outer"
411 (outer /
"outer.txt").write_bytes(b
"outer-state\n")
412 _git(outer, [
"add",
"outer.txt"])
413 _git(outer, [
"commit",
"-qm",
"outer seed"])
414 before = _outer_repo_snapshot(outer)
416 "GIT_DIR": str(outer /
".git"),
417 "GIT_INDEX_FILE": str(outer /
".git/index"),
419 "GIT_WORK_TREE": str(outer),
421 previous = {name: os.environ.get(name)
for name
in hook_env}
422 failures: list[str] = []
424 os.environ.update(hook_env)
425 failures.extend(_selftest_fixture(base /
"inner"))
426 except (ContextError, OSError, tarfile.TarError)
as exc:
427 failures.append(f
"inner fixture failed under outer hook environment: {exc}")
429 for name, value
in previous.items():
431 os.environ.pop(name,
None)
433 os.environ[name] = value
434 if _outer_repo_snapshot(outer) != before:
436 "inner fixture changed the outer repository HEAD/index/config/status/worktree"
438 reported = set(os.fsdecode(_git(outer, [
"rev-parse",
"--local-env-vars"])).splitlines())
439 if not reported.issubset(LOCAL_GIT_ENVIRONMENT):
440 failures.append(
"LOCAL_GIT_ENVIRONMENT omits a name from `git rev-parse --local-env-vars`")
444def run_selftest() -> int:
445 """Prove inclusion, exclusion, reproducibility, and drift detection."""
446 with tempfile.TemporaryDirectory(prefix=
"ra8-context-selftest-")
as temp:
448 failures = _selftest_fixture(base /
"repo")
449 failures.extend(_hook_environment_selftest(base /
"hook-env"))
450 archive = base /
"context one.tar"
451 extracted = base /
"extracted"
452 extra = extracted /
"scripts/ignored.pyc"
453 extra.write_bytes(b
"residue")
454 findings = verify_archive(archive, extracted)
455 if "unexpected: scripts/ignored.pyc" not in findings:
456 failures.append(
"an unexpected ignored-style residue did not fail verification")
458 target = extracted /
"scripts/tracked.sh"
459 target.chmod(REGULAR_MODE)
460 findings = verify_archive(archive, extracted)
461 if "mode mismatch: scripts/tracked.sh" not in findings:
462 failures.append(
"executable mode drift did not fail verification")
464 for failure
in failures:
465 print(f
"FAIL: {failure}", file=sys.stderr)
467 print(
"stage_worktree_context.py selftest: PASS")
471def _parser() -> argparse.ArgumentParser:
472 """Build the command-line parser."""
473 parser = argparse.ArgumentParser(description=__doc__)
474 parser.add_argument(
"--selftest", action=
"store_true")
475 subparsers = parser.add_subparsers(dest=
"command")
476 create = subparsers.add_parser(
"create", help=
"create a deterministic candidate archive")
477 create.add_argument(
"--root", type=Path, required=
True)
478 create.add_argument(
"--output", type=Path, required=
True)
479 create.add_argument(
"paths", nargs=
"+")
480 verify = subparsers.add_parser(
"verify", help=
"compare an archive to an extracted directory")
481 verify.add_argument(
"--archive", type=Path, required=
True)
482 verify.add_argument(
"--directory", type=Path, required=
True)
487 """Dispatch archive creation, exact verification, or the selftest."""
488 args = _parser().parse_args()
490 if args.command
is not None:
491 print(
"--selftest cannot be combined with a command", file=sys.stderr)
493 return run_selftest()
495 if args.command ==
"create":
496 digest = create_archive(args.root.resolve(), args.output.resolve(), tuple(args.paths))
499 if args.command ==
"verify":
500 findings = verify_archive(args.archive, args.directory)
501 for finding
in findings:
502 print(finding, file=sys.stderr)
503 return EXIT_MISMATCH
if findings
else 0
504 except (ContextError, OSError, tarfile.TarError)
as exc:
505 print(f
"stage_worktree_context.py: {exc}", file=sys.stderr)
507 print(
"choose create, verify, or --selftest", file=sys.stderr)
511if __name__ ==
"__main__":
512 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.