Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a736f92809 |
@@ -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 ───────────────────────────────────────
|
// ── cmdWorkflowList — parent traversal ───────────────────────────────────────
|
||||||
|
|
||||||
describe("cmdWorkflowList — parent traversal", () => {
|
describe("cmdWorkflowList — parent traversal", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execFileSync, spawn } from "node:child_process";
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
import { access, readFile } from "node:fs/promises";
|
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 type { VarStore } from "@ocas/core";
|
||||||
import { validate } from "@ocas/core";
|
import { validate } from "@ocas/core";
|
||||||
import type {
|
import type {
|
||||||
@@ -287,6 +287,16 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
|
|||||||
return null;
|
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`.
|
* Traverse parent directories looking for `.workflow/<name>.yaml` or `.workflow/<name>.yml`.
|
||||||
* Returns the absolute path if found, otherwise null.
|
* Returns the absolute path if found, otherwise null.
|
||||||
@@ -302,6 +312,11 @@ async function findWorkflowInParents(startDir: string, name: string): Promise<st
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop at .git boundary (repo root)
|
||||||
|
if (await hasGitMarker(currentDir)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop at filesystem root
|
// Stop at filesystem root
|
||||||
if (currentDir === root) {
|
if (currentDir === root) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -82,6 +82,31 @@ async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
|
|||||||
return result;
|
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
|
* Discover project-local workflows by walking from `startDir` up through parent
|
||||||
* directories. The nearest directory that contains a `.workflow/` or `.workflows/`
|
* 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
|
* `uwf thread start`, so `uwf workflow list` and `uwf thread start` agree on
|
||||||
* what's discoverable from any given subdirectory.
|
* what's discoverable from any given subdirectory.
|
||||||
*
|
*
|
||||||
* Returns an empty array if no `.workflow/` or `.workflows/` directory exists
|
* Traversal stops at the first `.git` boundary (directory or file) or the
|
||||||
* anywhere from `startDir` up to the filesystem root.
|
* filesystem root. Returns an empty array if no `.workflow/` or `.workflows/`
|
||||||
|
* directory exists within that range.
|
||||||
*/
|
*/
|
||||||
export async function discoverProjectWorkflows(startDir: string): Promise<ProjectWorkflowEntry[]> {
|
export async function discoverProjectWorkflows(startDir: string): Promise<ProjectWorkflowEntry[]> {
|
||||||
let currentDir = resolvePath(startDir);
|
let currentDir = resolvePath(startDir);
|
||||||
@@ -108,14 +134,12 @@ export async function discoverProjectWorkflows(startDir: string): Promise<Projec
|
|||||||
const legacy = await scanWorkflowDir(join(currentDir, ".workflows"));
|
const legacy = await scanWorkflowDir(join(currentDir, ".workflows"));
|
||||||
|
|
||||||
if (primary.length > 0 || legacy.length > 0) {
|
if (primary.length > 0 || legacy.length > 0) {
|
||||||
const seen = new Set(primary.map((e) => e.name));
|
return mergeWorkflowEntries(primary, legacy);
|
||||||
const merged = [...primary];
|
|
||||||
for (const entry of legacy) {
|
|
||||||
if (!seen.has(entry.name)) {
|
|
||||||
merged.push(entry);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return merged;
|
// Stop at .git boundary (repo root)
|
||||||
|
if (await hasGitMarker(currentDir)) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop at filesystem root
|
// Stop at filesystem root
|
||||||
|
|||||||
Reference in New Issue
Block a user