mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
fix(skills): redirect PROJECT_ROOT out of git worktrees (#133)
When /understand or /understand-domain runs from a CWD inside an ephemeral git worktree (the default for parallel-agent / isolation sessions), every output file goes to the worktree path. Claude Code deletes the worktree on session end, taking knowledge-graph.json, domain-graph.json, meta.json, intermediate batches and ~hundreds of K of analysis tokens with it. Resolve PROJECT_ROOT through a worktree check before any output: compare git rev-parse --git-dir against --git-common-dir; in a normal checkout (and in a submodule) they're the same path, in a worktree they differ and parent(--git-common-dir) is the main repo root. UNDERSTAND_NO_WORKTREE_REDIRECT=1 opts out for the rare per-worktree case. - skills/understand/SKILL.md Phase 0 step 1: add the redirect after PROJECT_ROOT is set from $ARGUMENTS or CWD, so an explicit arg path is also rescued from a worktree but can be opted out of. - skills/understand-domain/SKILL.md: add an explicit Phase 0 (it previously inferred "current project" implicitly), then thread $PROJECT_ROOT through Phases 2-5 so subsequent steps honor the redirect. - New worktree-redirect.test.mjs: 5 vitest cases covering main repo, worktree root, worktree subdir, opt-out env var, and non-git CWD. Mirrors the bash snippet inline (no shared lib in this repo). Submodule false-positive ruled out by probe — submodules see git-dir == git-common-dir (both point at <super>/.git/modules/<name>).
This commit is contained in:
@@ -16,9 +16,35 @@ Extracts business domain knowledge — domains, business flows, and process step
|
||||
|
||||
## Instructions
|
||||
|
||||
### Phase 0: Resolve `PROJECT_ROOT`
|
||||
|
||||
Set `PROJECT_ROOT` to the current working directory.
|
||||
|
||||
**Worktree redirect.** If `PROJECT_ROOT` is inside a git worktree (not the main checkout), redirect output to the main repository root. Worktrees managed by Claude Code are ephemeral — `.understand-anything/` written there is destroyed when the session ends, taking the domain graph with it (issue #133). Detect a worktree by comparing `git rev-parse --git-dir` against `git rev-parse --git-common-dir`; in a normal checkout or submodule they resolve to the same path, in a worktree they differ and the parent of `--git-common-dir` is the main repo root.
|
||||
|
||||
```bash
|
||||
COMMON_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)
|
||||
GIT_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-dir 2>/dev/null)
|
||||
if [ -n "$COMMON_DIR" ] && [ -n "$GIT_DIR" ]; then
|
||||
COMMON_ABS=$(cd "$PROJECT_ROOT" && cd "$COMMON_DIR" 2>/dev/null && pwd -P)
|
||||
GIT_ABS=$(cd "$PROJECT_ROOT" && cd "$GIT_DIR" 2>/dev/null && pwd -P)
|
||||
if [ -n "$COMMON_ABS" ] && [ "$COMMON_ABS" != "$GIT_ABS" ]; then
|
||||
MAIN_ROOT=$(dirname "$COMMON_ABS")
|
||||
if [ -d "$MAIN_ROOT" ] && [ "${UNDERSTAND_NO_WORKTREE_REDIRECT:-0}" != "1" ]; then
|
||||
echo "[understand-domain] Detected git worktree at $PROJECT_ROOT"
|
||||
echo "[understand-domain] Redirecting output to main repo root: $MAIN_ROOT"
|
||||
echo "[understand-domain] (Set UNDERSTAND_NO_WORKTREE_REDIRECT=1 to keep PROJECT_ROOT as the worktree.)"
|
||||
PROJECT_ROOT="$MAIN_ROOT"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
Use `$PROJECT_ROOT` (not the bare CWD) for every reference to "the current project" / `<project-root>` in subsequent phases.
|
||||
|
||||
### Phase 1: Detect Existing Graph
|
||||
|
||||
1. Check if `.understand-anything/knowledge-graph.json` exists in the current project
|
||||
1. Check if `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` exists
|
||||
2. If it exists AND `--full` was NOT passed → proceed to Phase 3 (derive from graph)
|
||||
3. Otherwise → proceed to Phase 2 (lightweight scan)
|
||||
|
||||
@@ -26,11 +52,11 @@ Extracts business domain knowledge — domains, business flows, and process step
|
||||
|
||||
The preprocessing script does NOT produce a domain graph — it produces **raw material** (file tree, entry points, exports/imports) so the domain-analyzer agent can focus on the actual domain analysis instead of spending dozens of tool calls exploring the codebase. Think of it as a cheat sheet: cheap Python preprocessing → expensive LLM gets a clean, small input → better results for less cost.
|
||||
|
||||
1. Run the preprocessing script bundled with this skill:
|
||||
1. Run the preprocessing script bundled with this skill, passing `$PROJECT_ROOT` from Phase 0:
|
||||
```
|
||||
python ./extract-domain-context.py <project-root>
|
||||
python ./extract-domain-context.py "$PROJECT_ROOT"
|
||||
```
|
||||
This outputs `<project-root>/.understand-anything/intermediate/domain-context.json` containing:
|
||||
This outputs `$PROJECT_ROOT/.understand-anything/intermediate/domain-context.json` containing:
|
||||
- File tree (respecting `.gitignore`)
|
||||
- Detected entry points (HTTP routes, CLI commands, event handlers, cron jobs, exported handlers)
|
||||
- File signatures (exports, imports per file)
|
||||
@@ -41,7 +67,7 @@ The preprocessing script does NOT produce a domain graph — it produces **raw m
|
||||
|
||||
### Phase 3: Derive from Existing Graph (Path 2)
|
||||
|
||||
1. Read `.understand-anything/knowledge-graph.json`
|
||||
1. Read `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`
|
||||
2. Format the graph data as structured context:
|
||||
- All nodes with their types, names, summaries, and tags
|
||||
- All edges with their types (especially `calls`, `imports`, `contains`)
|
||||
@@ -54,15 +80,15 @@ The preprocessing script does NOT produce a domain graph — it produces **raw m
|
||||
|
||||
1. Read the domain-analyzer agent prompt from `agents/domain-analyzer.md`
|
||||
2. Dispatch a subagent with the domain-analyzer prompt + the context from Phase 2 or 3
|
||||
3. The agent writes its output to `.understand-anything/intermediate/domain-analysis.json`
|
||||
3. The agent writes its output to `$PROJECT_ROOT/.understand-anything/intermediate/domain-analysis.json`
|
||||
|
||||
### Phase 5: Validate and Save
|
||||
|
||||
1. Read the domain analysis output
|
||||
2. Validate using the standard graph validation pipeline (the schema now supports domain/flow/step types)
|
||||
3. If validation fails, log warnings but save what's valid (error tolerance)
|
||||
4. Save to `.understand-anything/domain-graph.json`
|
||||
5. Clean up `.understand-anything/intermediate/domain-analysis.json` and `.understand-anything/intermediate/domain-context.json`
|
||||
4. Save to `$PROJECT_ROOT/.understand-anything/domain-graph.json`
|
||||
5. Clean up `$PROJECT_ROOT/.understand-anything/intermediate/domain-analysis.json` and `$PROJECT_ROOT/.understand-anything/intermediate/domain-context.json`
|
||||
|
||||
### Phase 6: Launch Dashboard
|
||||
|
||||
|
||||
@@ -29,6 +29,27 @@ Determine whether to run a full analysis or incremental update.
|
||||
- Verify the resolved path exists and is a directory (run `test -d <path>`). If it does not exist or is not a directory, report an error to the user and **STOP**.
|
||||
- Set `PROJECT_ROOT` to the resolved absolute path.
|
||||
- If no directory path argument is found, set `PROJECT_ROOT` to the current working directory.
|
||||
- **Worktree redirect.** If `PROJECT_ROOT` is inside a git worktree (not the main checkout), redirect output to the main repository root. Worktrees managed by Claude Code are ephemeral — `.understand-anything/` written there is destroyed when the session ends, taking the knowledge graph with it (issue #133). Detect a worktree by comparing `git rev-parse --git-dir` against `git rev-parse --git-common-dir`; in a normal checkout or submodule they resolve to the same path, in a worktree they differ and the parent of `--git-common-dir` is the main repo root.
|
||||
|
||||
```bash
|
||||
COMMON_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)
|
||||
GIT_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-dir 2>/dev/null)
|
||||
if [ -n "$COMMON_DIR" ] && [ -n "$GIT_DIR" ]; then
|
||||
COMMON_ABS=$(cd "$PROJECT_ROOT" && cd "$COMMON_DIR" 2>/dev/null && pwd -P)
|
||||
GIT_ABS=$(cd "$PROJECT_ROOT" && cd "$GIT_DIR" 2>/dev/null && pwd -P)
|
||||
if [ -n "$COMMON_ABS" ] && [ "$COMMON_ABS" != "$GIT_ABS" ]; then
|
||||
MAIN_ROOT=$(dirname "$COMMON_ABS")
|
||||
if [ -d "$MAIN_ROOT" ] && [ "${UNDERSTAND_NO_WORKTREE_REDIRECT:-0}" != "1" ]; then
|
||||
echo "[understand] Detected git worktree at $PROJECT_ROOT"
|
||||
echo "[understand] Redirecting output to main repo root: $MAIN_ROOT"
|
||||
echo "[understand] (Set UNDERSTAND_NO_WORKTREE_REDIRECT=1 to keep PROJECT_ROOT as the worktree.)"
|
||||
PROJECT_ROOT="$MAIN_ROOT"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
Set `UNDERSTAND_NO_WORKTREE_REDIRECT=1` if you intentionally want a per-worktree graph (rare — most users want the redirect).
|
||||
1.5. **Ensure the plugin is built.** Later phases invoke Node scripts that import `@understand-anything/core`. On a fresh install `packages/core/dist/` does not exist yet — build once.
|
||||
|
||||
**Important:** do **not** assume the plugin root is simply two directories above the skill path string. In many installations `~/.agents/skills/understand` is a symlink into the real plugin checkout. Prefer runtime-provided plugin roots first (for Claude), then fall back to universal symlinks, skill symlink resolution, and common clone-based install paths.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Validates the worktree-redirect bash snippet embedded in
|
||||
// `skills/understand/SKILL.md` Phase 0 step 1 and
|
||||
// `skills/understand-domain/SKILL.md` Phase 0.
|
||||
//
|
||||
// If you edit the snippet in either SKILL.md, mirror the change to RESOLVE_SNIPPET
|
||||
// below — there is no shared script to source (per-skill convention in this repo).
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const RESOLVE_SNIPPET = `
|
||||
COMMON_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)
|
||||
GIT_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-dir 2>/dev/null)
|
||||
if [ -n "$COMMON_DIR" ] && [ -n "$GIT_DIR" ]; then
|
||||
COMMON_ABS=$(cd "$PROJECT_ROOT" && cd "$COMMON_DIR" 2>/dev/null && pwd -P)
|
||||
GIT_ABS=$(cd "$PROJECT_ROOT" && cd "$GIT_DIR" 2>/dev/null && pwd -P)
|
||||
if [ -n "$COMMON_ABS" ] && [ "$COMMON_ABS" != "$GIT_ABS" ]; then
|
||||
MAIN_ROOT=$(dirname "$COMMON_ABS")
|
||||
if [ -d "$MAIN_ROOT" ] && [ "\${UNDERSTAND_NO_WORKTREE_REDIRECT:-0}" != "1" ]; then
|
||||
PROJECT_ROOT="$MAIN_ROOT"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "$PROJECT_ROOT"
|
||||
`;
|
||||
|
||||
function runResolve(projectRoot, env = {}) {
|
||||
// No `set -e` — the snippet relies on `git ... 2>/dev/null` returning empty
|
||||
// strings when not in a git repo; `set -e` would short-circuit instead.
|
||||
const script = `PROJECT_ROOT=${JSON.stringify(projectRoot)}\n${RESOLVE_SNIPPET}`;
|
||||
return execFileSync("bash", ["-c", script], {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
let tmpRoot;
|
||||
let mainRepo;
|
||||
let worktree;
|
||||
let subdir;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpRoot = realpathSync(mkdtempSync(join(tmpdir(), "ua-wt-")));
|
||||
mainRepo = join(tmpRoot, "main");
|
||||
worktree = join(tmpRoot, "wt");
|
||||
subdir = join(worktree, "src", "deep");
|
||||
|
||||
execFileSync("git", ["init", "-q", "-b", "main", mainRepo]);
|
||||
execFileSync("git", ["-C", mainRepo, "config", "user.email", "t@t"]);
|
||||
execFileSync("git", ["-C", mainRepo, "config", "user.name", "t"]);
|
||||
writeFileSync(join(mainRepo, "README.md"), "main\n");
|
||||
execFileSync("git", ["-C", mainRepo, "add", "."]);
|
||||
execFileSync("git", ["-C", mainRepo, "commit", "-q", "-m", "init"]);
|
||||
execFileSync("git", ["-C", mainRepo, "worktree", "add", "-q", worktree]);
|
||||
mkdirSync(subdir, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("worktree-redirect snippet (issue #133)", () => {
|
||||
it("leaves PROJECT_ROOT alone in a normal checkout", () => {
|
||||
expect(runResolve(mainRepo)).toBe(mainRepo);
|
||||
});
|
||||
|
||||
it("redirects PROJECT_ROOT to the main repo when started in a worktree", () => {
|
||||
expect(runResolve(worktree)).toBe(mainRepo);
|
||||
});
|
||||
|
||||
it("redirects from a subdirectory inside a worktree", () => {
|
||||
expect(runResolve(subdir)).toBe(mainRepo);
|
||||
});
|
||||
|
||||
it("respects UNDERSTAND_NO_WORKTREE_REDIRECT=1", () => {
|
||||
expect(runResolve(worktree, { UNDERSTAND_NO_WORKTREE_REDIRECT: "1" })).toBe(worktree);
|
||||
});
|
||||
|
||||
it("leaves PROJECT_ROOT alone when not inside a git repo", () => {
|
||||
// Use a path under the resolved tmp root so we never accidentally land
|
||||
// inside a parent git repo (e.g. when /tmp is symlinked into one).
|
||||
const nonGit = join(tmpRoot, "no-git");
|
||||
mkdirSync(nonGit, { recursive: true });
|
||||
expect(runResolve(nonGit)).toBe(nonGit);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user