Skip to content

Keep worktree dev state isolated#4555

Open
t3dotgg wants to merge 3 commits into
mainfrom
agent/dev-worktree-isolation
Open

Keep worktree dev state isolated#4555
t3dotgg wants to merge 3 commits into
mainfrom
agent/dev-worktree-isolation

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Jul 26, 2026

Copy link
Copy Markdown
Member

What

  • Resolve linked worktree roots and default development state to that worktree’s ignored .t3 directory.
  • Keep an explicit --home-dir authoritative while preventing an ambient T3CODE_HOME from selecting the installed app’s live data.
  • Document and test the resolution rules.

Why

A dev command launched from a linked worktree could otherwise inherit the shared application home and write development state into the database used by the installed app.

Impact

This is the foundational slice of the dev-server overhaul. It changes only worktree state isolation and is the base for the two follow-up PRs.

Verification

  • vp test run packages/shared/src/devHome.test.ts scripts/dev-runner.test.ts (32 tests)
  • targeted lint
  • vp run --filter=@t3tools/shared typecheck

Note

Isolate dev state to the linked git worktree's .t3 directory by default

  • Adds resolveGitWorktreePath and resolveWorktreeT3Home utilities in packages/shared/src/devHome.ts to detect when the process runs inside a linked git worktree and derive its local .t3 path.
  • Updates scripts/dev-runner.ts to apply a new precedence order: explicit --home-dir > worktree-local <worktree>/.t3 > ambient T3CODE_HOME. Blank --home-dir is treated as unset.
  • Removes the implicit T3CODE_HOME fallback from the --home-dir CLI flag; absence of the flag now delegates to the new precedence logic.
  • Updates docs and AGENTS.md to document the new precedence rules, including the note that submodules are not worktrees.
  • Behavioral Change: when running inside a linked worktree, the dev server now ignores T3CODE_HOME unless --home-dir is absent and no worktree .t3 is detected.

Macroscope summarized 6404fc1.


Note

Medium Risk
Changes which SQLite database dev commands use in worktrees (behavioral shift away from shared/live userdata); logic is well-tested but mis-detection could still point at the wrong home.

Overview
Linked git worktrees now get an isolated dev data directory at <worktree>/.t3 (userdata under <worktree>/.t3/userdata) when you run vp run dev, so feature work does not inherit the installed app’s shared home.

The dev runner resolves home in order: explicit --home-dir (whitespace-only counts as unset), then worktree .t3, then ambient T3CODE_HOME. Worktree detection walks up from cwd, treats a .git file whose gitdir: ends in worktrees/<name> as a linked worktree (not main checkout, submodules, or invalid pointers), and is implemented in new @t3tools/shared/devHome helpers with tests. createDevRunnerEnv no longer merges ambient T3CODE_HOME when no explicit base dir is passed; precedence is applied in runDevRunnerWithInput using injectable HostProcessWorkingDirectory. Agent docs and test-t3-app / scripts reference reflect the new default and pairing/SQLite paths for worktree-local state.

Reviewed by Cursor Bugbot for commit 6404fc1. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Development environments in linked worktrees now use a worktree-local .t3 directory by default.
    • Added clear precedence for development home selection: explicit --home-dir, worktree .t3, then ambient configuration.
    • Added a shared development-home export for tooling integrations.
  • Documentation

    • Clarified development server setup, state storage, isolation, and pairing-token recovery guidance.
  • Tests

    • Added coverage for worktree detection and development-home precedence across repository layouts and configuration scenarios.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 01fdfc92-b503-4ace-821b-c30a995b924f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/dev-worktree-isolation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jul 26, 2026
@t3dotgg
t3dotgg marked this pull request as ready for review July 26, 2026 05:21
Comment thread packages/shared/src/devHome.ts Outdated
Comment thread scripts/dev-runner.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2654583. Configure here.

Comment thread scripts/dev-runner.ts Outdated
Comment thread packages/shared/src/devHome.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR changes runtime behavior by modifying how dev servers resolve their home directory, with worktree-local .t3 now outranking ambient T3CODE_HOME. While well-tested and scoped to dev scenarios, this precedence change warrants human review.

You can customize Macroscope's approvability policy. Learn more.

A submodule's `.git` is also a file, so the file-vs-directory check alone
classified every submodule as a linked worktree and handed it a
worktree-local `.t3`. Read the `gitdir:` pointer and require it to land in
`.git/worktrees` (compared as path segments, not a substring).

Resolve cwd through a HostProcessWorkingDirectory reference rather than
calling process.cwd() directly, so the precedence chain is testable.

Add tests for the submodule and malformed-`.git`-file cases, plus
end-to-end coverage of all four home-precedence outcomes, which had no
test before. Document that worktree state lands in `.t3/userdata`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@t3dotgg

t3dotgg commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Reviewed and pushed fixes in 2a3ef8c.

Findings

1. Submodules were misclassified as worktrees (correctness bug)

resolveGitWorktreePath treated any .git file as a linked worktree. But a git submodule's .git is also a file — it just points somewhere else:

worktree:  gitdir: /repo/.git/worktrees/x
submodule: gitdir: ../.git/modules/sub

Verified against a real submodule, the helper returned the submodule root, so vp run dev from inside one would silently write state to <submodule>/.t3 — a directory that is not gitignored by the parent repo's .gitignore. Fixed by parsing the gitdir: pointer and requiring .git/worktrees as path segments (not a substring, so a directory legitimately named worktrees can't false-positive). A malformed or unreadable .git file now falls through to the normal precedence rather than claiming a worktree.

2. process.cwd() read directly, making precedence untestable

runDevRunnerWithInput called process.cwd() inline while reading every other ambient input through a Context.Reference. That's why the precedence chain — the actual point of this PR — had zero test coverage: there was no way to vary cwd. Added HostProcessWorkingDirectory alongside the existing HostProcessEnvironment/HostProcessPlatform references and routed the call through it.

3. Precedence chain had no end-to-end test

devHome.test.ts covered the helper and createDevRunnerEnv covered env construction, but nothing tested that they compose into the documented --home-dir > worktree .t3 > T3CODE_HOME > default order. Added four tests asserting the T3CODE_HOME actually handed to the spawned process for each branch.

4. Stale doc claim

test-t3-app/SKILL.md said the <base>/dev fallback applies to "an implicit dev home." A worktree .t3 is passed as an explicit base dir, so it resolves to .t3/userdata, not .t3/dev — worth stating since the skill tells agents where to find state.sqlite.

Verification

  • vp test run packages/shared scripts — 479 passed (38 in the two touched files, up from 32)
  • vp run --filter=@t3tools/shared typecheck, scripts typecheck — clean
  • lint on all touched files — clean
  • Manual --dry-run across all four paths:
cwd ambient T3CODE_HOME flag resolved baseDir
worktree set <worktree>/.t3
worktree set --home-dir the flag
main checkout set /home/theo/.t3
submodule set /home/theo/.t3

The core design is right and the ambient-T3CODE_HOME hazard it addresses is real — I hit exactly that setup locally (T3CODE_HOME=/home/theo/.t3), where the old behavior would have pointed a worktree dev server at the installed app's live userdata database.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jul 26, 2026
Comment thread packages/shared/src/devHome.ts Outdated
The `.git/worktrees` segment check rejected worktrees whose common dir is
not named `.git`. A worktree of a bare repo points at
`<repo>.git/worktrees/<name>`, and $GIT_COMMON_DIR can be anything. Git
always uses the `worktrees/<name>` tail, so match on that instead;
`<git-dir>/modules/<name>` still fails it.

A blank `--home-dir` also counted as a selection under `??`, skipping the
worktree default and landing on the shared home — the outcome this
precedence exists to prevent. Trim before choosing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/shared/src/devHome.ts`:
- Around line 28-33: Update pointsAtLinkedWorktree() to validate the .git
pointer file using only its first meaningful line, rejecting files with
arbitrary content before a gitdir: declaration instead of accepting later lines.
Preserve valid linked-worktree resolution and add a regression fixture covering
junk followed by a gitdir: line.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b5bb3d2-5c90-4ecb-a211-9582b446b766

📥 Commits

Reviewing files that changed from the base of the PR and between 5719e8a and 6404fc1.

📒 Files selected for processing (9)
  • .agents/skills/test-t3-app/SKILL.md
  • AGENTS.md
  • docs/reference/scripts.md
  • packages/shared/package.json
  • packages/shared/src/devHome.test.ts
  • packages/shared/src/devHome.ts
  • packages/shared/src/hostProcess.ts
  • scripts/dev-runner.test.ts
  • scripts/dev-runner.ts

Comment on lines +28 to +33
const gitdir = gitFileContents
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith("gitdir:"))
?.slice("gitdir:".length)
.trim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate devHome =="
fd -a 'devHome\.' . | sed 's#^\./##'

echo "== diff/stat summary =="
git diff --stat || true

echo "== inspect packages/shared/src/devHome.ts =="
if [ -f packages/shared/src/devHome.ts ]; then
  nl -ba packages/shared/src/devHome.ts | sed -n '1,140p'
fi

echo "== search devHome usages/tests =="
rg -n "devHome|linked worktree|worktrees|gitdir|\.t3" packages/shared -S || true

Repository: pingdotgg/t3code

Length of output: 390


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== packages/shared/src/devHome.ts =="
cat -n packages/shared/src/devHome.ts | sed -n '1,160p'

echo "== related tests =="
cat -n packages/shared/src/devHome.test.ts | sed -n '1,220p'

echo "== behavioral parser probe from source text =="
python3 - <<'PY'
raw = open('packages/shared/src/devHome.ts', encoding='utf-8').read()
print('finds split/find:', 'find((line) => line.startsWith("gitdir:"))' in raw)
print('contains .split limit:', re := __import__('re'); bool(re.search(r'split\([^;]*,\\s*1\\s*\)', raw)))

cases = {
    'single': 'gitdir: /repo/path\n',
    'junk_after': 'junk\n gitdir: /repo/path \n',
    'junk_before': '\n junk\n gitdir: /repo/path \n',
    'no_gitdir': 'junk\n',
    'empty': '',
}
def current(contents):
    gitdir = (
        contents
        .split('\n')
        .map(lambda line: line.strip())
        .find(lambda line: line.startswith('gitdir:'))
        .removeprefix('gitdir:')
        .strip()
        if any(line.strip().startswith('gitdir:') for line in contents.split('\n')) else None
    )
    return gitdir
for name, value in cases.items():
    print(name, repr(current(value)))
PY

Repository: pingdotgg/t3code

Length of output: 10757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== focused test command availability/state =="
command -v vp || true
command -v find || true

echo "== deterministic scan behavior for parser condition =="
python3 - <<'PY'
samples = [
    ("main .git file", "git\nrefs\n"),
    ("single valid pointer", "gitdir: /elsewhere/.git/worktrees/x\n"),
    ("malformed pointer with later valid line", "junk\n gitdir: /elsewhere/.git/worktrees/x \n"),
    ("valid pointer with junk line", "gitdir: /elsewhere/.git/worktrees/x\njunk\n"),
    ("submodule pointer", "gitdir: ../.git/modules/sub\n"),
    ("empty unreadable", ""),
]

def current_gitdir(contents):
    gitdir = next(
        (line.strip().removeprefix("gitdir:").strip()
         for line in contents.split("\n") if line.strip().startswith("gitdir:")),
        None,
    )
    if gitdir is None:
        return None
    segments = gitdir.replace("\\", "/").rstrip("/").split("/")
    return None if len(segments) < 3 or segments[-2] != "worktrees" else True, gitdir

def proposed_gitdir(contents):
    first = next(iter(contents.split("\n", 1)), "")
    s = first.strip()
    gitdir = s.removeprefix("gitdir:").strip() if s.startswith("gitdir:") else None
    if gitdir is None:
        return None
    segments = gitdir.replace("\\", "/").rstrip("/").split("/")
    return None if len(segments) < 3 or segments[-2] != "worktrees" else True, gitdir

print("name|current_worktree|current_gitdir|proposed_worktree|proposed_gitdir")
for name, contents in samples:
    c = current_gitdir(contents)
    p = proposed_gitdir(contents)
    print(f"{name}|{c[0] if c[0] is not None else None}|{c[1]}|{p[0] if p[0] is not None else None}|{p[1]}")
PY

echo "== check package scripts/tests =="
cat -n package.json | sed -n '1,160p' 2>/dev/null || true
cat -n packages/shared/package.json | sed -n '1,160p' 2>/dev/null || true

Repository: pingdotgg/t3code

Length of output: 464


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deterministic scan behavior for .git parser =="
python3 - <<'PY'
samples = [
    ("main .git file", "git\nrefs\n"),
    ("single valid pointer", "gitdir: /elsewhere/.git/worktrees/x\n"),
    ("malformed pointer with later valid line", "junk\n gitdir: /elsewhere/.git/worktrees/x \n"),
    ("valid pointer with junk line", "gitdir: /elsewhere/.git/worktrees/x\njunk\n"),
    ("submodule pointer", "gitdir: ../.git/modules/sub\n"),
    ("empty unreadable", ""),
]

def current_result(contents):
    for line in contents.split("\n"):
        s = line.strip()
        if s.startswith("gitdir:"):
            gitdir = s.removeprefix("gitdir:").strip()
            segments = gitdir.replace("\\", "/").rstrip("/").split("/")
            return (len(segments) >= 3 and segments[-2] == "worktrees"), gitdir
    return None, None

def proposed_result(contents):
    first = next(iter(contents.split("\n", 1)), "")
    s = first.strip()
    if not s.startswith("gitdir:"):
        return None, None
    gitdir = s.removeprefix("gitdir:").strip()
    segments = gitdir.replace("\\", "/").rstrip("/").split("/")
    return (len(segments) >= 3 and segments[-2] == "worktrees"), gitdir

print("name|current_worktree|current_gitdir|proposed_worktree|proposed_gitdir")
for name, contents in samples:
    c = current_result(contents)
    p = proposed_result(contents)
    print(f"{name}|{c[0]}|{c[1]}|{p[0]}|{p[1]}")
PY

echo "== package.json scripts =="
if [ -f package.json ]; then
  python3 - <<'PY'
import json
from pathlib import Path
for path in [Path("package.json"), Path("packages/shared/package.json")]:
    if path.exists():
        data = json.loads(path.read_text())
        print(path)
        print(json.dumps(data.get("scripts", {}), indent=2, sort_keys=True))
PY
fi

Repository: pingdotgg/t3code

Length of output: 3358


Reject malformed .git pointer files.

pointsAtLinkedWorktree() accepts later gitdir: lines after arbitrary content, so .git with junk\ngitdir: /elsewhere/.git/worktrees/x can be treated as a linked worktree. Parse only the first pointer line and add a regression fixture.

Proposed fix
-  const gitdir = gitFileContents
-    .split(/\r?\n/)
-    .map((line) => line.trim())
-    .find((line) => line.startsWith("gitdir:"))
-    ?.slice("gitdir:".length)
-    .trim();
+  const [pointerLine = ""] = gitFileContents.split(/\r?\n/, 1);
+  const gitdir = pointerLine.trim().startsWith("gitdir:")
+    ? pointerLine.trim().slice("gitdir:".length).trim()
+    : undefined;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const gitdir = gitFileContents
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith("gitdir:"))
?.slice("gitdir:".length)
.trim();
const [pointerLine = ""] = gitFileContents.split(/\r?\n/, 1);
const gitdir = pointerLine.trim().startsWith("gitdir:")
? pointerLine.trim().slice("gitdir:".length).trim()
: undefined;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/devHome.ts` around lines 28 - 33, Update
pointsAtLinkedWorktree() to validate the .git pointer file using only its first
meaningful line, rejecting files with arbitrary content before a gitdir:
declaration instead of accepting later lines. Preserve valid linked-worktree
resolution and add a regression fixture covering junk followed by a gitdir:
line.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant