fix(cli): stop parent traversal at .git boundary #170

Open
xiaoju wants to merge 1 commits from fix/168-git-boundary into main
4 changed files with 176 additions and 11 deletions
Showing only changes of commit a736f92809 - Show all commits
+10
View File
@@ -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.
@@ -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", () => {
+16 -1
View File
@@ -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<string | nu
return null;
}
/** Check if a directory contains a .git marker (directory or file). */
async function hasGitMarker(dir: string): Promise<boolean> {
try {
await access(join(dir, ".git"));
return true;
} catch {
return false;
}
}
/**
* Traverse parent directories looking for `.workflow/<name>.yaml` or `.workflow/<name>.yml`.
* Returns the absolute path if found, otherwise null.
@@ -302,6 +312,11 @@ async function findWorkflowInParents(startDir: string, name: string): Promise<st
return found;
}
// Stop at .git boundary (repo root)
if (await hasGitMarker(currentDir)) {
break;
}
// Stop at filesystem root
if (currentDir === root) {
break;
+34 -10
View File
@@ -82,6 +82,31 @@ async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
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<boolean> {
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<ProjectWorkflowEntry[]> {
* `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<ProjectWorkflowEntry[]> {
let currentDir = resolvePath(startDir);
@@ -108,14 +134,12 @@ export async function discoverProjectWorkflows(startDir: string): Promise<Projec
const legacy = await scanWorkflowDir(join(currentDir, ".workflows"));
if (primary.length > 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