fix(cli): stop parent traversal at .git boundary #170
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user