[GH-ISSUE #7551] [Bug] Linked-worktree imports hash Git administration files into source identities #4490

Closed
opened 2026-10-31 00:50:14 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 27, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/7551

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

Running node scripts/rea.mjs import-reference-source "$ROOT" --detail summary --format json on two clean linked Git worktrees at the same commit produces different historical-source identities. Both imports succeed, but their root_sha256, graph_sha256, and manifest_id differ. Each inventory counts the root .git pointer as a hashed regular source file.

Independent verification compared full graphs as well: their only differing facts were the .git file hash and the derived root digest. The application source, Git HEAD, provenance, and remaining graph fields matched. Repeating an import of the same worktree kept its identity stable.

Reproduction

  1. From the built baseline checkout, put Node.js 24.22.1 on PATH. The following script creates a small committed source tree and two native Git linked worktrees. HOME/XDG directories are isolated; the script removes the fixture afterward.
  2. Execute the script below with python3 -I. It invokes the public CLI directly and prints the identity comparisons and controls. This complete script was executed once; the initial independent fixture run reproduced the same failure and controls.
import json, os, subprocess, tempfile
from pathlib import Path

with tempfile.TemporaryDirectory(prefix="rea-gitfile-") as directory:
    work = Path(directory).resolve()
    env = os.environ.copy()
    for key in ("HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME",
                "XDG_DATA_HOME", "XDG_STATE_HOME", "TMPDIR"):
        target = work / key.lower()
        target.mkdir()
        env[key] = str(target)
    env.pop("REA_REFERENCE_SECRET_PATTERNS_JSON", None)
    repo = work / "repository"
    (repo / "src").mkdir(parents=True)
    (repo / "src/main.js").write_text("export const value = 7;\n")
    (repo / ".gitignore").write_text("*.log\n")

    def git(*args):
        return subprocess.check_output(
            ["git", "-C", str(repo), *args], text=True, env=env).strip()

    git("init", "-q", "-b", "main")
    git("add", ".")
    git("-c", "user.name=Fixture", "-c",
        "user.email=fixture@example.invalid", "commit", "-qm", "fixture")
    roots = [work / "tree-a", work / "tree-b"]
    for root in roots:
        git("worktree", "add", "--detach", "-q", str(root), "HEAD")

    def imported(root, detail="summary"):
        result = subprocess.run(
            ["node", "scripts/rea.mjs", "import-reference-source", str(root),
             "--detail", detail, "--format", "json"],
            env=env, capture_output=True, text=True, check=True)
        return json.loads(result.stdout)

    try:
        identities = [subprocess.check_output(
            ["git", "-C", str(root), "rev-parse", "HEAD", "HEAD^{tree}"],
            text=True, env=env) for root in roots]
        clean = all(subprocess.check_output(
            ["git", "-C", str(root), "status", "--porcelain"],
            text=True, env=env) == "" for root in roots)
        left, right = [imported(root) for root in roots]
        keys = ("root_sha256", "graph_sha256", "manifest_id")
        equality = {key: left["manifest"][key] == right["manifest"][key]
                    for key in keys}
        graphs = [imported(root, "full") for root in roots]
        print(json.dumps({"same_commit_and_tree": identities[0] == identities[1],
                          "both_clean": clean, "root_identity_equal": equality,
                          "git_entry": next(e for e in graphs[0]["entries"]
                                            if e["path"] == ".git")}, indent=2))
        for graph in graphs:
            graph.pop("root_sha256")
            next(e for e in graph["entries"]
                 if e["path"] == ".git")["sha256"] = "POINTER_HASH"
        print("equal_after_normalizing_only_pointer_hash_and_root", graphs[0] == graphs[1])
        print("repeat_manifest_equal", imported(roots[0])["manifest"] == left["manifest"])
        subtrees = [imported(root / "src") for root in roots]
        print("subtree_manifest_equal", subtrees[0]["manifest"] == subtrees[1]["manifest"])
        print("ordinary_checkout_exclusions", imported(repo)["exclusions"])
        env["REA_REFERENCE_SECRET_PATTERNS_JSON"] = '[".git"]'
        excluded = [imported(root) for root in roots]
        print("configured_exclusion_manifest_equal",
              excluded[0]["manifest"] == excluded[1]["manifest"])
    finally:
        for root in roots:
            git("worktree", "remove", "--force", str(root))

Expected behavior

A supported linked-worktree import should apply the Git administration exclusion to the .git pointer representation as well as the directory representation. Checkout-specific Git pointers should not count as source files or alter the committed source identity when the selected source tree, VCS facts, and import policy are otherwise equivalent.

The historical-source guide documents Git metadata reads and linked-worktree configuration support separately from source inventory. The default policy excludes Git administration directories, the Git locator understands pointer files, and the manifest builder promises deterministic, relocation-independent identities. Exact source locators are below.

Impact and scope

P2: the observed error changes retained evidence identity and adds an administration file to the source inventory for linked-worktree root inputs. This tiny source fixture has two intended files, but each linked-root summary reports three files because it includes .git. No source mutation, data loss, or security boundary crossing was observed.

A demonstrated workaround is REA_REFERENCE_SECRET_PATTERNS_JSON='[".git"]' on the same import command: both worktrees then have equal manifests, with the pointer recorded as a configured-secret exclusion. That preserves the application source but labels Git metadata as sensitive. Importing each src directory also restores equality while narrowing the selected source set. Downstream comparisons, submodules, MCP, and other platforms were not tested.

REA version

Exact node scripts/rea.mjs --version output:

6.8.0

Source baseline: 65f1c447344a423fa2785847dbcea1e9a9e2454a.

Runtime and platform

Node.js v24.22.1; macOS arm64, Darwin 27.0.0. Native Git created the linked worktrees; no external analysis provider was involved.

Target and artifact context

One synthetic commit containing .gitignore (*.log) and src/main.js (export const value = 7;). Both linked worktrees have identical HEAD and tree IDs and empty Git status. Their administration pointers are generated by Git and differ only in which worktree metadata directory they name.

Interface

CLI

Earliest failure stage

Artifact or evidence registration

Relevant output or evidence

The executed script prints the following comparisons; the variable pointer hash and size are omitted here:

same_commit_and_tree: true
both_clean: true
root_identity_equal: {root_sha256: false, graph_sha256: false, manifest_id: false}
equal_after_normalizing_only_pointer_hash_and_root True
repeat_manifest_equal True
subtree_manifest_equal True
configured_exclusion_manifest_equal True

The full graph contains a .git entry with kind: "file", content_state: "hashed", classifications: ["unknown"], and a SHA-256 digest. An ordinary checkout instead records this exclusion:

{"path":".git","entry_kind":"directory","reason":"default-ignored","pattern":".git/"}

Contract and implementation at the baseline:

  • docs/cli.md:643-657: source import records enclosing Git facts, supports linked-worktree shared configuration, and applies default exclusions. Its relocation statement at lines 603-610 specifically concerns permission failures; this report does not extend that wording to arbitrary differing inputs.
  • docs/javascript-recovery.md: describes the separate Wakaru recovery workflow and supplies no Git administration exclusion contract.
  • src/application/ReferenceSourceImportTypes.ts:117-127: default .git/ and .git/hooks/ exclusions.
  • src/application/ReferenceSourceGitDirectory.ts:26-44: recognizes .git regular files as Git-directory pointers.
  • src/domain/referenceSourceGraph.ts:588-611: deterministic, relocation-independent manifest construction.
  • tests/boundary/filesystem/referenceSourceImport.test.ts:605-640: existing equivalent-tree relocation equality test; inspected as contract evidence, not executed in this verification.

Typed outcome or recovery expected

Keep successful imports and correct Git HEAD provenance, but exclude the pointer from inventoried source files and their content commitments. A restoration check should keep equal identities across the two linked roots and on a same-root retry. The ordinary-checkout control should continue excluding its Git directory. Equality between ordinary and linked checkout roots is not asserted because their exclusion records can differ.

Analysis or suspected cause

Source-derived explanation: src/application/ReferenceSourceImportPolicy.ts:250-285 passes directory-only defaults to the ignore matcher. The file form reaches appendFile in src/application/ReferenceSourceImportEntries.ts:540-565, which hashes its bytes. src/domain/referenceSourceGraph.ts:512-558 includes every file hash in the root commitment. This matches the observed attribution: after replacing only the pointer hash and derived root digest, the two full graphs are equal.

Additional context

Searched open and closed issues, issue comments, and open/closed/merged PRs. Related feature #2443 and open PRs #2630/#5472 request caller-selected exclusions; they do not report the default Git pointer policy defect. Closed #1609 and merged #677 concern Git HEAD discovery, which works in this reproduction. Closed #5620 concerns host paths in unreadable-file diagnostics; this fixture has readable files with empty entry limitations and changes the root digest itself. None is a duplicate of this observed behavior.

Originally created by @coygeek on GitHub (Oct 27, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7551 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior Running `node scripts/rea.mjs import-reference-source "$ROOT" --detail summary --format json` on two clean linked Git worktrees at the same commit produces different historical-source identities. Both imports succeed, but their `root_sha256`, `graph_sha256`, and `manifest_id` differ. Each inventory counts the root `.git` pointer as a hashed regular source file. Independent verification compared full graphs as well: their only differing facts were the `.git` file hash and the derived root digest. The application source, Git HEAD, provenance, and remaining graph fields matched. Repeating an import of the same worktree kept its identity stable. ### Reproduction 1. From the built baseline checkout, put Node.js 24.22.1 on PATH. The following script creates a small committed source tree and two native Git linked worktrees. HOME/XDG directories are isolated; the script removes the fixture afterward. 2. Execute the script below with `python3 -I`. It invokes the public CLI directly and prints the identity comparisons and controls. This complete script was executed once; the initial independent fixture run reproduced the same failure and controls. ```python import json, os, subprocess, tempfile from pathlib import Path with tempfile.TemporaryDirectory(prefix="rea-gitfile-") as directory: work = Path(directory).resolve() env = os.environ.copy() for key in ("HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "TMPDIR"): target = work / key.lower() target.mkdir() env[key] = str(target) env.pop("REA_REFERENCE_SECRET_PATTERNS_JSON", None) repo = work / "repository" (repo / "src").mkdir(parents=True) (repo / "src/main.js").write_text("export const value = 7;\n") (repo / ".gitignore").write_text("*.log\n") def git(*args): return subprocess.check_output( ["git", "-C", str(repo), *args], text=True, env=env).strip() git("init", "-q", "-b", "main") git("add", ".") git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture") roots = [work / "tree-a", work / "tree-b"] for root in roots: git("worktree", "add", "--detach", "-q", str(root), "HEAD") def imported(root, detail="summary"): result = subprocess.run( ["node", "scripts/rea.mjs", "import-reference-source", str(root), "--detail", detail, "--format", "json"], env=env, capture_output=True, text=True, check=True) return json.loads(result.stdout) try: identities = [subprocess.check_output( ["git", "-C", str(root), "rev-parse", "HEAD", "HEAD^{tree}"], text=True, env=env) for root in roots] clean = all(subprocess.check_output( ["git", "-C", str(root), "status", "--porcelain"], text=True, env=env) == "" for root in roots) left, right = [imported(root) for root in roots] keys = ("root_sha256", "graph_sha256", "manifest_id") equality = {key: left["manifest"][key] == right["manifest"][key] for key in keys} graphs = [imported(root, "full") for root in roots] print(json.dumps({"same_commit_and_tree": identities[0] == identities[1], "both_clean": clean, "root_identity_equal": equality, "git_entry": next(e for e in graphs[0]["entries"] if e["path"] == ".git")}, indent=2)) for graph in graphs: graph.pop("root_sha256") next(e for e in graph["entries"] if e["path"] == ".git")["sha256"] = "POINTER_HASH" print("equal_after_normalizing_only_pointer_hash_and_root", graphs[0] == graphs[1]) print("repeat_manifest_equal", imported(roots[0])["manifest"] == left["manifest"]) subtrees = [imported(root / "src") for root in roots] print("subtree_manifest_equal", subtrees[0]["manifest"] == subtrees[1]["manifest"]) print("ordinary_checkout_exclusions", imported(repo)["exclusions"]) env["REA_REFERENCE_SECRET_PATTERNS_JSON"] = '[".git"]' excluded = [imported(root) for root in roots] print("configured_exclusion_manifest_equal", excluded[0]["manifest"] == excluded[1]["manifest"]) finally: for root in roots: git("worktree", "remove", "--force", str(root)) ``` ### Expected behavior A supported linked-worktree import should apply the Git administration exclusion to the `.git` pointer representation as well as the directory representation. Checkout-specific Git pointers should not count as source files or alter the committed source identity when the selected source tree, VCS facts, and import policy are otherwise equivalent. The historical-source guide documents Git metadata reads and linked-worktree configuration support separately from source inventory. The default policy excludes Git administration directories, the Git locator understands pointer files, and the manifest builder promises deterministic, relocation-independent identities. Exact source locators are below. ### Impact and scope P2: the observed error changes retained evidence identity and adds an administration file to the source inventory for linked-worktree root inputs. This tiny source fixture has two intended files, but each linked-root summary reports three files because it includes `.git`. No source mutation, data loss, or security boundary crossing was observed. A demonstrated workaround is `REA_REFERENCE_SECRET_PATTERNS_JSON='[".git"]'` on the same import command: both worktrees then have equal manifests, with the pointer recorded as a configured-secret exclusion. That preserves the application source but labels Git metadata as sensitive. Importing each `src` directory also restores equality while narrowing the selected source set. Downstream comparisons, submodules, MCP, and other platforms were not tested. ### REA version Exact `node scripts/rea.mjs --version` output: ```text 6.8.0 ``` Source baseline: `65f1c447344a423fa2785847dbcea1e9a9e2454a`. ### Runtime and platform Node.js v24.22.1; macOS arm64, Darwin 27.0.0. Native Git created the linked worktrees; no external analysis provider was involved. ### Target and artifact context One synthetic commit containing `.gitignore` (`*.log`) and `src/main.js` (`export const value = 7;`). Both linked worktrees have identical HEAD and tree IDs and empty Git status. Their administration pointers are generated by Git and differ only in which worktree metadata directory they name. ### Interface CLI ### Earliest failure stage Artifact or evidence registration ### Relevant output or evidence The executed script prints the following comparisons; the variable pointer hash and size are omitted here: ```text same_commit_and_tree: true both_clean: true root_identity_equal: {root_sha256: false, graph_sha256: false, manifest_id: false} equal_after_normalizing_only_pointer_hash_and_root True repeat_manifest_equal True subtree_manifest_equal True configured_exclusion_manifest_equal True ``` The full graph contains a `.git` entry with `kind: "file"`, `content_state: "hashed"`, `classifications: ["unknown"]`, and a SHA-256 digest. An ordinary checkout instead records this exclusion: ```json {"path":".git","entry_kind":"directory","reason":"default-ignored","pattern":".git/"} ``` Contract and implementation at the baseline: - `docs/cli.md:643-657`: source import records enclosing Git facts, supports linked-worktree shared configuration, and applies default exclusions. Its relocation statement at lines 603-610 specifically concerns permission failures; this report does not extend that wording to arbitrary differing inputs. - `docs/javascript-recovery.md`: describes the separate Wakaru recovery workflow and supplies no Git administration exclusion contract. - `src/application/ReferenceSourceImportTypes.ts:117-127`: default `.git/` and `.git/hooks/` exclusions. - `src/application/ReferenceSourceGitDirectory.ts:26-44`: recognizes `.git` regular files as Git-directory pointers. - `src/domain/referenceSourceGraph.ts:588-611`: deterministic, relocation-independent manifest construction. - `tests/boundary/filesystem/referenceSourceImport.test.ts:605-640`: existing equivalent-tree relocation equality test; inspected as contract evidence, not executed in this verification. ### Typed outcome or recovery expected Keep successful imports and correct Git HEAD provenance, but exclude the pointer from inventoried source files and their content commitments. A restoration check should keep equal identities across the two linked roots and on a same-root retry. The ordinary-checkout control should continue excluding its Git directory. Equality between ordinary and linked checkout roots is not asserted because their exclusion records can differ. ### Analysis or suspected cause Source-derived explanation: `src/application/ReferenceSourceImportPolicy.ts:250-285` passes directory-only defaults to the ignore matcher. The file form reaches `appendFile` in `src/application/ReferenceSourceImportEntries.ts:540-565`, which hashes its bytes. `src/domain/referenceSourceGraph.ts:512-558` includes every file hash in the root commitment. This matches the observed attribution: after replacing only the pointer hash and derived root digest, the two full graphs are equal. ### Additional context Searched open and closed issues, issue comments, and open/closed/merged PRs. Related feature #2443 and open PRs #2630/#5472 request caller-selected exclusions; they do not report the default Git pointer policy defect. Closed #1609 and merged #677 concern Git HEAD discovery, which works in this reproduction. Closed #5620 concerns host paths in unreadable-file diagnostics; this fixture has readable files with empty entry limitations and changes the root digest itself. None is a duplicate of this observed behavior.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#4490
No description provided.