diff --git a/packages/cli/drizzle/0002_grey_genesis.sql b/packages/cli/drizzle/0002_grey_genesis.sql new file mode 100644 index 0000000..461c518 --- /dev/null +++ b/packages/cli/drizzle/0002_grey_genesis.sql @@ -0,0 +1 @@ +ALTER TABLE `chapter_run` ADD `originUrl` text; \ No newline at end of file diff --git a/packages/cli/drizzle/meta/0002_snapshot.json b/packages/cli/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..09420be --- /dev/null +++ b/packages/cli/drizzle/meta/0002_snapshot.json @@ -0,0 +1,444 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "298158d7-c4df-4a0c-a849-4b8b25cdfb25", + "prevId": "a6131def-40ab-4b96-9fae-27a67273f1d6", + "tables": { + "chapter": { + "name": "chapter", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapterIndex": { + "name": "chapterIndex", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hunkRefs": { + "name": "hunkRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyChanges": { + "name": "keyChanges", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "chapter_run_idx_unique": { + "name": "chapter_run_idx_unique", + "columns": [ + "runId", + "chapterIndex" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_runId_chapter_run_id_fk": { + "name": "chapter_runId_chapter_run_id_fk", + "tableFrom": "chapter", + "tableTo": "chapter_run", + "columnsFrom": [ + "runId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_run": { + "name": "chapter_run", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repoRoot": { + "name": "repoRoot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originUrl": { + "name": "originUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopeKind": { + "name": "scopeKind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workingTreeRef": { + "name": "workingTreeRef", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "baseSha": { + "name": "baseSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headSha": { + "name": "headSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mergeBaseSha": { + "name": "mergeBaseSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generatedAt": { + "name": "generatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chapter_run_created_at_idx": { + "name": "chapter_run_created_at_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_view": { + "name": "chapter_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chapter_view_user_chapter_unique": { + "name": "chapter_view_user_chapter_unique", + "columns": [ + "userId", + "chapterId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_view_chapterId_chapter_id_fk": { + "name": "chapter_view_chapterId_chapter_id_fk", + "tableFrom": "chapter_view", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_change": { + "name": "key_change", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lineRefs": { + "name": "lineRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "key_change_chapter_id_idx": { + "name": "key_change_chapter_id_idx", + "columns": [ + "chapterId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "key_change_chapterId_chapter_id_fk": { + "name": "key_change_chapterId_chapter_id_fk", + "tableFrom": "key_change", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_change_view": { + "name": "key_change_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "keyChangeId": { + "name": "keyChangeId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "key_change_view_key_change_id_idx": { + "name": "key_change_view_key_change_id_idx", + "columns": [ + "keyChangeId" + ], + "isUnique": false + }, + "key_change_view_user_key_change_unique": { + "name": "key_change_view_user_key_change_unique", + "columns": [ + "userId", + "keyChangeId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "key_change_view_keyChangeId_key_change_id_fk": { + "name": "key_change_view_keyChangeId_key_change_id_fk", + "tableFrom": "key_change_view", + "tableTo": "key_change", + "columnsFrom": [ + "keyChangeId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/cli/drizzle/meta/_journal.json b/packages/cli/drizzle/meta/_journal.json index 628e1e2..6c3d864 100644 --- a/packages/cli/drizzle/meta/_journal.json +++ b/packages/cli/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777682827832, "tag": "0001_view_state", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1777793684990, + "tag": "0002_grey_genesis", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/cli/src/__tests__/fixtures.ts b/packages/cli/src/__tests__/fixtures.ts index 536a63f..072538d 100644 --- a/packages/cli/src/__tests__/fixtures.ts +++ b/packages/cli/src/__tests__/fixtures.ts @@ -1,3 +1,4 @@ +import type { RepoContext } from "../git.js"; import type { ChaptersFile } from "../schema.js"; const SHA = { @@ -6,6 +7,10 @@ const SHA = { mergeBase: "3333333333333333333333333333333333333333", } as const; +export function makeRepoContext(over: Partial = {}): RepoContext { + return { root: "/repo", originUrl: null, ...over }; +} + export function makeFixture(over: Partial = {}): ChaptersFile { return { scope: { diff --git a/packages/cli/src/__tests__/git.test.ts b/packages/cli/src/__tests__/git.test.ts new file mode 100644 index 0000000..5c341b9 --- /dev/null +++ b/packages/cli/src/__tests__/git.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseRepoName } from "../git.js"; + +describe("parseRepoName", () => { + const FALLBACK_ROOT = "/Users/dev/conductor/workspaces/stage-cli/monterrey-v3"; + + it("extracts the repo name from an SSH URL", () => { + expect(parseRepoName("git@github.com:ReviewStage/stage-cli.git", FALLBACK_ROOT)).toBe( + "stage-cli", + ); + }); + + it("extracts the repo name from an HTTPS URL", () => { + expect(parseRepoName("https://github.com/ReviewStage/stage-cli.git", FALLBACK_ROOT)).toBe( + "stage-cli", + ); + }); + + it("extracts the repo name from an HTTPS URL without .git suffix", () => { + expect(parseRepoName("https://github.com/ReviewStage/stage-cli", FALLBACK_ROOT)).toBe( + "stage-cli", + ); + }); + + it("extracts the repo name from an ssh:// URL", () => { + expect(parseRepoName("ssh://git@github.com/ReviewStage/stage-cli.git", FALLBACK_ROOT)).toBe( + "stage-cli", + ); + }); + + it("falls back to the worktree basename when originUrl is null", () => { + expect(parseRepoName(null, FALLBACK_ROOT)).toBe("monterrey-v3"); + }); + + it("falls back to the worktree basename for an empty/garbage URL", () => { + expect(parseRepoName("", FALLBACK_ROOT)).toBe("monterrey-v3"); + expect(parseRepoName(".git", FALLBACK_ROOT)).toBe("monterrey-v3"); + }); +}); diff --git a/packages/cli/src/__tests__/import-chapters.test.ts b/packages/cli/src/__tests__/import-chapters.test.ts index 22f8507..fae70b0 100644 --- a/packages/cli/src/__tests__/import-chapters.test.ts +++ b/packages/cli/src/__tests__/import-chapters.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { closeDb, getDb } from "../db/client.js"; import { chapter, chapterRun, keyChange } from "../db/schema/index.js"; import { importChaptersFile, insertChaptersFile } from "../runs/import-chapters.js"; -import { makeFixture } from "./fixtures.js"; +import { makeFixture, makeRepoContext } from "./fixtures.js"; let tmpDir: string; let dbPath: string; @@ -60,8 +60,8 @@ describe("chapter import", () => { const db = getDb({ dbPath }); const fixture = makeFixture(); - const first = insertChaptersFile(db, fixture, "/repo"); - const second = insertChaptersFile(db, fixture, "/repo"); + const first = insertChaptersFile(db, fixture, makeRepoContext()); + const second = insertChaptersFile(db, fixture, makeRepoContext()); expect(first.runId).not.toBe(second.runId); expect(db.select().from(chapterRun).all()).toHaveLength(2); @@ -72,8 +72,8 @@ describe("chapter import", () => { const db = getDb({ dbPath }); const fixture = makeFixture(); - insertChaptersFile(db, fixture, "/repo"); - insertChaptersFile(db, fixture, "/repo"); + insertChaptersFile(db, fixture, makeRepoContext()); + insertChaptersFile(db, fixture, makeRepoContext()); const all = db.select().from(keyChange).all(); expect(all).toHaveLength(2); @@ -82,8 +82,8 @@ describe("chapter import", () => { it("derives stable chapter externalIds across repeated imports of the same scope", () => { const db = getDb({ dbPath }); - insertChaptersFile(db, makeFixture(), "/repo"); - insertChaptersFile(db, makeFixture(), "/repo"); + insertChaptersFile(db, makeFixture(), makeRepoContext()); + insertChaptersFile(db, makeFixture(), makeRepoContext()); const all = db.select().from(chapter).all(); expect(all).toHaveLength(2); @@ -100,8 +100,8 @@ describe("chapter import", () => { }; const scopeB = { ...scopeA, headSha: "4".repeat(40) }; - insertChaptersFile(db, makeFixture({ scope: scopeA }), "/repo"); - insertChaptersFile(db, makeFixture({ scope: scopeB }), "/repo"); + insertChaptersFile(db, makeFixture({ scope: scopeA }), makeRepoContext()); + insertChaptersFile(db, makeFixture({ scope: scopeB }), makeRepoContext()); const chapters = db.select().from(chapter).all(); expect(chapters).toHaveLength(2); @@ -120,11 +120,15 @@ describe("chapter import", () => { mergeBaseSha: "3".repeat(40), }; - insertChaptersFile(db, makeFixture({ scope: { kind: "committed", ...shas } }), "/repo"); + insertChaptersFile( + db, + makeFixture({ scope: { kind: "committed", ...shas } }), + makeRepoContext(), + ); insertChaptersFile( db, makeFixture({ scope: { kind: "workingTree", ref: "work", ...shas } }), - "/repo", + makeRepoContext(), ); const chapters = db.select().from(chapter).all(); @@ -145,7 +149,7 @@ describe("chapter import", () => { mergeBaseSha: "3".repeat(40), }, }), - "/repo", + makeRepoContext(), ); const [row] = db.select().from(chapterRun).all(); @@ -172,12 +176,12 @@ describe("chapter import", () => { it("runs migrations idempotently across reopens", () => { const db1 = getDb({ dbPath }); - insertChaptersFile(db1, makeFixture(), "/repo"); + insertChaptersFile(db1, makeFixture(), makeRepoContext()); closeDb(); const db2 = getDb({ dbPath }); expect(db2.select().from(chapterRun).all()).toHaveLength(1); - insertChaptersFile(db2, makeFixture(), "/repo"); + insertChaptersFile(db2, makeFixture(), makeRepoContext()); expect(db2.select().from(chapterRun).all()).toHaveLength(2); }); @@ -186,7 +190,7 @@ describe("chapter import", () => { const dbPathB = path.join(tmpDir, "b.sqlite"); const dbA = getDb({ dbPath: dbPathA }); - insertChaptersFile(dbA, makeFixture(), "/repo-a"); + insertChaptersFile(dbA, makeFixture(), makeRepoContext({ root: "/repo-a" })); closeDb(); const dbB = getDb({ dbPath: dbPathB }); diff --git a/packages/cli/src/__tests__/path.test.ts b/packages/cli/src/__tests__/path.test.ts index e92329b..b65b0d1 100644 --- a/packages/cli/src/__tests__/path.test.ts +++ b/packages/cli/src/__tests__/path.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { getRepoRoot, NotInGitRepoError } from "../db/path.js"; +import { NotInGitRepoError, readRepoRoot } from "../git.js"; function expectedPath(repoRoot: string): string { const hash = createHash("sha256").update(repoRoot.trim()).digest("hex").slice(0, 12); @@ -27,7 +27,7 @@ describe("getDbPath layout", () => { }); }); -describe("getRepoRoot outside a git repo", () => { +describe("readRepoRoot outside a git repo", () => { let tmpDir: string; let originalCwd: string; @@ -43,6 +43,6 @@ describe("getRepoRoot outside a git repo", () => { }); it("throws NotInGitRepoError instead of silently falling back to cwd", () => { - expect(() => getRepoRoot()).toThrow(NotInGitRepoError); + expect(() => readRepoRoot()).toThrow(NotInGitRepoError); }); }); diff --git a/packages/cli/src/__tests__/runs.routes.test.ts b/packages/cli/src/__tests__/runs.routes.test.ts index b45ab88..aedb5e4 100644 --- a/packages/cli/src/__tests__/runs.routes.test.ts +++ b/packages/cli/src/__tests__/runs.routes.test.ts @@ -7,7 +7,7 @@ import { closeDb, getDb } from "../db/client.js"; import { runRoutes } from "../routes/runs.js"; import { insertChaptersFile } from "../runs/import-chapters.js"; import { LOOPBACK_HOST, type ServerHandle, startServer } from "../server.js"; -import { makeFixture } from "./fixtures.js"; +import { makeFixture, makeRepoContext } from "./fixtures.js"; let tmpDir: string; let dbPath: string; @@ -93,7 +93,7 @@ describe("runs API", () => { }, ], }); - const { runId } = insertChaptersFile(db, fixture, "/repo"); + const { runId } = insertChaptersFile(db, fixture, makeRepoContext()); const { port } = await startWithRoutes(); const res = await getJson(port, `/api/runs/${runId}/chapters`); @@ -144,7 +144,7 @@ describe("runs API", () => { }, ], }); - const { runId } = insertChaptersFile(db, fixture, "/repo"); + const { runId } = insertChaptersFile(db, fixture, makeRepoContext()); const { port } = await startWithRoutes(); const res = await getJson(port, `/api/runs/${runId}/chapters`); @@ -161,7 +161,7 @@ describe("runs API", () => { it("omits the denormalized chapter.keyChanges content array from the response", async () => { const db = getDb({ dbPath }); - const { runId } = insertChaptersFile(db, makeFixture(), "/repo"); + const { runId } = insertChaptersFile(db, makeFixture(), makeRepoContext()); const { port } = await startWithRoutes(); const res = await getJson(port, `/api/runs/${runId}/chapters`); diff --git a/packages/cli/src/__tests__/view-state.routes.test.ts b/packages/cli/src/__tests__/view-state.routes.test.ts index d0da1cc..8499948 100644 --- a/packages/cli/src/__tests__/view-state.routes.test.ts +++ b/packages/cli/src/__tests__/view-state.routes.test.ts @@ -10,7 +10,7 @@ import { runRoutes } from "../routes/runs.js"; import { viewStateRoutes } from "../routes/view-state.js"; import { insertChaptersFile } from "../runs/import-chapters.js"; import { LOOPBACK_HOST, type ServerHandle, startServer } from "../server.js"; -import { makeFixture } from "./fixtures.js"; +import { makeFixture, makeRepoContext } from "./fixtures.js"; let tmpDir: string; let dbPath: string; @@ -79,7 +79,7 @@ function seedRun(): { keyChangeExternalId: string; } { const db = getDb({ dbPath }); - insertChaptersFile(db, makeFixture(), "/repo"); + insertChaptersFile(db, makeFixture(), makeRepoContext()); const [chapterRow] = db.select().from(chapter).limit(1).all(); if (!chapterRow) throw new Error("seed: missing chapter"); const [keyChangeRow] = db @@ -245,8 +245,8 @@ describe("view-state API", () => { mergeBaseSha: "f".repeat(40), }, }); - const runA = insertChaptersFile(db, fixtureA, "/repo"); - const runB = insertChaptersFile(db, fixtureB, "/repo"); + const runA = insertChaptersFile(db, fixtureA, makeRepoContext()); + const runB = insertChaptersFile(db, fixtureB, makeRepoContext()); const [chapterA] = db.select().from(chapter).where(eq(chapter.runId, runA.runId)).all(); const [chapterB] = db.select().from(chapter).where(eq(chapter.runId, runB.runId)).all(); @@ -294,9 +294,9 @@ describe("view-state API", () => { // POST to that externalId must mark both runs viewed; otherwise GET on whichever run // was missed comes back empty even though the content is identical. const db = getDb({ dbPath }); - insertChaptersFile(db, makeFixture(), "/repo"); + insertChaptersFile(db, makeFixture(), makeRepoContext()); const runA = db.select().from(chapter).all(); - insertChaptersFile(db, makeFixture(), "/repo"); + insertChaptersFile(db, makeFixture(), makeRepoContext()); const allChapters = db.select().from(chapter).all(); const chapterB = allChapters.find((c) => !runA.some((a) => a.id === c.id)); if (!chapterB) throw new Error("seed: expected a second chapter row from the re-import"); diff --git a/packages/cli/src/db/path.ts b/packages/cli/src/db/path.ts index 00738d9..abbcc0b 100644 --- a/packages/cli/src/db/path.ts +++ b/packages/cli/src/db/path.ts @@ -1,36 +1,18 @@ -import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdirSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; +import { readRepoRoot } from "../git.js"; const STAGE_HOME = ".stage"; const DB_FILE = "db.sqlite"; const REPO_HASH_LEN = 12; -export class NotInGitRepoError extends Error { - constructor() { - super("stage-cli must be run inside a git repository"); - this.name = "NotInGitRepoError"; - } -} - export function getDbPath(): string { - const dir = ensureRepoDir(getRepoRoot()); + const dir = ensureRepoDir(readRepoRoot()); return path.join(dir, DB_FILE); } -export function getRepoRoot(): string { - try { - return execFileSync("git", ["rev-parse", "--show-toplevel"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - throw new NotInGitRepoError(); - } -} - function ensureRepoDir(repoRoot: string): string { const hash = createHash("sha256").update(repoRoot.trim()).digest("hex").slice(0, REPO_HASH_LEN); const dir = path.join(homedir(), STAGE_HOME, hash); diff --git a/packages/cli/src/db/schema/chapter-run.ts b/packages/cli/src/db/schema/chapter-run.ts index a2a4e42..99db82d 100644 --- a/packages/cli/src/db/schema/chapter-run.ts +++ b/packages/cli/src/db/schema/chapter-run.ts @@ -7,6 +7,7 @@ export const chapterRun = sqliteTable( { ...baseColumns(), repoRoot: text().notNull(), + originUrl: text(), scopeKind: text({ enum: [SCOPE_KIND.COMMITTED, SCOPE_KIND.WORKING_TREE] }).notNull(), workingTreeRef: text({ enum: [WORKING_TREE_REF.WORK, WORKING_TREE_REF.STAGED, WORKING_TREE_REF.UNSTAGED], diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts new file mode 100644 index 0000000..04a8586 --- /dev/null +++ b/packages/cli/src/git.ts @@ -0,0 +1,68 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; + +export class NotInGitRepoError extends Error { + constructor() { + super("stage-cli must be run inside a git repository"); + this.name = "NotInGitRepoError"; + } +} + +/** + * Snapshot of the git context a chapter run was generated against. Captured + * at import time and stored on `chapter_run` so the run keeps reading + * consistently even if the repo's remote is later renamed or detached. + */ +export interface RepoContext { + /** Absolute path to the worktree root (`git rev-parse --show-toplevel`). */ + root: string; + /** `origin` remote URL, or null when no `origin` is configured. */ + originUrl: string | null; +} + +export function readRepoContext(): RepoContext { + const root = readRepoRoot(); + return { root, originUrl: readOriginUrl(root) }; +} + +export function readRepoRoot(): string { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + throw new NotInGitRepoError(); + } +} + +function readOriginUrl(repoRoot: string): string | null { + try { + const out = execFileSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return out || null; + } catch { + return null; + } +} + +/** + * Derive the repo's display name from its origin URL, falling back to the + * worktree directory's basename when the URL is missing or unparseable. + * + * Handles the URL shapes git emits in practice: + * git@github.com:owner/repo(.git) + * https://github.com/owner/repo(.git) + * ssh://git@github.com/owner/repo(.git) + */ +export function parseRepoName(originUrl: string | null, repoRoot: string): string { + if (originUrl) { + const trimmed = originUrl.replace(/\.git$/, ""); + const lastSeparator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf(":")); + const segment = trimmed.slice(lastSeparator + 1); + if (segment) return segment; + } + return path.basename(repoRoot); +} diff --git a/packages/cli/src/routes/runs.ts b/packages/cli/src/routes/runs.ts index e66bcb9..26b9348 100644 --- a/packages/cli/src/routes/runs.ts +++ b/packages/cli/src/routes/runs.ts @@ -2,6 +2,7 @@ import type { Chapter, ChapterRun, KeyChange } from "@stage-cli/types/chapters"; import { asc, eq, inArray } from "drizzle-orm"; import type { StageDb } from "../db/client.js"; import { chapter, chapterRun, keyChange } from "../db/schema/index.js"; +import { parseRepoName } from "../git.js"; import type { Route } from "../server.js"; import { writeJson } from "./json.js"; @@ -35,7 +36,7 @@ function mapChapter(ch: ChapterRow, kcs: KeyChangeRow[]): Chapter { } function mapRun(run: ChapterRunRow): ChapterRun { - return { id: run.id }; + return { id: run.id, repoName: parseRepoName(run.originUrl, run.repoRoot) }; } export function runRoutes(db: StageDb): Route[] { diff --git a/packages/cli/src/runs/import-chapters.ts b/packages/cli/src/runs/import-chapters.ts index 1358873..184bee7 100644 --- a/packages/cli/src/runs/import-chapters.ts +++ b/packages/cli/src/runs/import-chapters.ts @@ -2,8 +2,8 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import path from "node:path"; import { getDb, type StageDb } from "../db/client.js"; -import { getRepoRoot } from "../db/path.js"; import { chapter, chapterRun, keyChange } from "../db/schema/index.js"; +import { type RepoContext, readRepoContext } from "../git.js"; import { type ChaptersFile, ChaptersFileSchema, SCOPE_KIND, type Scope } from "../schema.js"; export interface ImportChaptersResult { @@ -17,19 +17,20 @@ export function importChaptersFile(jsonPath: string, db: StageDb = getDb()): Imp const raw = readFileSync(absolute, "utf8"); const parsed = JSON.parse(raw) as unknown; const file = ChaptersFileSchema.parse(parsed); - return insertChaptersFile(db, file, getRepoRoot()); + return insertChaptersFile(db, file, readRepoContext()); } export function insertChaptersFile( db: StageDb, file: ChaptersFile, - repoRoot: string, + repo: RepoContext, ): ImportChaptersResult { return db.transaction((tx) => { const [runRow] = tx .insert(chapterRun) .values({ - repoRoot, + repoRoot: repo.root, + originUrl: repo.originUrl, scopeKind: file.scope.kind, workingTreeRef: file.scope.kind === SCOPE_KIND.WORKING_TREE ? file.scope.ref : null, baseSha: file.scope.baseSha, diff --git a/packages/types/src/chapters.ts b/packages/types/src/chapters.ts index 784e5db..91cf457 100644 --- a/packages/types/src/chapters.ts +++ b/packages/types/src/chapters.ts @@ -48,6 +48,7 @@ export type Chapter = z.infer; export const ChapterRunSchema = z.object({ id: z.string(), + repoName: z.string(), }); export type ChapterRun = z.infer; diff --git a/packages/web/index.html b/packages/web/index.html index 14634d5..e9dcf2d 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -4,6 +4,26 @@ Stage CLI +
diff --git a/packages/web/package.json b/packages/web/package.json index 8827907..4485534 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,6 +14,7 @@ "@stage-cli/types": "workspace:*", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index a58749c..97114e9 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,9 +1,10 @@ +import { Topbar } from "@/components/layout/topbar"; import { useHashRunId } from "@/lib/use-hash-run-id"; import { PullRequestLayout } from "@/routes/pull-request-layout"; function NoRunSelected() { return ( -
+

No run selected

@@ -17,6 +18,10 @@ function NoRunSelected() { export function App() { const runId = useHashRunId(); - if (!runId) return ; - return ; + return ( +

+ + {runId ? : } +
+ ); } diff --git a/packages/web/src/components/layout/theme-toggle.tsx b/packages/web/src/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..d8122bc --- /dev/null +++ b/packages/web/src/components/layout/theme-toggle.tsx @@ -0,0 +1,46 @@ +import { Monitor, Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { USER_THEME, type UserTheme, useTheme } from "@/lib/theme"; + +interface Option { + value: UserTheme; + label: string; + icon: React.ElementType; +} + +const OPTIONS: Option[] = [ + { value: USER_THEME.LIGHT, label: "Light", icon: Sun }, + { value: USER_THEME.DARK, label: "Dark", icon: Moon }, + { value: USER_THEME.SYSTEM, label: "System", icon: Monitor }, +]; + +export function ThemeToggle() { + const { userTheme, setTheme } = useTheme(); + + return ( + + + + + + {OPTIONS.map(({ value, label, icon: Icon }) => ( + setTheme(value)} className="cursor-pointer"> + + {label} + {userTheme === value && } + + ))} + + + ); +} diff --git a/packages/web/src/components/layout/topbar.tsx b/packages/web/src/components/layout/topbar.tsx new file mode 100644 index 0000000..eef7c64 --- /dev/null +++ b/packages/web/src/components/layout/topbar.tsx @@ -0,0 +1,18 @@ +import { ThemeToggle } from "@/components/layout/theme-toggle"; +import { useChapters } from "@/lib/use-chapters"; + +export function Topbar({ runId }: { runId: string | null }) { + const { data } = useChapters(runId); + const repoName = data?.run.repoName; + + return ( +
+
+ {repoName && {repoName}} +
+
+ +
+
+ ); +} diff --git a/packages/web/src/components/ui/dropdown-menu.tsx b/packages/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..b358174 --- /dev/null +++ b/packages/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,59 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +export { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger }; diff --git a/packages/web/src/lib/theme.tsx b/packages/web/src/lib/theme.tsx new file mode 100644 index 0000000..5f9b621 --- /dev/null +++ b/packages/web/src/lib/theme.tsx @@ -0,0 +1,106 @@ +import { + createContext, + type ReactNode, + use, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +export const USER_THEME = { + LIGHT: "light", + DARK: "dark", + SYSTEM: "system", +} as const; +export type UserTheme = (typeof USER_THEME)[keyof typeof USER_THEME]; + +export const APP_THEME = { + LIGHT: "light", + DARK: "dark", +} as const; +export type AppTheme = (typeof APP_THEME)[keyof typeof APP_THEME]; + +const STORAGE_KEY = "ui-theme"; + +const VALID_THEMES: ReadonlySet = new Set(Object.values(USER_THEME)); + +function isValidUserTheme(value: string): value is UserTheme { + return VALID_THEMES.has(value); +} + +function getStoredUserTheme(): UserTheme { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && isValidUserTheme(stored)) return stored; + } catch { + // localStorage unavailable + } + return USER_THEME.SYSTEM; +} + +function getSystemTheme(): AppTheme { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? APP_THEME.DARK + : APP_THEME.LIGHT; +} + +function applyThemeToDOM(userTheme: UserTheme): void { + const root = document.documentElement; + root.classList.remove(APP_THEME.LIGHT, APP_THEME.DARK); + const resolved = userTheme === USER_THEME.SYSTEM ? getSystemTheme() : userTheme; + root.classList.add(resolved); +} + +interface ThemeContextValue { + userTheme: UserTheme; + appTheme: AppTheme; + setTheme: (theme: UserTheme) => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [userTheme, setUserTheme] = useState(getStoredUserTheme); + const [systemTheme, setSystemTheme] = useState(getSystemTheme); + + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = () => { + const next = mq.matches ? APP_THEME.DARK : APP_THEME.LIGHT; + setSystemTheme(next); + if (userTheme === USER_THEME.SYSTEM) { + applyThemeToDOM(USER_THEME.SYSTEM); + } + }; + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [userTheme]); + + const appTheme: AppTheme = userTheme === USER_THEME.SYSTEM ? systemTheme : userTheme; + + const setTheme = useCallback((next: UserTheme) => { + setUserTheme(next); + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + // localStorage unavailable + } + applyThemeToDOM(next); + }, []); + + const contextValue = useMemo( + () => ({ userTheme, appTheme, setTheme }), + [userTheme, appTheme, setTheme], + ); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = use(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return ctx; +} diff --git a/packages/web/src/lib/use-chapters.ts b/packages/web/src/lib/use-chapters.ts new file mode 100644 index 0000000..5939d82 --- /dev/null +++ b/packages/web/src/lib/use-chapters.ts @@ -0,0 +1,21 @@ +import { type ChaptersResponse, ChaptersResponseSchema } from "@stage-cli/types/chapters"; +import { skipToken, useQuery } from "@tanstack/react-query"; +import { jsonFetch } from "@/lib/use-view-state"; + +async function fetchChapters(runId: string): Promise { + // Parse at the boundary — schema drift surfaces here as a query error, + // not as a render crash deeper in the component tree. + const raw = await jsonFetch(`/api/runs/${encodeURIComponent(runId)}/chapters`); + return ChaptersResponseSchema.parse(raw); +} + +/** + * Shared chapters query. Multiple components calling this hook with the same + * runId dedupe to a single network fetch via TanStack Query's cache. + */ +export function useChapters(runId: string | null) { + return useQuery({ + queryKey: ["chapters", runId], + queryFn: runId === null ? skipToken : () => fetchChapters(runId), + }); +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index b989cb7..86cb049 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { ThemeProvider } from "./lib/theme"; import "./styles/globals.css"; const rootElement = document.getElementById("root"); @@ -22,8 +23,10 @@ const queryClient = new QueryClient({ createRoot(rootElement).render( - - - + + + + + , ); diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx index 0992c42..b810ab3 100644 --- a/packages/web/src/routes/pull-request-layout.tsx +++ b/packages/web/src/routes/pull-request-layout.tsx @@ -1,10 +1,9 @@ -import { type ChaptersResponse, ChaptersResponseSchema } from "@stage-cli/types/chapters"; -import { useQuery } from "@tanstack/react-query"; import { BookOpen, FileText } from "lucide-react"; import { useMemo, useState } from "react"; import { SectionLabel } from "@/components/pull-request/section-label"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { jsonFetch, useViewStateData } from "@/lib/use-view-state"; +import { useChapters } from "@/lib/use-chapters"; +import { useViewStateData } from "@/lib/use-view-state"; import { cn } from "@/lib/utils"; import { ChaptersIndexPage } from "./chapters-index-page"; @@ -77,7 +76,7 @@ function TabLink({ tab, isActive, onSelect, countLabel }: TabLinkProps) { function ErrorState({ error }: { error: unknown }) { return ( -
+

Couldn't load chapters

@@ -89,15 +88,7 @@ function ErrorState({ error }: { error: unknown }) { } export function PullRequestLayout({ runId }: { runId: string }) { - const { data, isLoading, error } = useQuery({ - queryKey: ["chapters", runId], - // Parse at the boundary — schema drift surfaces here as a query error, - // not as a render crash inside ChaptersIndexPage. - queryFn: async () => { - const raw = await jsonFetch(`/api/runs/${encodeURIComponent(runId)}/chapters`); - return ChaptersResponseSchema.parse(raw); - }, - }); + const { data, isLoading, error } = useChapters(runId); const [activeTab, setActiveTab] = useState(PR_TAB.CHAPTERS); // Lift the viewed count out of ChaptersIndexPage so the tab strip can render @@ -125,13 +116,13 @@ export function PullRequestLayout({ runId }: { runId: string }) { if (error) return ; return ( -

+
Run

{data?.run.id ?? runId}

-