diff --git a/docs/research/windows-git-reader-integration.md b/docs/research/windows-git-reader-integration.md new file mode 100644 index 0000000000..a087002aa9 --- /dev/null +++ b/docs/research/windows-git-reader-integration.md @@ -0,0 +1,166 @@ +# Windows ownership in the shared Git blob reader + +The shared `git_blobs` reader now uses the gated Job Object owner and launch +status channel from [PR5463](https://github.com/CrocSwap/integer-mult-bounds/pull/5463) +and [PR5524](https://github.com/CrocSwap/integer-mult-bounds/pull/5524) on Windows. +Previously, a descendant retaining a pipe could survive the Git leader or keep +a response blocked after the direct-child timeout. The reader now stops the +owned job before closing buffered pipes on success, timeout, generator close +and consumer failure. The requested executable's native launch errors remain +exceptions; only a successfully launched command exiting 129 can trigger the +existing compatibility retry. + +This is an author-checked tooling integration. It makes no mathematical, +selected-frontier, general subprocess, or independent-review claim. + +## Task and source boundaries + +- Task: `portable-replay`, verifier engineering, chosen from the production + adoption handoff in [Discussion1166](https://github.com/CrocSwap/integer-mult-bounds/discussions/1166) + and PR5524's **Open next questions**. The retrieved catalogue card had nonce + `0f543fba2362863680106573ad15451a`, scope key `91a9a2afec282c926282`, + and priority revision 7. +- **Changed scope:** integrate the existing native owner at one shared process + factory and exercise its consumers, rather than compare compressed replays. + This addresses the predecessor's explicit adoption gap without opening a + new construction family. +- Started from canonical main `5e0052ca0aa3036a3afa8b0438927377c8d97d53`; + refreshed to `df0137efd35e11b4445734bf612bdd4c7ebc330c` before submission. + Priority revision 7 and the relevant shared reader/predecessors were unchanged. + The initial selected orientation returned + `PASS_RESEARCH_BASELINE_BINDINGS_ONLY` for frontier5413. The refresh selected + frontier5599; no cold mathematical replay of either selection is claimed. +- Direct sources: PR5463 head `991da9010925914ec64a5293286edb10a7910218` + and PR5524 head `b11f5ab7df36fbde44e9f2de3498e3e77f2eb4ee`. + `scripts/research_windows_git.py` combines PR5524's + `vendor/owned_pipe.py` and `spawn_checked.py`; its wrapper uses the same-module + owner instead of accepting an owner module parameter. Frozen packages remain + unchanged. [PR5461](https://github.com/CrocSwap/integer-mult-bounds/pull/5461) + is the predecessor diagnosis, not a new audit result here. +- Milestone: shared-tool integration with bounded native regression evidence. + Success means descendant termination observed **before fixture teardown**, + exact binary/source-pin behavior, preserved launch exceptions and fallback, + and existing consumer checks. A direct-child replacement must fail all six + lifetime cases. No cost-law, supplier-restoration or kappa claim applies. +- Overlap: read the start/handoff discussions and active discussion listing; + targeted live PR searches for `5524` and `Windows Git` found the predecessor + work, but no equivalent shared integration. The offline helper covered only + 3 of 7,611 local tracked records in the initial sparse checkout and had no + refreshed remote cache. The canonical investigation thread could not be + fetched. These checks are partial coverage, not proof of novelty. + +## Integration contract + +`_git_blob_popen` is the launch boundary. POSIX retains its existing `Popen` +session and process-group behavior. Windows lazily imports the companion module, +including when the reader is loaded by file path under isolated Python. Protocol +fixtures patch this boundary; installed-Git and native ownership fixtures retain +the real factory. The scaffold check still observes two metadata calls and one +blob stream; its optional benchmark also counts the Git launch inside the native +gate. No timing improvement is claimed. Standalone copies of `research_package.py` that read blobs on +Windows must include `research_windows_git.py` beside it. Temporary CLI fixtures +have been updated accordingly. + +Two existing fixture assumptions needed Windows fixes: the historical object +store's `alternates` file now uses literal LF instead of translated CRLF, and +the sparse nonregular-manifest test creates its Git symlink-mode entry directly +without requiring filesystem symlink privilege. The tested predicates remain +the same. + +The native helper creates the job and event, starts an isolated Python gate, +assigns that gate to the job, and only then releases it to launch the requested +argv. A separate bounded JSON status file preserves launch diagnostics without +touching Git's binary stdout. Job termination is idempotent and synchronized. +The per-response timer still excludes time spent in the consumer. Startup has a +separate ten-second report deadline, and cleanup retains the predecessor's +ten-second accounting bound. + +The planner treats the companion module as a shared control, so a helper-only +change selects all registered packages, including omitted sparse manifests. +`git_source_rows`, separate `git()` metadata calls and the registered verifier +process runner are outside this change. + +## Reproduction and evidence + +Host: Windows 11 build 26300, CPython 3.12.10, Git for Windows 2.53.0.windows.3. +Use Python 3.12 and installed Git from the repository root. Windows process +creation and a writable temporary directory are required. Warnings were errors. + +```sh +python -W error -B -m unittest discover -s tests -p 'test_research_git_*.py' -v +python -W error -O -B tests/test_research_git_windows.py -v +python -W error -B -m unittest discover -s tests -p 'test_research_export_*.py' -v +python -W error -B tests/test_research_commit_bytes.py -v +python -W error -B tests/test_research_source_history.py -v +python -W error -B tests/test_research_scaffold_batch.py -v +python -W error -B tests/test_research_sparse_planning.py -v +python -W error -B tests/test_research_head_pin_paths.py -v +python -W error -B tests/test_research_onboarding.py -v +python -W error -B tests/test_research_package_diagnostics.py -v +python -W error -B tests/test_research_replay_selection.py -v +python -W error -B tests/test_research_input_paths.py -v +python -W error -B tests/test_research_symlink_inputs.py -v +python -W error -B tests/test_research_descendant_source_roles.py -v +git diff --check +``` + +All listed commands passed. The final results were: + +| Check | Tests run | Skipped on this host | +|---|---:|---:| +| Git reader suites, including five native Windows tests | 67 | 8 | +| Native Windows tests under `-O` | 5 | 0 | +| Export suites | 26 | 0 | +| Commit bytes / source history | 11 / 13 | 0 | +| Scaffold / sparse planning / head-pin paths | 3 / 11 / 3 | 0 / 0 / 2 | +| Onboarding / diagnostics | 27 / 16 | 0 | +| Replay selection / input paths / symlink inputs | 9 / 5 / 14 | 1 / 0 / 5 | +| Descendant source-role compatibility | 10 | 0 | + +The source-role fixture requires its historical objects in the local Git store; +these were materialized before the successful run in this partial clone. The +optional timing benchmark was not run. The scaffold regression verifies the +three-call budget and exact generated pins instead. + +The native regression retains process handles rather than relying on PID lookup. +Its six lifecycle cases are success, header stall, payload stall, terminator +stall, early close and consumer error. A four-second rescue guard bounds broken +implementations; any use of that guard fails the test. The direct-child mutation +is required to produce six failures and no fixture errors. Launch comparisons +check exception type and all five native error fields. The launched-exit control +checks 0, 1, 7 and 129; only 129 retries, returning two exact 80,000-byte blobs. +Unicode argv/environment/cwd and unmodified binary stdout have a separate case. + +Before integration, the unchanged PR5524 native verifier passed its 25 named +case groups on this host. That is predecessor evidence, not a substitute for +the shared-reader checks above. + +## Open next questions and limits + +1. Run the shared consumer and POSIX descendant suites on Linux/macOS. This + session has a native Windows host only; POSIX cases and unsupported filename + cases skip. Windows filesystem-symlink tests also skip without the privilege; + junction tests and the Git-index symlink-mode check do run. +2. Review the adopted adapter and repeat native checks on another Windows/Python + version and under restrictive enclosing jobs. Hard owner death during setup, + abrupt parent termination and job-policy denial are not newly certified here. +3. The helper supports the reader's nonempty string argv, binary streams and + context-manager usage. It does not implement general `Popen` options. Launch + timing does not bound all Windows process-creation or filesystem operations. + The status file is a trusted local control channel, not a security boundary. + Installed older Git was not available; the launched-129 fixture exercises the + retry with actual current Git serving the resulting blobs. + +## Attribution + +Credit Mihir Srivastava for the PR5463 owner and PR5524 launch diagnostics; +ywz65 for the gated Job Object lifecycle (PR2469) and retained native-handle +observer (PR2452); Brian / JoyboyBrian for response deadlines (PR4738) and POSIX +lifetime controls (PR5185); Nick Petalas for the blob/compatibility reader +(PR2752); and Douglas Colkitt for the package runner. Arham Tahir, Woklerx, +yssite, Paul Romeo, Tekalign and the retained source/fixture lineage keep their +credit. The predecessor `NOTICE` and `vendor/NOTICE` remain intact. + +Integration and regressions prepared for josecarlosgonzalezroque-design with +substantial OpenAI Codex assistance, one agent. Apache-2.0. No independent human +or separate-agent review has been performed by this session. diff --git a/scripts/research_package.py b/scripts/research_package.py index 48fe9feb46..4e75869665 100644 --- a/scripts/research_package.py +++ b/scripts/research_package.py @@ -173,7 +173,7 @@ def _closed_stdin_error(error): def _stop_git_blob_process(process): - """Stop this reader's owned POSIX group even after its leader exits.""" + """Stop this reader's owned process tree even after its leader exits.""" try: if os.name == "posix": # Reap an exited leader first: signaling a group containing only @@ -193,7 +193,7 @@ def _git_blob_response_deadline(stop): On POSIX the reader owns a session: ordinary descendants retaining a pipe are stopped with the leader. Escaped sessions and separate git() calls are - outside this contract; non-POSIX retains the direct-child deadline. + outside this contract. Windows uses the gated kill-on-close Job Object. """ expired = threading.Event() @@ -212,13 +212,26 @@ def terminate(): raise subprocess.TimeoutExpired("git cat-file blob response", GIT_BLOB_RESPONSE_TIMEOUT) +def _git_blob_popen(argv, **kwargs): + """Single launch seam for native ownership and protocol-only fixtures.""" + if os.name == "nt": + # Also support callers importing this file directly under Python -I. + sys.path.insert(0, str(Path(__file__).resolve().parent)) + try: + from research_windows_git import LaunchCheckedPopen + finally: + sys.path.pop(0) + return LaunchCheckedPopen(argv, **kwargs) + return subprocess.Popen(argv, **kwargs) + + def _git_blob_batch(root, specs, nul): argv = ["git", "cat-file", "--batch"] + (["-z"] if nul else []) # Usage diagnostics from the unsupported-option probe are expected. Avoid # a second pipe that could block while we consume the framed stdout reply. - with subprocess.Popen(argv, cwd=root, env=git_source_env(), stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - start_new_session=(os.name == "posix")) as process: + with _git_blob_popen(argv, cwd=root, env=git_source_env(), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + start_new_session=(os.name == "posix")) as process: stopped = False def stop(): @@ -261,7 +274,7 @@ def stop(): finally: # Terminate ordinary descendants before closing buffered pipes. This # also covers successful leader exit and an early generator close. - if os.name == "posix": + if os.name in ("posix", "nt"): stop() # Option rejection can close stdin before our first write/flush. try: @@ -897,7 +910,8 @@ def plan_details(root, changed): head_tracked = tracked_input_paths(root) tracked = head_tracked | changed replay_changed = sorted(changed.intersection({ - "scripts/research_package.py", ".github/workflows/research-packages.yml", + "scripts/research_package.py", "scripts/research_windows_git.py", + ".github/workflows/research-packages.yml", ".github/workflows/research-package-batch.yml"})) # Group literal Git paths once. Keep removed paths and exact spellings. changed_packages = {} diff --git a/scripts/research_windows_git.py b/scripts/research_windows_git.py new file mode 100644 index 0000000000..54e33b7556 --- /dev/null +++ b/scripts/research_windows_git.py @@ -0,0 +1,293 @@ +"""Windows Job Object ownership and launch diagnostics for Git blob streams. + +Adapted from Mihir Srivastava's PR5463/PR5524 adapters and ywz65's gated +Job Object implementation, with their Apache-2.0 attribution retained. +Shared-reader integration for josecarlosgonzalezroque-design with substantial +OpenAI Codex assistance. This is a binary, context-managed argv subset of Popen. +""" +from contextlib import contextmanager +import ctypes +from ctypes import wintypes +import json +from pathlib import Path +import tempfile +import subprocess +import sys +import threading +import time +import uuid + +REAL_POPEN = subprocess.Popen + + +@contextmanager +def windows_pipe(argv, gate, **kwargs): + """Yield a gated process and an idempotent, thread-safe whole-job stop. + + Only the job handle owner may release the gate. The requested command's + stdin/stdout are inherited through the gate without Python-level copying. + """ + class BasicLimits(ctypes.Structure): + _fields_ = [("ProcessTime", ctypes.c_int64), ("JobTime", ctypes.c_int64), + ("Flags", wintypes.DWORD), ("MinWorkingSet", ctypes.c_size_t), + ("MaxWorkingSet", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), ("Priority", wintypes.DWORD), + ("Scheduling", wintypes.DWORD)] + + class IOCounts(ctypes.Structure): + _fields_ = [(name, ctypes.c_uint64) for name in + ("ReadOps", "WriteOps", "OtherOps", "ReadBytes", "WriteBytes", "OtherBytes")] + + class ExtendedLimits(ctypes.Structure): + _fields_ = [("Basic", BasicLimits), ("IO", IOCounts), + ("ProcessMemory", ctypes.c_size_t), ("JobMemory", ctypes.c_size_t), + ("PeakProcessMemory", ctypes.c_size_t), ("PeakJobMemory", ctypes.c_size_t)] + + class Accounting(ctypes.Structure): + _fields_ = [(name, ctypes.c_int64) for name in + ("UserTime", "KernelTime", "PeriodUserTime", "PeriodKernelTime")] + [ + (name, wintypes.DWORD) for name in + ("PageFaults", "TotalProcesses", "ActiveProcesses", "TerminatedProcesses")] + + api = ctypes.WinDLL("kernel32", use_last_error=True) + + def native(name, result, *arguments): + fn = getattr(api, name) + fn.restype, fn.argtypes = result, arguments + return fn + + handle, dword, boolean = wintypes.HANDLE, wintypes.DWORD, wintypes.BOOL + create_job = native("CreateJobObjectW", handle, ctypes.c_void_p, wintypes.LPCWSTR) + set_info = native("SetInformationJobObject", boolean, handle, ctypes.c_int, ctypes.c_void_p, dword) + assign = native("AssignProcessToJobObject", boolean, handle, handle) + terminate = native("TerminateJobObject", boolean, handle, wintypes.UINT) + query = native("QueryInformationJobObject", boolean, handle, ctypes.c_int, + ctypes.c_void_p, dword, ctypes.c_void_p) + create_event = native("CreateEventW", handle, ctypes.c_void_p, boolean, boolean, wintypes.LPCWSTR) + set_event = native("SetEvent", boolean, handle) + close = native("CloseHandle", boolean, handle) + + def checked(ok): + if not ok: + raise ctypes.WinError(ctypes.get_last_error()) + return ok + + job = checked(create_job(None, None)) + event, process, assigned = None, None, False + stopped = False + lock = threading.Lock() + + def stop(): + nonlocal stopped + with lock: + if stopped: + return + checked(terminate(job, 1)) + deadline = time.monotonic() + 10 + while True: + state = Accounting() + checked(query(job, 1, ctypes.byref(state), ctypes.sizeof(state), None)) + if not state.ActiveProcesses: + break + if time.monotonic() >= deadline: + raise RuntimeError("owned Git process cleanup timed out") + time.sleep(0.01) + stopped = True + + try: + limits = ExtendedLimits() + limits.Basic.Flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + checked(set_info(job, 9, ctypes.byref(limits), ctypes.sizeof(limits))) + name = "Local\\research-git-stream-" + uuid.uuid4().hex + event = checked(create_event(None, True, False, name)) + if ctypes.get_last_error() == 183: + raise ValueError("Git ownership gate name already exists") + process = REAL_POPEN([sys.executable, "-I", "-S", "-c", gate, name, + json.dumps(argv, ensure_ascii=False)], **kwargs) + checked(assign(job, int(process._handle))) + assigned = True + checked(set_event(event)) + yield process, stop + finally: + try: + if process is not None: + if assigned: + stop() + elif process.poll() is None: + process.kill() + process.wait(timeout=10) + finally: + try: + if process is not None: + # In the actual reader these are already closed. Setup + # failure can reach here before the reader owns any streams. + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None: + stream.close() + finally: + try: + if event: + checked(close(event)) + finally: + checked(close(job)) + + +class OwnedPopen: + """The Popen subset used by the blob reader, with job-aware kill/exit. + + This is a reader adapter, not a general subprocess replacement. + The caller must use the context manager, as the actual reader does. + """ + def __init__(self, argv, gate, **kwargs): + self.context = windows_pipe(argv, gate, **kwargs) + self.process, self.stop = self.context.__enter__() + + def __getattr__(self, name): + return getattr(self.process, name) + + def kill(self): + self.stop() + + def __enter__(self): + return self + + def __exit__(self, *exception): + return self.context.__exit__(*exception) + + +STARTUP_TIMEOUT = 10 +MAX_REPORT_BYTES = 65536 + +# REPORT is inserted as a Python string literal by make_gate. The requested +# command receives its original argv/env/cwd/standard handles, not this channel. +GATE = r''' +import ctypes +from ctypes import wintypes +import json +import os +import subprocess +import sys +api = ctypes.WinDLL('kernel32', use_last_error=True) +api.OpenEventW.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.LPCWSTR] +api.OpenEventW.restype = wintypes.HANDLE +api.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] +api.WaitForSingleObject.restype = wintypes.DWORD +api.CloseHandle.argtypes = [wintypes.HANDLE] +api.CloseHandle.restype = wintypes.BOOL +event = api.OpenEventW(0x00100000, False, sys.argv[1]) +if not event: + raise ctypes.WinError(ctypes.get_last_error()) +try: + if api.WaitForSingleObject(event, 30000) != 0: + raise RuntimeError('Git launch ownership gate was not released') +finally: + api.CloseHandle(event) + +def report(value): + with open(REPORT + '.pending', 'x', encoding='utf-8') as stream: + json.dump(value, stream, ensure_ascii=True) + os.replace(REPORT + '.pending', REPORT) + +# Only this OSError is reported as a requested-executable launch error. Failure +# of the gate/reporting mechanism fails closed via the parent's bounded wait. +try: + child = subprocess.Popen(json.loads(sys.argv[2])) +except OSError as error: + report({'status': 'oserror', 'errno': error.errno, + 'winerror': error.winerror, 'strerror': error.strerror, + 'filename': error.filename, 'filename2': error.filename2}) + raise SystemExit(1) +report({'status': 'started'}) +raise SystemExit(child.wait()) +''' + + +def make_gate(report): + return 'REPORT = ' + repr(str(report)) + '\n' + GATE + + +def read_payload(path): + with path.open('rb') as stream: + return stream.read(MAX_REPORT_BYTES + 1) + + +def interpret_report(payload): + # Truncated, oversized, malformed or unknown reports never become success, + # and never trigger the reader's legacy retry. + if len(payload) > MAX_REPORT_BYTES: + raise ValueError('oversized Git launch report') + value = json.loads(payload) + if value == {'status': 'started'}: + return + fields = {'status', 'errno', 'winerror', 'strerror', 'filename', 'filename2'} + if (not isinstance(value, dict) or set(value) != fields + or value['status'] != 'oserror' + or type(value['errno']) is not int or type(value['winerror']) is not int + or not isinstance(value['strerror'], str) + or any(value[k] is not None and not isinstance(value[k], str) + for k in ('filename', 'filename2'))): + raise ValueError('invalid Git launch report') + # Windows OSError maps the original native code to the appropriate subclass + # and errno, just as direct CreateProcess/Popen did in the gate. + raise OSError(value['errno'], value['strerror'], value['filename'], + value['winerror'], value['filename2']) + + +def read_report(path): + interpret_report(read_payload(path)) + + +def await_launch(process, report, argv, timeout): + deadline = time.monotonic() + timeout + while True: + if report.exists(): + try: + payload = read_payload(report) + except PermissionError: + # Windows can briefly deny opening a newly renamed report. + # Retry only channel I/O, within the original startup bound. + # A requested executable's PermissionError is reconstructed + # below, outside this handler, and must propagate immediately. + pass + else: + interpret_report(payload) + return + elif process.poll() is not None: + # A fast process may publish between the existence check and poll. + if not report.exists(): + raise RuntimeError('Git ownership gate exited without a launch report') + if time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(argv, timeout) + time.sleep(0.01) + + +@contextmanager +def checked_pipe(argv, **kwargs): + """Report launch before returning streams; stop the owned job on any failure. + + The owner retains the PR5463 gated Job Object protocol. Startup has its own + bound; the existing per-response watchdog still excludes consumer pauses. + """ + if not isinstance(argv, list) or not argv or any(type(a) is not str for a in argv): + raise TypeError('Git launch wrapper requires a nonempty list of strings') + with tempfile.TemporaryDirectory(prefix='git launch status ') as temporary: + report = Path(temporary) / 'status.json' + with OwnedPopen(argv, make_gate(report), **kwargs) as process: + await_launch(process, report, argv, STARTUP_TIMEOUT) + yield process + + +class LaunchCheckedPopen: + """Only the Popen subset consumed by the blob reader, used with with.""" + def __init__(self, argv, **kwargs): + self.context = checked_pipe(argv, **kwargs) + self.process = self.context.__enter__() + + def __getattr__(self, name): + return getattr(self.process, name) + + def __enter__(self): + return self + + def __exit__(self, *exception): + return self.context.__exit__(*exception) diff --git a/tests/benchmark_research_scaffold.py b/tests/benchmark_research_scaffold.py index 6b899f548e..cffcc51bfa 100644 --- a/tests/benchmark_research_scaffold.py +++ b/tests/benchmark_research_scaffold.py @@ -51,11 +51,19 @@ def benchmark(pairs): for mode in (('before', 'after') if sample % 2 == 0 else ('after', 'before')): create = namespace['create'] if mode == 'before' else current.create commands, real = [], subprocess.Popen + real_blob = package._git_blob_popen def tracked(argv, *args, **kwargs): commands.append(argv) return real(argv, *args, **kwargs) + def tracked_blob(argv, *args, **kwargs): + # On Windows the isolated owner launches Git through its + # gate, outside the direct-Popen observer in this process. + if sys.platform == 'win32': + commands.append(argv) + return real_blob(argv, *args, **kwargs) start = time.perf_counter() - with patch.object(subprocess, 'Popen', side_effect=tracked): + with (patch.object(subprocess, 'Popen', side_effect=tracked), + patch.object(package, '_git_blob_popen', side_effect=tracked_blob)): target = create(roots[mode], 'benchmark-' + str(sample), 'bit-new-register-model', 'Benchmark fixture', sources) measurements.append({'sample': sample, 'mode': mode, diff --git a/tests/test_research_commit_bytes.py b/tests/test_research_commit_bytes.py index 3b367bfbc3..95910c2fa7 100644 --- a/tests/test_research_commit_bytes.py +++ b/tests/test_research_commit_bytes.py @@ -124,7 +124,7 @@ def test_unicode_and_wildcard_characters_are_literal_input_paths(self): self.assertEqual(self.check()['files'], 3) def test_cli_pass_fail_and_read_only_behavior(self): - for name in ('check_research_commit.py', 'research_package.py'): + for name in ('check_research_commit.py', 'research_package.py', 'research_windows_git.py'): self.write('scripts/'+name, (ROOT/'scripts'/name).read_bytes()) self.commit() script = self.root/'scripts/check_research_commit.py' diff --git a/tests/test_research_descendant_source_roles.py b/tests/test_research_descendant_source_roles.py index 5a357259cf..40d7cef5fe 100644 --- a/tests/test_research_descendant_source_roles.py +++ b/tests/test_research_descendant_source_roles.py @@ -32,7 +32,7 @@ def setUp(self): self.root = Path(temporary.name) self.original = json.loads(git(ROOT, 'show', ORIGINAL + ':' + PACKAGE + '/SOURCES.json')) self.manifest = json.loads((ROOT / PACKAGE / 'VERIFY.json').read_bytes()) - names = set(self.manifest['inputs']) | { + names = set(self.manifest['inputs']) | {'scripts/research_windows_git.py'} | { PACKAGE + '/' + name for name in ('SOURCES.json', 'VERIFY.json', 'TASK.json', 'README.md', 'verify.py', 'RESULT.json')} for name in names: @@ -49,7 +49,10 @@ def setUp(self): objects = Path(git(ROOT, 'rev-parse', '--git-path', 'objects').decode().strip()) if not objects.is_absolute(): objects = ROOT / objects - (self.root / '.git/objects/info/alternates').write_text(str(objects.resolve()) + '\n') + # Git treats a CR from Windows text newline conversion as part of the + # object-directory name. The alternates file requires literal LF. + (self.root / '.git/objects/info/alternates').write_bytes( + (objects.resolve().as_posix() + '\n').encode('utf-8')) git(self.root, 'config', 'user.name', 'Fixture') git(self.root, 'config', 'user.email', 'fixture@example.invalid') git(self.root, 'config', 'core.autocrlf', 'false') diff --git a/tests/test_research_export_drive_paths.py b/tests/test_research_export_drive_paths.py index f1a9f66334..6cba30ba9f 100644 --- a/tests/test_research_export_drive_paths.py +++ b/tests/test_research_export_drive_paths.py @@ -89,7 +89,7 @@ def git_fixture(self, names): repo = Path(tempfile.mkdtemp(prefix='repo-', dir=self.root)) scripts = repo / 'scripts' scripts.mkdir() - for name in ('export_research_package.py', 'research_package.py'): + for name in ('export_research_package.py', 'research_package.py', 'research_windows_git.py'): shutil.copyfile(ROOT / 'scripts' / name, scripts / name) def git(*args, data=None): return subprocess.check_output(['git', *args], cwd=repo, input=data, diff --git a/tests/test_research_export_topology.py b/tests/test_research_export_topology.py index 2c1e49fc1c..17608dd647 100644 --- a/tests/test_research_export_topology.py +++ b/tests/test_research_export_topology.py @@ -24,7 +24,7 @@ def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) - for name in ('export_research_package.py', 'research_package.py'): + for name in ('export_research_package.py', 'research_package.py', 'research_windows_git.py'): path = self.root / 'scripts' / name path.parent.mkdir(exist_ok=True) shutil.copyfile(ROOT / 'scripts' / name, path) diff --git a/tests/test_research_git_compatibility.py b/tests/test_research_git_compatibility.py index 4e6a4c66a4..705633b5dc 100644 --- a/tests/test_research_git_compatibility.py +++ b/tests/test_research_git_compatibility.py @@ -56,7 +56,10 @@ def start(argv, **kwargs): children.append(child) return child - with patch.object(PACKAGE.subprocess, "Popen", side_effect=start): + # Protocol fixtures intentionally bypass native ownership; the native + # suite exercises the platform factory, including its exit-129 path. + with (patch.object(PACKAGE.subprocess, "Popen", side_effect=start), + patch.object(PACKAGE, "_git_blob_popen", side_effect=start)): yield calls, children self.assertTrue(all(child.poll() is not None for child in children)) @@ -128,6 +131,7 @@ def start(argv, **kwargs): return child with (patch.object(PACKAGE.subprocess, "Popen", side_effect=start), + patch.object(PACKAGE, "_git_blob_popen", side_effect=start), closing(PACKAGE.git_blobs(self.root, ["HEAD:empty", "HEAD:binary"])) as rows): self.assertEqual(next(rows), (blob_id(b""), b"")) with self.assertRaisesRegex(ValueError, "missing or non-blob"): diff --git a/tests/test_research_git_deadline.py b/tests/test_research_git_deadline.py index 31fba1d3a1..9432d10591 100644 --- a/tests/test_research_git_deadline.py +++ b/tests/test_research_git_deadline.py @@ -38,6 +38,7 @@ def start(argv, **kwargs): ready = Path(directory) / "response-ready" try: with (patch.object(PACKAGE.subprocess, "Popen", side_effect=start), + patch.object(PACKAGE, "_git_blob_popen", side_effect=start), patch.object(PACKAGE, "GIT_BLOB_RESPONSE_TIMEOUT", timeout, create=True)): yield Path(directory), children, calls finally: diff --git a/tests/test_research_git_installed.py b/tests/test_research_git_installed.py index 5da77cfbe3..d4954e5134 100644 --- a/tests/test_research_git_installed.py +++ b/tests/test_research_git_installed.py @@ -78,7 +78,8 @@ def installed_protocol(self): @contextmanager def observe(self, wait_for_probe_exit=False): """Record launches without replacing an executable, reply or pipe.""" - real_popen = subprocess.Popen + real_popen = PACKAGE._git_blob_popen + real_git = PACKAGE.git calls, children = [], [] def start(argv, **kwargs): @@ -91,8 +92,13 @@ def start(argv, **kwargs): self.assertEqual(child.wait(timeout=15), 129) return child + def git_call(root, *args): + calls.append(["git", *args]) + return real_git(root, *args) + try: - with patch.object(PACKAGE.subprocess, "Popen", side_effect=start): + with (patch.object(PACKAGE, "_git_blob_popen", side_effect=start), + patch.object(PACKAGE, "git", side_effect=git_call)): yield calls finally: try: diff --git a/tests/test_research_git_pipe_errors.py b/tests/test_research_git_pipe_errors.py index 1eab6148a9..9ec163bd8c 100644 --- a/tests/test_research_git_pipe_errors.py +++ b/tests/test_research_git_pipe_errors.py @@ -103,7 +103,8 @@ def kill_group(pid, signum): name=platform, environ=os.environ, killpg=kill_group)), patch.object(PACKAGE.signal, "SIGKILL", getattr(PACKAGE.signal, "SIGKILL", 9), create=True), - patch.object(PACKAGE.subprocess, "Popen", side_effect=start)): + patch.object(PACKAGE.subprocess, "Popen", side_effect=start), + patch.object(PACKAGE, "_git_blob_popen", side_effect=start)): yield calls, pipes finally: self.assertTrue(all(child.poll() is not None for child in children)) diff --git a/tests/test_research_git_windows.py b/tests/test_research_git_windows.py new file mode 100644 index 0000000000..1a76cd8e20 --- /dev/null +++ b/tests/test_research_git_windows.py @@ -0,0 +1,225 @@ +"""Native Windows ownership at the shared Git reader, before fixture teardown. + +Fixtures build on Mihir Srivastava's PR5524 and ywz65's native handle observer. +Integration regressions for josecarlosgonzalezroque-design with substantial +OpenAI Codex assistance. Apache-2.0. +""" +from contextlib import closing +import ctypes +from ctypes import wintypes +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('windows_blob_package', ROOT / 'scripts/research_package.py') +PACKAGE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PACKAGE) + +# Retain a native handle: PID reuse and later teardown cannot manufacture PASS. +PRODUCER = r''' +import os, subprocess, sys, time +from pathlib import Path +root, mode = Path(sys.argv[1]), sys.argv[2] +child = subprocess.Popen([sys.executable, '-I', '-S', '-c', 'import time; time.sleep(60)'], + stdin=sys.stdin, stdout=sys.stdout, stderr=subprocess.DEVNULL) +(root/'child.pending').write_text(str(child.pid)) +(root/'child.pending').replace(root/'child.pid') +while not (root/'observed').exists(): time.sleep(.01) +while sys.stdin.buffer.read(1) not in (bytes([0]), bytes([10]), b''): pass +reply = b'a'*40+b' blob 4\n\x00\xff\r\n\n' +prefix = {'header': b'', 'payload': reply[:-3], 'terminator': reply[:-1]}.get(mode, reply) +sys.stdout.buffer.write(prefix) +sys.stdout.buffer.flush() +os._exit(0) +''' + + +class ProcessHandle: + def __init__(self, pid): + self.api = ctypes.WinDLL('kernel32', use_last_error=True) + for name, result, args in ( + ('OpenProcess', wintypes.HANDLE, [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]), + ('WaitForSingleObject', wintypes.DWORD, [wintypes.HANDLE, wintypes.DWORD]), + ('TerminateProcess', wintypes.BOOL, [wintypes.HANDLE, wintypes.UINT]), + ('CloseHandle', wintypes.BOOL, [wintypes.HANDLE]), + ): + fn = getattr(self.api, name) + fn.restype, fn.argtypes = result, args + self.handle = self.api.OpenProcess(0x00100001, False, pid) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + + def exited(self, timeout=0): + result = self.api.WaitForSingleObject(self.handle, timeout) + if result not in (0, 258): + raise ctypes.WinError(ctypes.get_last_error()) + return result == 0 + + def terminate(self): + if not self.exited(): + if not self.api.TerminateProcess(self.handle, 1): + raise ctypes.WinError(ctypes.get_last_error()) + if not self.exited(5000): + raise RuntimeError('fixture descendant did not terminate') + + def close(self): + self.terminate() + if not self.api.CloseHandle(self.handle): + raise ctypes.WinError(ctypes.get_last_error()) + + +@unittest.skipUnless(os.name == 'nt', 'native Windows Job Objects required') +class WindowsGitReaderTests(unittest.TestCase): + def test_direct_child_mutation_is_rejected(self): + # Replacing the job owner with direct Popen must fail all six boundaries. + # The inner fixture still cleans its children after recording failures. + case = WindowsGitReaderTests('test_descendants_and_consumer_pause_at_the_shared_reader') + result = unittest.TestResult() + with patch.object(PACKAGE, '_git_blob_popen', subprocess.Popen): + case.run(result) + self.assertEqual(len(result.failures), 6) + self.assertEqual(result.errors, []) + + def test_descendants_and_consumer_pause_at_the_shared_reader(self): + for mode in ('success', 'header', 'payload', 'terminator', 'early-close', 'consumer-error'): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + real_factory = PACKAGE._git_blob_popen + handles, gates, guards = [], [], [] + guard_fired = threading.Event() + + def start(argv, **kwargs): + process = real_factory( + [sys.executable, '-I', '-S', '-c', PRODUCER, str(root), mode], **kwargs) + gates.append(process) + deadline = time.monotonic() + 5 + while not (root / 'child.pid').exists(): + if time.monotonic() >= deadline: + process.__exit__(None, None, None) + self.fail('descendant readiness timed out') + time.sleep(.01) + handle = ProcessHandle(int((root / 'child.pid').read_text())) + handles.append(handle) + self.assertFalse(handle.exited()) + + def guard(): + guard_fired.set() + handle.terminate() + timer = threading.Timer(4, guard) + guards.append(timer) + timer.start() + (root / 'observed').touch() + return process + + try: + with (patch.object(PACKAGE, '_git_blob_popen', side_effect=start), + patch.object(PACKAGE, 'GIT_BLOB_RESPONSE_TIMEOUT', .25), + closing(PACKAGE.git_blobs(root, ['HEAD:x'])) as rows): + if mode in ('header', 'payload', 'terminator'): + with self.assertRaises(subprocess.TimeoutExpired): + list(rows) + elif mode == 'success': + self.assertEqual(list(rows), [('a' * 40, b'\0\xff\r\n')]) + else: + self.assertEqual(next(rows), ('a' * 40, b'\0\xff\r\n')) + time.sleep(.4) # Consumer time is outside the response deadline. + self.assertFalse(handles[0].exited()) + if mode == 'consumer-error': + with self.assertRaisesRegex(ValueError, 'validation failed'): + with closing(rows): + raise ValueError('validation failed') + else: + rows.close() + self.assertFalse(guard_fired.is_set(), 'fallback teardown rescued the reader') + self.assertEqual(len(handles), 1) + self.assertTrue(handles[0].exited(1000), 'live descendant before teardown') + self.assertTrue(all(g.poll() is not None for g in gates)) + self.assertTrue(all(g.stdin.closed and g.stdout.closed for g in gates)) + finally: + for timer in guards: + timer.cancel() + timer.join() + for handle in handles: + handle.close() + for gate in gates: + gate.__exit__(None, None, None) + + def test_native_launch_errors_do_not_become_blob_errors_or_retry(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + invalid = root / 'invalid.exe' + invalid.write_bytes(b'not an executable') + for executable in (root / 'missing.exe', invalid, root): + with self.subTest(executable=executable.name): + argv = [str(executable)] + with self.assertRaises(OSError) as direct: + subprocess.Popen(argv) + real_factory = PACKAGE._git_blob_popen + with (patch.object(PACKAGE, '_git_blob_popen', + side_effect=lambda ignored, **kw: real_factory(argv, **kw)) as launch, + self.assertRaises(OSError) as wrapped): + list(PACKAGE.git_blobs(root, ['HEAD:missing'])) + fields = ('errno', 'winerror', 'strerror', 'filename', 'filename2') + self.assertEqual(type(wrapped.exception), type(direct.exception)) + self.assertEqual([getattr(wrapped.exception, f) for f in fields], + [getattr(direct.exception, f) for f in fields]) + self.assertEqual(launch.call_count, 1) + + def test_unicode_argv_environment_cwd_and_binary_output(self): + with tempfile.TemporaryDirectory(prefix='git-\u03ba-') as temporary: + root = Path(temporary) + payload = b'\0\xff\r\n' * 20000 + code = ('import os,sys; from pathlib import Path; ' + 'assert sys.argv[1] == "\u03ba"; assert os.environ["GIT_TEST_VALUE"] == "\u03ba"; ' + 'assert Path.cwd() == Path(sys.argv[2]); ' + 'sys.stdin.buffer.read(1); sys.stdout.buffer.write(b"\\0\\xff\\r\\n"*20000)') + with PACKAGE._git_blob_popen( + [sys.executable, '-I', '-S', '-c', code, '\u03ba', str(root)], cwd=root, + env=dict(os.environ, GIT_TEST_VALUE='\u03ba'), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) as process: + process.stdin.write(b'x') + process.stdin.close() + self.assertEqual(process.stdout.read(), payload) + self.assertEqual(process.wait(timeout=5), 0) + + def test_launched_exit_129_retries_once_with_native_owner(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + subprocess.run(['git', 'init', '-q', str(root)], check=True, capture_output=True) + payload = b'\0\xff\r\n' * 20000 + oid = subprocess.run(['git', 'hash-object', '-w', '--stdin'], cwd=root, + input=payload, capture_output=True, check=True).stdout.decode().strip() + for status in (0, 1, 7, 129): + with self.subTest(status=status): + real_factory = PACKAGE._git_blob_popen + calls = [] + + def start(argv, **kwargs): + calls.append(argv) + command = ([sys.executable, '-I', '-S', '-c', f'raise SystemExit({status})'] + if len(calls) == 1 else argv) + return real_factory(command, **kwargs) + + with patch.object(PACKAGE, '_git_blob_popen', side_effect=start): + if status == 129: + self.assertEqual(list(PACKAGE.git_blobs(root, [oid, oid])), + [(oid, payload), (oid, payload)]) + else: + with self.assertRaisesRegex(ValueError, 'missing or non-blob'): + list(PACKAGE.git_blobs(root, [oid])) + expected = [['git', 'cat-file', '--batch', '-z']] + if status == 129: + expected.append(['git', 'cat-file', '--batch']) + self.assertEqual(calls, expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_research_head_pin_paths.py b/tests/test_research_head_pin_paths.py index 6287d6d017..b040a90274 100644 --- a/tests/test_research_head_pin_paths.py +++ b/tests/test_research_head_pin_paths.py @@ -27,7 +27,7 @@ def fixture(self, name): self.package = 'research/fixture' self.name = name (self.root / 'scripts').mkdir() - for script in ('research_package.py', 'check_research_commit.py'): + for script in ('research_package.py', 'check_research_commit.py', 'research_windows_git.py'): shutil.copyfile(ROOT / 'scripts' / script, self.root / 'scripts' / script) self.card = {'id': 'fixture', 'question': 'Check exact source paths'} self.catalog = 'docs/research/agent-task-catalog.json' diff --git a/tests/test_research_input_paths.py b/tests/test_research_input_paths.py index e8fcf5a183..670a886251 100644 --- a/tests/test_research_input_paths.py +++ b/tests/test_research_input_paths.py @@ -145,6 +145,9 @@ def git(*args): (self.root / "scripts/research_package.py").write_bytes( (ROOT / "scripts/research_package.py").read_bytes() ) + (self.root / "scripts/research_windows_git.py").write_bytes( + (ROOT / "scripts/research_windows_git.py").read_bytes() + ) self.write_manifest("./fixtures/input.txt") git("add", ".") git("-c", "gc.auto=0", "commit", "-qm", "before") diff --git a/tests/test_research_onboarding.py b/tests/test_research_onboarding.py index 3eeec180bf..9df3aa45a2 100644 --- a/tests/test_research_onboarding.py +++ b/tests/test_research_onboarding.py @@ -388,6 +388,7 @@ def test_cli_replay_cannot_accept_assertion_failure_from_environment(self): scripts.mkdir() runner = scripts / "research_package.py" runner.write_bytes((ROOT / "scripts/research_package.py").read_bytes()) + (scripts / "research_windows_git.py").write_bytes((ROOT / "scripts/research_windows_git.py").read_bytes()) verifier = self.target / "verify.py" argv = [sys.executable, "-B", str(runner), "--package", "research/fixture"] for value in ("", "0", "1", "2"): @@ -414,6 +415,7 @@ def test_cli_unicode_coverage_survives_legacy_stdout(self): scripts.mkdir() runner = scripts / "research_package.py" runner.write_bytes((ROOT / "scripts/research_package.py").read_bytes()) + (scripts / "research_windows_git.py").write_bytes((ROOT / "scripts/research_windows_git.py").read_bytes()) verifier = self.target / "verify.py" argv = [sys.executable, "-B", str(runner), "--package", "research/fixture"] for encoding in ("cp1252", "ascii", "utf-8"): @@ -444,6 +446,7 @@ def test_cli_unicode_coverage_does_not_relax_source_pins(self): scripts.mkdir() runner = scripts / "research_package.py" runner.write_bytes((ROOT / "scripts/research_package.py").read_bytes()) + (scripts / "research_windows_git.py").write_bytes((ROOT / "scripts/research_windows_git.py").read_bytes()) (self.root / "input.txt").write_text("changed input\n", encoding="utf-8") (self.target / "verify.py").write_text('print("UNEXPECTED_CHILD")\n', encoding="utf-8") result = subprocess.run([sys.executable, "-B", str(runner), "--package", "research/fixture"], diff --git a/tests/test_research_package_diagnostics.py b/tests/test_research_package_diagnostics.py index 170de56a03..2ce405c71d 100644 --- a/tests/test_research_package_diagnostics.py +++ b/tests/test_research_package_diagnostics.py @@ -47,7 +47,7 @@ def setUp(self): self.base = package.git(self.root, "rev-parse", "HEAD").decode().strip() scripts = self.root / "scripts" scripts.mkdir() - for name in ("research_package.py", "new_research_package.py"): + for name in ("research_package.py", "research_windows_git.py", "new_research_package.py"): shutil.copy(ROOT / "scripts" / name, scripts / name) self.target = scaffold.create(self.root, "fixture", "fixture", "Fixture", ["input.txt"]) self.ready() diff --git a/tests/test_research_replay_selection.py b/tests/test_research_replay_selection.py index 9385a323ff..c1ca036887 100644 --- a/tests/test_research_replay_selection.py +++ b/tests/test_research_replay_selection.py @@ -29,7 +29,7 @@ def setUp(self): for key, value in (("user.name", "Fixture"), ("user.email", "fixture@example.invalid"), ("core.autocrlf", "false"), ("diff.renames", "true")): self.git("config", key, value) - for name in ("scripts/research_package.py", WORKFLOW): + for name in ("scripts/research_package.py", "scripts/research_windows_git.py", WORKFLOW): target = self.root / name target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes((ROOT / name).read_bytes()) @@ -92,7 +92,7 @@ def test_renamed_input_still_selects_its_old_consumer(self): self.assertEqual(self.selected(base), ["research/alpha"]) def test_global_controls_select_all_registered_packages(self): - for name in (WORKFLOW, "scripts/research_package.py"): + for name in (WORKFLOW, "scripts/research_package.py", "scripts/research_windows_git.py"): with self.subTest(name=name): base = self.git("rev-parse", "HEAD") with (self.root / name).open("a", encoding="utf-8") as stream: diff --git a/tests/test_research_scaffold_batch.py b/tests/test_research_scaffold_batch.py index ff25ec92da..786675f202 100644 --- a/tests/test_research_scaffold_batch.py +++ b/tests/test_research_scaffold_batch.py @@ -45,14 +45,22 @@ def create(self, slug='fixture', sources=None): def test_exact_pins_binary_names_deduplication_and_constant_process_count(self): children, commands = [], [] - real = subprocess.Popen + real, real_git = package._git_blob_popen, scaffold.git def tracked(argv, *args, **kwargs): child = real(argv, *args, **kwargs) children.append(child); commands.append(argv) return child - with patch.object(subprocess, 'Popen', side_effect=tracked): + def metadata(root, *args): + commands.append(['git', *args]) + return real_git(root, *args) + with (patch.object(package, '_git_blob_popen', side_effect=tracked), + patch.object(scaffold, 'git', side_effect=metadata)): target = self.create(sources=self.inputs + self.inputs[:3]) + # One stream serves the entire source inventory; platform ownership may + # add a gate but must not launch one Git process per source. self.assertEqual(len(commands), 3, commands) + self.assertEqual([cmd for cmd in commands if cmd[1] == 'cat-file'], + [["git", "cat-file", "--batch", "-z"]]) self.assertTrue(all(child.poll() is not None for child in children)) rows = json.loads((target / 'SOURCES.json').read_bytes())['files'] expected = [] @@ -66,7 +74,7 @@ def tracked(argv, *args, **kwargs): {'TASK.json', 'SOURCES.json', 'VERIFY.json', 'verify.py', 'README.md'}) def test_live_drift_rejects_first_and_last_inputs_without_draft_or_child_leak(self): - real = subprocess.Popen + real = package._git_blob_popen for index, name in enumerate((self.inputs[0], self.inputs[-1])): with self.subTest(name=name): path = self.root / name @@ -74,7 +82,7 @@ def test_live_drift_rejects_first_and_last_inputs_without_draft_or_child_leak(se children = [] def tracked(*args, **kwargs): child = real(*args, **kwargs); children.append(child); return child - with patch.object(subprocess, 'Popen', side_effect=tracked): + with patch.object(package, '_git_blob_popen', side_effect=tracked): with self.assertRaisesRegex(ValueError, 'source has uncommitted changes'): self.create('drift-' + str(index)) self.assertFalse((self.root / 'research' / ('drift-' + str(index))).exists()) diff --git a/tests/test_research_source_history.py b/tests/test_research_source_history.py index aaaa47c607..65e6a15f59 100644 --- a/tests/test_research_source_history.py +++ b/tests/test_research_source_history.py @@ -219,7 +219,7 @@ def test_metadata_shape_and_resource_bounds(self): self.check() def test_real_cli_is_read_only_and_reports_no_pass_on_history_failure(self): - for name in ('check_research_commit.py', 'research_package.py'): + for name in ('check_research_commit.py', 'research_package.py', 'research_windows_git.py'): destination = self.root / 'scripts' / name destination.parent.mkdir(exist_ok=True) destination.write_bytes((ROOT / 'scripts' / name).read_bytes()) diff --git a/tests/test_research_sparse_planning.py b/tests/test_research_sparse_planning.py index 9ef82e601e..ff065229e5 100644 --- a/tests/test_research_sparse_planning.py +++ b/tests/test_research_sparse_planning.py @@ -19,6 +19,7 @@ def setUp(self): self.git('init','-q');self.git('config','user.name','Fixture') self.git('config','user.email','fixture@example.invalid');self.git('config','core.autocrlf','false') self.write('scripts/research_package.py',(ROOT/'scripts/research_package.py').read_text()) + self.write('scripts/research_windows_git.py',(ROOT/'scripts/research_windows_git.py').read_text()) self.write('inputs/value.txt','1') for name in ['alpha','hidden']: self.write('research/'+name+'/VERIFY.json',json.dumps({'inputs':['inputs/value.txt']})) @@ -57,6 +58,7 @@ def test_full_and_sparse_matrices_match_for_input_and_global_changes(self): self.assertEqual(actual.stdout,expected.stdout) self.assertIn('1 unchanged sparse manifests',actual.stderr) self.assertEqual(runner.plan(self.root,['scripts/research_package.py']),['research/alpha','research/hidden']) + self.assertEqual(runner.plan(self.root,['scripts/research_windows_git.py']),['research/alpha','research/hidden']) self.assertFalse((self.root/self.manifest).exists()) def test_explanations_bind_identical_manifest_bytes_in_sparse_checkout(self): @@ -132,8 +134,11 @@ def test_working_edits_and_new_local_manifests_remain_visible(self): def test_nonregular_head_manifest_is_never_a_sparse_json_input(self): (self.root/self.manifest).unlink() - (self.root/self.manifest).symlink_to('README.md') - self.commit();self.sparse() + # This is a Git-mode check. Build the tree entry directly so Windows + # does not need permission to materialize a filesystem symlink. + blob=self.git('hash-object','-w','--stdin',input=b'README.md').decode().strip() + self.git('update-index','--cacheinfo','120000,'+blob+','+self.manifest) + self.git('commit','-qm','symlink manifest');self.sparse() result=self.cli();self.assertNotEqual(result.returncode,0);self.assertEqual(result.stdout,'') diff --git a/tests/test_research_symlink_inputs.py b/tests/test_research_symlink_inputs.py index d0c4cce7ae..6a33c00e62 100644 --- a/tests/test_research_symlink_inputs.py +++ b/tests/test_research_symlink_inputs.py @@ -40,6 +40,7 @@ def setUp(self): }.items(): self.write(name, text) self.write("scripts/research_package.py", (ROOT / "scripts/research_package.py").read_text()) + self.write("scripts/research_windows_git.py", (ROOT / "scripts/research_windows_git.py").read_text()) self.commit() self.target = scaffold.create(self.root, "alpha", "fixture", "Fixture") task_path = self.target / "TASK.json"