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