Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju a736f92809 fix(cli): stop parent traversal at .git boundary
CI / check (pull_request) Successful in 2m24s
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
2026-06-07 15:45:27 +00:00
13 changed files with 200 additions and 46 deletions
+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.
+1 -1
View File
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.4.1",
"@ocas/core": "^0.4.0",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^"
},
+1 -1
View File
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.4.1",
"@ocas/core": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^"
+1 -1
View File
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.4.1",
"@ocas/core": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^"
+1 -1
View File
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.4.1",
"@ocas/core": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^",
+1 -1
View File
@@ -11,7 +11,7 @@
"uwf": "./dist/cli.js"
},
"dependencies": {
"@ocas/core": "^0.4.1",
"@ocas/core": "^0.4.0",
"@ocas/fs": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
@@ -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
+1 -1
View File
@@ -22,7 +22,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.4.1",
"@ocas/core": "^0.4.0",
"@ocas/fs": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
+1 -1
View File
@@ -18,7 +18,7 @@
"test:ci": "vitest run src/__tests__/"
},
"dependencies": {
"@ocas/core": "^0.4.1",
"@ocas/core": "^0.4.0",
"@ocas/fs": "^0.4.0"
},
"devDependencies": {
+1 -1
View File
@@ -18,7 +18,7 @@
"test:ci": "vitest run __tests__/ src/__tests__/"
},
"dependencies": {
"@ocas/core": "^0.4.1",
"@ocas/core": "^0.4.0",
"@ocas/fs": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
+16 -27
View File
@@ -45,8 +45,8 @@ importers:
packages/agent-builtin:
dependencies:
'@ocas/core':
specifier: ^0.4.1
version: 0.4.1
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/util':
specifier: workspace:^
version: link:../util
@@ -61,8 +61,8 @@ importers:
packages/agent-claude-code:
dependencies:
'@ocas/core':
specifier: ^0.4.1
version: 0.4.1
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/protocol':
specifier: workspace:^
version: link:../protocol
@@ -80,8 +80,8 @@ importers:
packages/agent-hermes:
dependencies:
'@ocas/core':
specifier: ^0.4.1
version: 0.4.1
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/protocol':
specifier: workspace:^
version: link:../protocol
@@ -99,8 +99,8 @@ importers:
packages/agent-mock:
dependencies:
'@ocas/core':
specifier: ^0.4.1
version: 0.4.1
specifier: ^0.4.0
version: 0.4.0
'@united-workforce/protocol':
specifier: workspace:^
version: link:../protocol
@@ -121,8 +121,8 @@ importers:
packages/cli:
dependencies:
'@ocas/core':
specifier: ^0.4.1
version: 0.4.1
specifier: ^0.4.0
version: 0.4.0
'@ocas/fs':
specifier: ^0.4.0
version: 0.4.0
@@ -231,8 +231,8 @@ importers:
packages/eval:
dependencies:
'@ocas/core':
specifier: ^0.4.1
version: 0.4.1
specifier: ^0.4.0
version: 0.4.0
'@ocas/fs':
specifier: ^0.4.0
version: 0.4.0
@@ -256,8 +256,8 @@ importers:
packages/protocol:
dependencies:
'@ocas/core':
specifier: ^0.4.1
version: 0.4.1
specifier: ^0.4.0
version: 0.4.0
'@ocas/fs':
specifier: ^0.4.0
version: 0.4.0
@@ -275,8 +275,8 @@ importers:
packages/util-agent:
dependencies:
'@ocas/core':
specifier: ^0.4.1
version: 0.4.1
specifier: ^0.4.0
version: 0.4.0
'@ocas/fs':
specifier: ^0.4.0
version: 0.4.0
@@ -896,10 +896,6 @@ packages:
resolution: {integrity: sha512-6JvHd3nr5GncMOBNaZTf9ZTWou/txONTfZbkrblmgqL/H+YuRj1FfeFY+b1ndUlfwR7AuJ6bvoSxR5RP+AbC0w==}
engines: {node: '>=22.5.0'}
'@ocas/core@0.4.1':
resolution: {integrity: sha512-rmnfe1Q/J/4RXzvt+zn65FLLvzK+F8atuJB2+5Qe4tS8lusWV6s3wB1XErhphVlKpJAZY/sokVlNJylWmIgArQ==}
engines: {node: '>=22.5.0'}
'@ocas/fs@0.4.0':
resolution: {integrity: sha512-AQG6dk1YCL1qpSszUWUgEY+LQhYbTv5hXYrs3J2pHAi2/lY615O2cTgjwEeh6JTcrqHsFwiDsDdKIKMpADchZA==}
engines: {node: '>=22.5.0'}
@@ -3909,13 +3905,6 @@ snapshots:
liquidjs: 10.27.0
xxhash-wasm: 1.1.0
'@ocas/core@0.4.1':
dependencies:
ajv: 8.20.0
cborg: 4.5.8
liquidjs: 10.27.0
xxhash-wasm: 1.1.0
'@ocas/fs@0.4.0':
dependencies:
'@ocas/core': 0.4.0