From a736f9280908e7534e132d4978a4720d8026c340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 7 Jun 2026 15:42:59 +0000 Subject: [PATCH] fix(cli): stop parent traversal at .git boundary findWorkflowInParents() and discoverProjectWorkflows() now check for .git (directory or file) after scanning the current directory for workflows and before moving to the parent. This prevents traversal from escaping the repository root and picking up unrelated .workflow/ directories in parent directories. Both .git as a directory (normal clone) and .git as a file (git worktree) are treated as boundaries. Fixes #168 --- .changeset/git-boundary.md | 10 ++ .../__tests__/workflow-list-recursive.test.ts | 116 ++++++++++++++++++ packages/cli/src/commands/thread.ts | 17 ++- packages/cli/src/store.ts | 44 +++++-- 4 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 .changeset/git-boundary.md diff --git a/.changeset/git-boundary.md b/.changeset/git-boundary.md new file mode 100644 index 0000000..62ecf17 --- /dev/null +++ b/.changeset/git-boundary.md @@ -0,0 +1,10 @@ +--- +"@united-workforce/cli": patch +--- + +fix: stop parent traversal at .git boundary + +`findWorkflowInParents()` and `discoverProjectWorkflows()` now stop traversing +parent directories when they encounter a `.git` directory or file (git worktree). +This prevents picking up unrelated `.workflow/` directories above the repository +root in monorepo setups. diff --git a/packages/cli/src/__tests__/workflow-list-recursive.test.ts b/packages/cli/src/__tests__/workflow-list-recursive.test.ts index 6421906..c328e95 100644 --- a/packages/cli/src/__tests__/workflow-list-recursive.test.ts +++ b/packages/cli/src/__tests__/workflow-list-recursive.test.ts @@ -183,6 +183,122 @@ describe("discoverProjectWorkflows — parent traversal", () => { }); }); +// ── discoverProjectWorkflows — .git boundary ───────────────────────────────── + +describe("discoverProjectWorkflows — .git boundary", () => { + test("G1: .git directory stops traversal", async () => { + // Setup: tmpDir/repo/.git/ (dir), tmpDir/.workflow/leak.yaml, start from tmpDir/repo/sub/deep/ + const repoDir = join(tmpDir, "repo"); + const gitDir = join(repoDir, ".git"); + await mkdir(gitDir, { recursive: true }); + + // Workflow above repo root — should NOT be reachable + const leakDir = join(tmpDir, ".workflow"); + await mkdir(leakDir, { recursive: true }); + await writeFile(join(leakDir, "leak.yaml"), await createWorkflowYaml("leak")); + + const startFrom = join(repoDir, "sub", "deep"); + await mkdir(startFrom, { recursive: true }); + + const entries = await discoverProjectWorkflows(startFrom); + expect(entries).toEqual([]); + }); + + test("G2: .git file (worktree) stops traversal", async () => { + // Setup: tmpDir/repo/.git as a FILE, tmpDir/.workflow/leak.yaml, start from tmpDir/repo/pkg/ + const repoDir = join(tmpDir, "repo"); + await mkdir(repoDir, { recursive: true }); + await writeFile(join(repoDir, ".git"), "gitdir: /some/other/path/.git/worktrees/repo"); + + const leakDir = join(tmpDir, ".workflow"); + await mkdir(leakDir, { recursive: true }); + await writeFile(join(leakDir, "leak.yaml"), await createWorkflowYaml("leak")); + + const startFrom = join(repoDir, "pkg"); + await mkdir(startFrom, { recursive: true }); + + const entries = await discoverProjectWorkflows(startFrom); + expect(entries).toEqual([]); + }); + + test("G3: workflow at .git boundary IS found", async () => { + // Setup: tmpDir/repo/.git/ (dir), tmpDir/repo/.workflow/local.yaml, start from tmpDir/repo/sub/ + const repoDir = join(tmpDir, "repo"); + const gitDir = join(repoDir, ".git"); + await mkdir(gitDir, { recursive: true }); + + const wfDir = join(repoDir, ".workflow"); + await mkdir(wfDir, { recursive: true }); + await writeFile(join(wfDir, "local.yaml"), await createWorkflowYaml("local")); + + const startFrom = join(repoDir, "sub"); + await mkdir(startFrom, { recursive: true }); + + const entries = await discoverProjectWorkflows(startFrom); + expect(entries.map((e) => e.name)).toContain("local"); + }); + + test("G4: workflow below .git is found, above is not", async () => { + // Setup: tmpDir/repo/.git/ + tmpDir/repo/.workflow/local.yaml + tmpDir/.workflow/leak.yaml + const repoDir = join(tmpDir, "repo"); + const gitDir = join(repoDir, ".git"); + await mkdir(gitDir, { recursive: true }); + + const localWfDir = join(repoDir, ".workflow"); + await mkdir(localWfDir, { recursive: true }); + await writeFile(join(localWfDir, "local.yaml"), await createWorkflowYaml("local")); + + const leakDir = join(tmpDir, ".workflow"); + await mkdir(leakDir, { recursive: true }); + await writeFile(join(leakDir, "leak.yaml"), await createWorkflowYaml("leak")); + + const startFrom = join(repoDir, "sub"); + await mkdir(startFrom, { recursive: true }); + + const entries = await discoverProjectWorkflows(startFrom); + expect(entries.map((e) => e.name)).toEqual(["local"]); + }); +}); + +// ── findWorkflowInParents (via cmdThreadStart) — .git boundary ─────────────── + +describe("findWorkflowInParents via cmdThreadStart — .git boundary", () => { + test("G5: .git stops traversal — workflow above boundary is not found", async () => { + await makeUwfStore(storageRoot); + const repoDir = join(tmpDir, "repo"); + const gitDir = join(repoDir, ".git"); + await mkdir(gitDir, { recursive: true }); + + // Workflow above .git boundary + const leakDir = join(tmpDir, ".workflow"); + await mkdir(leakDir, { recursive: true }); + await writeFile(join(leakDir, "leak.yaml"), await createWorkflowYaml("leak")); + + const startFrom = join(repoDir, "sub"); + await mkdir(startFrom, { recursive: true }); + + // cmdThreadStart should fail — "leak" is above the .git boundary + await expect(cmdThreadStart(storageRoot, "leak", "prompt", startFrom)).rejects.toThrow(); + }); + + test("G6: workflow at .git boundary IS found via cmdThreadStart", async () => { + await makeUwfStore(storageRoot); + const repoDir = join(tmpDir, "repo"); + const gitDir = join(repoDir, ".git"); + await mkdir(gitDir, { recursive: true }); + + const wfDir = join(repoDir, ".workflow"); + await mkdir(wfDir, { recursive: true }); + await writeFile(join(wfDir, "local.yaml"), await createWorkflowYaml("local")); + + const startFrom = join(repoDir, "sub"); + await mkdir(startFrom, { recursive: true }); + + const result = await cmdThreadStart(storageRoot, "local", "prompt", startFrom); + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); +}); + // ── cmdWorkflowList — parent traversal ─────────────────────────────────────── describe("cmdWorkflowList — parent traversal", () => { diff --git a/packages/cli/src/commands/thread.ts b/packages/cli/src/commands/thread.ts index 4676cd8..bc19f76 100644 --- a/packages/cli/src/commands/thread.ts +++ b/packages/cli/src/commands/thread.ts @@ -1,6 +1,6 @@ import { execFileSync, spawn } from "node:child_process"; import { access, readFile } from "node:fs/promises"; -import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; +import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; import type { VarStore } from "@ocas/core"; import { validate } from "@ocas/core"; import type { @@ -287,6 +287,16 @@ async function findWorkflowInDir(dir: string, name: string): Promise { + try { + await access(join(dir, ".git")); + return true; + } catch { + return false; + } +} + /** * Traverse parent directories looking for `.workflow/.yaml` or `.workflow/.yml`. * Returns the absolute path if found, otherwise null. @@ -302,6 +312,11 @@ async function findWorkflowInParents(startDir: string, name: string): Promise { return result; } +/** Merge primary (.workflow/) and legacy (.workflows/) entries, primary wins on name collision. */ +function mergeWorkflowEntries( + primary: ProjectWorkflowEntry[], + legacy: ProjectWorkflowEntry[], +): ProjectWorkflowEntry[] { + const seen = new Set(primary.map((e) => e.name)); + const merged = [...primary]; + for (const entry of legacy) { + if (!seen.has(entry.name)) { + merged.push(entry); + } + } + return merged; +} + +/** Check if a directory contains a .git marker (directory or file). */ +async function hasGitMarker(dir: string): Promise { + try { + await access(join(dir, ".git")); + return true; + } catch { + return false; + } +} + /** * Discover project-local workflows by walking from `startDir` up through parent * directories. The nearest directory that contains a `.workflow/` or `.workflows/` @@ -96,8 +121,9 @@ async function scanWorkflowDir(dir: string): Promise { * `uwf thread start`, so `uwf workflow list` and `uwf thread start` agree on * what's discoverable from any given subdirectory. * - * Returns an empty array if no `.workflow/` or `.workflows/` directory exists - * anywhere from `startDir` up to the filesystem root. + * Traversal stops at the first `.git` boundary (directory or file) or the + * filesystem root. Returns an empty array if no `.workflow/` or `.workflows/` + * directory exists within that range. */ export async function discoverProjectWorkflows(startDir: string): Promise { let currentDir = resolvePath(startDir); @@ -108,14 +134,12 @@ export async function discoverProjectWorkflows(startDir: string): Promise 0 || legacy.length > 0) { - const seen = new Set(primary.map((e) => e.name)); - const merged = [...primary]; - for (const entry of legacy) { - if (!seen.has(entry.name)) { - merged.push(entry); - } - } - return merged; + return mergeWorkflowEntries(primary, legacy); + } + + // Stop at .git boundary (repo root) + if (await hasGitMarker(currentDir)) { + return []; } // Stop at filesystem root -- 2.43.0