DEV Community

Dakota Huang
Dakota Huang

Posted on

CWD Drift Breaks Path Extracts. Snapshot the Filesystem First.

Path extracts fail when cwd still moves. Snapshot the filesystem before any helper move. Then change one path function against that snapshot.

Path helpers look isolated on a first read. Relative writes still bind to process cwd. A hidden os.chdir call will invalidate later extracts.

Do not extract a path helper yet. Capture file creation deltas first. Keep the extract tiny after that snapshot matches.

The failure is not the new function

Most messy modules mix three path jobs. They resolve input paths from mixed sources. They create output files with relative names.

They also mutate cwd during a single run. Later helpers can look cleaner in isolation. The parent script still assumes the old cwd.

Tests pass in the original nested layout. They fail when a caller changes directory. The extract did not record disk behavior first.

This article treats filesystem deltas as a contract. The contract is recorded before any extract. The extract is rejected if the delta changes.

What to pin before a path extract

Pin four observables, not the helper name. Each observable is cheap to record. Each one catches a different extract failure.

  1. Record absolute cwd before the module returns control.
  2. Record absolute cwd after the module returns control.
  3. Record files created, modified, or removed during the run.
  4. Record resolved absolute paths for every relative string.

Skip network, CPU, and log text here. Those belong to other characterization passes. Mixing them hides the path contract.

Decision table for a path extract

Use one row, then stop. Do not pin every observable at once. One new signal per change keeps blame clear.

Symptom after extract Likely unpinned fact Smallest next pin
cwd differs in CI hidden chdir or Path.cwd cwd before and after
extra file in /tmp tempfile without cleanup created-file set
missing sidecar json relative write to old cwd file delta by name
tests pass, scripts fail argv path versus __file__ path resolved absolute map
identical names, wrong bytes write to a copied fixture path sha256 per relative file

The table is a selection tool. It is not a full test plan. Rank rows by how often CI already flakes.

Artifact: a filesystem delta harness

The harness below is a labeled example. It is not production telemetry. Run it against a local messy script only.

It copies a fixture tree into a temp directory. It runs the target as a subprocess. It writes JSON for cwd roots and file hashes.

# fs_delta_harness.py — example, not a published package
from __future__ import annotations

import hashlib
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path


def _tree_state(root: Path) -> dict[str, str]:
    state: dict[str, str] = {}
    for path in sorted(root.rglob("*")):
        if not path.is_file():
            continue
        rel = path.relative_to(root).as_posix()
        digest = hashlib.sha256(path.read_bytes()).hexdigest()
        state[rel] = digest
    return state


def _delta(before: dict[str, str], after: dict[str, str]) -> dict:
    created = sorted(set(after) - set(before))
    removed = sorted(set(before) - set(after))
    modified = sorted(
        p for p in set(before) & set(after) if before[p] != after[p]
    )
    return {
        "created": created,
        "removed": removed,
        "modified": modified,
    }


def snapshot_run(
    fixture_src: Path,
    command: list[str],
    snapshot_path: Path,
) -> dict:
    work = Path(os.environ.get("FS_DELTA_WORK", "/tmp")) / "fs_delta_work"
    if work.exists():
        shutil.rmtree(work)
    shutil.copytree(fixture_src, work)

    before_tree = _tree_state(work)
    proc = subprocess.run(
        command,
        cwd=work,
        capture_output=True,
        text=True,
        check=False,
    )
    after_tree = _tree_state(work)

    payload = {
        "command": command,
        "returncode": proc.returncode,
        "work_root": str(work.resolve()),
        "delta": _delta(before_tree, after_tree),
        "file_count_after": len(after_tree),
    }
    snapshot_path.write_text(json.dumps(payload, indent=2) + "\n")
    return payload


def compare(old_path: Path, new_path: Path) -> dict:
    old = json.loads(old_path.read_text())
    new = json.loads(new_path.read_text())
    keys = ("returncode", "work_root", "delta")
    drift = {
        k: {"old": old[k], "new": new[k]}
        for k in keys
        if old[k] != new[k]
    }
    return {"ok": not drift, "drift": drift}


if __name__ == "__main__":
    if sys.argv[1] == "record":
        snapshot_run(
            fixture_src=Path(sys.argv[2]),
            command=sys.argv[3:],
            snapshot_path=Path("fs_delta.snapshot.json"),
        )
    elif sys.argv[1] == "compare":
        report = compare(Path(sys.argv[2]), Path(sys.argv[3]))
        print(json.dumps(report, indent=2))
        raise SystemExit(0 if report["ok"] else 1)
    else:
        raise SystemExit("usage: record <fixture> <cmd...> | compare a b")
Enter fullscreen mode Exit fullscreen mode

Note one gap in this example. The child process cwd is not read back. A later probe file can close that gap.

Stdout is intentionally absent from compare keys. Transcript pinning is a different contract. This pass only answers what hit disk.

Numbered workflow

Follow the steps in order. Do not skip the snapshot commit.

  1. Copy a realistic fixture tree into git. Include relative outputs the script already writes. Exclude secrets and large binaries from that fixture.
  2. Record a baseline snapshot with the harness. Commit fs_delta.snapshot.json next to the fixture. Treat the JSON as a characterization test, not documentation.
  3. List path-related functions in the messy module. Rank them by relative writes, not by name length. Pick the smallest function that only formats or joins paths.
  4. Extract or rewrite that one function only. Keep signatures and call sites stable. Do not move chdir calls in the same diff.
  5. Re-run the harness against the same fixture and command. Compare the new snapshot to the committed baseline. Fail the change if created, removed, or modified sets drift.
  6. If the snapshot matches, keep the extract. If it drifts, revert the extract. Then pin the missing observable and repeat from step two.

The order is the method. Reversing it turns the extract into a guess. Guessing path behavior is how cwd drift ships.

Probe the child cwd when needed

The harness cannot see os.chdir inside the child. Add a probe only after the baseline exists. The probe must be a one-line write.

# probe_cwd.py — example child wrapper, not a library
from pathlib import Path
import runpy
import sys

target = sys.argv[1]
rest = sys.argv[2:]
Path("_cwd_probe_before.txt").write_text(str(Path.cwd().resolve()) + "\n")
sys.argv = [target, *rest]
runpy.run_path(target, run_name="__main__")
Path("_cwd_probe_after.txt").write_text(str(Path.cwd().resolve()) + "\n")
Enter fullscreen mode Exit fullscreen mode

Record both probe files in the delta. Then the extract cannot hide a chdir. Remove the probe after the path helper is stable.

A probe is still characterization, not production logging. Leave it out of released scripts. Keep it in the fixture command only.

Commands to run locally

Use a dedicated fixture directory. Do not point the harness at a live home directory.

python fs_delta_harness.py record ./fixtures/report_job \
  python messy_report.py --out report.json

git add fixtures/report_job fs_delta.snapshot.json
git commit -m "test: pin filesystem delta for report job"

# after the one-function extract
python fs_delta_harness.py record ./fixtures/report_job \
  python messy_report.py --out report.json
mv fs_delta.snapshot.json fs_delta.after.json

python fs_delta_harness.py compare \
  fs_delta.snapshot.json fs_delta.after.json
Enter fullscreen mode Exit fullscreen mode

A non-zero compare exit means disk behavior changed. That is a failed characterization test. Do not argue with the JSON.

Restore the committed snapshot name after a pass. Keep one baseline file in git. Extra after-files belong in the worktree only.

How to read a drifting delta

Read created first, then removed, then modified. New files often mean a cwd shift. Missing files often mean a resolve-too-early change.

Modified hashes with stable names mean content drift. The path helper may have changed encoding or line endings. That is still a failed extract for this pass.

work_root drift means the harness environment moved. Fix FS_DELTA_WORK before blaming the module. Unstable work roots make every compare noisy.

returncode drift is not a path lesson by itself. Still treat it as a hard fail. Path extracts should not change process success.

Smallest safe change examples

Safe extracts usually join paths only. They do not call os.chdir. They do not create directories as a side effect.

# before: mixed resolve and write in one block
out = Path("report.json")
out.write_text(payload)

# after: one helper, same relative target
def report_path(name: str) -> Path:
    return Path(name)

out = report_path("report.json")
out.write_text(payload)
Enter fullscreen mode Exit fullscreen mode

The helper does not call Path.resolve. Resolving too early bakes in cwd. Keep resolution at the write site if the original did.

Unsafe in the same diff: adding mkdir, switching to tempfile, or using __file__. Those change the delta on purpose. Handle them as behavior changes, not extracts.

Another unsafe move is Path.cwd() / name when the original used a bare name. That can look more correct and still drift. Correctness here means matching the recorded delta.

Where a free coding workspace fits

Some teams generate candidate extracts with a coding model. The snapshot must remain the gate, not the model. A matching delta is the only pass signal.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option. Those can host the harness loop without extra local setup. The model may propose the one-function extract. The committed snapshot still accepts or rejects that extract.

Do not skip the compare step because a diff looks tidy. Tidy diffs still call chdir. Tidy diffs still write beside the old cwd.

Limitations

The harness hashes file bytes only. It ignores permission bits and timestamps. Two writes with identical bytes look unchanged.

It also ignores files outside the fixture root. Absolute writes to /tmp will not appear. Add an explicit allow-list of extra roots if needed.

Race conditions can poison the snapshot. Parallel tests that share one work directory will collide. Use one work directory per command.

Line endings and umask differ across operating systems. Record snapshots on the same OS you ship. Do not commit a macOS snapshot for a Linux job without a second record.

The harness does not pin environment variables. PYTHONPATH and HOME still affect path resolution. Pin those in a later pass if the script reads them.

Symbolic links can collapse in copytree on some platforms. If the messy module depends on symlink shape, the fixture is wrong. Recreate the link structure before recording.

Who should not use this approach

Skip this workflow for greenfield packages with no disk writes. There is no delta to pin. Unit tests on pure functions are enough.

Skip it when the change must alter output paths. Characterization tests freeze behavior. A planned path migration needs new fixtures, not a matching snapshot.

Skip it for binary formats you cannot copy into git. Large media fixtures will not stay reviewable. Use a smaller synthetic fixture or a different contract.

Skip it if the module must chdir to load plugins. Pin that chdir as required behavior first. Then extract helpers that do not touch cwd.

Skip it for multi-user servers that write under /var. Fixture copies will not match those roots. Use a dedicated path contract test instead.

What the snapshot is not

The snapshot is not a performance benchmark. It is not a security audit. It is not proof the helper is well designed.

It only answers one question after an extract. Did the process still touch the same files? If yes, the path extract is allowed to stay.

A clean snapshot also does not bless extra refactors. Do not bundle import cleanup with the path helper. One behavioral axis per diff remains the rule.

Close

Path extracts fail from cwd drift, not from ugly names. Snapshot file deltas before the first helper move. Change one function only after the JSON matches.

Keep the harness boring and local. Keep the extract tiny. Let the filesystem delta reject confident but wrong diffs.

Commit the snapshot, then extract one path helper against it.

Top comments (0)