From 1ea43b26339f237c4ec7ba42ce40b87d782c8e88 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Thu, 8 Oct 2026 11:37:23 +0900 Subject: [PATCH 1/2] basou: resolve the trail commands from a workspace view From a workspace view (a directory outside git that links its planning repo), verify, view, status, stats, report generate, decisions generate, approval *, decision record and import stopped with "Not a git repository. Run 'git init' first", advice that would turn the view into a repository. They resolved the repository root with resolveRepositoryRoot directly, while orient, refresh, note, decision capture, task and the rest go through resolveBasouRootForCommand. They now go through it too, so a view resolves to the repository it links and a portfolio member to the workspace that aggregates it. init, project new, exec and run work on the repository they run in, so they keep resolving in place, through a new resolveRepositoryRootInPlace. Outside git it tells a workspace view apart from a plain directory and, for a view, says what the directory is instead of advising git init. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 24 +++++++ packages/cli/src/commands/approval.test.ts | 17 +++++ packages/cli/src/commands/approval.ts | 25 ++------ packages/cli/src/commands/decision.test.ts | 30 ++++++++- packages/cli/src/commands/decision.ts | 17 +---- packages/cli/src/commands/decisions.test.ts | 31 ++++++++- packages/cli/src/commands/decisions.ts | 18 +----- packages/cli/src/commands/exec.test.ts | 24 +++++++ packages/cli/src/commands/exec.ts | 17 +---- packages/cli/src/commands/import.test.ts | 21 +++++++ packages/cli/src/commands/import.ts | 17 +---- packages/cli/src/commands/init.test.ts | 17 +++++ packages/cli/src/commands/init.ts | 22 +------ packages/cli/src/commands/project.test.ts | 23 +++++++ packages/cli/src/commands/project.ts | 30 ++------- packages/cli/src/commands/report.test.ts | 21 ++++++- packages/cli/src/commands/report.ts | 18 +----- packages/cli/src/commands/run.test.ts | 23 +++++++ packages/cli/src/commands/run.ts | 17 +---- packages/cli/src/commands/stats.test.ts | 19 +++++- packages/cli/src/commands/stats.ts | 17 +---- packages/cli/src/commands/status.test.ts | 20 ++++++ packages/cli/src/commands/status.ts | 22 +------ packages/cli/src/commands/verify.test.ts | 19 ++++++ packages/cli/src/commands/verify.ts | 17 +---- packages/cli/src/commands/view.test.ts | 19 ++++++ packages/cli/src/commands/view.ts | 17 +---- packages/cli/src/lib/repo-root.test.ts | 69 ++++++++++++++++++++- packages/cli/src/lib/repo-root.ts | 62 +++++++++++++++++- 29 files changed, 460 insertions(+), 233 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb481bd..999ac93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ 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. + ## 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..52a997de 100644 --- a/packages/cli/src/commands/approval.test.ts +++ b/packages/cli/src/commands/approval.test.ts @@ -1534,3 +1534,20 @@ 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 }); + } + }); +}); 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..47f0123f 100644 --- a/packages/cli/src/commands/decision.test.ts +++ b/packages/cli/src/commands/decision.test.ts @@ -1,5 +1,14 @@ 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 { promisify } from "node:util"; @@ -1516,3 +1525,22 @@ 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"); + await findAdHocSessionId(repo); // the session is in the linked repo's store + expect(await readdir(view)).toEqual(["fixture-planning"]); + } finally { + await rm(view, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/decision.ts b/packages/cli/src/commands/decision.ts index 986bf7f9..114ab9d1 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); @@ -1269,20 +1268,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..285439c0 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; @@ -348,7 +348,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 +1063,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..4333218c 100644 --- a/packages/cli/src/commands/verify.test.ts +++ b/packages/cli/src/commands/verify.test.ts @@ -494,3 +494,22 @@ 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 }); + } + }); +}); 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..0d6298d6 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1595,3 +1595,22 @@ 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 }); + } + }); +}); diff --git a/packages/cli/src/commands/view.ts b/packages/cli/src/commands/view.ts index f55d0d07..fb1901d4 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, @@ -218,7 +218,7 @@ export async function doRunView(options: ViewOptions, ctx: ViewContext): Promise /** Single-workspace mode: resolve the cwd's repo (git required) 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 +388,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/repo-root.test.ts b/packages/cli/src/lib/repo-root.test.ts index 82eb8b3c..5399d0d1 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,64 @@ 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("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..dda01eb5 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`. */ @@ -19,9 +20,9 @@ export type ResolveRootOptions = { /** * 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}, @@ -78,6 +79,61 @@ export async function resolveBasouRootForCommand( 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 { From f444cfdd99127c3a62e2240ba0b65d24f587a267 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Thu, 8 Oct 2026 12:16:16 +0900 Subject: [PATCH 2/2] basou: close the gaps the review of the view resolution found - basou view: a portfolio card with no store of its own (a workspace view, a portfolio member) refuses a refresh or an import with "Workspace not initialized", as 0.65.0 did. Its import now resolves to the workspace that aggregates it while the card's paths stay put, so an import wrote into another card's store and a refresh wrote part of its work and then failed. - Capture coverage resolves each registered entry as basou import does (resolveBasouRootForCommand, quietly, against the registry it checks), so a registered view or member declares the roots of the workspace it resolves to instead of being reported as an entry import cannot run in. - Tests: show, approve and reject from a view; verify and decision record from a portfolio member (HOME stubbed to a fixture registry); the working directory decision record records from a view; a failing git in an existing repository surfaces as "Git command failed"; the uninitialized card; coverage of a registered view and member. Coverage tests resolve against an absent registry instead of the developer's own. - Comments that the change made stale (decision, view, import, coverage). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++ packages/cli/src/commands/approval.test.ts | 30 +++++++++++ packages/cli/src/commands/decision.test.ts | 53 ++++++++++++++++++- packages/cli/src/commands/decision.ts | 8 ++- packages/cli/src/commands/import.ts | 8 +-- packages/cli/src/commands/verify.test.ts | 46 +++++++++++++++- packages/cli/src/commands/view.test.ts | 30 +++++++++++ packages/cli/src/commands/view.ts | 8 ++- .../cli/src/lib/portfolio-coverage.test.ts | 52 +++++++++++++++++- packages/cli/src/lib/portfolio-coverage.ts | 39 ++++++++++---- packages/cli/src/lib/repo-root.test.ts | 8 +++ packages/cli/src/lib/repo-root.ts | 19 +++++-- packages/cli/src/lib/view-server.ts | 12 +++++ 13 files changed, 292 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 999ac93e..a2005bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ All notable changes to **basou** are recorded here. The project follows 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 diff --git a/packages/cli/src/commands/approval.test.ts b/packages/cli/src/commands/approval.test.ts index 52a997de..6f58f776 100644 --- a/packages/cli/src/commands/approval.test.ts +++ b/packages/cli/src/commands/approval.test.ts @@ -1550,4 +1550,34 @@ describe("basou approval from a workspace view", () => { 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/decision.test.ts b/packages/cli/src/commands/decision.test.ts index 47f0123f..bd51e33e 100644 --- a/packages/cli/src/commands/decision.test.ts +++ b/packages/cli/src/commands/decision.test.ts @@ -10,7 +10,7 @@ import { 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, @@ -1537,10 +1537,59 @@ describe("doRunDecisionRecord from a workspace view", () => { 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"); - await findAdHocSessionId(repo); // the session is in the linked repo's store + // 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 114ab9d1..485761d7 100644 --- a/packages/cli/src/commands/decision.ts +++ b/packages/cli/src/commands/decision.ts @@ -431,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); diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts index 285439c0..7b8d9440 100644 --- a/packages/cli/src/commands/import.ts +++ b/packages/cli/src/commands/import.ts @@ -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[]; diff --git a/packages/cli/src/commands/verify.test.ts b/packages/cli/src/commands/verify.test.ts index 4333218c..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 { @@ -513,3 +513,47 @@ describe("basou verify from a workspace view", () => { } }); }); + +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/view.test.ts b/packages/cli/src/commands/view.test.ts index 0d6298d6..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, @@ -1614,3 +1615,32 @@ describe("basou view from a workspace view", () => { } }); }); + +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 fb1901d4..e87dd29e 100644 --- a/packages/cli/src/commands/view.ts +++ b/packages/cli/src/commands/view.ts @@ -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,7 +219,10 @@ 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 resolveBasouRootForCommand(cwd, "view"); const paths = basouPaths(repositoryRoot); 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 5399d0d1..a5e55c66 100644 --- a/packages/cli/src/lib/repo-root.test.ts +++ b/packages/cli/src/lib/repo-root.test.ts @@ -288,6 +288,14 @@ describe("resolveRepositoryRootInPlace", () => { ); }); + 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); diff --git a/packages/cli/src/lib/repo-root.ts b/packages/cli/src/lib/repo-root.ts index dda01eb5..dc8e9dfa 100644 --- a/packages/cli/src/lib/repo-root.ts +++ b/packages/cli/src/lib/repo-root.ts @@ -16,6 +16,12 @@ 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; }; /** @@ -46,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") { @@ -70,9 +77,11 @@ 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; } } 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 }),