Repository navigation
update writes manifest.json to the CWD instead of the target — cross-project contamination, and the target gets no manifest #2316
Description
Summary
graphify update <target> writes manifest.json into the current working directory's graphify-out/, not the target's — while every other artifact (graph.json, graph.html, GRAPH_REPORT.md, cache/, .graphify_root) goes to the target correctly.
Two things break at once:
- The CWD project gets a foreign manifest. Its
graphify-out/manifest.jsonnow describes another project's files. Any tool that reads the manifest as "what the indexer saw for this project" gets a confidently wrong answer. - The target ends up with no manifest at all, so its incremental detection has nothing to compare against on the next run.
Running graphify update over a list of N projects from one script therefore leaves N-1 manifests wrong and one project's graphify-out/ holding the last-processed project's file list.
This is the same root cause family as #2081 (built_at_commit read from CWD instead of the extraction target), but a different field and a different code path — #2081 is extract, this is the watch-based update path.
Reproduction
mkdir -p /tmp/repro/projA /tmp/repro/projB
printf 'def alpha_only_in_A():\n return 1\n' > /tmp/repro/projA/a_file.py
printf 'def beta_only_in_B():\n return 2\n' > /tmp/repro/projB/b_file.py
for d in projA projB; do (cd /tmp/repro/$d && git init -q . && git add -A && git commit -qm init); done
# run from projA, targeting projB
cd /tmp/repro/projA
graphify update /tmp/repro/projB
ls /tmp/repro/projA/graphify-out/
ls /tmp/repro/projB/graphify-out/
cat /tmp/repro/projA/graphify-out/manifest.jsonActual (graphify 0.9.13):
/tmp/repro/projA/graphify-out/: manifest.json <-- only this, and it is projB's
/tmp/repro/projB/graphify-out/: cache/ graph.json graph.html GRAPH_REPORT.md
.graphify_labels.json .graphify_root
(no manifest.json)
$ cat /tmp/repro/projA/graphify-out/manifest.json
{
"b_file.py": { <-- projB's file, in projA's output dir
"mtime": 1785360486.2794108,
"ast_hash": "259d778f11c851feb2ab5285781ebcd3",
"semantic_hash": ""
}
}
Expected — manifest.json lands next to the graph it describes, in projB/graphify-out/, and projA is not touched.
Root cause
detect.py:28 freezes the manifest location at import time, from a relative path:
# graphify/paths.py:23
GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out") # relative by default
# graphify/paths.py:224
def out_path(*parts: str) -> Path:
return Path(GRAPHIFY_OUT, *parts)
# graphify/detect.py:28
_MANIFEST_PATH = str(out_path("manifest.json")) # module-level -> resolves against the CWDsave_manifest, load_manifest and detect_incremental all default manifest_path=_MANIFEST_PATH.
cli.py does the right thing and passes the path explicitly (e.g. cli.py:2472):
_save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target)But the watch rebuild path does not — watch.py:970, 1009, 1087:
save_manifest(detected["files"], kind="ast", root=project_root)It passes root=project_root, so the correct root is already in hand, but lets the file location fall back to the CWD-frozen constant. The correct output dir is also already in scope as out — used two lines earlier:
(out / ".graphify_root").write_text(str(watch_path), encoding="utf-8")
try:
from graphify.detect import save_manifest
save_manifest(detected["files"], kind="ast", root=project_root)
except Exception:
passSuggested fix (verified)
One line at each of the three watch.py call sites:
save_manifest(detected["files"], manifest_path=str(out / "manifest.json"), kind="ast", root=project_root)Applied locally against 0.9.13 and re-ran the reproduction above:
projA/graphify-out (CWD) |
projB/graphify-out/manifest.json (target) |
|
|---|---|---|
| before | manifest.json, containing b_file.py |
absent |
| after | not created at all | present, containing b_file.py |
Side note
The three call sites are wrapped in except Exception: pass. A manifest write that fails for any other reason is also silent, so incremental detection would quietly degrade to a full rescan with nothing in the output to say why. Not the bug reported here, but it is why this one was invisible for so long — worth a log line even if the swallow stays.
Workaround
cd into the target before calling update (or set the process CWD to the target per project when driving update from a script). With CWD == target, the frozen relative path happens to resolve to the right place.
Environment
- graphify 0.9.13
- Windows 11, Python 3.10 (uv tool install), also reproducible with a POSIX shell
Activity
Rishet11 commentedon Jul 31, 2026
Reproduced on current
v8(4fe1109, 0.9.31) — the report is accurate, and there are two extra consequences worth recording before a fix lands.Confirmed mechanism.
_rebuild_code(graphify/watch.py:861) computes the correct output dir atwatch.py:897:and uses it for every artifact, including one line above each manifest write (
out / ".graphify_root",out / "needs_update"). But the threesave_manifest(...)calls atwatch.py:1232,1275,1421passkind=,root=project_rootandscan_corpus=and omitmanifest_path=, so they fall through todetect._MANIFEST_PATH(graphify/detect.py:29), a module-import-time constant built from the relativeout_path("manifest.json")(graphify/paths.py:288) and therefore resolved against the process CWD.cli.pyis the reference-correct wiring:manifest_path = graphify_out / "manifest.json"atcli.py:2815, passed explicitly at2853,3404,3510,3657.Extra 1 — it is data loss on the CWD project, not just a foreign file.
save_manifestalso reads the same path first (detect.py:1615,existing = load_manifest(manifest_path, root=root)), and the full-scan callers passscan_corpus(the #1908 pruning). So the CWD project's own rows are pruned as "not in the scan corpus" and overwritten:projA now needs a full rescan too, so the incremental baseline is lost on both projects, not only the target.
Extra 2 — with a relative target the manifest keys also come out absolute.
watch.py:950:For
graphify update ../projBtheroot=handed tosave_manifestis projA, so projB's files are out-of-root and_to_relative_for_storagewrites them absolute — breaking the #777/#1964 portability guarantee even after the location is fixed:The reporter's repro uses an absolute path, which is why this one stayed hidden. Both the location and the anchor need to point at the target; fixing only the location would leave relative-target runs writing a non-portable manifest into the right directory.
I'm working on this now (
manifest_path=str(out / "manifest.json")at the three sites, plus anchoringproject_rootto the target), with tests covering all three branches of_rebuild_codeand the relative-target case. Happy to hand it over if someone is already on it.fix(watch): write manifest.json to the target's graphify-out (#2316)
test(watch): cover manifest location, anchor and commit provenance (#…
chore: bump to 0.9.32; changelog for #2333/#2334/#2336/#2342/#2326/#2323
safishamsi commentedon Aug 1, 2026
Fixed in v0.9.32.
graphify updatenow writesmanifest.jsonto the target graphify-out instead of the current working directory, so running it from elsewhere can no longer prune the target manifest rows. Thanks.fix(watch): write manifest.json to the target's graphify-out (Graphif…
test(watch): cover manifest location, anchor and commit provenance (G…
chore: bump to 0.9.32; changelog for Graphify-Labs#2333/Graphify-Labs…
0.9.47+trifour.6: fix merge regressions, restore fork features, adapt…