From ec7829e230da117118dc6444f4c1f21d2ddf69ca Mon Sep 17 00:00:00 2001 From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:26:40 -0700 Subject: [PATCH] feat: align wspace surfaces with git-native conventions and fail-closed checks (#9, #11-#17) --- README.md | 74 ++++++++++++++++++ src/cli.ts | 49 +++++++++--- src/env.ts | 88 +++++++++++++++++++-- src/manifest.ts | 61 ++++++++++++--- src/status.ts | 159 +++++++++++++++++++++++++++----------- src/types.ts | 7 +- src/worktrees.ts | 2 + tests/integration_test.ts | 106 +++++++++++++++++++++++++ tests/manifest_test.ts | 47 ++++++++--- 9 files changed, 510 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 4f4de5e..452e8f1 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,80 @@ Design principles: - `wspace sync` — alias for `wspace init`. - `wspace validate` — validate the manifest without touching any repository. +## Beginner Worktree Lifecycle + +All workspace commands run from the workspace root (the directory containing +`repos.json`). When creating worktrees for parallel feature development, always +follow this standard lifecycle: + +1. **Check workspace status**: + ```sh + wspace check + ``` +2. **Refresh clean default branches**: + ```sh + wspace update + ``` +3. **Create a feature worktree**: + ```sh + git -C repos/ worktree add "$PWD/worktrees//" -b + ``` + _(Or using `wspace`: `wspace worktree add `)_ +4. **Develop inside the worktree**: + ```sh + cd worktrees// + # make edits, run tests, commit changes + ``` +5. **Sync local secrets when needed**: + ```sh + wspace env sync --dry-run # preview changes + wspace env sync # copy secrets with mode 0600 permissions + ``` +6. **Push and open a PR**: + ```sh + git push -u origin + gh pr create + ``` +7. **Find stale worktrees after PR merge**: + ```sh + wspace worktree list --stale + ``` +8. **Clean up merged worktree**: + ```sh + wspace worktree remove + ``` + +### Important Path Resolution Rules + +- **Workspace Root Anchor**: All repository and worktree paths in `repos.json` + resolve relative to the directory containing `repos.json` (the manifest file), + regardless of the caller's current working directory. +- **Why `$PWD` is required with `git -C`**: `git -C repos/` changes Git's + working directory to `repos/` before executing. If you pass a relative + path like `worktrees//`, Git creates the worktree nested inside + `repos//worktrees/...` instead of at the workspace root. Using + `"$PWD/worktrees//"` resolves `$PWD` from the workspace root + before Git runs. +- **Default Worktree Baseline**: `wspace worktree add` branches from + `origin/` (resolved via `origin/HEAD`), ensuring feature branches + start from the remote baseline rather than a local dirty state or arbitrary + `HEAD`. + +### Troubleshooting & Common Pitfalls + +- **`PATH_BLOCKED` or `INVALID` during `init`**: An existing directory or file + occupies the expected repository path but is not a valid Git repository. + `wspace init` fails closed without touching or overwriting the path. Remove or + relocate the blocking path manually. +- **`SKIP_FEATURE` / `FEATURE_CLEAN`**: Indicates that `repos/` is checked + out on a feature branch instead of the default branch. `wspace update` skips + updating feature branches to protect user work. +- **Symlink rejection in `env sync`**: For security, `wspace env sync` will + refuse to overwrite any destination path that is a symbolic link. +- **Dirty linked worktrees**: `wspace check` inspects both primary checkouts and + linked feature worktrees. If any linked worktree has uncommitted changes, + `wspace check` returns a non-zero exit code (`1`). + ## Install Install from JSR as the `wspace` binary: diff --git a/src/cli.ts b/src/cli.ts index 0f7ed0d..2bb99d0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,6 +42,7 @@ interface CliOptions { manifestPath: string; json: boolean; stale: boolean; + dryRun: boolean; positional: string[]; } @@ -56,17 +57,25 @@ Usage: wspace worktree add [] wspace worktree list [--stale] [--json] wspace worktree remove - wspace env sync + wspace env sync [--dry-run] [--json] wspace validate Options: --manifest Manifest path (default: repos.json) - --json Machine-readable output`); + --json Machine-readable output + --stale Filter worktrees fully merged into origin/ (or missing branch) + --dry-run Preview environment sync operations without modifying files + +Worktree Commands: + worktree add Creates a worktree at worktrees// on branch . + Start-point defaults to origin/ (resolved via origin/HEAD). + worktree list Lists active worktrees. With --stale, lists safe removal candidates. + worktree remove Removes a worktree at worktrees// and prunes stale references.`); } function parseCliArgs(args: string[]): CliOptions { const parsed = parseArgs(args, { - boolean: ["help", "json", "stale"], + boolean: ["help", "json", "stale", "dry-run"], string: ["manifest"], alias: { h: "help" }, }); @@ -87,6 +96,7 @@ function parseCliArgs(args: string[]): CliOptions { manifestPath: parsed.manifest ?? "repos.json", json: parsed.json ?? false, stale: parsed.stale ?? false, + dryRun: parsed["dry-run"] ?? false, positional: positional.slice(2), }; } @@ -161,6 +171,10 @@ async function runWorktree( return 2; } const worktreePath = join(paths.worktreesDirectory, repoName, feature); + if (await exists(worktreePath)) { + console.error(`Worktree path already exists: ${worktreePath}`); + return 2; + } const reattach = await branchExists(g, repoPath, feature); let startPointArg: string | undefined = startPoint; if (reattach) { @@ -234,7 +248,15 @@ async function cloneMissing( for (const repository of manifest.repositories) { const repoPath = resolveRepositoryPath(repository, paths); if (await exists(repoPath)) { - rows.push({ name: repository.name, state: "EXISTS" }); + if (await exists(join(repoPath, ".git"))) { + rows.push({ name: repository.name, state: "EXISTS" }); + } else { + rows.push({ + name: repository.name, + state: "PATH_BLOCKED", + detail: "Path exists but is not a Git repository", + }); + } continue; } const result = await clone(g, repository.url, repoPath); @@ -242,7 +264,7 @@ async function cloneMissing( result.code === 0 ? { name: repository.name, state: "CLONED" } : { name: repository.name, state: "CLONE_FAILED", - detail: result.stderr, + detail: result.stderr.trim() || `Exit code ${result.code}`, }, ); } @@ -273,7 +295,13 @@ async function runCommand( } else { console.table(rows); } - if (opts.command === "init") { + const failed = rows.some( + (r) => + r.state === "CLONE_FAILED" || + r.state === "PATH_BLOCKED" || + r.state === "INVALID", + ); + if (opts.command === "init" && !failed) { console.error( `NOTE: Fresh clones do not contain files listed in .gitignore. Required setup steps may include: @@ -282,7 +310,7 @@ Required setup steps may include: - Any repo-specific setup documented in each repo's README`, ); } - return 0; + return failed ? 1 : 0; } case "update": { const rows = await runUpdate(g, manifest, paths); @@ -297,16 +325,17 @@ Required setup steps may include: return await runWorktree(opts, manifest, paths, g); case "env": { if (opts.subcommand !== "sync") { - console.error("Usage: wspace env sync"); + console.error("Usage: wspace env sync [--dry-run] [--json]"); return 2; } - const rows = await syncEnv(g, manifest, paths); + const rows = await syncEnv(g, manifest, paths, { dryRun: opts.dryRun }); if (opts.json) { console.log(JSON.stringify(rows, null, 2)); } else { console.table(rows); } - return 0; + const hasFailed = rows.some((r) => r.action === "FAILED"); + return hasFailed ? 1 : 0; } case "validate": { validateManifest(manifest); diff --git a/src/env.ts b/src/env.ts index bcef7f3..d791d9d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -36,16 +36,29 @@ function matchPattern(pattern: string, name: string): boolean { return false; } +export interface SyncEnvOptions { + dryRun?: boolean; +} + export interface SyncEnvResult { repo: string; file: string; destination: string; + action: + | "CREATED" + | "OVERWRITTEN" + | "WOULD_CREATE" + | "WOULD_OVERWRITE" + | "SKIPPED" + | "FAILED"; + reason?: string; } export async function syncEnv( g: GitRunner, manifest: WorkspaceManifest, paths: ManifestPaths, + options: SyncEnvOptions = {}, ): Promise { const synced: SyncEnvResult[] = []; if (!(await exists(paths.vaultDirectory))) { @@ -80,12 +93,75 @@ export async function syncEnv( continue; } const destination = join(target, fileEntry.name); - await Deno.copyFile(source, destination); - synced.push({ - repo: repository.name, - file: fileEntry.name, - destination, - }); + + let destExists = false; + try { + const lstat = await Deno.lstat(destination); + destExists = true; + if (lstat.isSymlink) { + synced.push({ + repo: repository.name, + file: fileEntry.name, + destination, + action: "FAILED", + reason: "Destination is a symlink (rejected for security)", + }); + continue; + } + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) { + synced.push({ + repo: repository.name, + file: fileEntry.name, + destination, + action: "FAILED", + reason: err instanceof Error ? err.message : String(err), + }); + continue; + } + } + + if (options.dryRun) { + synced.push({ + repo: repository.name, + file: fileEntry.name, + destination, + action: destExists ? "WOULD_OVERWRITE" : "WOULD_CREATE", + }); + continue; + } + + try { + const tempDest = `${destination}.tmp.${ + Math.random().toString(36).slice(2) + }`; + await Deno.copyFile(source, tempDest); + try { + await Deno.chmod(tempDest, 0o600); + } catch { + // ignore chmod errors on unsupported OS / filesystems + } + await Deno.rename(tempDest, destination); + try { + await Deno.chmod(destination, 0o600); + } catch { + // ignore chmod errors + } + synced.push({ + repo: repository.name, + file: fileEntry.name, + destination, + action: destExists ? "OVERWRITTEN" : "CREATED", + }); + } catch (err) { + synced.push({ + repo: repository.name, + file: fileEntry.name, + destination, + action: "FAILED", + reason: err instanceof Error ? err.message : String(err), + }); + } } } } diff --git a/src/manifest.ts b/src/manifest.ts index 4875128..47497f7 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,4 +1,4 @@ -import { dirname, isAbsolute, join } from "@std/path"; +import { dirname, isAbsolute, normalize, resolve } from "@std/path"; import type { RepositoryEntry, WorkspaceManifest } from "./types.ts"; export const CURRENT_SCHEMA_VERSION = 1; @@ -10,6 +10,23 @@ export interface ManifestPaths { vaultDirectory: string; } +export function validateSafeName(name: string, contextName = "Name"): void { + if (!name || typeof name !== "string" || name.trim() === "") { + throw new Error(`${contextName} cannot be empty`); + } + if ( + name.includes("/") || + name.includes("\\") || + name === "." || + name === ".." || + name.includes("..") + ) { + throw new Error( + `${contextName} "${name}" contains invalid characters or path traversal`, + ); + } +} + export function validateManifest(manifest: WorkspaceManifest): void { if ( manifest.schemaVersion !== undefined && @@ -28,6 +45,7 @@ export function validateManifest(manifest: WorkspaceManifest): void { }`, ); } + validateSafeName(repository.name, "Repository name"); if (seen.has(repository.name)) { throw new Error(`Duplicate repository name: ${repository.name}`); } @@ -40,30 +58,49 @@ export function resolveRepositoryPath( paths: ManifestPaths, ): string { if (!repository.path) { - return join(paths.repositoriesDirectory, repository.name); + return normalize(resolve(paths.repositoriesDirectory, repository.name)); } if (isAbsolute(repository.path)) { - return repository.path; + return normalize(resolve(repository.path)); } return repository.path === "." ? paths.root - : join(paths.root, repository.path); + : normalize(resolve(paths.root, repository.path)); } export function manifestPaths( manifest: WorkspaceManifest, manifestPath: string, ): ManifestPaths { - const manifestDir = dirname(manifestPath); - const root = manifest.workspaceRoot ?? manifestDir; + const manifestDir = dirname(resolve(manifestPath)); + const rawRoot = manifest.workspaceRoot ?? manifestDir; + const root = isAbsolute(rawRoot) + ? normalize(resolve(rawRoot)) + : normalize(resolve(manifestDir, rawRoot)); + + const repositoriesDirectory = manifest.repositoriesDirectory + ? isAbsolute(manifest.repositoriesDirectory) + ? normalize(resolve(manifest.repositoriesDirectory)) + : normalize(resolve(root, manifest.repositoriesDirectory)) + : normalize(resolve(root, "repos")); + + const worktreesDirectory = manifest.worktreesDirectory + ? isAbsolute(manifest.worktreesDirectory) + ? normalize(resolve(manifest.worktreesDirectory)) + : normalize(resolve(root, manifest.worktreesDirectory)) + : normalize(resolve(root, "worktrees")); + + const vaultDirectory = manifest.vaultDirectory + ? isAbsolute(manifest.vaultDirectory) + ? normalize(resolve(manifest.vaultDirectory)) + : normalize(resolve(root, manifest.vaultDirectory)) + : normalize(resolve(root, "secrets")); + return { root, - repositoriesDirectory: join( - root, - manifest.repositoriesDirectory ?? "repos", - ), - worktreesDirectory: join(root, manifest.worktreesDirectory ?? "worktrees"), - vaultDirectory: join(root, manifest.vaultDirectory ?? "secrets"), + repositoriesDirectory, + worktreesDirectory, + vaultDirectory, }; } diff --git a/src/status.ts b/src/status.ts index 10c0d2c..b0f9150 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,4 +1,4 @@ -import { join } from "@std/path"; +import { join, normalize, relative, resolve } from "@std/path"; import type { GitRunner } from "./git.ts"; import { branchAb, @@ -11,6 +11,7 @@ import { import { exists, resolveRepositoryPath } from "./manifest.ts"; import type { ManifestPaths } from "./manifest.ts"; import type { RepositoryEntry, RepoState, RepoStatus } from "./types.ts"; +import { listWorktrees } from "./worktrees.ts"; export interface ClassifyInput { dirty: boolean; @@ -66,49 +67,57 @@ export async function repoStatus( return { ...base, state: "INVALID" }; } - const branch = await currentBranch(g, repoPath); - const defaultBr = await defaultBranch(g, repoPath); - const dirty = await isDirty(g, repoPath); - const upstream = branch - ? await configuredUpstream(g, repoPath, branch) - : undefined; - const featureBranch = branch !== undefined && branch !== defaultBr; + try { + const branch = await currentBranch(g, repoPath); + const defaultBr = await defaultBranch(g, repoPath); + const dirty = await isDirty(g, repoPath); + const upstream = branch + ? await configuredUpstream(g, repoPath, branch) + : undefined; + const featureBranch = branch !== undefined && branch !== defaultBr; - let ahead: number | undefined; - let behind: number | undefined; - let upstreamRefExists: boolean | undefined; - if (upstream && branch) { - upstreamRefExists = await hasRef(g, repoPath, `refs/remotes/${upstream}`); - if (upstreamRefExists) { - const ab = await branchAb(g, repoPath, upstream); - if (ab) { - ahead = ab.ahead; - behind = ab.behind; + let ahead: number | undefined; + let behind: number | undefined; + let upstreamRefExists: boolean | undefined; + if (upstream && branch) { + upstreamRefExists = await hasRef(g, repoPath, `refs/remotes/${upstream}`); + if (upstreamRefExists) { + const ab = await branchAb(g, repoPath, upstream); + if (ab) { + ahead = ab.ahead; + behind = ab.behind; + } } } - } - const classified = classifyState({ - dirty, - featureBranch, - hasDefaultBranch: defaultBr !== undefined, - upstream, - upstreamRefExists, - aheadBehind: ahead !== undefined - ? { ahead, behind: behind ?? 0 } - : undefined, - }); + const classified = classifyState({ + dirty, + featureBranch, + hasDefaultBranch: defaultBr !== undefined, + upstream, + upstreamRefExists, + aheadBehind: ahead !== undefined + ? { ahead, behind: behind ?? 0 } + : undefined, + }); - return { - ...base, - branch, - defaultBranch: defaultBr, - upstream, - ahead, - behind, - state: classified.state, - detail: classified.detail, - }; + return { + ...base, + branch, + defaultBranch: defaultBr, + upstream, + ahead, + behind, + state: classified.state, + detail: classified.detail, + }; + } catch (err) { + return { + ...base, + state: "ERROR", + detail: err instanceof Error ? err.message : String(err), + }; + } } export async function collectStatus( @@ -118,6 +127,11 @@ export async function collectStatus( ): Promise { const rows: RepoStatus[] = []; const managed = new Set(manifest.repositories.map((r) => r.name)); + const managedPaths = new Set( + manifest.repositories.map((r) => + normalize(resolveRepositoryPath(r, paths)) + ), + ); const reposDir = paths.repositoriesDirectory; if (await exists(reposDir)) { @@ -125,7 +139,13 @@ export async function collectStatus( if (!entry.isDirectory || entry.name === ".git") { continue; } - const candidatePath = join(reposDir, entry.name); + if (managed.has(entry.name)) { + continue; + } + const candidatePath = normalize(resolve(reposDir, entry.name)); + if (managedPaths.has(candidatePath)) { + continue; + } if (await exists(join(candidatePath, ".git"))) { rows.push( await repoStatus( @@ -140,10 +160,63 @@ export async function collectStatus( for (const repository of manifest.repositories) { const repoPath = resolveRepositoryPath(repository, paths); - if (!managed.has(repository.name)) { - continue; + const mainStatus = await repoStatus(g, repository, repoPath); + rows.push(mainStatus); + + if (mainStatus.state !== "MISSING" && mainStatus.state !== "INVALID") { + try { + const wts = await listWorktrees(g, repoPath); + for (const wt of wts) { + if (normalize(wt.path) === normalize(repoPath)) { + continue; + } + const wtExist = await exists(wt.path); + let wtState: RepoState = "FEATURE_CLEAN"; + let wtDetail: string | undefined; + + if (!wtExist) { + wtState = "MISSING"; + wtDetail = "worktree directory missing"; + } else { + try { + const dirty = await isDirty(g, wt.path); + if (dirty) { + wtState = "WORKTREE_DIRTY"; + wtDetail = "uncommitted changes"; + } else if (wt.detached) { + wtState = "FEATURE_CLEAN"; + wtDetail = "detached HEAD"; + } + } catch (err) { + wtState = "ERROR"; + wtDetail = err instanceof Error ? err.message : String(err); + } + } + + rows.push({ + name: `${repository.name} (worktree: ${ + wt.branch ?? relative(paths.root, wt.path) + })`, + path: wt.path, + branch: wt.branch, + state: wtState, + detail: wtDetail, + isWorktree: true, + worktreePath: wt.path, + }); + } + } catch (err) { + rows.push({ + name: `${repository.name} (worktrees)`, + path: repoPath, + state: "ERROR", + detail: `Failed to inspect worktrees: ${ + err instanceof Error ? err.message : String(err) + }`, + isWorktree: true, + }); + } } - rows.push(await repoStatus(g, repository, repoPath)); } return rows; } diff --git a/src/types.ts b/src/types.ts index 03e3b68..46d1a59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,11 +22,14 @@ export interface WorkspaceManifest { export type RepoState = | "MISSING" | "INVALID" + | "PATH_BLOCKED" | "DIRTY" + | "WORKTREE_DIRTY" | "FEATURE_CLEAN" | "DIVERGED" | "CLEAN" - | "UNKNOWN"; + | "UNKNOWN" + | "ERROR"; export interface RepoStatus { name: string; @@ -38,6 +41,8 @@ export interface RepoStatus { behind?: number; state: RepoState; detail?: string; + isWorktree?: boolean; + worktreePath?: string; } export interface Worktree { diff --git a/src/worktrees.ts b/src/worktrees.ts index 4cba253..18e7602 100644 --- a/src/worktrees.ts +++ b/src/worktrees.ts @@ -1,6 +1,7 @@ import { normalize } from "@std/path"; import { defaultBranch, hasRef } from "./git.ts"; import type { GitResult, GitRunner } from "./git.ts"; +import { validateSafeName } from "./manifest.ts"; import type { Worktree } from "./types.ts"; export function parseWorktreesPorcelain(output: string): Worktree[] { @@ -72,6 +73,7 @@ export async function addWorktree( branch: string, startPoint?: string, ): Promise { + validateSafeName(branch, "Feature branch name"); if (await branchExists(g, repoPath, branch)) { return await g.run(["worktree", "add", worktreePath, branch], repoPath); } diff --git a/tests/integration_test.ts b/tests/integration_test.ts index 1bfa732..aac1010 100644 --- a/tests/integration_test.ts +++ b/tests/integration_test.ts @@ -462,4 +462,110 @@ Deno.test("collectStatus reports hasErrors for MISSING and INVALID", async () => } }); +Deno.test("collectStatus does not double-list managed repositories under reposDir", async () => { + const dir = await Deno.makeTempDir(); + try { + const reposDir = join(dir, "repos"); + await Deno.mkdir(reposDir, { recursive: true }); + const work = join(reposDir, "a"); + const origin = join(dir, "a.git"); + assert((await g.run(["init", "--bare", origin])).code === 0); + assert((await g.run(["clone", origin, work])).code === 0); + + const rows = await collectStatus( + g, + { repositories: [{ name: "a", url: "u", path: work }] }, + { ...pathsFor(dir), repositoriesDirectory: reposDir }, + ); + const names = rows.map((r) => r.name); + assertEquals( + names, + ["a"], + "Managed repository should be reported exactly once", + ); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("wspace init fails when destination exists but is not a Git repo", async () => { + const dir = await Deno.makeTempDir(); + try { + const nonGitPath = join(dir, "blocked"); + await Deno.mkdir(nonGitPath, { recursive: true }); + const manifestPath = join(dir, "repos.json"); + await Deno.writeTextFile( + manifestPath, + JSON.stringify({ + repositories: [{ name: "blocked", url: "u", path: nonGitPath }], + }), + ); + const code = await run(["init", "--manifest", manifestPath]); + assertEquals( + code, + 1, + "wspace init should exit non-zero when path is blocked", + ); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("collectStatus reports linked worktrees and flags dirty linked worktrees", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const worktreePath = join(dir, "worktrees", "a", "feat-1"); + assertEquals((await addWorktree(g, work, worktreePath, "feat-1")).code, 0); + await Deno.writeTextFile(join(worktreePath, "dirty.txt"), "uncommitted"); + + const rows = await collectStatus( + g, + { repositories: [{ name: "a", url: "u", path: work }] }, + pathsFor(dir), + ); + const wtRow = rows.find((r) => r.isWorktree); + assert(wtRow, "Linked worktree row should be present"); + assertEquals(wtRow.state, "WORKTREE_DIRTY"); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("env sync --dry-run previews sync without modifying filesystem", async () => { + const dir = await Deno.makeTempDir(); + try { + const vaultDir = join(dir, "secrets", "a"); + await Deno.mkdir(vaultDir, { recursive: true }); + await Deno.writeTextFile(join(vaultDir, ".env"), "SECRET=123"); + + const work = await makeRepoWithMain(dir, "a"); + const manifestPath = join(dir, "repos.json"); + await Deno.writeTextFile( + manifestPath, + JSON.stringify({ + workspaceRoot: dir, + vaultDirectory: "secrets", + repositories: [{ name: "a", url: "u", path: work }], + }), + ); + + const code = await run([ + "env", + "sync", + "--dry-run", + "--manifest", + manifestPath, + ]); + assertEquals(code, 0); + assertEquals( + await exists(join(work, ".env")), + false, + "File should not be copied during dry-run", + ); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + export type { GitRunner }; diff --git a/tests/manifest_test.ts b/tests/manifest_test.ts index f1db553..855c0bb 100644 --- a/tests/manifest_test.ts +++ b/tests/manifest_test.ts @@ -34,24 +34,49 @@ Deno.test("validateManifest rejects a newer schema version", () => { Deno.test("manifestPaths applies defaults under the manifest directory", () => { const manifest: WorkspaceManifest = { repositories: [] }; - const paths = manifestPaths(manifest, join("ws", "repos.json")); - assertEquals(paths.root, dirname(join("ws", "repos.json"))); - assertEquals(paths.repositoriesDirectory, join("ws", "repos")); - assertEquals(paths.worktreesDirectory, join("ws", "worktrees")); - assertEquals(paths.vaultDirectory, join("ws", "secrets")); + const manifestFile = join(Deno.cwd(), "ws", "repos.json"); + const paths = manifestPaths(manifest, manifestFile); + assertEquals(paths.root, dirname(manifestFile)); + assertEquals( + paths.repositoriesDirectory, + join(dirname(manifestFile), "repos"), + ); + assertEquals( + paths.worktreesDirectory, + join(dirname(manifestFile), "worktrees"), + ); + assertEquals(paths.vaultDirectory, join(dirname(manifestFile), "secrets")); }); -Deno.test("manifestPaths honors workspaceRoot override", () => { +Deno.test("manifestPaths resolves relative workspaceRoot from manifest directory", () => { const manifest: WorkspaceManifest = { workspaceRoot: "..", repositories: [] }; - const paths = manifestPaths(manifest, join("ws", "repos.json")); - assertEquals(paths.root, ".."); + const manifestFile = join(Deno.cwd(), "ws", "repos.json"); + const paths = manifestPaths(manifest, manifestFile); + assertEquals(paths.root, dirname(dirname(manifestFile))); }); Deno.test("manifestPaths honors absolute workspaceRoot", () => { const manifest: WorkspaceManifest = { - workspaceRoot: "C:\\wazoo", + workspaceRoot: Deno.build.os === "windows" ? "C:\\wazoo" : "/wazoo", repositories: [], }; - const paths = manifestPaths(manifest, "C:\\ws\\repos.json"); - assertEquals(paths.root, "C:\\wazoo"); + const manifestFile = Deno.build.os === "windows" + ? "C:\\ws\\repos.json" + : "/ws/repos.json"; + const paths = manifestPaths(manifest, manifestFile); + assertEquals( + paths.root, + Deno.build.os === "windows" ? "C:\\wazoo" : "/wazoo", + ); +}); + +Deno.test("validateManifest rejects invalid repo names or traversal", () => { + const manifest: WorkspaceManifest = { + repositories: [{ name: "../traversal", url: "https://example.com/a.git" }], + }; + assertThrows( + () => validateManifest(manifest), + Error, + "invalid characters or path traversal", + ); });