Skip to content

feat(tia): support projects in git repository subdirectories (monorepos) - #1809

Open
einar-hansen wants to merge 1 commit into
pestphp:5.xfrom
einar-hansen:tia-monorepo-support
Open

feat(tia): support projects in git repository subdirectories (monorepos)#1809
einar-hansen wants to merge 1 commit into
pestphp:5.xfrom
einar-hansen:tia-monorepo-support

Conversation

@einar-hansen

Copy link
Copy Markdown

What:

  • Bug Fix
  • New Feature

Description:

Love the Tia engine! Unfortunately I couldn't try it on one of my projects, since it's a monorepo — so I decided to use AI to build monorepo support instead. This PR was written by Claude Fable (Anthropic); I've reviewed it multiple times and it works on my machine. 🙂

The problem

Today, Tia refuses to start if your Laravel app lives in a subfolder of a bigger repo:

ERROR Tia mode requires the git repository root.
This project sits in a subdirectory of a larger repo apps/api.
Give the project its own git repository to use Tia.

That's a dealbreaker for monorepos — one repo containing apps/api, apps/web, shared tooling.

The reason for the restriction turns out to be simple: git and Tia describe files from two different starting points. When Tia asks git "what changed?", git answers with paths measured from the repo root: apps/api/app/Models/User.php. But Tia's dependency graph stores everything measured from the project root: app/Models/User.php. When the project is the repo root, those are the same string and everything works. In a subfolder, every single lookup misses — so the current code just blocks the whole scenario.

The fix

Teach the git boundary to translate. ChangedFiles asks git once where the project sits inside the repo (git rev-parse --show-prefixapps/api/), and then:

  • Everything git reports gets the prefix stripped before it touches the graph: apps/api/app/Models/User.php becomes app/Models/User.php, which is what the graph knows.
  • Everything Tia asks git about gets the prefix added: looking up composer.lock at an old commit becomes git show <sha>:apps/api/composer.lock.
  • Changes outside the project folder are ignored. If a commit only touches apps/web, your api tests shouldn't rerun — sibling projects are as invisible to this project as files outside the repo are for a normal project today. Without this, every unknown path would hit Tia's "I don't know this file, rerun everything" safety net, and monorepo replays would never actually replay.

Two git flags matter more than they look:

  • -z — without it, git escapes filenames containing non-ASCII characters ("apps/api/\303\246.php" instead of apps/api/æ.php). An escaped path doesn't match the prefix, so the change would be silently dropped — meaning tests that should rerun would be replayed from cache instead. That's the one failure Tia can never afford. -z makes git output raw bytes.
  • --no-renames — when git detects a rename it only reports the new location. If a file moves out of the project folder, the new path is outside the prefix and gets ignored — and the old path is never mentioned, so the file just… vanishes from Tia's view, while its dependents keep replaying stale results. Disabling rename detection makes a rename look like "old file deleted, new file added", so both sides are visible. (Fun side effect: this also covers root-level projects where someone has diff.renames=true in their global git config, which quietly has the same problem today.)

Beyond ChangedFiles, three other places assumed "project root == repo root", all fixed via a small new GitRepository helper that walks up parent directories to find the governing .git:

  • BaselineSync looked for .git/config inside the project folder to figure out which GitHub repo to fetch baselines from — in a subfolder there's no .git there, so baseline fetching silently never activated.
  • Storage derives its cache key from the git origin URL. Two apps in the same repo share that URL, so apps/api and services/api would have collided on one graph store — the key now includes the subfolder path. For a project at the repo root the subfolder is empty, so existing root-level projects keep byte-identical keys (pinned by a test) and no caches are invalidated by upgrading.
  • Fingerprint's check for "is this file gitignored?" needed to know it's inside a repo at all.

Root-level projects are untouched by all of this. The prefix is an empty string for them, so every translation is a no-op, storage keys come out byte-for-byte identical (nobody's cache gets invalidated by upgrading), and a regression test pins that.

Verification

  • 22 new tests that build real throwaway git repos and cover: prefix detection, path translation, ignoring sibling/root changes, escaped (UTF-8) filenames, renames out of the subtree, nested repos, git worktrees, storage-key separation, and unchanged root-level behaviour.
  • composer test passes in full, including updated visual snapshots.
  • Real world: an 11,168-test Laravel app living in apps/api of a monorepo. Recording takes ~6 minutes; after that, a full-suite replay takes ~10.5 seconds, editing one test file reruns only that file, and commits that only touch apps/web or the repo root replay 100% with zero reruns.

The real-world runs were done with the fix from #1806 applied — we hit #1785 independently while testing and landed on the same fix. Without it, suites whose teardown touches the Laravel container see replays alternate green/red, as described in that issue.

One known limitation: the baseline artifact name is fixed (pest-tia-baseline), so two Tia-baselined projects in the same repository would collide on it. Happy to follow up on that if it's a setup you'd want supported.

Tia previously refused to run when the project root was not the git
repository root, telling monorepo users to "give the project its own git
repository". The underlying problem was a path-space mismatch: git's
plumbing (status --porcelain, diff --name-only, show SHA:path) speaks
repository-root-relative paths, while the dependency graph, watch patterns
and coverage edges all speak project-root-relative paths.

ChangedFiles now detects the project's location inside the repository once
(git rev-parse --show-prefix) and translates at every git boundary:

- status/diff output is mapped to project-relative paths, and paths outside
  the project subtree are dropped — they are as invisible to the project's
  graph as files outside the repository are for a root-level project, and
  leaking them through would hit the unknown-file catch-all and force a
  full rerun on every sibling-project change;
- the diff listing runs with -z (git C-quotes non-ASCII paths, which would
  silently fail the prefix match and drop a real change — a stale replay)
  and --no-renames (rename detection reports only the NEW path, so a
  project file renamed to a path outside the subtree would vanish from the
  listing; as delete+add both sides surface);
- git show SHA:path (behavioural-change filtering, composer.lock drift
  summaries) resolves project-relative paths through the prefix.

A new GitRepository helper locates the governing repository by walking
ancestors, fixing three more root==project assumptions: Fingerprint's
tracked-lockfile check, BaselineSync's GitHub-repo detection (baseline
fetching silently never activated for subdirectory projects), and
Storage's origin-based project key — which now also carries the
subdirectory prefix, so `apps/api` and `services/api` in one repository
never share a graph store.

Root-level projects keep their exact existing behaviour: the prefix is
empty and every translation is the identity. The TiaRequiresRepositoryRoot
exception is removed along with the guard.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant