mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Preserve snapshot working bytes and repair Windows CI fixtures
Keep the normal Git baseline while copying original regular-file bytes into delegated snapshots. Reject a source change before registration and preserve existing links, patch semantics and cleanup. Correct Windows cmd quoting and UTF-8/newline fixtures, and synchronize lifecycle tests on the actual phase.
This commit is contained in:
parent
627ee94471
commit
88cc7f8c71
8 changed files with 208 additions and 55 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue