From 8b3f1df78e3f6e17248f12bbb2d650920808e02c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:34:30 +0000 Subject: [PATCH 1/8] Initial plan From a4e48049c8ba87ff0c9196466a1cba394ad54e8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:40:10 +0000 Subject: [PATCH 2/8] Fix dream parent lineage fallbacks Co-authored-by: xingdi-eric-yuan <4028684+xingdi-eric-yuan@users.noreply.github.com> --- skills/shadow-frog-dream/dream-reconcile.py | 75 ++++++++++- .../shadow-frog-meditate/meditate-repair.py | 70 ++++++++--- .../shadow_frog_dream/test_dream_reconcile.py | 117 ++++++++++++++++++ .../test_meditate_repair.py | 113 +++++++++++++++++ 4 files changed, 352 insertions(+), 23 deletions(-) diff --git a/skills/shadow-frog-dream/dream-reconcile.py b/skills/shadow-frog-dream/dream-reconcile.py index 524fe7f..ded8f1b 100755 --- a/skills/shadow-frog-dream/dream-reconcile.py +++ b/skills/shadow-frog-dream/dream-reconcile.py @@ -824,6 +824,67 @@ def _resolve_tip_commit(repo_root, branch): return 'unknown' +def _manifest_lineage_values(manifest, key): + """Return non-empty string values for one manifest lineage key.""" + value = manifest.get(key) or '' + values = value if isinstance(value, list) else [value] + return [item.strip() for item in values + if isinstance(item, str) and item.strip()] + + +def _resolve_remote_parent_branch(repo_root, parent_ref): + """Resolve a dream ID or branch reference to one remote dream branch.""" + dream_id = parent_ref.rsplit('/', 1)[-1] + raw = git( + 'for-each-ref', + '--format=%(refname:strip=3)', + f'refs/remotes/origin/dream/*/{dream_id}', + cwd=repo_root, + check=False, + ) + matches = [line.strip() for line in raw.splitlines() if line.strip()] + if parent_ref.startswith('dream/') and parent_ref in matches: + return parent_ref + return matches[0] if len(matches) == 1 else '' + + +def _report_parent_branch(report): + """Read parent_branch from report frontmatter.""" + if not report: + return '' + frontmatter = re.match(r'^\ufeff?\s*---\r?\n(.*?)\r?\n---', report, re.S) + if not frontmatter: + return '' + match = re.search( + r'^parent_branch:\s*["\']?([^"\'\r\n]+)', + frontmatter.group(1), + re.M, + ) + parent = match.group(1).strip() if match else '' + return '' if parent.lower() in ('null', '~') else parent + + +def _resolve_parent_branch(repo_root, branch, dream_id, manifest): + """Resolve manifest/report lineage to the canonical parent branch.""" + for key in ('parent_branch', 'base_branch'): + values = _manifest_lineage_values(manifest, key) + if values: + return values[0] + + for key in ('parent_dream_id', 'builds_on'): + for parent_ref in _manifest_lineage_values(manifest, key): + parent = _resolve_remote_parent_branch(repo_root, parent_ref) + if parent: + return parent + + report = git_show( + f'origin/{branch}', + f'.shadow/_dreams/{dream_id}/report.md', + cwd=repo_root, + ) + return _report_parent_branch(report) or 'main' + + def update_index(repo_root, manifests, dry_run=False): """Add entries to _dreams/_index.md for reconciled branches.""" index_path = os.path.join(repo_root, '.shadow', '_dreams', '_index.md') @@ -845,12 +906,18 @@ def update_index(repo_root, manifests, dry_run=False): with open(index_path, 'a', encoding="utf-8") as f: for branch, dream_id, manifest in manifests: tip = _resolve_tip_commit(repo_root, branch) - cat = re.sub(r'\s*\(.*\)\s*$', '', manifest.get('category', 'unknown').lower().strip()) - verdict = manifest.get('verdict', 'unknown').lower().strip() - parent = manifest.get('parent_branch', 'main').strip() + cat = re.sub( + r'\s*\(.*\)\s*$', + '', + (manifest.get('category') or 'unknown').lower().strip(), + ) + verdict = (manifest.get('verdict') or 'unknown').lower().strip() + parent = _resolve_parent_branch( + repo_root, branch, dream_id, manifest + ) # Get title from manifest or report heading - title = manifest.get('title', '') + title = manifest.get('title') or '' if not title: report = git_show(f'origin/{branch}', f'.shadow/_dreams/{dream_id}/report.md', cwd=repo_root) diff --git a/skills/shadow-frog-meditate/meditate-repair.py b/skills/shadow-frog-meditate/meditate-repair.py index 7b632f3..9e64d50 100755 --- a/skills/shadow-frog-meditate/meditate-repair.py +++ b/skills/shadow-frog-meditate/meditate-repair.py @@ -23,10 +23,10 @@ signals so 'not useful' doesn't match 'useful'. 4. Title repair: replace generic titles (raw slug, "Dream Report: ", "Dream t##: ") with the first H1 or first ## Summary line. -5. Parent linkage from manifest: for rows where parent is 'main', check - manifest.json and report.md frontmatter for a different parent_branch. - Validate the parent exists in the index (match by slug if timestamps - differ). +5. Parent linkage from metadata: for rows where parent is 'main', check + manifest parent_branch, base_branch, parent_dream_id/builds_on, then + report.md frontmatter parent_branch. Validate the parent exists in the + index (match by slug if timestamps differ). 6. Parent linkage from slug heuristics: for rows still parented to 'main' with no manifest info, infer from compounding suffixes (-extend, -fix, -deeper, -improve, -integration, -cleanup, -metrics, -remaining). @@ -178,6 +178,28 @@ def resolve_parent_in_index( return None +def manifest_lineage_values(manifest: dict, key: str) -> list[str]: + """Return non-empty string values for one manifest lineage key.""" + value = manifest.get(key) or '' + values = value if isinstance(value, list) else [value] + return [item.strip() for item in values + if isinstance(item, str) and item.strip()] + + +def report_parent_branch(content: str) -> str: + """Read parent_branch from report frontmatter.""" + frontmatter = re.match(r'^\ufeff?\s*---\r?\n(.*?)\r?\n---', content, re.S) + if not frontmatter: + return '' + match = re.search( + r'^parent_branch:\s*["\']?([^"\'\r\n]+)', + frontmatter.group(1), + re.M, + ) + parent = match.group(1).strip() if match else '' + return '' if parent.lower() in ('null', '~') else parent + + def repair_parent( parts: list[str], dreams_dir: str, all_dream_ids: list[str], branch_by_dream_id: dict[str, str], @@ -201,33 +223,43 @@ def repair_parent( # Step 10: manifest.json lookup manifest_path = os.path.join(dream_dir, 'manifest.json') - parent_branch_raw: str | None = None if os.path.exists(manifest_path): try: with open(manifest_path, encoding="utf-8") as mf: mdata = json.load(mf) - pb = mdata.get('parent_branch', '').strip() - if pb and pb != 'main': - parent_branch_raw = pb + if isinstance(mdata, dict): + for key in ( + 'parent_branch', 'base_branch', + 'parent_dream_id', 'builds_on', + ): + for parent_ref in manifest_lineage_values(mdata, key): + if parent_ref == 'main': + return False + candidate_id = parent_ref.rsplit('/', 1)[-1] + resolved_did = resolve_parent_in_index( + candidate_id, all_dream_ids + ) + if resolved_did is not None: + break + if resolved_did is not None: + break except (OSError, json.JSONDecodeError): pass # Fallback: report.md frontmatter - if parent_branch_raw is None: + if resolved_did is None: report_path = os.path.join(dream_dir, 'report.md') if os.path.exists(report_path): with open(report_path, encoding="utf-8") as rf: rcontent = rf.read() - fm_match = re.search(r'parent_branch:\s*["\']?([^"\'\n]+)', rcontent) - if fm_match: - pb = fm_match.group(1).strip() - if pb and pb != 'main': - parent_branch_raw = pb - - if parent_branch_raw: - # Extract dream_id from branch path (last /-separated segment) - candidate_id = parent_branch_raw.split('/')[-1] - resolved_did = resolve_parent_in_index(candidate_id, all_dream_ids) + parent_ref = report_parent_branch(rcontent) + if parent_ref == 'main': + return False + if parent_ref: + candidate_id = parent_ref.rsplit('/', 1)[-1] + resolved_did = resolve_parent_in_index( + candidate_id, all_dream_ids + ) # Step 11: slug heuristic (only if step 10 didn't resolve anything) if resolved_did is None: diff --git a/tests/skills/shadow_frog_dream/test_dream_reconcile.py b/tests/skills/shadow_frog_dream/test_dream_reconcile.py index f198084..4f3a2d6 100644 --- a/tests/skills/shadow_frog_dream/test_dream_reconcile.py +++ b/tests/skills/shadow_frog_dream/test_dream_reconcile.py @@ -1898,6 +1898,123 @@ def test_update_index_normalizes_category_with_parens( assert "(notes" not in row +@pytest.mark.slow +def test_update_index_uses_base_branch_when_parent_branch_is_null( + dream_reconcile, tmp_git_repo +): + env = _seed_repo(tmp_git_repo) + _add_bare_remote(tmp_git_repo, env) + parent_id = "20260420-030800Z-parent" + child_id = "20260420-030900Z-child" + parent_branch = make_dream_branch( + tmp_git_repo, env, "proj", parent_id, _default_manifest(parent_id) + ) + child_manifest = _default_manifest(child_id) + child_manifest["parent_branch"] = None + child_manifest["base_branch"] = parent_branch + child_branch = make_dream_branch( + tmp_git_repo, env, "proj", child_id, child_manifest + ) + + dream_reconcile.update_index( + str(tmp_git_repo), [(child_branch, child_id, child_manifest)] + ) + + body = (tmp_git_repo / ".shadow" / "_dreams" / "_index.md").read_text( + encoding="utf-8" + ) + row = [line for line in body.splitlines() if child_id in line][0] + assert f"| {child_branch} | {parent_branch} |" in row + + +@pytest.mark.slow +@pytest.mark.parametrize( + ("lineage_key", "as_list"), + [("parent_dream_id", False), ("builds_on", True)], +) +def test_update_index_resolves_parent_id_from_remote_branch( + dream_reconcile, tmp_git_repo, lineage_key, as_list +): + env = _seed_repo(tmp_git_repo) + _add_bare_remote(tmp_git_repo, env) + parent_id = "20260420-031000Z-parent" + child_id = "20260420-031100Z-child" + parent_branch = make_dream_branch( + tmp_git_repo, env, "proj", parent_id, _default_manifest(parent_id) + ) + child_manifest = _default_manifest(child_id) + del child_manifest["parent_branch"] + child_manifest[lineage_key] = [parent_id] if as_list else parent_id + child_branch = make_dream_branch( + tmp_git_repo, env, "proj", child_id, child_manifest + ) + + dream_reconcile.update_index( + str(tmp_git_repo), [(child_branch, child_id, child_manifest)] + ) + + body = (tmp_git_repo / ".shadow" / "_dreams" / "_index.md").read_text( + encoding="utf-8" + ) + row = [line for line in body.splitlines() if child_id in line][0] + assert f"| {child_branch} | {parent_branch} |" in row + + +@pytest.mark.slow +def test_update_index_falls_back_to_report_parent( + dream_reconcile, tmp_git_repo +): + env = _seed_repo(tmp_git_repo) + _add_bare_remote(tmp_git_repo, env) + parent_id = "20260420-031200Z-parent" + child_id = "20260420-031300Z-child" + parent_branch = make_dream_branch( + tmp_git_repo, env, "proj", parent_id, _default_manifest(parent_id) + ) + child_manifest = _default_manifest(child_id) + del child_manifest["parent_branch"] + report = _default_report(child_id).replace( + "---\n\n", f'parent_branch: "{parent_branch}"\n---\n\n' + ) + child_branch = make_dream_branch( + tmp_git_repo, env, "proj", child_id, child_manifest, report=report + ) + + dream_reconcile.update_index( + str(tmp_git_repo), [(child_branch, child_id, child_manifest)] + ) + + body = (tmp_git_repo / ".shadow" / "_dreams" / "_index.md").read_text( + encoding="utf-8" + ) + row = [line for line in body.splitlines() if child_id in line][0] + assert f"| {child_branch} | {parent_branch} |" in row + + +@pytest.mark.slow +def test_update_index_without_lineage_records_main( + dream_reconcile, tmp_git_repo +): + env = _seed_repo(tmp_git_repo) + _add_bare_remote(tmp_git_repo, env) + dream_id = "20260420-031400Z-root" + manifest = _default_manifest(dream_id) + del manifest["parent_branch"] + branch = make_dream_branch( + tmp_git_repo, env, "proj", dream_id, manifest, report=None + ) + + dream_reconcile.update_index( + str(tmp_git_repo), [(branch, dream_id, manifest)] + ) + + body = (tmp_git_repo / ".shadow" / "_dreams" / "_index.md").read_text( + encoding="utf-8" + ) + row = [line for line in body.splitlines() if dream_id in line][0] + assert f"| {branch} | main |" in row + + # =========================================================================== # update_state # =========================================================================== diff --git a/tests/skills/shadow_frog_meditate/test_meditate_repair.py b/tests/skills/shadow_frog_meditate/test_meditate_repair.py index a861181..41a66c8 100644 --- a/tests/skills/shadow_frog_meditate/test_meditate_repair.py +++ b/tests/skills/shadow_frog_meditate/test_meditate_repair.py @@ -265,6 +265,119 @@ def test_manifest_provides_parent(self, meditate_repair, tmp_path): # Parent column is a BRANCH NAME (the resolved row's branch), not a dream_id. assert parts[6] == f"dream/proj/{parent_did}" + def test_null_parent_branch_falls_back_to_base_branch( + self, meditate_repair, tmp_path + ): + dreams = tmp_path / "_dreams" + parent_did = "20250101-120000Z-base" + child_did = "20250102-120000Z-child" + make_dream(dreams, parent_did) + make_dream( + dreams, + child_did, + manifest={ + "parent_branch": None, + "base_branch": f"dream/proj/{parent_did}", + }, + ) + + all_ids = [parent_did, child_did] + bmap = {d: f"dream/proj/{d}" for d in all_ids} + parts = ["", child_did, "investigation", "useful", "Child", + f"dream/proj/{child_did}", "main", "abc"] + + changed = meditate_repair.repair_parent( + parts, str(dreams), all_ids, bmap + ) + + assert changed + assert parts[6] == f"dream/proj/{parent_did}" + + @pytest.mark.parametrize( + ("lineage_key", "as_list"), + [("parent_dream_id", False), ("builds_on", True)], + ) + def test_manifest_parent_id_fields_resolve_through_index( + self, meditate_repair, tmp_path, lineage_key, as_list + ): + dreams = tmp_path / "_dreams" + parent_did = "20250101-120000Z-base" + child_did = "20250102-120000Z-child" + make_dream(dreams, parent_did) + parent_ref = [parent_did] if as_list else parent_did + make_dream( + dreams, child_did, manifest={lineage_key: parent_ref} + ) + + all_ids = [parent_did, child_did] + bmap = {d: f"dream/proj/{d}" for d in all_ids} + parts = ["", child_did, "investigation", "useful", "Child", + f"dream/proj/{child_did}", "main", "abc"] + + changed = meditate_repair.repair_parent( + parts, str(dreams), all_ids, bmap + ) + + assert changed + assert parts[6] == f"dream/proj/{parent_did}" + + def test_report_parent_branch_is_last_metadata_fallback( + self, meditate_repair, tmp_path + ): + dreams = tmp_path / "_dreams" + parent_did = "20250101-120000Z-base" + child_did = "20250102-120000Z-child" + make_dream(dreams, parent_did) + make_dream( + dreams, + child_did, + manifest={"parent_branch": None}, + report=( + "---\n" + f'dream_id: "{child_did}"\n' + f'parent_branch: "dream/proj/{parent_did}"\n' + "---\n# Child\n" + ), + ) + + all_ids = [parent_did, child_did] + bmap = {d: f"dream/proj/{d}" for d in all_ids} + parts = ["", child_did, "investigation", "useful", "Child", + f"dream/proj/{child_did}", "main", "abc"] + + changed = meditate_repair.repair_parent( + parts, str(dreams), all_ids, bmap + ) + + assert changed + assert parts[6] == f"dream/proj/{parent_did}" + + def test_unconfirmed_manifest_parent_keeps_main( + self, meditate_repair, tmp_path + ): + dreams = tmp_path / "_dreams" + child_did = "20250102-120000Z-child" + make_dream( + dreams, + child_did, + manifest={ + "parent_branch": None, + "base_branch": "dream/proj/20250101-120000Z-missing", + }, + ) + parts = ["", child_did, "investigation", "useful", "Child", + f"dream/proj/{child_did}", "main", "abc"] + + changed = meditate_repair.repair_parent( + parts, + str(dreams), + [child_did], + {child_did: f"dream/proj/{child_did}"}, + ) + + assert not changed + assert parts[6] == "main" + def test_missing_manifest_and_slug_heuristic(self, meditate_repair, tmp_path): """Step 11: slug heuristic with compounding suffix.""" dreams = tmp_path / "_dreams" From 63451f3e08acfb551ad2fb582d1a938c5b497ae7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:09:18 +0000 Subject: [PATCH 3/8] Fix Windows shell-test runner to use Git Bash helper Co-authored-by: xingdi-eric-yuan <4028684+xingdi-eric-yuan@users.noreply.github.com> --- tests/_shell.py | 95 +++++++++++++++++++ tests/hooks/test_check_init_sh.py | 5 +- tests/hooks/test_hook_fault_injection.py | 8 +- tests/hooks/test_pre_tool_sh.py | 18 ++-- .../test_dream_cleanup_sh.py | 8 +- .../shadow_frog_dream/test_dream_gc_sh.py | 8 +- .../shadow_frog_dream/test_dream_setup_sh.py | 8 +- tests/test_install_sh.py | 6 +- 8 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 tests/_shell.py diff --git a/tests/_shell.py b/tests/_shell.py new file mode 100644 index 0000000..7f50245 --- /dev/null +++ b/tests/_shell.py @@ -0,0 +1,95 @@ +"""Cross-platform helpers for invoking POSIX shell scripts in tests.""" + +from __future__ import annotations + +import atexit +import os +import shlex +import shutil +import sys +import tempfile +from pathlib import Path + + +def _is_wsl_stub(path: str | None) -> bool: + if not path: + return False + return path.lower().endswith(r"\windows\system32\bash.exe") + + +def _find_bash() -> str | None: + which_bash = shutil.which("bash") + if os.name != "nt": + return which_bash + if which_bash and not _is_wsl_stub(which_bash): + return which_bash + + candidates: list[Path] = [] + git = shutil.which("git") + if git: + git_path = Path(git) + candidates.extend([ + git_path.parent / "bash.exe", + git_path.parent.parent / "bin" / "bash.exe", + git_path.parent.parent / "usr" / "bin" / "bash.exe", + ]) + for env_var in ("ProgramFiles", "ProgramFiles(x86)"): + base = os.environ.get(env_var) + if base: + candidates.extend([ + Path(base) / "Git" / "bin" / "bash.exe", + Path(base) / "Git" / "usr" / "bin" / "bash.exe", + ]) + + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + return None + + +def shell_path(path: str | os.PathLike[str]) -> str: + value = os.fspath(path) + if os.name != "nt": + return value + value = value.replace("\\", "/") + if len(value) >= 3 and value[1] == ":" and value[2] == "/": + return f"/{value[0].lower()}{value[2:]}" + return value + + +_FOUND_BASH = _find_bash() +BASH = _FOUND_BASH or "bash" +HAVE_BASH = _FOUND_BASH is not None + +_WINDOWS_SHIM_DIR: str | None = None + + +def _windows_shims() -> list[str]: + global _WINDOWS_SHIM_DIR + if os.name != "nt": + return [] + if _WINDOWS_SHIM_DIR is not None: + return [_WINDOWS_SHIM_DIR] + + shim_dir = tempfile.mkdtemp(prefix="shadowfrog-shell-") + atexit.register(lambda: shutil.rmtree(shim_dir, ignore_errors=True)) + python3 = Path(shim_dir) / "python3" + python3.write_text( + "#!/usr/bin/env sh\n" + f"exec {shlex.quote(shell_path(sys.executable))} \"$@\"\n", + encoding="utf-8", + ) + python3.chmod(0o755) + _WINDOWS_SHIM_DIR = shim_dir + return [shim_dir] + + +def prepend_path(env: dict, *entries: str | os.PathLike[str]) -> dict: + updated = dict(env) + paths = [os.fspath(item) for item in entries if item] + paths.extend(_windows_shims()) + current = updated.get("PATH", "") + if current: + paths.append(current) + updated["PATH"] = os.pathsep.join(paths) + return updated diff --git a/tests/hooks/test_check_init_sh.py b/tests/hooks/test_check_init_sh.py index 34a52c9..cdfef7c 100644 --- a/tests/hooks/test_check_init_sh.py +++ b/tests/hooks/test_check_init_sh.py @@ -9,9 +9,11 @@ from pathlib import Path import pytest +from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent HOOK_SCRIPT = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-check-init.sh" +pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _base_env(cwd: Path, extras: dict | None = None) -> dict: @@ -22,6 +24,7 @@ def _base_env(cwd: Path, extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } + env = prepend_path(env) if extras: env.update(extras) return env @@ -31,7 +34,7 @@ def run_hook(cwd: Path, env_extra: dict | None = None) -> subprocess.CompletedPr """Run the check-init hook (stdin is ignored but must exist).""" env = _base_env(cwd, env_extra) return subprocess.run( - ["bash", str(HOOK_SCRIPT)], + [BASH, shell_path(HOOK_SCRIPT)], input="{}", capture_output=True, text=True, diff --git a/tests/hooks/test_hook_fault_injection.py b/tests/hooks/test_hook_fault_injection.py index 52b1398..723a2da 100644 --- a/tests/hooks/test_hook_fault_injection.py +++ b/tests/hooks/test_hook_fault_injection.py @@ -40,10 +40,12 @@ from pathlib import Path import pytest +from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent PRE_TOOL_HOOK = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-pre-tool.sh" CHECK_INIT_HOOK = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-check-init.sh" +pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") # Configured hook timeout in hook-templates/shadow-frog-hooks.json. Tests must complete # below this or production would have been killed by the runner. @@ -75,6 +77,7 @@ def _base_env(cwd: Path, extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } + env = prepend_path(env) if extras: env.update(extras) return env @@ -108,8 +111,9 @@ def _run(hook: Path, cwd: Path, stdin: str, env_extra: dict | None = None, extras["SHADOWFROG_TMP_DIR"] = str(cwd / "_sf_dedup") t0 = time.perf_counter() cp = subprocess.run( - ["bash", str(hook)], + [BASH, shell_path(hook)], input=stdin, capture_output=True, text=True, + encoding="utf-8", cwd=cwd, env=_base_env(cwd, extras), timeout=timeout, ) return cp, time.perf_counter() - t0 @@ -302,7 +306,7 @@ def test_binary_fault_injection(tmp_path, hook, scenario_id, stub_factory): stub_dir = tmp_path / "stubbin" stub_factory(stub_dir) - env = {"PATH": f"{stub_dir}:{os.environ.get('PATH', '')}"} + env = prepend_path({}, str(stub_dir)) payload = json.dumps({"toolName": "edit", "toolInput": {"file_path": "a.py"}}) diff --git a/tests/hooks/test_pre_tool_sh.py b/tests/hooks/test_pre_tool_sh.py index b0d2625..bafbe4f 100644 --- a/tests/hooks/test_pre_tool_sh.py +++ b/tests/hooks/test_pre_tool_sh.py @@ -14,9 +14,11 @@ from pathlib import Path import pytest +from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent HOOK_SCRIPT = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-pre-tool.sh" +pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _make_failing_git_stub(stub_dir: Path, fail_subcommand: str = "diff") -> Path: @@ -51,6 +53,7 @@ def _base_env(cwd: Path, extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } + env = prepend_path(env) if extras: env.update(extras) return env @@ -60,10 +63,11 @@ def run_hook(json_input: dict, cwd: Path, env_extra: dict | None = None) -> subp """Run the pre-tool hook with given JSON on stdin.""" env = _base_env(cwd, env_extra) return subprocess.run( - ["bash", str(HOOK_SCRIPT)], + [BASH, shell_path(HOOK_SCRIPT)], input=json.dumps(json_input), capture_output=True, text=True, + encoding="utf-8", cwd=cwd, env=env, ) @@ -336,7 +340,7 @@ def test_git_diff_failure_does_not_deny(self, coupon_demo, tmp_path): self._set_stale_state(coupon_demo) stub = tmp_path / "stubbin" _make_failing_git_stub(stub, "diff") - env = {"PATH": f"{stub}:{os.environ.get('PATH', '')}"} + env = prepend_path({}, str(stub)) result = run_hook( {"tool_name": "Bash", "tool_input": {"command": "ls"}}, cwd=coupon_demo, env_extra=env, @@ -377,7 +381,7 @@ def test_git_rev_parse_failure_does_not_deny(self, coupon_demo, tmp_path): self._set_stale_state(coupon_demo) stub = tmp_path / "stubbin" _make_failing_git_stub(stub, "rev-parse") - env = {"PATH": f"{stub}:{os.environ.get('PATH', '')}"} + env = prepend_path({}, str(stub)) result = run_hook( {"tool_name": "edit", "tool_input": {"file_path": "cart.py"}}, cwd=coupon_demo, env_extra=env, @@ -463,13 +467,14 @@ def _spawn_and_signal(self, coupon_demo, tmp_path, env_extra=None, ) t0 = time.perf_counter() proc = subprocess.Popen( - ["bash", str(HOOK_SCRIPT)], + [BASH, shell_path(HOOK_SCRIPT)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=coupon_demo, env=env, text=True, + encoding="utf-8", ) # Schedule SIGTERM in a background thread so communicate() can # handle the stdin write + reads atomically. This avoids the @@ -550,7 +555,7 @@ def test_sigterm_during_hung_subprocess_still_eventually_exits( rc, stdout, stderr, elapsed = self._spawn_and_signal( coupon_demo, tmp_path, env_extra={ - "PATH": f"{stub}:{os.environ.get('PATH','')}", + **prepend_path({}, str(stub)), "SHADOWFROG_TMP_DIR": str(tmp_path / "dedup"), }, signal_delay=0.05, @@ -597,10 +602,11 @@ def test_happy_path_meets_strict_production_budget( for attempt in range(3): t0 = time.perf_counter() result = subprocess.run( - ["bash", str(HOOK_SCRIPT)], + [BASH, shell_path(HOOK_SCRIPT)], input=payload, capture_output=True, text=True, + encoding="utf-8", cwd=coupon_demo, env=_base_env(coupon_demo, { "SHADOWFROG_TMP_DIR": str(tmp_path / f"dedup_{attempt}"), diff --git a/tests/skills/shadow_frog_dream/test_dream_cleanup_sh.py b/tests/skills/shadow_frog_dream/test_dream_cleanup_sh.py index 258999a..d1f4cb2 100644 --- a/tests/skills/shadow_frog_dream/test_dream_cleanup_sh.py +++ b/tests/skills/shadow_frog_dream/test_dream_cleanup_sh.py @@ -15,9 +15,11 @@ from pathlib import Path import pytest +from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent CLEANUP_SH = REPO_ROOT / "skills" / "shadow-frog-dream" / "dream-cleanup.sh" +pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _base_env(extras: dict | None = None) -> dict: @@ -28,6 +30,7 @@ def _base_env(extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } + env = prepend_path(env) if extras: env.update(extras) return env @@ -47,8 +50,9 @@ def _make_repo(path: Path) -> Path: def _run(args: list[str], env_extra: dict | None = None) -> subprocess.CompletedProcess: + shell_args = [shell_path(arg) for arg in args] return subprocess.run( - ["bash", str(CLEANUP_SH), *args], + [BASH, shell_path(CLEANUP_SH), *shell_args], capture_output=True, text=True, env=_base_env(env_extra), ) @@ -277,7 +281,7 @@ def test_missing_safety_module_exits_4(self, tmp_path): wt = base / "proj" / "dream-foo" wt.mkdir(parents=True) r = subprocess.run( - ["bash", str(cleanup), str(wt)], + [BASH, shell_path(cleanup), shell_path(wt)], capture_output=True, text=True, env=_base_env({"DREAM_WORKTREE_BASE": str(base)}), ) diff --git a/tests/skills/shadow_frog_dream/test_dream_gc_sh.py b/tests/skills/shadow_frog_dream/test_dream_gc_sh.py index 3cba88c..91b8a8d 100644 --- a/tests/skills/shadow_frog_dream/test_dream_gc_sh.py +++ b/tests/skills/shadow_frog_dream/test_dream_gc_sh.py @@ -10,9 +10,11 @@ from pathlib import Path import pytest +from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent GC_SH = REPO_ROOT / "skills" / "shadow-frog-dream" / "dream-gc.sh" +pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _base_env(extras: dict | None = None) -> dict: @@ -23,6 +25,7 @@ def _base_env(extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } + env = prepend_path(env) if extras: env.update(extras) return env @@ -55,8 +58,9 @@ def _orphan_worktree(parent: Path, name: str = "dream-orphan", old: bool = True) def _run(args: list[str], env_extra: dict | None = None) -> subprocess.CompletedProcess: + shell_args = [shell_path(arg) for arg in args] return subprocess.run( - ["bash", str(GC_SH), *args], + [BASH, shell_path(GC_SH), *shell_args], capture_output=True, text=True, env=_base_env(env_extra), ) @@ -237,7 +241,7 @@ def test_missing_safety_module_exits_4(self, tmp_path): assert wt.exists() r = subprocess.run( - ["bash", str(broken / "dream-gc.sh")], + [BASH, shell_path(broken / "dream-gc.sh")], capture_output=True, text=True, env=_base_env({"DREAM_WORKTREE_BASE": str(base)}), ) diff --git a/tests/skills/shadow_frog_dream/test_dream_setup_sh.py b/tests/skills/shadow_frog_dream/test_dream_setup_sh.py index ef02238..3965481 100644 --- a/tests/skills/shadow_frog_dream/test_dream_setup_sh.py +++ b/tests/skills/shadow_frog_dream/test_dream_setup_sh.py @@ -9,9 +9,11 @@ from pathlib import Path import pytest +from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent DREAM_SETUP = REPO_ROOT / "skills" / "shadow-frog-dream" / "dream-setup.sh" +pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _base_env(cwd: Path, extras: dict | None = None) -> dict: @@ -22,6 +24,7 @@ def _base_env(cwd: Path, extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } + env = prepend_path(env) if extras: env.update(extras) return env @@ -47,8 +50,9 @@ def run_dream_setup( ) -> subprocess.CompletedProcess: """Run dream-setup.sh with given args.""" env = _base_env(cwd, env_extra) + shell_args = [shell_path(arg) for arg in args] return subprocess.run( - ["bash", str(DREAM_SETUP), *args], + [BASH, shell_path(DREAM_SETUP), *shell_args], capture_output=True, text=True, cwd=cwd, @@ -107,7 +111,7 @@ def test_creates_worktree_and_branch(self, tmp_path): ["git", "worktree", "list"], cwd=repo, capture_output=True, text=True, env=env, ) - assert str(wt_dir) in wt_list.stdout + assert shell_path(wt_dir) in wt_list.stdout.replace("\\", "/") def test_worktree_has_same_head_as_base(self, tmp_path): repo = tmp_path / "repo" diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index 4654b36..56a8ae6 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -9,9 +9,11 @@ import json import pytest +from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent INSTALL_SCRIPT = REPO_ROOT / "install.sh" +pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") EXPECTED_SKILLS = [ "shadow-frog", @@ -31,6 +33,7 @@ def _base_env(extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } + env = prepend_path(env) if extras: env.update(extras) return env @@ -39,8 +42,9 @@ def _base_env(extras: dict | None = None) -> dict: def run_install(*args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess: """Run install.sh with given arguments.""" env = _base_env(env_extra) + shell_args = [shell_path(arg) for arg in args] return subprocess.run( - ["bash", str(INSTALL_SCRIPT), *args], + [BASH, shell_path(INSTALL_SCRIPT), *shell_args], capture_output=True, text=True, env=env, From c636f3aa184692a6f14ff608c32798cb1d49c5a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:28:25 +0000 Subject: [PATCH 4/8] Fix Windows path handling in dream GC Co-authored-by: xingdi-eric-yuan <4028684+xingdi-eric-yuan@users.noreply.github.com> --- skills/shadow-frog-dream/dream-gc.sh | 10 ++++++---- tests/hooks/test_pre_tool_sh.py | 4 ++++ tests/skills/shadow_frog_dream/test_dream_gc_sh.py | 6 ++++-- tests/skills/shadow_frog_dream/test_dream_reconcile.py | 4 ++-- tests/skills/shadow_frog_dream/test_dream_setup_sh.py | 2 +- tests/skills/shadow_frog_dream/test_worktree_safety.py | 9 ++++++--- tests/skills/shadow_frog_init/test_shadow_init.py | 6 ++++-- 7 files changed, 27 insertions(+), 14 deletions(-) diff --git a/skills/shadow-frog-dream/dream-gc.sh b/skills/shadow-frog-dream/dream-gc.sh index 884db0c..b56b3ba 100755 --- a/skills/shadow-frog-dream/dream-gc.sh +++ b/skills/shadow-frog-dream/dream-gc.sh @@ -227,10 +227,12 @@ while IFS= read -r -d '' candidate; do is_orphan=true else # Relative gitdir paths (rare but legal) resolve relative to the - # .git file's directory — NOT the cwd. - if [[ "$gitdir_target" != /* ]]; then - gitdir_target="$candidate/$gitdir_target" - fi + # .git file's directory — NOT the cwd. Git for Windows writes + # drive-letter paths, which must not be mistaken for relative. + case "$gitdir_target" in + /*|[A-Za-z]:[\\/]*|\\\\*) ;; + *) gitdir_target="$candidate/$gitdir_target" ;; + esac if [[ ! -e "$gitdir_target" ]]; then is_orphan=true fi diff --git a/tests/hooks/test_pre_tool_sh.py b/tests/hooks/test_pre_tool_sh.py index bafbe4f..bcc868e 100644 --- a/tests/hooks/test_pre_tool_sh.py +++ b/tests/hooks/test_pre_tool_sh.py @@ -452,6 +452,10 @@ def test_mutation_tool_triggers_file_specific_branch( @pytest.mark.slow @pytest.mark.integration +@pytest.mark.skipif( + os.name == "nt", + reason="Windows terminates the process instead of delivering catchable SIGTERM", +) class TestPreToolSigterm: """Behavioral verification of `trap 'exit 0' TERM` — distinct from static CI checker coverage.""" diff --git a/tests/skills/shadow_frog_dream/test_dream_gc_sh.py b/tests/skills/shadow_frog_dream/test_dream_gc_sh.py index 91b8a8d..84c13b0 100644 --- a/tests/skills/shadow_frog_dream/test_dream_gc_sh.py +++ b/tests/skills/shadow_frog_dream/test_dream_gc_sh.py @@ -92,6 +92,7 @@ def test_invalid_min_age_errors(self, bad_age): @pytest.mark.slow @pytest.mark.integration class TestBaseSafety: + @pytest.mark.skipif(os.name == "nt", reason="POSIX sensitive-path semantics") @pytest.mark.parametrize("base", [ "/", "/tmp", "/etc", "/var", "/home", "/Users", "/private/tmp", "/private/etc", @@ -261,6 +262,7 @@ class TestGitdirParserRobust: DELETED. New parser must preserve `:` chars after the `gitdir: ` prefix. """ + @pytest.mark.skipif(os.name == "nt", reason="Windows forbids colons in filenames") def test_gitdir_path_with_colon_is_not_orphan(self, tmp_path): # Build a fake target the parser will think exists. gitdir_real = tmp_path / "container:with:colons" / "worktrees" / "foo" @@ -569,7 +571,7 @@ def test_task_complete_does_NOT_cross_namespaces(self, tmp_path): ["git", "-C", str(repo_b), "worktree", "list", "--porcelain"], capture_output=True, text=True, env=_base_env(), ) - assert str(cand_b) in list_b.stdout + assert cand_b.as_posix() in list_b.stdout.replace("\\", "/") def test_task_complete_refuses_locked_worktree_no_rm_fallback(self, tmp_path): """If `git worktree remove --force` refuses (locked), we WARN and skip. @@ -628,7 +630,7 @@ def test_task_complete_skips_other_repos_worktree_no_rm_fallback(self, tmp_path) ["git", "-C", str(repo_owner), "worktree", "list", "--porcelain"], capture_output=True, text=True, env=_base_env(), ) - assert str(candidate) in list_owner.stdout + assert candidate.as_posix() in list_owner.stdout.replace("\\", "/") def test_task_complete_dream_namespace_env_works(self, tmp_path): """DREAM_NAMESPACE env satisfies the --namespace requirement.""" diff --git a/tests/skills/shadow_frog_dream/test_dream_reconcile.py b/tests/skills/shadow_frog_dream/test_dream_reconcile.py index 4f3a2d6..7180aa6 100644 --- a/tests/skills/shadow_frog_dream/test_dream_reconcile.py +++ b/tests/skills/shadow_frog_dream/test_dream_reconcile.py @@ -3229,8 +3229,8 @@ def test_cleanup_branches_worktree_gc_refuses_unsafe_base( decoy.mkdir() (decoy / "important.txt").write_text("keep me\n", encoding="utf-8") - # Point DREAM_WORKTREE_BASE at /tmp — gate must refuse. - monkeypatch.setenv("DREAM_WORKTREE_BASE", "/tmp") + # Point DREAM_WORKTREE_BASE at $HOME — gate must refuse on every OS. + monkeypatch.setenv("DREAM_WORKTREE_BASE", str(Path.home())) deleted, _ = dream_reconcile.cleanup_branches( str(tmp_git_repo), diff --git a/tests/skills/shadow_frog_dream/test_dream_setup_sh.py b/tests/skills/shadow_frog_dream/test_dream_setup_sh.py index 3965481..16a574f 100644 --- a/tests/skills/shadow_frog_dream/test_dream_setup_sh.py +++ b/tests/skills/shadow_frog_dream/test_dream_setup_sh.py @@ -111,7 +111,7 @@ def test_creates_worktree_and_branch(self, tmp_path): ["git", "worktree", "list"], cwd=repo, capture_output=True, text=True, env=env, ) - assert shell_path(wt_dir) in wt_list.stdout.replace("\\", "/") + assert wt_dir.as_posix() in wt_list.stdout.replace("\\", "/") def test_worktree_has_same_head_as_base(self, tmp_path): repo = tmp_path / "repo" diff --git a/tests/skills/shadow_frog_dream/test_worktree_safety.py b/tests/skills/shadow_frog_dream/test_worktree_safety.py index fe95a43..7381ec0 100644 --- a/tests/skills/shadow_frog_dream/test_worktree_safety.py +++ b/tests/skills/shadow_frog_dream/test_worktree_safety.py @@ -175,11 +175,13 @@ def test_rejects_symlinked_leaf_escaping_base(self, tmp_path): safe_worktree_path(str(link), str(base)) def test_rejects_symlinked_parent_escaping_base(self, tmp_path): - # base/escape-ns is a symlink to /etc. base/escape-ns/dream-x must + # base/escape-ns is a symlink outside base. base/escape-ns/dream-x must # be refused even though the LITERAL input looks valid. base = tmp_path / "b" base.mkdir() - (base / "escape-ns").symlink_to("/etc") + escape = tmp_path / "escape-target" + escape.mkdir() + (base / "escape-ns").symlink_to(escape, target_is_directory=True) with pytest.raises(UnsafePath, match="strictly under base"): safe_worktree_path(str(base / "escape-ns" / "dream-x"), str(base)) @@ -268,7 +270,8 @@ def test_exit_2_when_safe_and_missing(self, tmp_path): assert r.returncode == 2 def test_exit_1_when_unsafe(self): - r = self._run("/tmp/proj/dream-foo", "/tmp") + home = Path.home() + r = self._run(str(home / "proj" / "dream-foo"), str(home)) assert r.returncode == 1 assert "ERROR" in r.stderr diff --git a/tests/skills/shadow_frog_init/test_shadow_init.py b/tests/skills/shadow_frog_init/test_shadow_init.py index 8bfd6cc..e2ab429 100644 --- a/tests/skills/shadow_frog_init/test_shadow_init.py +++ b/tests/skills/shadow_frog_init/test_shadow_init.py @@ -945,8 +945,10 @@ def test_walk_files_lists_all_non_excluded(shadow_init, tmp_path): # _load_shadowignore: unreadable file (lines 225-227) # --------------------------------------------------------------------------- -@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, - reason="root can read 0o000 files") +@pytest.mark.skipif( + os.name == "nt" or (hasattr(os, "geteuid") and os.geteuid() == 0), + reason="platform can read 0o000 files", +) def test_load_shadowignore_unreadable_file_warns(shadow_init, tmp_path, reset_diagnostics): ignore = tmp_path / ".shadowignore" From b97b2c00eaa3176e7a161ce9ee86d1412ae49d56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:29:59 +0000 Subject: [PATCH 5/8] Verify Windows CI fixes Co-authored-by: xingdi-eric-yuan <4028684+xingdi-eric-yuan@users.noreply.github.com> --- coverage.xml | 3717 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3717 insertions(+) create mode 100644 coverage.xml diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 0000000..7cfc7da --- /dev/null +++ b/coverage.xml @@ -0,0 +1,3717 @@ + + + + + + /home/runner/work/ShadowFrog/ShadowFrog/skills + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From ee1bc22c2e40ff205f0dabcb4cbccf0e8eebb6f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:30:30 +0000 Subject: [PATCH 6/8] Remove generated coverage artifact Co-authored-by: xingdi-eric-yuan <4028684+xingdi-eric-yuan@users.noreply.github.com> --- coverage.xml | 3717 -------------------------------------------------- 1 file changed, 3717 deletions(-) delete mode 100644 coverage.xml diff --git a/coverage.xml b/coverage.xml deleted file mode 100644 index 7cfc7da..0000000 --- a/coverage.xml +++ /dev/null @@ -1,3717 +0,0 @@ - - - - - - /home/runner/work/ShadowFrog/ShadowFrog/skills - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 57f9616981abd94da68a12b5302a082124d3557f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:38:55 +0000 Subject: [PATCH 7/8] Reuse active Bash for dream auto-GC Co-authored-by: xingdi-eric-yuan <4028684+xingdi-eric-yuan@users.noreply.github.com> --- skills/shadow-frog-dream/dream-setup.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/shadow-frog-dream/dream-setup.sh b/skills/shadow-frog-dream/dream-setup.sh index d68c16c..ed69a2a 100755 --- a/skills/shadow-frog-dream/dream-setup.sh +++ b/skills/shadow-frog-dream/dream-setup.sh @@ -233,7 +233,9 @@ if [[ "$DRY_RUN" == "false" ]]; then # of a tombstone-vs-gc race is one extra GC pass; never a # missed cleanup. touch "$TOMBSTONE" 2>/dev/null || true - bash "$GC_SCRIPT" \ + # Reuse this interpreter: on Windows, PATH may resolve + # `bash` to the WSL stub even when this script runs in Git Bash. + "$BASH" "$GC_SCRIPT" \ --repo-root "$REPO_ROOT" \ --quiet \ --min-age-min "$GC_AGE_MIN" \ From af4d5d6b4f7acd91a75eedb1181eb98ba1974e70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:49:28 +0000 Subject: [PATCH 8/8] Remove out-of-scope Windows CI changes Co-authored-by: xingdi-eric-yuan <4028684+xingdi-eric-yuan@users.noreply.github.com> --- skills/shadow-frog-dream/dream-gc.sh | 10 +- skills/shadow-frog-dream/dream-setup.sh | 4 +- tests/_shell.py | 95 ------------------- tests/hooks/test_check_init_sh.py | 5 +- tests/hooks/test_hook_fault_injection.py | 8 +- tests/hooks/test_pre_tool_sh.py | 22 ++--- .../test_dream_cleanup_sh.py | 8 +- .../shadow_frog_dream/test_dream_gc_sh.py | 14 +-- .../shadow_frog_dream/test_dream_reconcile.py | 4 +- .../shadow_frog_dream/test_dream_setup_sh.py | 8 +- .../shadow_frog_dream/test_worktree_safety.py | 9 +- .../shadow_frog_init/test_shadow_init.py | 6 +- tests/test_install_sh.py | 6 +- 13 files changed, 30 insertions(+), 169 deletions(-) delete mode 100644 tests/_shell.py diff --git a/skills/shadow-frog-dream/dream-gc.sh b/skills/shadow-frog-dream/dream-gc.sh index b56b3ba..884db0c 100755 --- a/skills/shadow-frog-dream/dream-gc.sh +++ b/skills/shadow-frog-dream/dream-gc.sh @@ -227,12 +227,10 @@ while IFS= read -r -d '' candidate; do is_orphan=true else # Relative gitdir paths (rare but legal) resolve relative to the - # .git file's directory — NOT the cwd. Git for Windows writes - # drive-letter paths, which must not be mistaken for relative. - case "$gitdir_target" in - /*|[A-Za-z]:[\\/]*|\\\\*) ;; - *) gitdir_target="$candidate/$gitdir_target" ;; - esac + # .git file's directory — NOT the cwd. + if [[ "$gitdir_target" != /* ]]; then + gitdir_target="$candidate/$gitdir_target" + fi if [[ ! -e "$gitdir_target" ]]; then is_orphan=true fi diff --git a/skills/shadow-frog-dream/dream-setup.sh b/skills/shadow-frog-dream/dream-setup.sh index ed69a2a..d68c16c 100755 --- a/skills/shadow-frog-dream/dream-setup.sh +++ b/skills/shadow-frog-dream/dream-setup.sh @@ -233,9 +233,7 @@ if [[ "$DRY_RUN" == "false" ]]; then # of a tombstone-vs-gc race is one extra GC pass; never a # missed cleanup. touch "$TOMBSTONE" 2>/dev/null || true - # Reuse this interpreter: on Windows, PATH may resolve - # `bash` to the WSL stub even when this script runs in Git Bash. - "$BASH" "$GC_SCRIPT" \ + bash "$GC_SCRIPT" \ --repo-root "$REPO_ROOT" \ --quiet \ --min-age-min "$GC_AGE_MIN" \ diff --git a/tests/_shell.py b/tests/_shell.py deleted file mode 100644 index 7f50245..0000000 --- a/tests/_shell.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Cross-platform helpers for invoking POSIX shell scripts in tests.""" - -from __future__ import annotations - -import atexit -import os -import shlex -import shutil -import sys -import tempfile -from pathlib import Path - - -def _is_wsl_stub(path: str | None) -> bool: - if not path: - return False - return path.lower().endswith(r"\windows\system32\bash.exe") - - -def _find_bash() -> str | None: - which_bash = shutil.which("bash") - if os.name != "nt": - return which_bash - if which_bash and not _is_wsl_stub(which_bash): - return which_bash - - candidates: list[Path] = [] - git = shutil.which("git") - if git: - git_path = Path(git) - candidates.extend([ - git_path.parent / "bash.exe", - git_path.parent.parent / "bin" / "bash.exe", - git_path.parent.parent / "usr" / "bin" / "bash.exe", - ]) - for env_var in ("ProgramFiles", "ProgramFiles(x86)"): - base = os.environ.get(env_var) - if base: - candidates.extend([ - Path(base) / "Git" / "bin" / "bash.exe", - Path(base) / "Git" / "usr" / "bin" / "bash.exe", - ]) - - for candidate in candidates: - if candidate.is_file(): - return str(candidate) - return None - - -def shell_path(path: str | os.PathLike[str]) -> str: - value = os.fspath(path) - if os.name != "nt": - return value - value = value.replace("\\", "/") - if len(value) >= 3 and value[1] == ":" and value[2] == "/": - return f"/{value[0].lower()}{value[2:]}" - return value - - -_FOUND_BASH = _find_bash() -BASH = _FOUND_BASH or "bash" -HAVE_BASH = _FOUND_BASH is not None - -_WINDOWS_SHIM_DIR: str | None = None - - -def _windows_shims() -> list[str]: - global _WINDOWS_SHIM_DIR - if os.name != "nt": - return [] - if _WINDOWS_SHIM_DIR is not None: - return [_WINDOWS_SHIM_DIR] - - shim_dir = tempfile.mkdtemp(prefix="shadowfrog-shell-") - atexit.register(lambda: shutil.rmtree(shim_dir, ignore_errors=True)) - python3 = Path(shim_dir) / "python3" - python3.write_text( - "#!/usr/bin/env sh\n" - f"exec {shlex.quote(shell_path(sys.executable))} \"$@\"\n", - encoding="utf-8", - ) - python3.chmod(0o755) - _WINDOWS_SHIM_DIR = shim_dir - return [shim_dir] - - -def prepend_path(env: dict, *entries: str | os.PathLike[str]) -> dict: - updated = dict(env) - paths = [os.fspath(item) for item in entries if item] - paths.extend(_windows_shims()) - current = updated.get("PATH", "") - if current: - paths.append(current) - updated["PATH"] = os.pathsep.join(paths) - return updated diff --git a/tests/hooks/test_check_init_sh.py b/tests/hooks/test_check_init_sh.py index cdfef7c..34a52c9 100644 --- a/tests/hooks/test_check_init_sh.py +++ b/tests/hooks/test_check_init_sh.py @@ -9,11 +9,9 @@ from pathlib import Path import pytest -from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent HOOK_SCRIPT = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-check-init.sh" -pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _base_env(cwd: Path, extras: dict | None = None) -> dict: @@ -24,7 +22,6 @@ def _base_env(cwd: Path, extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } - env = prepend_path(env) if extras: env.update(extras) return env @@ -34,7 +31,7 @@ def run_hook(cwd: Path, env_extra: dict | None = None) -> subprocess.CompletedPr """Run the check-init hook (stdin is ignored but must exist).""" env = _base_env(cwd, env_extra) return subprocess.run( - [BASH, shell_path(HOOK_SCRIPT)], + ["bash", str(HOOK_SCRIPT)], input="{}", capture_output=True, text=True, diff --git a/tests/hooks/test_hook_fault_injection.py b/tests/hooks/test_hook_fault_injection.py index 723a2da..52b1398 100644 --- a/tests/hooks/test_hook_fault_injection.py +++ b/tests/hooks/test_hook_fault_injection.py @@ -40,12 +40,10 @@ from pathlib import Path import pytest -from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent PRE_TOOL_HOOK = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-pre-tool.sh" CHECK_INIT_HOOK = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-check-init.sh" -pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") # Configured hook timeout in hook-templates/shadow-frog-hooks.json. Tests must complete # below this or production would have been killed by the runner. @@ -77,7 +75,6 @@ def _base_env(cwd: Path, extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } - env = prepend_path(env) if extras: env.update(extras) return env @@ -111,9 +108,8 @@ def _run(hook: Path, cwd: Path, stdin: str, env_extra: dict | None = None, extras["SHADOWFROG_TMP_DIR"] = str(cwd / "_sf_dedup") t0 = time.perf_counter() cp = subprocess.run( - [BASH, shell_path(hook)], + ["bash", str(hook)], input=stdin, capture_output=True, text=True, - encoding="utf-8", cwd=cwd, env=_base_env(cwd, extras), timeout=timeout, ) return cp, time.perf_counter() - t0 @@ -306,7 +302,7 @@ def test_binary_fault_injection(tmp_path, hook, scenario_id, stub_factory): stub_dir = tmp_path / "stubbin" stub_factory(stub_dir) - env = prepend_path({}, str(stub_dir)) + env = {"PATH": f"{stub_dir}:{os.environ.get('PATH', '')}"} payload = json.dumps({"toolName": "edit", "toolInput": {"file_path": "a.py"}}) diff --git a/tests/hooks/test_pre_tool_sh.py b/tests/hooks/test_pre_tool_sh.py index bcc868e..b0d2625 100644 --- a/tests/hooks/test_pre_tool_sh.py +++ b/tests/hooks/test_pre_tool_sh.py @@ -14,11 +14,9 @@ from pathlib import Path import pytest -from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent HOOK_SCRIPT = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-pre-tool.sh" -pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _make_failing_git_stub(stub_dir: Path, fail_subcommand: str = "diff") -> Path: @@ -53,7 +51,6 @@ def _base_env(cwd: Path, extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } - env = prepend_path(env) if extras: env.update(extras) return env @@ -63,11 +60,10 @@ def run_hook(json_input: dict, cwd: Path, env_extra: dict | None = None) -> subp """Run the pre-tool hook with given JSON on stdin.""" env = _base_env(cwd, env_extra) return subprocess.run( - [BASH, shell_path(HOOK_SCRIPT)], + ["bash", str(HOOK_SCRIPT)], input=json.dumps(json_input), capture_output=True, text=True, - encoding="utf-8", cwd=cwd, env=env, ) @@ -340,7 +336,7 @@ def test_git_diff_failure_does_not_deny(self, coupon_demo, tmp_path): self._set_stale_state(coupon_demo) stub = tmp_path / "stubbin" _make_failing_git_stub(stub, "diff") - env = prepend_path({}, str(stub)) + env = {"PATH": f"{stub}:{os.environ.get('PATH', '')}"} result = run_hook( {"tool_name": "Bash", "tool_input": {"command": "ls"}}, cwd=coupon_demo, env_extra=env, @@ -381,7 +377,7 @@ def test_git_rev_parse_failure_does_not_deny(self, coupon_demo, tmp_path): self._set_stale_state(coupon_demo) stub = tmp_path / "stubbin" _make_failing_git_stub(stub, "rev-parse") - env = prepend_path({}, str(stub)) + env = {"PATH": f"{stub}:{os.environ.get('PATH', '')}"} result = run_hook( {"tool_name": "edit", "tool_input": {"file_path": "cart.py"}}, cwd=coupon_demo, env_extra=env, @@ -452,10 +448,6 @@ def test_mutation_tool_triggers_file_specific_branch( @pytest.mark.slow @pytest.mark.integration -@pytest.mark.skipif( - os.name == "nt", - reason="Windows terminates the process instead of delivering catchable SIGTERM", -) class TestPreToolSigterm: """Behavioral verification of `trap 'exit 0' TERM` — distinct from static CI checker coverage.""" @@ -471,14 +463,13 @@ def _spawn_and_signal(self, coupon_demo, tmp_path, env_extra=None, ) t0 = time.perf_counter() proc = subprocess.Popen( - [BASH, shell_path(HOOK_SCRIPT)], + ["bash", str(HOOK_SCRIPT)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=coupon_demo, env=env, text=True, - encoding="utf-8", ) # Schedule SIGTERM in a background thread so communicate() can # handle the stdin write + reads atomically. This avoids the @@ -559,7 +550,7 @@ def test_sigterm_during_hung_subprocess_still_eventually_exits( rc, stdout, stderr, elapsed = self._spawn_and_signal( coupon_demo, tmp_path, env_extra={ - **prepend_path({}, str(stub)), + "PATH": f"{stub}:{os.environ.get('PATH','')}", "SHADOWFROG_TMP_DIR": str(tmp_path / "dedup"), }, signal_delay=0.05, @@ -606,11 +597,10 @@ def test_happy_path_meets_strict_production_budget( for attempt in range(3): t0 = time.perf_counter() result = subprocess.run( - [BASH, shell_path(HOOK_SCRIPT)], + ["bash", str(HOOK_SCRIPT)], input=payload, capture_output=True, text=True, - encoding="utf-8", cwd=coupon_demo, env=_base_env(coupon_demo, { "SHADOWFROG_TMP_DIR": str(tmp_path / f"dedup_{attempt}"), diff --git a/tests/skills/shadow_frog_dream/test_dream_cleanup_sh.py b/tests/skills/shadow_frog_dream/test_dream_cleanup_sh.py index d1f4cb2..258999a 100644 --- a/tests/skills/shadow_frog_dream/test_dream_cleanup_sh.py +++ b/tests/skills/shadow_frog_dream/test_dream_cleanup_sh.py @@ -15,11 +15,9 @@ from pathlib import Path import pytest -from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent CLEANUP_SH = REPO_ROOT / "skills" / "shadow-frog-dream" / "dream-cleanup.sh" -pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _base_env(extras: dict | None = None) -> dict: @@ -30,7 +28,6 @@ def _base_env(extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } - env = prepend_path(env) if extras: env.update(extras) return env @@ -50,9 +47,8 @@ def _make_repo(path: Path) -> Path: def _run(args: list[str], env_extra: dict | None = None) -> subprocess.CompletedProcess: - shell_args = [shell_path(arg) for arg in args] return subprocess.run( - [BASH, shell_path(CLEANUP_SH), *shell_args], + ["bash", str(CLEANUP_SH), *args], capture_output=True, text=True, env=_base_env(env_extra), ) @@ -281,7 +277,7 @@ def test_missing_safety_module_exits_4(self, tmp_path): wt = base / "proj" / "dream-foo" wt.mkdir(parents=True) r = subprocess.run( - [BASH, shell_path(cleanup), shell_path(wt)], + ["bash", str(cleanup), str(wt)], capture_output=True, text=True, env=_base_env({"DREAM_WORKTREE_BASE": str(base)}), ) diff --git a/tests/skills/shadow_frog_dream/test_dream_gc_sh.py b/tests/skills/shadow_frog_dream/test_dream_gc_sh.py index 84c13b0..3cba88c 100644 --- a/tests/skills/shadow_frog_dream/test_dream_gc_sh.py +++ b/tests/skills/shadow_frog_dream/test_dream_gc_sh.py @@ -10,11 +10,9 @@ from pathlib import Path import pytest -from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent GC_SH = REPO_ROOT / "skills" / "shadow-frog-dream" / "dream-gc.sh" -pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _base_env(extras: dict | None = None) -> dict: @@ -25,7 +23,6 @@ def _base_env(extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } - env = prepend_path(env) if extras: env.update(extras) return env @@ -58,9 +55,8 @@ def _orphan_worktree(parent: Path, name: str = "dream-orphan", old: bool = True) def _run(args: list[str], env_extra: dict | None = None) -> subprocess.CompletedProcess: - shell_args = [shell_path(arg) for arg in args] return subprocess.run( - [BASH, shell_path(GC_SH), *shell_args], + ["bash", str(GC_SH), *args], capture_output=True, text=True, env=_base_env(env_extra), ) @@ -92,7 +88,6 @@ def test_invalid_min_age_errors(self, bad_age): @pytest.mark.slow @pytest.mark.integration class TestBaseSafety: - @pytest.mark.skipif(os.name == "nt", reason="POSIX sensitive-path semantics") @pytest.mark.parametrize("base", [ "/", "/tmp", "/etc", "/var", "/home", "/Users", "/private/tmp", "/private/etc", @@ -242,7 +237,7 @@ def test_missing_safety_module_exits_4(self, tmp_path): assert wt.exists() r = subprocess.run( - [BASH, shell_path(broken / "dream-gc.sh")], + ["bash", str(broken / "dream-gc.sh")], capture_output=True, text=True, env=_base_env({"DREAM_WORKTREE_BASE": str(base)}), ) @@ -262,7 +257,6 @@ class TestGitdirParserRobust: DELETED. New parser must preserve `:` chars after the `gitdir: ` prefix. """ - @pytest.mark.skipif(os.name == "nt", reason="Windows forbids colons in filenames") def test_gitdir_path_with_colon_is_not_orphan(self, tmp_path): # Build a fake target the parser will think exists. gitdir_real = tmp_path / "container:with:colons" / "worktrees" / "foo" @@ -571,7 +565,7 @@ def test_task_complete_does_NOT_cross_namespaces(self, tmp_path): ["git", "-C", str(repo_b), "worktree", "list", "--porcelain"], capture_output=True, text=True, env=_base_env(), ) - assert cand_b.as_posix() in list_b.stdout.replace("\\", "/") + assert str(cand_b) in list_b.stdout def test_task_complete_refuses_locked_worktree_no_rm_fallback(self, tmp_path): """If `git worktree remove --force` refuses (locked), we WARN and skip. @@ -630,7 +624,7 @@ def test_task_complete_skips_other_repos_worktree_no_rm_fallback(self, tmp_path) ["git", "-C", str(repo_owner), "worktree", "list", "--porcelain"], capture_output=True, text=True, env=_base_env(), ) - assert candidate.as_posix() in list_owner.stdout.replace("\\", "/") + assert str(candidate) in list_owner.stdout def test_task_complete_dream_namespace_env_works(self, tmp_path): """DREAM_NAMESPACE env satisfies the --namespace requirement.""" diff --git a/tests/skills/shadow_frog_dream/test_dream_reconcile.py b/tests/skills/shadow_frog_dream/test_dream_reconcile.py index 7180aa6..4f3a2d6 100644 --- a/tests/skills/shadow_frog_dream/test_dream_reconcile.py +++ b/tests/skills/shadow_frog_dream/test_dream_reconcile.py @@ -3229,8 +3229,8 @@ def test_cleanup_branches_worktree_gc_refuses_unsafe_base( decoy.mkdir() (decoy / "important.txt").write_text("keep me\n", encoding="utf-8") - # Point DREAM_WORKTREE_BASE at $HOME — gate must refuse on every OS. - monkeypatch.setenv("DREAM_WORKTREE_BASE", str(Path.home())) + # Point DREAM_WORKTREE_BASE at /tmp — gate must refuse. + monkeypatch.setenv("DREAM_WORKTREE_BASE", "/tmp") deleted, _ = dream_reconcile.cleanup_branches( str(tmp_git_repo), diff --git a/tests/skills/shadow_frog_dream/test_dream_setup_sh.py b/tests/skills/shadow_frog_dream/test_dream_setup_sh.py index 16a574f..ef02238 100644 --- a/tests/skills/shadow_frog_dream/test_dream_setup_sh.py +++ b/tests/skills/shadow_frog_dream/test_dream_setup_sh.py @@ -9,11 +9,9 @@ from pathlib import Path import pytest -from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent DREAM_SETUP = REPO_ROOT / "skills" / "shadow-frog-dream" / "dream-setup.sh" -pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") def _base_env(cwd: Path, extras: dict | None = None) -> dict: @@ -24,7 +22,6 @@ def _base_env(cwd: Path, extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } - env = prepend_path(env) if extras: env.update(extras) return env @@ -50,9 +47,8 @@ def run_dream_setup( ) -> subprocess.CompletedProcess: """Run dream-setup.sh with given args.""" env = _base_env(cwd, env_extra) - shell_args = [shell_path(arg) for arg in args] return subprocess.run( - [BASH, shell_path(DREAM_SETUP), *shell_args], + ["bash", str(DREAM_SETUP), *args], capture_output=True, text=True, cwd=cwd, @@ -111,7 +107,7 @@ def test_creates_worktree_and_branch(self, tmp_path): ["git", "worktree", "list"], cwd=repo, capture_output=True, text=True, env=env, ) - assert wt_dir.as_posix() in wt_list.stdout.replace("\\", "/") + assert str(wt_dir) in wt_list.stdout def test_worktree_has_same_head_as_base(self, tmp_path): repo = tmp_path / "repo" diff --git a/tests/skills/shadow_frog_dream/test_worktree_safety.py b/tests/skills/shadow_frog_dream/test_worktree_safety.py index 7381ec0..fe95a43 100644 --- a/tests/skills/shadow_frog_dream/test_worktree_safety.py +++ b/tests/skills/shadow_frog_dream/test_worktree_safety.py @@ -175,13 +175,11 @@ def test_rejects_symlinked_leaf_escaping_base(self, tmp_path): safe_worktree_path(str(link), str(base)) def test_rejects_symlinked_parent_escaping_base(self, tmp_path): - # base/escape-ns is a symlink outside base. base/escape-ns/dream-x must + # base/escape-ns is a symlink to /etc. base/escape-ns/dream-x must # be refused even though the LITERAL input looks valid. base = tmp_path / "b" base.mkdir() - escape = tmp_path / "escape-target" - escape.mkdir() - (base / "escape-ns").symlink_to(escape, target_is_directory=True) + (base / "escape-ns").symlink_to("/etc") with pytest.raises(UnsafePath, match="strictly under base"): safe_worktree_path(str(base / "escape-ns" / "dream-x"), str(base)) @@ -270,8 +268,7 @@ def test_exit_2_when_safe_and_missing(self, tmp_path): assert r.returncode == 2 def test_exit_1_when_unsafe(self): - home = Path.home() - r = self._run(str(home / "proj" / "dream-foo"), str(home)) + r = self._run("/tmp/proj/dream-foo", "/tmp") assert r.returncode == 1 assert "ERROR" in r.stderr diff --git a/tests/skills/shadow_frog_init/test_shadow_init.py b/tests/skills/shadow_frog_init/test_shadow_init.py index e2ab429..8bfd6cc 100644 --- a/tests/skills/shadow_frog_init/test_shadow_init.py +++ b/tests/skills/shadow_frog_init/test_shadow_init.py @@ -945,10 +945,8 @@ def test_walk_files_lists_all_non_excluded(shadow_init, tmp_path): # _load_shadowignore: unreadable file (lines 225-227) # --------------------------------------------------------------------------- -@pytest.mark.skipif( - os.name == "nt" or (hasattr(os, "geteuid") and os.geteuid() == 0), - reason="platform can read 0o000 files", -) +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, + reason="root can read 0o000 files") def test_load_shadowignore_unreadable_file_warns(shadow_init, tmp_path, reset_diagnostics): ignore = tmp_path / ".shadowignore" diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index 56a8ae6..4654b36 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -9,11 +9,9 @@ import json import pytest -from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path REPO_ROOT = Path(__file__).resolve().parent.parent INSTALL_SCRIPT = REPO_ROOT / "install.sh" -pytestmark = pytest.mark.skipif(not HAVE_BASH, reason="POSIX bash not available") EXPECTED_SKILLS = [ "shadow-frog", @@ -33,7 +31,6 @@ def _base_env(extras: dict | None = None) -> dict: "GIT_CONFIG_SYSTEM": "/dev/null", "LANG": "en_US.UTF-8", } - env = prepend_path(env) if extras: env.update(extras) return env @@ -42,9 +39,8 @@ def _base_env(extras: dict | None = None) -> dict: def run_install(*args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess: """Run install.sh with given arguments.""" env = _base_env(env_extra) - shell_args = [shell_path(arg) for arg in args] return subprocess.run( - [BASH, shell_path(INSTALL_SCRIPT), *shell_args], + ["bash", str(INSTALL_SCRIPT), *args], capture_output=True, text=True, env=env,