diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8a696015f..00088a812 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1639,7 +1639,7 @@ WHERE a mutating run's changes are destined is the second, separate record — t A payload target gets a standalone private Git snapshot (`subagent_worktrees.provision_payload_snapshot`): the live payload is never initialized as Git; the loader-visible inventory is committed as a registered synthetic baseline, and capture trusts nothing under the child-writable snapshot's `.git`. Disposition applies a live, index-free `git apply` under a whole-payload content-hash CAS (drift = typed conflict; identical content = idempotent applied). The post-apply outcome set is complete: a live loader hash equal to the recorded RESULT hash is the success; a hash equal to the recorded BASELINE hash with a non-empty touched set is a provable non-mutation that RESOLVES the apply intent (`apply_no_op`: no success, no disposition, no reconcile queued, retry lane open) independently of whether a result hash was ever recorded; anything else is the ambiguous mismatch, whose intent stays PENDING and whose reconcile marker IS queued because the payload did mutate. A successful apply queues the extension reconcile and the skill's review goes STALE pending fresh `skill_preflight`/`skill_review`. The `apply_no_op` arm carries no content residual: a run whose ONLY change is a mode flip is already refused at CAPTURE time as `unreviewable_metadata_change`, and any patch that did land content would move the whole-payload hash, so a live hash still equal to the baseline means nothing was written. -**A mutating run executes in a PRIVATE EXECUTION SNAPSHOT, never in the shared tree** (metered children keep sharing the tree). At `delegate_start` the host snapshots the target's real state — tracked + staged + eligible untracked, with the sensitive/credential veto DECIDED BEFORE ANYTHING IS HASHED: a blanket `git add -A` would write vetoed secrets, `.env` included, into the shared object database. The baseline commit is pinned by a `refs/ouroboros/delegated/` ref and checked out as a detached worktree (`provision_execution_snapshot`); `scope.root` stays the stable authority target while `execution.workspaceRoot` names the snapshot. The typed snapshot binding is recorded durably on the custody rows BEFORE the POST; an explicit retry reproduces it exactly (a GC-collected snapshot is a typed `execution_snapshot_missing` refusal, never a re-mint). The run still runs `live` from the engine's view, which is why the scoped-HOME/`delegated` marker below applies unchanged. +**A mutating run executes in a PRIVATE EXECUTION SNAPSHOT, never in the shared tree** (metered children keep sharing the tree). At `delegate_start` the host snapshots the target's real state — tracked + staged + eligible untracked, with the sensitive/credential veto DECIDED BEFORE ANYTHING IS HASHED: a blanket `git add -A` would write vetoed secrets, `.env` included, into the shared object database. The baseline commit is pinned by a `refs/ouroboros/delegated/` ref and checked out as a detached worktree (`provision_execution_snapshot`). Git keeps its normal filtered baseline; `artifacts.copy_artifact_file` restores the original regular-file working bytes after checkout, and one Git comparison rejects concurrent source drift before registration. Links, gitlinks and baseline deletions remain Git-owned; `scope.root` stays the stable authority target while `execution.workspaceRoot` names the snapshot. The typed snapshot binding is recorded durably on the custody rows BEFORE the POST; an explicit retry reproduces it exactly (a GC-collected snapshot is a typed `execution_snapshot_missing` refusal, never a re-mint). The run still runs `live` from the engine's view, which is why the scoped-HOME/`delegated` marker below applies unchanged. At terminal, `delegate_wait` captures the run's diff against the baseline durably into the task's artifact store; NOTHING reaches the target automatically — the nanny explicitly applies or rejects through `integrate_delegated_patch`. Only the staging substrate differs between lanes: a GIT workspace target applies under the repo git lock — first PROVING no touched path drifted from `baseline_sha` (a plain `git apply` relocates hunks by offset; the touched-path set is read from `git apply --numstat` in BOTH directions because each direction names only the paths it writes) — then applying and STAGING, never committing; a skill-payload target applies live under the whole-payload CAS. A SKILL-PAYLOAD target captures through the payload adapter over a parent-owned trusted index (`_write_payload_patch_artifacts`: nothing under the child-writable snapshot's `.git` is trusted, the baseline comes from the host-owned snapshot registry, and the final loader inventory is staged from raw bytes) and applies LIVE into the non-Git payload — nothing is staged into any active root, no `.git` or index is created in the payload, and a successful apply invalidates the skill's review and queues the extension reconcile before the verdict artifact. The protected-path gate applies only when the target IS the Ouroboros body; a conflict (proven drift) is owned by the still-running nanny, with snapshot and patch persisting until explicit resolution or discard. Mutation rides an apply-intent protocol: a durable `delegate_run_patch_apply_started` row lands before any tree mutation, so on replay a pending intent without a disposition answers typed `INTEGRATE_DELEGATED_APPLY_AMBIGUOUS`, resolved only by explicit `acknowledge_ambiguous=true`. The provably non-mutating outcomes that instead RESOLVE the intent are a lock error, proven baseline drift, a failed apply, a verified revert, and a live payload hash equal to the baseline after a payload apply. `artifacts.delegated_capture_read_target` narrowly rebinds `artifact_store` READS for the owning task's own `delegated_runs/` prefix, so a child-drive nanny can inspect the patch it must dispose without widened write authority. Its sibling `delegate_shared.orphan_capture_read_target` extends the same READ, still read-only and still confined to one capture directory, to the terminal-owner ORPHAN the disposition rule already authorizes: the actor that MAY apply a foreign capture may also read it, resolved by path shape first (so an ordinary refusal never replays the custody log) and confirmed by `orphan_disposition_status` before any path is returned. A read-only child stays in Claudexor's default envelope — one transport with one derived difference, not a second pipeline. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1a765cfd7..f495db464 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1940,6 +1940,10 @@ both critical. The imperatives: invariant and the tool description; every other guard (owner terminality, top-level principal, proven drift, protected paths, staged-never-committed) is unchanged (`tests/test_delegated_run_isolation_orphans.py`). + Snapshot tests compare raw LF/CRLF inputs under Git checkout filters, require + zero patch before child edits, and retain normal Git apply semantics afterwards. + Copy failures or a source change against the baseline leave no registered + snapshot or pinned ref (`tests/test_snapshot_file_inputs.py`). - Outcome honesty: a delegating parent must not produce a clean no-tool final answer while direct children run undecided — one bounded absorption reminder, then best-effort (`children_unabsorbed`); while that gate is diff --git a/ouroboros/subagent_worktrees.py b/ouroboros/subagent_worktrees.py index 03c7b62c6..09a105451 100644 --- a/ouroboros/subagent_worktrees.py +++ b/ouroboros/subagent_worktrees.py @@ -477,7 +477,9 @@ def provision_execution_snapshot( Baseline construction never touches the target's own index, HEAD or working files: a TEMPORARY index is seeded from HEAD and stages the eligible - tracked/staged/text inputs (``.gitignore`` respected). Binary and large + tracked/staged/text inputs (``.gitignore`` respected). The execution copy + retains original regular-file bytes despite Git checkout filters; a Git + comparison binds the copied content to that same baseline. Binary and large untracked inputs are streamed into the execution root outside Git's ODB, with exact preimages retained in the existing snapshot record. The Git tree is committed as a synthetic baseline pinned by a ref under @@ -583,6 +585,24 @@ def provision_execution_snapshot( try: wt_path.parent.mkdir(parents=True, exist_ok=True) _git(target, "worktree", "add", "--detach", str(wt_path), baseline_sha) + from ouroboros.artifacts import copy_artifact_file + + # Git owns the baseline representation, but the child must see the + # source's actual working bytes, not checkout's CRLF/smudge rewrite. + # Read the existing tree inventory so deletions, links and gitlinks + # keep Git's semantics and excluded paths can never enter the copy. + for item in manifest_raw.split(b"\0"): + metadata, separator, raw_path = item.partition(b"\t") + if separator and metadata.split()[0] in (b"100644", b"100755"): + relative = raw_path.decode("utf-8", errors="surrogateescape") + original = target / relative + if original.is_symlink(): + raise OSError(f"snapshot input changed from a regular file: {relative}") + copy_artifact_file(original, wt_path / relative) + # A concurrent source edit must not appear as the child's work. + # Use the same Git representation as ordinary patch capture, once + # for the whole tree, before the separately tracked file inputs. + _git(wt_path, "diff", "--quiet", "--no-ext-diff", baseline_sha, "--") from ouroboros.workspace_file_outputs import copy_snapshot_file_inputs file_baseline = copy_snapshot_file_inputs(target, wt_path, file_inputs) if file_baseline: diff --git a/tests/test_deep_review_book_sources.py b/tests/test_deep_review_book_sources.py index f94bc341f..77cb67e5f 100644 --- a/tests/test_deep_review_book_sources.py +++ b/tests/test_deep_review_book_sources.py @@ -11,7 +11,7 @@ from ouroboros.reference_books import compose_book, load_reference_book from tests.test_deep_review_slot import _native_row, _ScriptedLLM, _tool_call -def _corpus(root): +def _corpus(root, *, newline="\n"): files = { "BIBLE.md": "# Constitution\n\nThe constitutional source.\n", "docs/CHECKLISTS.md": "# Checklists\n\n## Review\n\nCheck the actual contract.\n", @@ -22,10 +22,11 @@ def _corpus(root): for name in ("flow", "state"): files[f"docs/{book_id}/{name}.md"] = ( f"# {name.title()}\n\nIntroduction to {book_id} {name}.\n\n## Contract\n\nExact {book_id} {name} contract body.\n") + files = {rel: text.replace("\n", newline) for rel, text in files.items()} for rel, text in files.items(): target = root / rel target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(text) + target.write_bytes(text.encode("utf-8")) return files @@ -37,13 +38,14 @@ def _required(root, rel): "range_basis": "unicode_text_universal_newlines"} -def test_packed_chaptered_books_are_complete_once_and_stable_before_atlas(tmp_path, monkeypatch): +@pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["lf", "crlf"]) +def test_packed_chaptered_books_are_complete_once_and_stable_before_atlas(tmp_path, monkeypatch, newline): monkeypatch.setattr(deep, "get_context_mode", lambda: "max") monkeypatch.setattr(deep, "_compute_graph_centrality", lambda *a: {}) prefixes = [] for name in ("first", "second"): repo, data = tmp_path / name, tmp_path / f"{name}-data" - files = _corpus(repo) + files = _corpus(repo, newline=newline) monkeypatch.setattr(deep, "_dulwich_tracked_paths", lambda *a: (list(files), [])) monkeypatch.chdir(tmp_path) pack, stats = deep.build_review_pack(repo, data) diff --git a/tests/test_nano_ui_static.py b/tests/test_nano_ui_static.py index a8d10013f..0d11ba266 100644 --- a/tests/test_nano_ui_static.py +++ b/tests/test_nano_ui_static.py @@ -4,8 +4,8 @@ ROOT = Path(__file__).parents[1] def test_settings_and_chat_expose_nano_context_mode(): - settings = (ROOT / 'web/modules/settings_ui.js').read_text() - chat = (ROOT / 'web/modules/chat.js').read_text() + settings = (ROOT / 'web/modules/settings_ui.js').read_text(encoding="utf-8") + chat = (ROOT / 'web/modules/chat.js').read_text(encoding="utf-8") assert "{ value: 'nano', label: 'Nano' }" in settings assert 'data-mode="nano">Nano' in chat assert "['nano', 'low', 'max'].includes(data.context_mode)" in chat @@ -13,8 +13,8 @@ def test_settings_and_chat_expose_nano_context_mode(): def test_chat_control_ids_match_instance_wiring_and_nano_has_active_style(): - chat = (ROOT / 'web/modules/chat.js').read_text() - styles = (ROOT / 'web/style.css').read_text() + chat = (ROOT / 'web/modules/chat.js').read_text(encoding="utf-8") + styles = (ROOT / 'web/style.css').read_text(encoding="utf-8") # The markup keeps the chat-* ids that byId() resolves for the main # instance (and namespaces for project panels), so handlers cannot drift # to a selector for a different control. diff --git a/tests/test_runtime_mode_elevation.py b/tests/test_runtime_mode_elevation.py index 307698a94..9ea8a18a5 100644 --- a/tests/test_runtime_mode_elevation.py +++ b/tests/test_runtime_mode_elevation.py @@ -1793,10 +1793,12 @@ def test_run_shell_writes_skill_state_examples(shape, filename, tmp_path, monkey # ``bash`` on hosted Windows resolves to WSL, which has no distro in # the GitHub runner image. Exercise the same redirect/copy shapes via # the native command interpreter instead of depending on WSL setup. + # Separate operands let subprocess quote paths without backslash-escaping + # the inner quotes of a prequoted cmd body. cmd = ( - ["cmd.exe", "/d", "/c", f'type "{payload}" > "{target}"'] + ["cmd.exe", "/d", "/c", "type", str(payload), ">", str(target)] if shape == "redirect" - else ["cmd.exe", "/d", "/c", f'copy /Y "{payload}" "{target}" >NUL'] + else ["cmd.exe", "/d", "/c", "copy", "/Y", str(payload), str(target), ">NUL"] ) else: cmd = ( diff --git a/tests/test_skill_review_persist_guard.py b/tests/test_skill_review_persist_guard.py index 8c1e2cf85..5a867960e 100644 --- a/tests/test_skill_review_persist_guard.py +++ b/tests/test_skill_review_persist_guard.py @@ -340,6 +340,16 @@ def test_direct_review_ignores_historical_completed_job_hash(tmp_path, monkeypat assert load_review_state(drive_root, "alpha").content_hash == content_hash +async def _wait_for_reconcile(task, started): + # Preparation includes filesystem work and imports, not a two-second contract. + # Still surface early lifecycle completion instead of waiting for a phase it skipped. + while not started.is_set(): + if task.done(): + result = await task + raise AssertionError(f"lifecycle finished before extension reconcile: {result!r}") + await asyncio.sleep(0.01) + + def test_cancellation_during_extension_reconcile_keeps_lifecycle_lane(tmp_path, monkeypatch): from ouroboros.skill_review import SkillReviewOutcome from ouroboros.skill_review_runner import run_skill_review_lifecycle @@ -375,7 +385,7 @@ def test_cancellation_during_extension_reconcile_keeps_lifecycle_lane(tmp_path, def fake_reconcile(*_args, **_kwargs): reconcile_started.set() - release_reconcile.wait(2) + release_reconcile.wait() return reconcile_receipt("extension_loaded", "ok") monkeypatch.setattr(runner, "_reconcile_extension_payload", fake_reconcile) @@ -384,30 +394,33 @@ def test_cancellation_during_extension_reconcile_keeps_lifecycle_lane(tmp_path, task = asyncio.create_task( run_skill_review_lifecycle(ctx, "alpha", source="test", review_impl=fake_review) ) - assert await asyncio.to_thread(reconcile_started.wait, 2) - task.cancel() - await asyncio.sleep(0.05) - task.cancel() - await asyncio.sleep(0.05) - active = lifecycle_queue.queue_snapshot()["active"] - assert active is not None - assert active["target"] == "alpha" - quick = asyncio.create_task( - lifecycle_queue.run_lifecycle_job( - kind="review", - target="beta", - dedupe_key="review:beta:hash", - runner=lambda: asyncio.sleep(0, result={"quick": True}), - options=lifecycle_queue.LifecycleJobOptions(drive_root=drive_root), + try: + await _wait_for_reconcile(task, reconcile_started) + task.cancel() + await asyncio.sleep(0.05) + task.cancel() + await asyncio.sleep(0.05) + active = lifecycle_queue.queue_snapshot()["active"] + assert active is not None + assert active["target"] == "alpha" + quick = asyncio.create_task( + lifecycle_queue.run_lifecycle_job( + kind="review", + target="beta", + dedupe_key="review:beta:hash", + runner=lambda: asyncio.sleep(0, result={"quick": True}), + options=lifecycle_queue.LifecycleJobOptions(drive_root=drive_root), + ) ) - ) - await asyncio.sleep(0.05) - assert not quick.done() - release_reconcile.set() - result = await asyncio.wait_for(task, timeout=2) - assert result["status"] == "clean" - assert await asyncio.wait_for(quick, timeout=2) == {"quick": True} - assert lifecycle_queue.queue_snapshot()["active"] is None + await asyncio.sleep(0.05) + assert not quick.done() + release_reconcile.set() + result = await asyncio.wait_for(task, timeout=2) + assert result["status"] == "clean" + assert await asyncio.wait_for(quick, timeout=2) == {"quick": True} + assert lifecycle_queue.queue_snapshot()["active"] is None + finally: + release_reconcile.set() asyncio.run(main()) @@ -451,7 +464,7 @@ def test_heartbeat_continues_during_extension_reconcile(tmp_path, monkeypatch): def fake_reconcile(*_args, **_kwargs): reconcile_started.set() - release_reconcile.wait(2) + release_reconcile.wait() return reconcile_receipt("extension_loaded", "ok") monkeypatch.setattr(runner, "_reconcile_extension_payload", fake_reconcile) @@ -460,22 +473,25 @@ def test_heartbeat_continues_during_extension_reconcile(tmp_path, monkeypatch): task = asyncio.create_task( run_skill_review_lifecycle(ctx, "alpha", source="test", review_impl=fake_review) ) - assert await asyncio.to_thread(reconcile_started.wait, 2) - job_path = review_job_state_path(drive_root, "alpha") - before = json.loads(job_path.read_text(encoding="utf-8"))["last_heartbeat_at"] - # A bounded wait, not 20 x 10 ms: on windows-latest the heartbeat's atomic replace can - # lose a few rounds to this very poll holding the file open (sharing violation, logged - # and retried by the beat), and the clock ticks at ~15.6 ms — 200 ms saw no change. - for _ in range(60): - await asyncio.sleep(0.05) - after = json.loads(job_path.read_text(encoding="utf-8"))["last_heartbeat_at"] - if after != before: - break - assert after != before, "no heartbeat within 3 s while the reconcile blocks" - release_reconcile.set() - result = await asyncio.wait_for(task, timeout=2) - assert result["status"] == "clean" - final = json.loads(job_path.read_text(encoding="utf-8")) - assert final["status"] == "completed" + try: + await _wait_for_reconcile(task, reconcile_started) + job_path = review_job_state_path(drive_root, "alpha") + before = json.loads(job_path.read_text(encoding="utf-8"))["last_heartbeat_at"] + # A bounded wait, not 20 x 10 ms: on windows-latest the heartbeat's atomic replace can + # lose a few rounds to this very poll holding the file open (sharing violation, logged + # and retried by the beat), and the clock ticks at ~15.6 ms — 200 ms saw no change. + for _ in range(60): + await asyncio.sleep(0.05) + after = json.loads(job_path.read_text(encoding="utf-8"))["last_heartbeat_at"] + if after != before: + break + assert after != before, "no heartbeat within 3 s while the reconcile blocks" + release_reconcile.set() + result = await asyncio.wait_for(task, timeout=2) + assert result["status"] == "clean" + final = json.loads(job_path.read_text(encoding="utf-8")) + assert final["status"] == "completed" + finally: + release_reconcile.set() asyncio.run(main()) diff --git a/tests/test_snapshot_file_inputs.py b/tests/test_snapshot_file_inputs.py index eca1a2b4a..4aa6cb4d8 100644 --- a/tests/test_snapshot_file_inputs.py +++ b/tests/test_snapshot_file_inputs.py @@ -1,4 +1,4 @@ -"""Copied binary/large inputs retain exact preimages without entering Git's ODB.""" +"""Snapshot working bytes stay exact; binary/large preimages stay outside Git's ODB.""" from hashlib import sha256 from pathlib import Path import subprocess @@ -172,3 +172,112 @@ def test_baseline_file_copy_failure_cleans_checkout_ref_and_record(tmp_path, mon assert find_execution_snapshot("input-snapshot", tmp_path / "data") is None assert not git(target, "for-each-ref", "refs/ouroboros/delegated/").stdout assert not list((tmp_path / "snapshots").glob("dlg_*")) + + +@pytest.mark.parametrize("newline", [b"\n", b"\r\n"], ids=["lf", "crlf"]) +@pytest.mark.parametrize("policy", ["autocrlf", "attributes"]) +def test_git_checkout_preserves_working_bytes_and_normal_patch_semantics(tmp_path, newline, policy): + target = target_tree(tmp_path) + git(target, "config", "core.autocrlf", "true" if policy == "autocrlf" else "false") + if policy == "attributes": + (target / ".gitattributes").write_bytes(b"*.txt text eol=crlf\n") + original = newline.join([b"original", b"working bytes", b""]) + for name in ("code.txt", "staged.txt", "untracked.txt"): + (target / name).write_bytes(original) + git(target, "add", "staged.txt") + (target / "deleted.txt").write_bytes(b"removed before snapshot\n") + git(target, "add", "deleted.txt") + (target / "deleted.txt").unlink() + before_status = git(target, "status", "--porcelain=v1", "-z").stdout + before_index = git(target, "ls-files", "--stage", "-z").stdout + before_head = git(target, "rev-parse", "HEAD").stdout + + handle = snapshot(tmp_path, target) + execution = Path(handle.path) + for name in ("code.txt", "staged.txt", "untracked.txt"): + assert (execution / name).read_bytes() == original + assert not (execution / "deleted.txt").exists() + assert git(target, "status", "--porcelain=v1", "-z").stdout == before_status + assert git(target, "ls-files", "--stage", "-z").stdout == before_index + assert git(target, "rev-parse", "HEAD").stdout == before_head + assert all((target / name).read_bytes() == original + for name in ("code.txt", "staged.txt", "untracked.txt")) + manifest, rows = capture(tmp_path, handle) + assert manifest["status"] == "ready_no_changes" and manifest["patch_size"] == 0 and rows == [] + + changed = original.replace(b"original", b"child edit") + (execution / "code.txt").write_bytes(changed) + manifest, rows = capture(tmp_path, handle) + assert manifest["status"] == "ready_with_changes" and rows == [] + assert manifest["tracked_changed"] == ["code.txt"] + patch = tmp_path / "capture" / "workspace.patch" + git(target, "apply", "--binary", "--check", str(patch)) + git(target, "apply", "--binary", str(patch)) + # Explicit apply retains the target's normal Git checkout representation. + assert (target / "code.txt").read_bytes() == b"child edit\r\nworking bytes\r\n" + assert (target / "staged.txt").read_bytes() == original + assert (target / "untracked.txt").read_bytes() == original + assert git(target, "ls-files", "--stage", "-z").stdout == before_index + assert git(target, "rev-parse", "HEAD").stdout == before_head + + +@pytest.mark.parametrize("failure", ["copy_error", "source_changed"]) +def test_regular_input_copy_failure_cleans_snapshot_without_rewriting_target(tmp_path, monkeypatch, failure): + from ouroboros import artifacts + + target = target_tree(tmp_path) + before_index = git(target, "ls-files", "--stage", "-z").stdout + before_head = git(target, "rev-parse", "HEAD").stdout + before_bytes = (target / "code.txt").read_bytes() + original = artifacts.copy_artifact_file + newer = b"concurrent owner edit\n" + + def copy(source, destination, **kwargs): + if Path(source) == target / "code.txt": + if failure == "copy_error": + raise OSError("source changed during copy") + Path(source).write_bytes(newer) + return original(source, destination, **kwargs) + + monkeypatch.setattr(artifacts, "copy_artifact_file", copy) + expected = OSError if failure == "copy_error" else subprocess.CalledProcessError + with pytest.raises(expected): + snapshot(tmp_path, target) + assert (target / "code.txt").read_bytes() == (newer if failure == "source_changed" else before_bytes) + assert git(target, "ls-files", "--stage", "-z").stdout == before_index + assert git(target, "rev-parse", "HEAD").stdout == before_head + assert find_execution_snapshot("input-snapshot", tmp_path / "data") is None + assert not git(target, "for-each-ref", "refs/ouroboros/delegated/").stdout + assert not list((tmp_path / "snapshots").glob("dlg_*")) + + +def test_snapshot_keeps_git_link_entries_out_of_regular_file_copy(tmp_path, monkeypatch): + from ouroboros import artifacts + + target = target_tree(tmp_path) + # Git's portable symlink checkout is a regular file containing the target; + # the baseline mode, not its host file type, still owns that representation. + git(target, "config", "core.symlinks", "false") + (target / "link").write_bytes(b"code.txt") + oid = git(target, "hash-object", "-w", "link").stdout.decode().strip() + git(target, "update-index", "--add", "--cacheinfo", f"120000,{oid},link") + commit = git(target, "rev-parse", "HEAD").stdout.decode().strip() + git(target, "update-index", "--add", "--cacheinfo", f"160000,{commit},nested") + git(target, "-c", "user.name=Fixture", "-c", "user.email=f@invalid", "commit", "-m", "link entries") + (target / "nested").mkdir() # An uninitialized gitlink stays Git-owned. + before_index = git(target, "ls-files", "--stage", "-z").stdout + original = artifacts.copy_artifact_file + + def copy(source, destination, **kwargs): + assert Path(source).name not in {"link", "nested"} + return original(source, destination, **kwargs) + + monkeypatch.setattr(artifacts, "copy_artifact_file", copy) + handle = snapshot(tmp_path, target) + execution = Path(handle.path) + assert (execution / "link").read_bytes() == b"code.txt" + assert git(execution, "ls-tree", "HEAD", "link").stdout.startswith(b"120000 blob ") + assert git(execution, "ls-tree", "HEAD", "nested").stdout.startswith(b"160000 commit ") + assert git(target, "ls-files", "--stage", "-z").stdout == before_index + manifest, rows = capture(tmp_path, handle) + assert manifest["status"] == "ready_no_changes" and rows == []