diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb481bd..a2005bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ All notable changes to **basou** are recorded here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0. +## Unreleased + +### Fixed + +- **The commands that read or write a workspace's trail now work from its + workspace view.** From a workspace view (a directory outside git that links + its planning repo), `basou verify`, `view`, `status`, `stats`, `report + generate`, `decisions generate`, `approval list`, `show`, `approve` and + `reject`, `decision record`, and `import claude-code` and `import codex` + stopped with `Not a git repository. Run 'git init' first, then re-run + 'basou '.`, advice that would have made the view a repository. They + now resolve to the repository the view links, as `orient`, `refresh`, + `note`, `decision capture`, `task` and the other commands already did, and + say so on stderr (`Resolved workspace view to ...`). Run from a portfolio + member (a repository whose trail another workspace aggregates through its + `import.source_roots`), they now resolve to that workspace too, as those + commands do. A directory that is neither keeps the `git init` advice. +- **`init`, `project new`, `exec` and `run` say so when they are run from a + workspace view.** They work on the repository they run in, so they still + stop outside git, but from a workspace view they now print `Not a git + repository: this is a workspace view (it links ). Run 'basou + ' inside a repository instead.` rather than advising `git init`. + A directory that is not a view keeps the `git init` advice. +- **`basou view --portfolio --check` counts a registered workspace view or + portfolio member as the workspace it resolves to.** Its capture-coverage + report listed such an entry among those `import cannot run in` (`not a git + repository`, or `no .basou store`) and counted the logs under the roots of + the workspace it resolves to as imported by no registered workspace, although + `basou refresh --portfolio` already imported them and `basou import` now + does too. It now resolves each entry as those commands do, against the + registry it checks. + ## 0.65.0 — 2026-10-08 ### Added diff --git a/packages/cli/src/commands/approval.test.ts b/packages/cli/src/commands/approval.test.ts index c3cb9cb6..6f58f776 100644 --- a/packages/cli/src/commands/approval.test.ts +++ b/packages/cli/src/commands/approval.test.ts @@ -1534,3 +1534,50 @@ describe.skipIf(process.platform === "win32")("an approval file that is not foll expect(events).toContain('"type":"approval_approved"'); }); }); + +describe("basou approval from a workspace view", () => { + it("resolves a git-untracked view to the repo it links", async () => { + const repo = await setupInitedRepo(); + const view = await mkdtemp(join(tmpdir(), "basou-approval-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const out = captureStdout(); + const err = captureStderr(); + await doRunApprovalList({}, { cwd: view }); + expect(joinCalls(out)).toBe("No approvals found."); + expect(joinCalls(err)).toContain("Resolved workspace view to"); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); + + it("shows, approves and rejects from a view, writing into the linked repo", async () => { + const repo = await setupInitedRepo(); + const toApprove = APPR("P01"); + const toReject = APPR("P02"); + await createApproval(repo, { id: toApprove, sessionId: SES("S01") }); + await appendRequestedEvent(repo, SES("S01"), toApprove, "2026-05-04T10:00:00+09:00", "E01"); + await createApproval(repo, { id: toReject, sessionId: SES("S02") }); + await appendRequestedEvent(repo, SES("S02"), toReject, "2026-05-04T10:00:00+09:00", "E02"); + const view = await mkdtemp(join(tmpdir(), "basou-approval-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const out = captureStdout(); + const err = captureStderr(); + await doRunApprovalShow(toApprove, {}, { cwd: view }); + expect(joinCalls(out)).toContain(`Approval: ${toApprove}`); + await runApprovalApprove(toApprove, {}, { cwd: view }); + await runApprovalReject(toReject, { reason: "Not allowed" }, { cwd: view }); + expect( + err.mock.calls.flat().filter((c) => String(c).includes("Resolved workspace view to")), + ).toHaveLength(3); + const resolved = await readdir(basouPaths(repo).approvals.resolved); + expect(resolved).toEqual(expect.arrayContaining([`${toApprove}.yaml`, `${toReject}.yaml`])); + expect(await readdir(basouPaths(repo).approvals.pending)).toEqual([]); + expect(await readdir(view)).toEqual(["fixture-planning"]); + expect(process.exitCode ?? 0).toBe(0); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/approval.ts b/packages/cli/src/commands/approval.ts index 1278c163..e8de665c 100644 --- a/packages/cli/src/commands/approval.ts +++ b/packages/cli/src/commands/approval.ts @@ -28,11 +28,11 @@ import { readSessionYaml, readYamlFile, replayEvents, - resolveRepositoryRoot, type UnfollowedApprovalEntry, } from "@basou/core"; import type { Command } from "commander"; import { isVerbose, printReplayWarning, renderCliError } from "../lib/error-render.js"; +import { resolveBasouRootForCommand } from "../lib/repo-root.js"; const APPR_PREFIX = "appr_"; const SHORT_ID_BASE_LEN = 6; @@ -156,7 +156,7 @@ export async function doRunApprovalList( ctx: ApprovalContext, ): Promise { const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForApproval(cwd, "list"); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "approval list"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); @@ -267,7 +267,7 @@ export async function doRunApprovalShow( ctx: ApprovalContext, ): Promise { const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForApproval(cwd, "show"); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "approval show"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); @@ -364,7 +364,7 @@ async function doRunApprovalResolve( } const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForApproval(cwd, decision); + const repositoryRoot = await resolveBasouRootForCommand(cwd, `approval ${decision}`); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); @@ -826,23 +826,6 @@ function truncate(value: string, maxLength: number): string { return `${value.slice(0, maxLength - 3)}...`; } -async function resolveRepositoryRootForApproval( - cwd: string, - subcmd: "list" | "show" | "approve" | "reject", -): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error( - `Not a git repository. Run 'git init' first, then re-run 'basou approval ${subcmd}'.`, - { cause: error }, - ); - } - throw error; - } -} - async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/cli/src/commands/decision.test.ts b/packages/cli/src/commands/decision.test.ts index 47e7e02d..bd51e33e 100644 --- a/packages/cli/src/commands/decision.test.ts +++ b/packages/cli/src/commands/decision.test.ts @@ -1,7 +1,16 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import { promisify } from "node:util"; import { basouPaths, @@ -1516,3 +1525,71 @@ describe("basou decision void", () => { ).rejects.toThrow(/cannot supersede itself/); }); }); + +describe("doRunDecisionRecord from a workspace view", () => { + it("resolves a git-untracked view to the repo it links and records there", async () => { + const repo = await setupInitedRepo(); + const view = await mkdtemp(join(tmpdir(), "basou-decision-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const out = captureStdout(); + const err = captureStderr(); + await doRunDecisionRecord({ title: "recorded from a view" }, { cwd: view, ...FIXED_CTX }); + expect(joinCalls(out)).toContain("Recorded decision_"); + expect(joinCalls(err)).toContain("Resolved workspace view to"); + // The session is in the linked repo's store and records that repo as its + // working directory, never the view. + const sid = await findAdHocSessionId(repo); + const session = (await readYamlFile( + join(basouPaths(repo).sessions, sid, "session.yaml"), + )) as { session: { working_directory: string } }; + expect(session.session.working_directory).toBe(repo); + expect(await readdir(view)).toEqual(["fixture-planning"]); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); + +describe("doRunDecisionRecord from a portfolio member", () => { + it("resolves a storeless member to the workspace that aggregates it and records there", async () => { + const repo = await setupInitedRepo(); + const root = await realpath(await mkdtemp(join(tmpdir(), "basou-decision-member-"))); + try { + const member = join(root, "fixture-member"); + await mkdir(member); + await execFileAsync("git", ["-c", "init.defaultBranch=main", "init"], { + cwd: member, + env: ENV, + }); + await writeManifest( + basouPaths(repo), + createManifest({ + workspaceName: "decision-ws", + now: FIXED_DATE, + workspaceId: FIXED_WS_ID, + sourceRoots: [".", relative(repo, member)], + }), + { force: true }, + ); + const home = join(root, "home"); + await mkdir(join(home, ".basou"), { recursive: true }); + await writeFile( + join(home, ".basou", "portfolio.yaml"), + `version: 1\nworkspaces:\n - path: ${repo}\n`, + "utf8", + ); + vi.stubEnv("HOME", home); + const out = captureStdout(); + const err = captureStderr(); + await doRunDecisionRecord({ title: "recorded from a member" }, { cwd: member, ...FIXED_CTX }); + expect(joinCalls(out)).toContain("Recorded decision_"); + expect(joinCalls(err)).toContain("Resolved portfolio member to"); + await findAdHocSessionId(repo); + expect(await readdir(member)).toEqual([".git"]); + } finally { + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/decision.ts b/packages/cli/src/commands/decision.ts index 986bf7f9..485761d7 100644 --- a/packages/cli/src/commands/decision.ts +++ b/packages/cli/src/commands/decision.ts @@ -21,7 +21,6 @@ import { prefixedUlid, readManifest, replayEvents, - resolveRepositoryRoot, resolveSessionId, type SessionStatus, sanitizePath, @@ -277,7 +276,7 @@ export async function doRunDecisionRecord( ctx: DecisionContext, ): Promise { const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForDecision(cwd); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "decision record"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); @@ -432,11 +431,9 @@ export async function doRunDecisionCapture( ctx: DecisionCaptureContext, ): Promise { const cwd = ctx.cwd ?? process.cwd(); - // View-aware resolution (like orient / refresh / note) so capture works from - // a workspace-view dir, redirecting to the planning repo where decisions.md - // and orient live. `basou decision record` predates this and uses a plain - // git-root resolver; aligning record is a separate, behavior-changing - // follow-up, not in scope for the capture slice. + // View-aware resolution (like orient / refresh / note / decision record) so + // capture works from a workspace-view dir, redirecting to the planning repo + // where decisions.md and orient live. const repositoryRoot = await resolveBasouRootForCommand(cwd, "decision capture"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); @@ -1269,20 +1266,6 @@ function printDecisionResult(options: DecisionRecordOptions, result: DecisionPri } } -async function resolveRepositoryRootForDecision(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error( - "Not a git repository. Run 'git init' first, then re-run 'basou decision record'.", - { cause: error }, - ); - } - throw error; - } -} - async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/cli/src/commands/decisions.test.ts b/packages/cli/src/commands/decisions.test.ts index b8022f27..a1c8b028 100644 --- a/packages/cli/src/commands/decisions.test.ts +++ b/packages/cli/src/commands/decisions.test.ts @@ -1,5 +1,15 @@ import { execFile } from "node:child_process"; -import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -442,3 +452,22 @@ describe("basou decisions generate", () => { expect(generate).toBeDefined(); }); }); + +describe("basou decisions generate from a workspace view", () => { + it("resolves a git-untracked view to the repo it links and writes there", async () => { + const repo = await setupInitedRepo(); + const view = await mkdtemp(join(tmpdir(), "basou-decisions-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const out = captureStdout(); + const err = captureStderr(); + await doRunDecisionsGenerate({}, { cwd: view, nowProvider: () => FIXED_DATE }); + expect(joinCalls(out)).toContain("decisions: 0"); + expect(joinCalls(err)).toContain("Resolved workspace view to"); + expect(await readFile(basouPaths(repo).files.decisions, "utf8")).toContain(GENERATED_START); + expect(await readdir(view)).toEqual(["fixture-planning"]); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/decisions.ts b/packages/cli/src/commands/decisions.ts index bab01224..92aa137d 100644 --- a/packages/cli/src/commands/decisions.ts +++ b/packages/cli/src/commands/decisions.ts @@ -5,7 +5,6 @@ import { readMarkdownFile, renderDecisions, renderWithMarkers, - resolveRepositoryRoot, writeMarkdownFile, } from "@basou/core"; import type { Command } from "commander"; @@ -15,6 +14,7 @@ import { printSessionSkip, renderCliError, } from "../lib/error-render.js"; +import { resolveBasouRootForCommand } from "../lib/repo-root.js"; export type DecisionsGenerateOptions = { verbose?: boolean }; @@ -56,7 +56,7 @@ export async function doRunDecisionsGenerate( ): Promise { void options; const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForDecisions(cwd); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "decisions generate"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); @@ -75,20 +75,6 @@ export async function doRunDecisionsGenerate( console.log(`Generated .basou/decisions.md (decisions: ${result.decisionCount})`); } -async function resolveRepositoryRootForDecisions(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error( - "Not a git repository. Run 'git init' first, then re-run 'basou decisions generate'.", - { cause: error }, - ); - } - throw error; - } -} - async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/cli/src/commands/exec.test.ts b/packages/cli/src/commands/exec.test.ts index 4af2a39d..a94a7d06 100644 --- a/packages/cli/src/commands/exec.test.ts +++ b/packages/cli/src/commands/exec.test.ts @@ -695,3 +695,27 @@ describe("runExec", () => { } }); }); + +describe("basou exec from a workspace view", () => { + it("refuses, saying the directory is a view, and records nothing", async () => { + const repo = await setupInitedRepo(); + const view = await mkdtemp(join(tmpdir(), "basou-exec-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const runner = makeFakeRunner({ exit_code: 0 }); + await expect( + runExec( + "node", + ["-e", "process.exit(0)"], + { cwd: view, snapshot: false }, + { runner, now: () => FIXED_DATE }, + ), + ).rejects.toThrow( + "Not a git repository: this is a workspace view (it links fixture-planning). Run 'basou exec' inside a repository instead.", + ); + expect(await readdir(basouPaths(repo).sessions)).toEqual([]); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/exec.ts b/packages/cli/src/commands/exec.ts index c74685e1..0d706578 100644 --- a/packages/cli/src/commands/exec.ts +++ b/packages/cli/src/commands/exec.ts @@ -22,7 +22,6 @@ import { type RunResult, readManifest, readYamlFile, - resolveRepositoryRoot, SESSION_SCHEMA_VERSION, type Session, SessionSchema, @@ -32,6 +31,7 @@ import { } from "@basou/core"; import type { Command } from "commander"; import { isVerbose, renderCliError } from "../lib/error-render.js"; +import { resolveRepositoryRootInPlace } from "../lib/repo-root.js"; // Appends one event to the session's events.jsonl. The `sessionDir` argument // is retained for the test-injection seam (ctx.appendEvent); the production @@ -108,7 +108,7 @@ export async function runExec( // 1. Resolve repository root before touching anything; matches existing // init/status semantics so subdir invocations still find `.basou/`. - const repoRoot = await resolveRepositoryRootForExec(cwd); + const repoRoot = await resolveRepositoryRootInPlace(cwd, "exec"); const paths = basouPaths(repoRoot); // 2. Workspace safety check (caller responsibility). @@ -517,16 +517,3 @@ async function finalizeSessionAsFailed( s.session.invocation.exit_code = null; }); } - -async function resolveRepositoryRootForExec(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou exec'.", { - cause: error, - }); - } - throw error; - } -} diff --git a/packages/cli/src/commands/import.test.ts b/packages/cli/src/commands/import.test.ts index b720e5ab..f20ef9c7 100644 --- a/packages/cli/src/commands/import.test.ts +++ b/packages/cli/src/commands/import.test.ts @@ -1663,3 +1663,24 @@ describe.skipIf(process.platform === "win32")( }); }, ); + +describe("basou import claude-code from a workspace view", () => { + it("resolves a git-untracked view to the repo it links and imports there", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-view", actionTranscript(repo)); + const view = await mkdtemp(join(tmpdir(), "basou-import-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + await doRunImportClaudeCode( + { all: true }, + { cwd: view, claudeProjectsDir: getProjectsRoot() }, + ); + expect(await listSessionDirs(repo)).toHaveLength(1); + expect(err.mock.calls.flat().join(" ")).toContain("Resolved workspace view to"); + expect(await readdir(view)).toEqual(["fixture-planning"]); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts index 247ea880..7b8d9440 100644 --- a/packages/cli/src/commands/import.ts +++ b/packages/cli/src/commands/import.ts @@ -28,7 +28,6 @@ import { readSessionObservation, readSessionYaml, reimportPreservingId, - resolveRepositoryRoot, SESSION_IMPORT_SCHEMA_VERSION, type Session, type SessionImportPayload, @@ -38,6 +37,7 @@ import { import type { Command } from "commander"; import { isVerbose, renderCliError } from "../lib/error-render.js"; import { warnIfObservationsRefused } from "../lib/observation-warn.js"; +import { resolveBasouRootForCommand } from "../lib/repo-root.js"; const SES_PREFIX = "ses_"; const SHORT_ID_LEN = 6; @@ -196,9 +196,11 @@ export async function runImportCodex( * the SAME function the import guard uses: coverage claims "import would not * take this session log", and that claim only holds while both sides resolve * roots identically. Note that identical resolution needs the same INPUT too — - * `resolveImportTarget` passes the git toplevel, so a caller that passes some - * other spelling of the repo (a symlink, a subdirectory) gets a different root - * set than the import it is trying to mirror. + * `resolveImportTarget` passes the root `resolveBasouRootForCommand` resolves + * (the git toplevel, or the repo a workspace view or a portfolio member resolves + * to), so a caller that passes some other spelling of the repo (a symlink, a + * subdirectory) gets a different root set than the import it is trying to + * mirror. */ export function resolveSourceRoots(args: { projectFlags: string[]; @@ -348,7 +350,7 @@ async function resolveImportTarget( ctx: ImportContext, ): Promise<{ repositoryRoot: string; paths: BasouPaths; manifest: Manifest }> { const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForImport(cwd); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "import"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); const manifest = await readManifest(paths); @@ -1063,19 +1065,6 @@ function shortId(id: string): string { return id.slice(0, SHORT_ID_LEN); } -async function resolveRepositoryRootForImport(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou import'.", { - cause: error, - }); - } - throw error; - } -} - async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 9ead0f15..666fe651 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -352,3 +352,20 @@ describe("doRunInit --source-root", () => { expect("import" in manifest).toBe(false); }); }); + +describe("basou init from a workspace view", () => { + it("refuses, saying the directory is a view, and creates nothing in it", async () => { + const repo = getTmpRepo(); + await doRunInit({}, { cwd: repo }); + const view = await mkdtemp(join(tmpdir(), "basou-init-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + await expect(doRunInit({}, { cwd: view })).rejects.toThrow( + "Not a git repository: this is a workspace view (it links fixture-planning). Run 'basou init' inside a repository instead.", + ); + expect(await readdir(view)).toEqual(["fixture-planning"]); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 5ddc562a..fd01da39 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,11 +3,11 @@ import { appendBasouGitignore, createManifest, ensureBasouDirectory, - resolveRepositoryRoot, writeManifest, } from "@basou/core"; import type { Command } from "commander"; import { extractCauseLabel, isVerbose, renderCliError } from "../lib/error-render.js"; +import { resolveRepositoryRootInPlace } from "../lib/repo-root.js"; export type InitOptions = { name?: string; @@ -93,7 +93,7 @@ export async function runInit(options: InitOptions, ctx: InitContext = {}): Prom */ export async function doRunInit(options: InitOptions, ctx: InitContext): Promise { const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForInit(cwd); + const repositoryRoot = await resolveRepositoryRootInPlace(cwd, "init"); const workspaceName = options.name ?? basename(repositoryRoot); // --repo-url is a deprecated no-op: project.repository_url was removed, so the @@ -156,21 +156,3 @@ function renderGitignoreWarning(error: unknown, verbose: boolean): void { if (label !== undefined) console.error(`Caused by: ${label}`); } } - -/** - * Wrap the core git capability so the CLI surfaces the command-specific - * "Run 'git init' first, then re-run 'basou init'." suffix while the - * capability layer remains command-agnostic. - */ -async function resolveRepositoryRootForInit(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", { - cause: error, - }); - } - throw error; - } -} diff --git a/packages/cli/src/commands/project.test.ts b/packages/cli/src/commands/project.test.ts index d0282e61..e9a9035c 100644 --- a/packages/cli/src/commands/project.test.ts +++ b/packages/cli/src/commands/project.test.ts @@ -4393,3 +4393,26 @@ describe("basou project derive: anchor seed (doRunProjectSeedAnchor)", () => { expect(await readFile(anchorDoc(), "utf8")).toContain(""); }); }); + +describe("basou project new from a workspace view", () => { + it("refuses, saying the directory is a view, and creates nothing in it", async () => { + const root = await mkdtemp(join(tmpdir(), "basou-new-view-")); + try { + const anchor = join(root, "anchor"); + await mkdir(join(anchor, ".basou"), { recursive: true }); + await execFileAsync("git", ["-c", "init.defaultBranch=main", "init"], { + cwd: anchor, + env: ENV, + }); + const view = join(root, "anchor-workspace"); + await mkdir(view); + await symlink(anchor, join(view, "anchor")); + await expect(doRunProjectNew([], {}, { cwd: view })).rejects.toThrow( + "Not a git repository: this is a workspace view (it links anchor). Run 'basou project new' inside a repository instead.", + ); + expect(await readdir(view)).toEqual(["anchor"]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index aa68e343..1151e369 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -68,7 +68,6 @@ import { renderAnchorStarter, renderViewPresetBlock, renderWithMarkers, - resolveRepositoryRoot, type SourceRootsReconcile, type SymlinkPlanSummary, safeSimpleGit, @@ -90,7 +89,7 @@ import { } from "@basou/core"; import type { Command } from "commander"; import { extractCauseLabel, isVerbose, renderCliError } from "../lib/error-render.js"; -import { resolveBasouRootForCommand } from "../lib/repo-root.js"; +import { resolveBasouRootForCommand, resolveRepositoryRootInPlace } from "../lib/repo-root.js"; import type { ImportContext } from "./import.js"; export type ProjectCheckOptions = { @@ -4431,28 +4430,6 @@ export async function runProjectNew( } } -/** - * Wrap the core git capability so the CLI surfaces the command-specific - * "Run 'git init' first, then re-run 'basou project new'." suffix while the - * capability layer remains command-agnostic. The anchor must already be a git - * repository: greenfield scaffolds the project declaration, never the repos. - * `resolveBasouRootForCommand` is deliberately NOT used — there is no `.basou` - * yet, so the workspace-resolution fallback would have nothing to resolve. - */ -async function resolveRepositoryRootForNew(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error( - "Not a git repository. Run 'git init' first, then re-run 'basou project new'.", - { cause: error }, - ); - } - throw error; - } -} - /** * Validate a `--project-name` value. It is stored as `manifest.project.name` AND * drives a filesystem path (the `-workspace` view stem), so it must be a @@ -4492,7 +4469,10 @@ export async function doRunProjectNew( ctx: ProjectNewContext, ): Promise { const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForNew(cwd); + // The anchor must already be a git repository: greenfield scaffolds the project + // declaration, never the repos. `resolveBasouRootForCommand` is deliberately not + // used — there is no `.basou` yet, so its fallbacks would have nothing to resolve. + const repositoryRoot = await resolveRepositoryRootInPlace(cwd, "project new"); const workspaceName = basename(repositoryRoot); // `--project-name` is the explicit, first-class product name: it drives // `project.name` and the default view stem. Because it also becomes part of a diff --git a/packages/cli/src/commands/report.test.ts b/packages/cli/src/commands/report.test.ts index 98780cd1..3282beb0 100644 --- a/packages/cli/src/commands/report.test.ts +++ b/packages/cli/src/commands/report.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -242,3 +242,22 @@ describe("basou report generate", () => { expect(joinCalls(err)).toContain("Workspace not initialized"); }); }); + +describe("basou report generate from a workspace view", () => { + it("resolves a git-untracked view to the repo it links", async () => { + const repo = await setupInitedRepo(); + await importChainedSession(repo); + const view = await mkdtemp(join(tmpdir(), "basou-report-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const out = captureStdout(); + const err = captureStderr(); + await runReportGenerate({}, { cwd: view, nowProvider: () => FIXED_DATE }); + expect(joinCalls(out)).toContain("1 verified"); + expect(joinCalls(err)).toContain("Resolved workspace view to"); + expect(process.exitCode ?? 0).toBe(0); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/report.ts b/packages/cli/src/commands/report.ts index 01074ade..d74c2bb4 100644 --- a/packages/cli/src/commands/report.ts +++ b/packages/cli/src/commands/report.ts @@ -4,7 +4,6 @@ import { basouPaths, findErrorCode, renderReport, - resolveRepositoryRoot, writeMarkdownFile, } from "@basou/core"; import type { Command } from "commander"; @@ -15,6 +14,7 @@ import { printTaskSkip, renderCliError, } from "../lib/error-render.js"; +import { resolveBasouRootForCommand } from "../lib/repo-root.js"; export type ReportGenerateOptions = { out?: string; @@ -87,7 +87,7 @@ export async function doRunReportGenerate( ctx: ReportContext, ): Promise { const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForReport(cwd); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "report generate"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); @@ -118,20 +118,6 @@ export async function doRunReportGenerate( } } -async function resolveRepositoryRootForReport(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error( - "Not a git repository. Run 'git init' first, then re-run 'basou report generate'.", - { cause: error }, - ); - } - throw error; - } -} - async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/cli/src/commands/run.test.ts b/packages/cli/src/commands/run.test.ts index 524b1252..a72aac96 100644 --- a/packages/cli/src/commands/run.test.ts +++ b/packages/cli/src/commands/run.test.ts @@ -1150,3 +1150,26 @@ describe("runCodex pre-spawn: trust of the registered SessionStart hook", () => expect(logs.join("\n")).not.toContain("SessionStart hook"); }); }); + +describe("basou run from a workspace view", () => { + it("refuses, saying the directory is a view, and records nothing", async () => { + const repo = await setupInitedRepo(); + const view = await mkdtemp(join(tmpdir(), "basou-run-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const runner = makeFakeRunner({ exit_code: 0 }); + await expect( + runClaudeCode( + [], + { cwd: view, snapshot: false }, + { runner, now: () => FIXED_DATE, resolveCommand: okResolve }, + ), + ).rejects.toThrow( + "Not a git repository: this is a workspace view (it links fixture-planning). Run 'basou run' inside a repository instead.", + ); + expect(await readdir(basouPaths(repo).sessions)).toEqual([]); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 7da03089..3d69a862 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -29,7 +29,6 @@ import { readYamlFile, resolveClaudeCodeCommand, resolveCodexCommand, - resolveRepositoryRoot, SESSION_SCHEMA_VERSION, type Session, SessionSchema, @@ -42,6 +41,7 @@ import { import type { Command } from "commander"; import { describeCodexHookTrust } from "../lib/codex-hook-trust.js"; import { isVerbose, renderCliError } from "../lib/error-render.js"; +import { resolveRepositoryRootInPlace } from "../lib/repo-root.js"; import { codexHookTrustFor, DEFAULT_CODEX_HOOKS_PATH } from "./hook.js"; // Appends one event to the session's events.jsonl. The `sessionDir` argument @@ -247,7 +247,7 @@ async function runTrackedTool( const cwd = options.cwd ?? process.cwd(); // 2. Resolve repository root (entry-fail when not in a git repo). - const repoRoot = await resolveRepositoryRootForRun(cwd); + const repoRoot = await resolveRepositoryRootInPlace(cwd, "run"); const paths = basouPaths(repoRoot); // 3. Workspace safety check. @@ -757,19 +757,6 @@ async function finalizeSessionAsFailed( }); } -async function resolveRepositoryRootForRun(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou run'.", { - cause: error, - }); - } - throw error; - } -} - /** * `basou run codex` pre-spawn: say whether the SessionStart hook that hands a * Codex session its workspace's position will actually run. The launcher diff --git a/packages/cli/src/commands/stats.test.ts b/packages/cli/src/commands/stats.test.ts index e5bb7541..3b714ccf 100644 --- a/packages/cli/src/commands/stats.test.ts +++ b/packages/cli/src/commands/stats.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -364,3 +364,20 @@ describe("basou stats", () => { expect(errSpy.mock.calls.flat().join(" ")).toContain("Workspace not initialized"); }); }); + +describe("basou stats from a workspace view", () => { + it("resolves a git-untracked view to the repo it links", async () => { + const repo = await setupInitedRepo(); + const view = await mkdtemp(join(tmpdir(), "basou-stats-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const out = captureStdout(); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + await doRunStats({}, ctx(view)); + expect(out.join("\n")).toContain("Sessions: 0"); + expect(err.mock.calls.flat().join(" ")).toContain("Resolved workspace view to"); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts index 4922e01e..6a499321 100644 --- a/packages/cli/src/commands/stats.ts +++ b/packages/cli/src/commands/stats.ts @@ -3,7 +3,6 @@ import { basouPaths, computeWorkStats, findErrorCode, - resolveRepositoryRoot, type SourceWorkStats, type WorkStatsResult, } from "@basou/core"; @@ -15,6 +14,7 @@ import { renderCliError, } from "../lib/error-render.js"; import { formatDurationMs } from "../lib/format-duration.js"; +import { resolveBasouRootForCommand } from "../lib/repo-root.js"; export type StatsOptions = { json?: boolean; @@ -61,7 +61,7 @@ export async function runStats(options: StatsOptions, ctx: StatsContext = {}): P /** Pure runner: resolve the workspace, aggregate, and print (text or JSON). */ export async function doRunStats(options: StatsOptions, ctx: StatsContext): Promise { const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForStats(cwd); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "stats"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); @@ -201,19 +201,6 @@ function formatInt(n: number): string { return n.toLocaleString("en-US"); } -async function resolveRepositoryRootForStats(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou stats'.", { - cause: error, - }); - } - throw error; - } -} - async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/cli/src/commands/status.test.ts b/packages/cli/src/commands/status.test.ts index 67a864aa..692a3705 100644 --- a/packages/cli/src/commands/status.test.ts +++ b/packages/cli/src/commands/status.test.ts @@ -281,3 +281,23 @@ describe("runStatus (process-state wrapper)", () => { }, ); }); + +describe("basou status from a workspace view", () => { + it("resolves a git-untracked view to the repo it links", async () => { + const { repo, paths } = await setupInitedRepo(); + const view = await mkdtemp(join(tmpdir(), "basou-status-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const out = captureStdout(); + const err = captureStderr(); + await doRunStatus({}, { cwd: view }); + expect(joinCalls(out)).toContain(`Workspace: client-foo-lp (${FIXED_WS_ID})`); + expect(joinCalls(err)).toContain("Resolved workspace view to"); + // status.json lands in the linked repo's store, never in the view. + expect((await readdir(paths.root)).includes("status.json")).toBe(true); + expect(await readdir(view)).toEqual(["fixture-planning"]); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index f96f6783..11dc1dcb 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -5,12 +5,12 @@ import { findErrorCode, type Manifest, readManifest, - resolveRepositoryRoot, type StatusSnapshot, writeStatus, } from "@basou/core"; import type { Command } from "commander"; import { isVerbose, renderCliError } from "../lib/error-render.js"; +import { resolveBasouRootForCommand } from "../lib/repo-root.js"; export type StatusOptions = { json?: boolean; @@ -61,7 +61,7 @@ export async function runStatus(options: StatusOptions, ctx: StatusContext = {}) */ export async function doRunStatus(options: StatusOptions, ctx: StatusContext): Promise { const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForStatus(cwd); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "status"); const paths = basouPaths(repositoryRoot); // Pre-condition: refuse to operate on a swapped/non-directory .basou root @@ -123,24 +123,6 @@ function renderTextStatus(s: StatusSnapshot): void { console.log(`Subdirectories present: ${present}/${total}`); } -/** - * Wrap the core git capability so the CLI surfaces the command-specific - * "Run 'git init' first, then re-run 'basou status'." suffix while the - * capability layer remains command-agnostic. - */ -async function resolveRepositoryRootForStatus(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou status'.", { - cause: error, - }); - } - throw error; - } -} - /** * If a manifest read failed the `.basou` format-version gate * ({@link SchemaVersionSchema}), return that issue's message. The gate message diff --git a/packages/cli/src/commands/verify.test.ts b/packages/cli/src/commands/verify.test.ts index 63a01352..54f4303f 100644 --- a/packages/cli/src/commands/verify.test.ts +++ b/packages/cli/src/commands/verify.test.ts @@ -11,7 +11,7 @@ import { writeFile, } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { @@ -494,3 +494,66 @@ describe("basou verify --json as documented", () => { expect(process.exitCode).toBe(1); }); }); + +describe("basou verify from a workspace view", () => { + it("resolves a git-untracked view to the repo it links", async () => { + const repo = await setupInitedRepo(); + const importedId = await importChainedSession(repo); + const view = await mkdtemp(join(tmpdir(), "basou-verify-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const out = captureStdout(); + const err = captureStderr(); + await runVerify({}, { cwd: view }); + expect(joinCalls(out).split("\n")).toContain(`${importedId} verified (3 events)`); + expect(joinCalls(err)).toContain("Resolved workspace view to"); + expect(process.exitCode ?? 0).toBe(0); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); + +describe("basou verify from a portfolio member", () => { + it("resolves a storeless member to the workspace that aggregates it", async () => { + const repo = await setupInitedRepo(); + const importedId = await importChainedSession(repo); + const root = await realpath(await mkdtemp(join(tmpdir(), "basou-verify-member-"))); + try { + const member = join(root, "fixture-member"); + await mkdir(member); + await execFileAsync("git", ["-c", "init.defaultBranch=main", "init"], { + cwd: member, + env: ENV, + }); + await writeManifest( + basouPaths(repo), + createManifest({ + workspaceName: "verify-ws", + now: FIXED_DATE, + workspaceId: FIXED_WS_ID, + sourceRoots: [".", relative(repo, member)], + }), + { force: true }, + ); + // The member is found through the registry under HOME, as on a real machine. + const home = join(root, "home"); + await mkdir(join(home, ".basou"), { recursive: true }); + await writeFile( + join(home, ".basou", "portfolio.yaml"), + `version: 1\nworkspaces:\n - path: ${repo}\n`, + "utf8", + ); + vi.stubEnv("HOME", home); + const out = captureStdout(); + const err = captureStderr(); + await runVerify({}, { cwd: member }); + expect(joinCalls(out).split("\n")).toContain(`${importedId} verified (3 events)`); + expect(joinCalls(err)).toContain("Resolved portfolio member to"); + expect(process.exitCode ?? 0).toBe(0); + } finally { + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 6e9bb276..6a6ef18f 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -5,12 +5,12 @@ import { type ChainVerdict, enumerateSessionEntries, findErrorCode, - resolveRepositoryRoot, resolveSessionId, verifyEventsChain, } from "@basou/core"; import type { Command } from "commander"; import { isVerbose, renderCliError } from "../lib/error-render.js"; +import { resolveBasouRootForCommand } from "../lib/repo-root.js"; export type VerifyOptions = { session?: string; @@ -82,7 +82,7 @@ async function doRunVerify(options: VerifyOptions, ctx: VerifyContext): Promise< } const cwd = ctx.cwd ?? process.cwd(); - const repositoryRoot = await resolveRepositoryRootForVerify(cwd); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "verify"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); @@ -162,19 +162,6 @@ async function enumerateAllSessionNames(paths: BasouPaths): Promise { return [...dirs, ...notDirectories].sort(); } -async function resolveRepositoryRootForVerify(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou verify'.", { - cause: error, - }); - } - throw error; - } -} - async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 0c475e09..6ef3705e 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -4,6 +4,7 @@ import { cp, mkdir, mkdtemp, + readdir, readFile, realpath, rename, @@ -1595,3 +1596,51 @@ describe("basou view: the board page", () => { ]); }); }); + +describe("basou view from a workspace view", () => { + it("resolves a git-untracked view to the repo it links", async () => { + const repo = await setupInitedRepo(); + const view = await mkdtemp(join(tmpdir(), "basou-view-view-")); + try { + await symlink(repo, join(view, "fixture-planning")); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + await withServer(view, {}, async (handle) => { + const { status, data } = await getJson(handle, "/api/overview"); + expect(status).toBe(200); + expect((data as { repoRoot: string }).repoRoot).toBe(repo); + }); + expect(err.mock.calls.flat().join(" ")).toContain("Resolved workspace view to"); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); + +describe("basou view --workspace: an uninitialized card", () => { + it("refuses a refresh or an import, writing nothing where its import resolves", async () => { + // A workspace view registered beside the repo it links: its card has no + // store, while an import run in it resolves to the linked repo. + const planning = await setupInitedRepo(); + const view = await realpath(await mkdtemp(join(tmpdir(), "basou-pf-view-card-"))); + try { + await symlink(planning, join(view, "fixture-planning")); + vi.spyOn(console, "error").mockImplementation(() => {}); + await withPortfolioServer([view, planning], hermeticLogRoots(), async (handle) => { + const { data } = await getJson(handle, "/api/portfolio"); + const cards = (data as { workspaces: Array<{ key: string; initialized: boolean }> }) + .workspaces; + const card = cards.find((w) => !w.initialized); + if (card === undefined) throw new Error("expected an uninitialized card"); + for (const sub of ["refresh", "import/claude-code", "import/codex"]) { + const r = await postJson(handle, `/api/ws/${encodeURIComponent(card.key)}/${sub}`, {}); + expect(r.status).toBe(500); + expect(r.data).toEqual({ error: "Workspace not initialized. Run 'basou init' first." }); + } + }); + expect(await readdir(basouPaths(planning).sessions)).toEqual([]); + expect(await readdir(view)).toEqual(["fixture-planning"]); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/view.ts b/packages/cli/src/commands/view.ts index f55d0d07..e87dd29e 100644 --- a/packages/cli/src/commands/view.ts +++ b/packages/cli/src/commands/view.ts @@ -8,12 +8,12 @@ import { loadPortfolioConfig, type PortfolioWorkspace, readManifest, - resolveRepositoryRoot, } from "@basou/core"; import { type Command, InvalidArgumentError } from "commander"; import { isVerbose, renderCliError } from "../lib/error-render.js"; import { checkPortfolioCoverage, formatCoverageReport } from "../lib/portfolio-coverage.js"; import { checkPortfolioSafety, formatSafetyReport } from "../lib/portfolio-safety.js"; +import { resolveBasouRootForCommand } from "../lib/repo-root.js"; import { type RemoteUrlResolver, startViewServer, @@ -160,6 +160,9 @@ export async function doRunView(options: ViewOptions, ctx: ViewContext): Promise ? { claudeProjectsDir: ctx.claudeProjectsDir } : {}), ...(ctx.codexSessionsDir !== undefined ? { codexSessionsDir: ctx.codexSessionsDir } : {}), + ...(ctx.portfolioConfigPath !== undefined + ? { portfolioConfigPath: ctx.portfolioConfigPath } + : {}), }); console.log(""); for (const line of formatCoverageReport(coverage)) console.log(line); @@ -216,9 +219,12 @@ export async function doRunView(options: ViewOptions, ctx: ViewContext): Promise } } -/** Single-workspace mode: resolve the cwd's repo (git required) and serve it alone. */ +/** + * Single-workspace mode: resolve the cwd's workspace (a git repo, or the repo a + * workspace view or a portfolio member resolves to) and serve it alone. + */ async function buildSingleDeps(ctx: ViewContext, cwd: string): Promise { - const repositoryRoot = await resolveRepositoryRootForView(cwd); + const repositoryRoot = await resolveBasouRootForCommand(cwd, "view"); const paths = basouPaths(repositoryRoot); await assertWorkspaceInitialized(paths.root); const entry = await buildWorkspaceEntry(repositoryRoot, ctx); @@ -388,19 +394,6 @@ function waitForShutdown(signal: AbortSignal | undefined): Promise { }); } -async function resolveRepositoryRootForView(cwd: string): Promise { - try { - return await resolveRepositoryRoot(cwd); - } catch (error: unknown) { - if (error instanceof Error && error.message === "Not a git repository") { - throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou view'.", { - cause: error, - }); - } - throw error; - } -} - async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/cli/src/lib/portfolio-coverage.test.ts b/packages/cli/src/lib/portfolio-coverage.test.ts index 3368a9c0..e6198f53 100644 --- a/packages/cli/src/lib/portfolio-coverage.test.ts +++ b/packages/cli/src/lib/portfolio-coverage.test.ts @@ -108,8 +108,15 @@ async function run( workspaces: WorkspaceEntry[], claudeProjectsDir: string, codexSessionsDir: string, + // An absent registry by default, so resolving a storeless entry never reads + // the developer's own ~/.basou/portfolio.yaml. + portfolioConfigPath = join(getParent(), "absent-portfolio.yaml"), ): Promise { - return checkPortfolioCoverage(workspaces, { claudeProjectsDir, codexSessionsDir }); + return checkPortfolioCoverage(workspaces, { + claudeProjectsDir, + codexSessionsDir, + portfolioConfigPath, + }); } describe("checkPortfolioCoverage", () => { @@ -342,6 +349,49 @@ describe("checkPortfolioCoverage", () => { expect(formatCoverageReport(result).join("\n")).toContain("not a git repository"); }); + it("declares the roots of the repo a registered workspace view links", async () => { + const root = getParent(); + const planning = join(root, "planning"); + const repo = join(root, "repo"); + await mkdir(repo, { recursive: true }); + await initWorkspace(planning, [".", "../repo"]); + const view = join(root, "planning-view"); // registered: outside git, links the planning repo + await mkdir(view); + await symlink(planning, join(view, "planning")); + const claude = join(root, "claude"); + const codex = join(root, "codex"); + await codexRollout(codex, "r1", repo); + + const result = await run([wsEntry(view, "view")], claude, codex); + + // `basou import` run in the view imports into the planning repo, so the view + // declares that repo's roots rather than being inert. + expect(result.inertWorkspaces).toEqual([]); + expect(result.attributed).toBe(1); + }); + + it("declares the roots of the workspace that aggregates a registered member", async () => { + const root = getParent(); + const planning = join(root, "planning"); + const member = join(root, "member"); + await mkdir(member, { recursive: true }); + await execFileAsync("git", ["-c", "init.defaultBranch=main", "init"], { + cwd: member, + env: GIT_ENV, + }); + await initWorkspace(planning, [".", "../member"]); + const registry = join(root, "portfolio.yaml"); + await writeFile(registry, `version: 1\nworkspaces:\n - path: ${planning}\n`, "utf8"); + const claude = join(root, "claude"); + const codex = join(root, "codex"); + await codexRollout(codex, "r1", member); + + const result = await run([wsEntry(member, "member")], claude, codex, registry); + + expect(result.inertWorkspaces).toEqual([]); + expect(result.attributed).toBe(1); + }); + it("reports a git repo with no .basou store as inert, not as a declared root", async () => { const root = getParent(); const repo = join(root, "repo"); diff --git a/packages/cli/src/lib/portfolio-coverage.ts b/packages/cli/src/lib/portfolio-coverage.ts index 564d2803..3e78bed8 100644 --- a/packages/cli/src/lib/portfolio-coverage.ts +++ b/packages/cli/src/lib/portfolio-coverage.ts @@ -3,8 +3,9 @@ import { readdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { createInterface } from "node:readline"; -import { basouPaths, readManifest, resolveRepositoryRoot } from "@basou/core"; +import { basouPaths, readManifest } from "@basou/core"; import { encodeProjectDir, readRolloutMeta, resolveSourceRoots } from "../commands/import.js"; +import { resolveBasouRootForCommand } from "./repo-root.js"; import type { WorkspaceEntry } from "./view-server.js"; /** @@ -49,8 +50,9 @@ export type InertReason = "not_a_git_repo" | "no_store" | "unreadable_store"; /** * A registered workspace that cannot import anything, so it claims no roots. - * Registration alone never causes capture: `basou import` resolves the git - * toplevel and asserts an initialized `.basou/` before it reads a single log. + * Registration alone never causes capture: `basou import` resolves its root (the + * git toplevel, or the repo a workspace view or a portfolio member resolves to) + * and asserts an initialized `.basou/` there before it reads a single log. * Counting such an entry's own path as a declared root would let registering a * directory move the coverage number without capturing one extra log. */ @@ -86,6 +88,11 @@ export type CoverageContext = { claudeProjectsDir?: string; /** Override the `~/.codex/sessions` root (must match the importer's). */ codexSessionsDir?: string; + /** + * The registry a storeless entry (a portfolio member) is resolved against, as + * `basou import` resolves it (defaults to `~/.basou/portfolio.yaml`). + */ + portfolioConfigPath?: string; }; /** @@ -124,7 +131,10 @@ export async function checkPortfolioCoverage( workspaces: ReadonlyArray, ctx: CoverageContext = {}, ): Promise { - const { roots, inertWorkspaces } = await collectDeclaredRoots(workspaces); + const { roots, inertWorkspaces } = await collectDeclaredRoots( + workspaces, + ctx.portfolioConfigPath, + ); // The per-project directories the Claude importer would list, for any // declared root. A transcript outside all of them is never read. const listedDirs = new Set([...roots].map((root) => encodeProjectDir(root))); @@ -160,13 +170,16 @@ export async function checkPortfolioCoverage( /** * Every path some registered workspace would import from, derived the way - * `basou import` derives it: resolve the git toplevel of the entry, require a - * readable `.basou/` there, then apply {@link resolveSourceRoots} to that - * manifest against that toplevel. + * `basou import` derives it: resolve the entry as the CLI resolves a command's + * root ({@link resolveBasouRootForCommand}: the git toplevel, or the repo a + * workspace view or a portfolio member resolves to), require a readable + * `.basou/` there, then apply {@link resolveSourceRoots} to that manifest + * against that root. * - * The toplevel matters. `basou import` starts from `resolveRepositoryRoot(cwd)`, - * so a portfolio entry registered by a symlinked (or otherwise non-toplevel) - * spelling imports under the toplevel spelling. Deriving roots from the + * The resolved root matters. `basou import` starts from it, so a portfolio entry + * registered by a symlinked (or otherwise non-toplevel) spelling imports under + * the toplevel spelling, and a registered view or member imports into the + * workspace it resolves to. Deriving roots from the * registered spelling instead would invert coverage for that whole workspace: * the symlinked spelling silently matched, the real one reported as missed. * @@ -175,13 +188,17 @@ export async function checkPortfolioCoverage( */ async function collectDeclaredRoots( workspaces: ReadonlyArray, + portfolioConfigPath: string | undefined, ): Promise<{ roots: Set; inertWorkspaces: InertWorkspace[] }> { const roots = new Set(); const inertWorkspaces: InertWorkspace[] = []; for (const ws of workspaces) { let importRoot: string; try { - importRoot = await resolveRepositoryRoot(ws.repoRoot); + importRoot = await resolveBasouRootForCommand(ws.repoRoot, "import", { + quiet: true, + ...(portfolioConfigPath !== undefined ? { portfolioConfigPath } : {}), + }); } catch { inertWorkspaces.push({ path: ws.repoRoot, reason: "not_a_git_repo" }); continue; diff --git a/packages/cli/src/lib/repo-root.test.ts b/packages/cli/src/lib/repo-root.test.ts index 82eb8b3c..a5e55c66 100644 --- a/packages/cli/src/lib/repo-root.test.ts +++ b/packages/cli/src/lib/repo-root.test.ts @@ -1,11 +1,15 @@ import { execFile } from "node:child_process"; -import { mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { createManifest, ensureBasouDirectory, writeManifest } from "@basou/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveBasouRootForCommand, resolveMemberToMaster } from "./repo-root.js"; +import { + resolveBasouRootForCommand, + resolveMemberToMaster, + resolveRepositoryRootInPlace, +} from "./repo-root.js"; const execFileAsync = promisify(execFile); const ENV = { ...process.env, GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_SYSTEM: devNull }; @@ -231,3 +235,72 @@ describe("resolveBasouRootForCommand (member → master)", () => { expect(await realpath(root)).toBe(await realpath(member)); }); }); + +describe("resolveRepositoryRootInPlace", () => { + /** A git-untracked directory under `parent` that links each target by name. */ + async function view(links: Record): Promise { + const dir = join(parent, "fixture-workspace"); + await mkdir(dir); + for (const [name, target] of Object.entries(links)) await symlink(target, join(dir, name)); + return dir; + } + + async function messageOf(cwd: string, commandName: string): Promise { + try { + await resolveRepositoryRootInPlace(cwd, commandName); + } catch (error: unknown) { + return (error as Error).message; + } + throw new Error("expected a rejection"); + } + + it("returns the toplevel of the repository it runs in, never redirecting", async () => { + const m = await master("planning", ["."]); + const sub = join(m, "src"); + await mkdir(sub); + expect(await realpath(await resolveRepositoryRootInPlace(sub, "exec"))).toBe(await realpath(m)); + }); + + it("says a workspace view is one instead of advising git init", async () => { + const m = await master("planning", ["."]); + const v = await view({ "fixture-planning": m }); + const message = await messageOf(v, "init"); + expect(message).toBe( + "Not a git repository: this is a workspace view (it links fixture-planning). Run 'basou init' inside a repository instead.", + ); + expect(message).not.toContain(parent); + }); + + it("says so for a view that links more than one repository with a store", async () => { + const a = await master("planning-a", ["."]); + const b = await master("planning-b", ["."]); + const v = await view({ a, b }); + expect(await messageOf(v, "run")).toBe( + "Not a git repository: this is a workspace view (it links more than one repository with a .basou store). Run 'basou run' inside a repository instead.", + ); + }); + + it("keeps the git init advice for a directory whose links hold no store", async () => { + const plain = await gitRepo("plain"); + const v = await view({ plain }); + expect(await messageOf(v, "project new")).toBe( + "Not a git repository. Run 'git init' first, then re-run 'basou project new'.", + ); + }); + + it("surfaces a git that fails in an existing repository, never advising git init", async () => { + const broken = await gitRepo("broken"); + await writeFile(join(broken, ".git", "config"), "[core\n", "utf8"); + await expect(resolveRepositoryRootInPlace(broken, "init")).rejects.toThrow( + /^Git command failed$/, + ); + }); + + it("keeps the git init advice for a plain directory", async () => { + const dir = join(parent, "plain-dir"); + await mkdir(dir); + expect(await messageOf(dir, "exec")).toBe( + "Not a git repository. Run 'git init' first, then re-run 'basou exec'.", + ); + }); +}); diff --git a/packages/cli/src/lib/repo-root.ts b/packages/cli/src/lib/repo-root.ts index 5736c3fc..dc8e9dfa 100644 --- a/packages/cli/src/lib/repo-root.ts +++ b/packages/cli/src/lib/repo-root.ts @@ -6,6 +6,7 @@ import { loadPortfolioConfig, readManifest, resolveBasouRepositoryRoot, + resolveRepositoryRoot, } from "@basou/core"; /** A planning master that aggregates the queried repo via its `source_roots`. */ @@ -15,13 +16,19 @@ export type MemberMaster = { root: string; label: string }; export type ResolveRootOptions = { /** Defaults to {@link defaultPortfolioConfigPath}. */ portfolioConfigPath?: string; + /** + * Leave out the notes on stderr that say where a view or a member resolved to, + * for a caller that resolves on a command's behalf without running it (the + * capture-coverage check). Diagnostics about a broken registry still print. + */ + quiet?: boolean; }; /** * Resolve the repository root for a CLI command with two fallbacks, shared by - * `orient` / `refresh` / `note` / `decision capture` / `project *` / - * `review-gaps` / `decision gaps` / `session` / `handoff generate` so they - * behave identically: + * every command that reads or writes a workspace's store (`orient`, `refresh`, + * `verify`, `status`, `view`, `decision *`, `approval *`, `import`, `task *`, + * `project *` but `new`, and the rest) so they behave identically: * * 1. A git-untracked workspace *view* dir that symlinks its planning repo * redirects to that repo (handled inside {@link resolveBasouRepositoryRoot}, @@ -45,8 +52,9 @@ export async function resolveBasouRootForCommand( let root: string; try { root = await resolveBasouRepositoryRoot(cwd, { - onRedirect: ({ via, root }) => - console.error(`Resolved workspace view to ${root} (via ${via}).`), + onRedirect: ({ via, root }) => { + if (opts.quiet !== true) console.error(`Resolved workspace view to ${root} (via ${via}).`); + }, }); } catch (error: unknown) { if (error instanceof Error && error.message === "Not a git repository") { @@ -69,15 +77,72 @@ export async function resolveBasouRootForCommand( opts.portfolioConfigPath ?? defaultPortfolioConfigPath(), ); if (master !== undefined) { - console.error( - `Resolved portfolio member to ${master.root} (via portfolio: ${master.label}).`, - ); + if (opts.quiet !== true) { + console.error( + `Resolved portfolio member to ${master.root} (via portfolio: ${master.label}).`, + ); + } return master.root; } } return root; } +/** + * Resolve the git repository a command works on in place: `init` and `project + * new` set up the repository they run in, and `exec` and `run` run a child there + * and record its git changes, so none of them may be redirected to another repo. + * Outside git, a workspace view is told apart from a plain directory: advising + * `git init` in a view would turn the view into a repository, which it is + * designed not to be, so the message says what the directory is instead. + * + * Pathless contract: the messages name only the view's link (a directory entry), + * never `cwd` or any absolute path. + */ +export async function resolveRepositoryRootInPlace( + cwd: string, + commandName: string, +): Promise { + try { + return await resolveRepositoryRoot(cwd); + } catch (error: unknown) { + if (!(error instanceof Error) || error.message !== "Not a git repository") throw error; + const links = await describeWorkspaceView(cwd); + if (links !== undefined) { + throw new Error( + `Not a git repository: this is a workspace view (it links ${links}). Run 'basou ${commandName}' inside a repository instead.`, + { cause: error }, + ); + } + throw new Error( + `Not a git repository. Run 'git init' first, then re-run 'basou ${commandName}'.`, + { cause: error }, + ); + } +} + +/** + * What a non-git `dir` links if it is a workspace view (a link name, or a phrase + * for more than one), or `undefined` if it is not one. Reuses the view detection + * of {@link resolveBasouRepositoryRoot}; any failure there means "not a view". + */ +async function describeWorkspaceView(dir: string): Promise { + let via: string | undefined; + try { + await resolveBasouRepositoryRoot(dir, { + onRedirect: (info) => { + via = info.via; + }, + }); + } catch (error: unknown) { + if (error instanceof Error && error.message.startsWith("Ambiguous workspace view:")) { + return "more than one repository with a .basou store"; + } + return undefined; + } + return via; +} + /** Whether `root` owns a `.basou/` store directory. */ async function hasBasouStore(root: string): Promise { try { diff --git a/packages/cli/src/lib/view-server.ts b/packages/cli/src/lib/view-server.ts index f3f09d16..e96e35af 100644 --- a/packages/cli/src/lib/view-server.ts +++ b/packages/cli/src/lib/view-server.ts @@ -362,6 +362,9 @@ async function handleWorkspaceGet( } /** POST routes scoped to one workspace. Returns false if `sub` matched nothing. */ +/** The POST routes that import into a card's store. */ +const IMPORT_SUBS: ReadonlySet = new Set(["import/claude-code", "import/codex"]); + async function handleWorkspacePost( res: ServerResponse, sub: string, @@ -373,6 +376,15 @@ async function handleWorkspacePost( const nowIso = deps.nowProvider().toISOString(); const actionOptions = readActionOptions(body); + // A card's import resolves its root the way the CLI does, which sends a card + // with no store of its own (a portfolio member, a workspace view) to the + // workspace that aggregates it while the card's own paths stay put. Such a card + // has nothing to refresh or import into: stop before either writes anything, + // with the error the import itself gave before it resolved. + if (!ws.initialized && (sub === "refresh" || IMPORT_SUBS.has(sub))) { + throw new Error("Workspace not initialized. Run 'basou init' first."); + } + if (sub === "refresh") { const result = await runExclusive(() => refreshAll({ options: actionOptions, ctx: ws.importCtx, paths: ws.paths, nowIso }),