diff --git a/packages/boxel-cli/plugin/.claude-plugin/plugin.json b/packages/boxel-cli/plugin/.claude-plugin/plugin.json index 16d696e03d..651c66ca66 100644 --- a/packages/boxel-cli/plugin/.claude-plugin/plugin.json +++ b/packages/boxel-cli/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "boxel-cli", "description": "Claude Code skills for working with Boxel realms via @cardstack/boxel-cli. Requires @cardstack/boxel-cli >= 0.0.1 installed on PATH (npm install -g @cardstack/boxel-cli).", - "version": "0.1.3", + "version": "0.1.4", "author": { "name": "Cardstack", "url": "https://boxel.ai" diff --git a/packages/boxel-cli/plugin/skills/realm-sync/SKILL.md b/packages/boxel-cli/plugin/skills/realm-sync/SKILL.md index 9b75bef04a..18de60b92f 100644 --- a/packages/boxel-cli/plugin/skills/realm-sync/SKILL.md +++ b/packages/boxel-cli/plugin/skills/realm-sync/SKILL.md @@ -21,7 +21,8 @@ Wraps the `boxel realm` subcommands that move data between a local directory and | "push my changes" / "deploy" | `boxel realm push ` | | "download a realm" / "pull it locally" | `boxel realm pull ` | | "sync" / "keep them in lockstep" | `boxel realm sync --prefer-newest` (or `--prefer-local` / `--prefer-remote`) | -| "watch the realm" / "live-mirror remote changes locally" | `boxel realm watch ` | +| "watch the realm" / "live-mirror remote changes locally" | `boxel realm watch start ` | +| "stop watching" / "kill the watcher" | `boxel realm watch stop ` | | "make a new realm" | `boxel realm create ` | | "delete this realm" / "remove a realm" | `boxel realm remove ` | | "what realms do I have access to" | `boxel realm list` | @@ -66,9 +67,24 @@ Bidirectional sync between a local directory and a Boxel realm - `--dry-run` — Preview without making changes - `--realm-secret-seed` — Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED) -### `boxel realm watch` +### `boxel realm watch start ` -Watch a Boxel realm; subcommands manage watch processes +Start watching a Boxel realm for server-side changes and pull them into a local directory + +**Arguments:** + +- `` — The URL of the realm to watch (e.g., https://app.boxel.ai/demo/) +- `` — The local directory to write changes into + +**Options:** + +- `-i, --interval ` — Polling interval in seconds +- `-d, --debounce ` — Seconds to wait after a burst of changes before applying them +- `--realm-secret-seed` — Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED) + +### `boxel realm watch stop` + +Stop all running boxel realm watch processes ### `boxel realm push ` diff --git a/packages/boxel-cli/scripts/build-plugin.ts b/packages/boxel-cli/scripts/build-plugin.ts index 30e05b3525..5d4a812e82 100644 --- a/packages/boxel-cli/scripts/build-plugin.ts +++ b/packages/boxel-cli/scripts/build-plugin.ts @@ -26,7 +26,8 @@ const SKILL_SPECS: SkillSpec[] = [ skill: 'realm-sync', commands: [ 'realm sync', - 'realm watch', + 'realm watch start', + 'realm watch stop', 'realm push', 'realm pull', 'realm create', diff --git a/packages/boxel-cli/src/commands/realm/index.ts b/packages/boxel-cli/src/commands/realm/index.ts index a6eee0157f..c661ee2115 100644 --- a/packages/boxel-cli/src/commands/realm/index.ts +++ b/packages/boxel-cli/src/commands/realm/index.ts @@ -3,6 +3,7 @@ import { registerCancelIndexingCommand } from './cancel-indexing'; import { registerCreateCommand } from './create'; import { registerHistoryCommand } from './history'; import { registerListCommand } from './list'; +import { registerMilestoneCommand } from './milestone'; import { registerPullCommand } from './pull'; import { registerPushCommand } from './push'; import { registerRemoveCommand } from './remove'; @@ -19,6 +20,7 @@ export function registerRealmCommand(program: Command): void { registerCreateCommand(realm); registerHistoryCommand(realm); registerListCommand(realm); + registerMilestoneCommand(realm); registerPullCommand(realm); registerPushCommand(realm); registerRemoveCommand(realm); diff --git a/packages/boxel-cli/src/commands/realm/milestone.ts b/packages/boxel-cli/src/commands/realm/milestone.ts new file mode 100644 index 0000000000..6e511db5c4 --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/milestone.ts @@ -0,0 +1,375 @@ +import * as fs from 'fs'; +import type { Command } from 'commander'; +import { + CheckpointManager, + type Checkpoint, +} from '../../lib/checkpoint-manager'; +import { cliLog } from '../../lib/cli-log'; +import { findCheckpoint } from '../../lib/find-checkpoint'; +import { + BOLD, + DIM, + FG_CYAN, + FG_GREEN, + FG_MAGENTA, + FG_RED, + FG_YELLOW, + RESET, +} from '../../lib/colors'; + +const DEFAULT_LIMIT = 100; + +export interface MilestoneOptions { + /** A 1-based index, short hash, or full hash to mark as milestone. Requires `name`. */ + mark?: string; + /** Name for the milestone (required when `mark` is given). */ + name?: string; + /** A 1-based index, short hash, or full hash whose milestone tag to remove. */ + remove?: string; + /** Max checkpoints to consider for ref resolution. Defaults to 100. */ + limit?: number; +} + +export interface MilestoneResult { + ok: boolean; + /** Populated in list mode. */ + milestones?: Checkpoint[]; + /** Populated when a milestone was marked. */ + marked?: Checkpoint; + /** Populated when a milestone was removed. */ + removed?: boolean; + error?: string; +} + +interface MilestoneCliOptions { + mark?: string; + name?: string; + remove?: string; + limit?: string; + json?: boolean; +} + +type StepResult = ({ ok: true } & T) | { ok: false; error: string }; + +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function formatRelativeDate(date: Date): string { + const diffMs = Date.now() - date.getTime(); + const minutes = Math.floor(diffMs / 60_000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + if (days > 7) + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; + if (days > 0) return `${days} day${days === 1 ? '' : 's'} ago`; + if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'} ago`; + if (minutes > 0) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + return 'just now'; +} + +async function resolveRef( + workspaceDir: string, + ref: string, + limit: number, +): Promise> { + try { + const manager = new CheckpointManager(workspaceDir); + if (!(await manager.isInitialized())) { + return { + ok: false, + error: + 'No checkpoint history found for this workspace. ' + + 'Checkpoints are created automatically during sync operations.', + }; + } + const checkpoints = await manager.getCheckpoints(limit); + const found = findCheckpoint(ref, checkpoints); + if (found.kind === 'none') { + return { + ok: false, + error: `Checkpoint not found: ${ref}. Use a number (1-${checkpoints.length}) or a commit hash.`, + }; + } + if (found.kind === 'ambiguous') { + const sample = found.matches + .slice(0, 5) + .map((cp) => cp.shortHash) + .join(', '); + const more = found.matches.length > 5 ? ', …' : ''; + return { + ok: false, + error: `Ambiguous reference: ${ref} matches ${found.matches.length} checkpoints (${sample}${more}). Use a longer prefix or full hash.`, + }; + } + return { ok: true, target: found.target }; + } catch (e) { + return { + ok: false, + error: `Failed to read checkpoints: ${errorMessage(e)}`, + }; + } +} + +async function listMilestonesStep( + workspaceDir: string, +): Promise> { + if (!fs.existsSync(workspaceDir)) { + return { ok: false, error: `Directory not found: ${workspaceDir}` }; + } + try { + const manager = new CheckpointManager(workspaceDir); + if (!(await manager.isInitialized())) { + return { ok: true, milestones: [] }; + } + const milestones = await manager.getMilestones(); + return { ok: true, milestones }; + } catch (e) { + return { + ok: false, + error: `Failed to read milestones: ${errorMessage(e)}`, + }; + } +} + +async function markMilestoneStep( + workspaceDir: string, + ref: string, + name: string, + limit: number, +): Promise> { + if (!fs.existsSync(workspaceDir)) { + return { ok: false, error: `Directory not found: ${workspaceDir}` }; + } + const trimmedName = name.trim(); + if (!trimmedName) { + return { ok: false, error: '--name must not be empty.' }; + } + const resolved = await resolveRef(workspaceDir, ref, limit); + if (!resolved.ok) return resolved; + + try { + const manager = new CheckpointManager(workspaceDir); + const result = await manager.markMilestone( + resolved.target.hash, + trimmedName, + ); + if (!result) { + return { + ok: false, + error: 'Could not mark milestone. The checkpoint may already have one.', + }; + } + const checkpoints = await manager.getCheckpoints(limit); + const marked = checkpoints.find((cp) => cp.hash === resolved.target.hash); + if (!marked) { + return { + ok: false, + error: 'Milestone created but checkpoint could not be re-read.', + }; + } + return { ok: true, marked }; + } catch (e) { + return { ok: false, error: `Failed to mark milestone: ${errorMessage(e)}` }; + } +} + +async function removeMilestoneStep( + workspaceDir: string, + ref: string, + limit: number, +): Promise> { + if (!fs.existsSync(workspaceDir)) { + return { ok: false, error: `Directory not found: ${workspaceDir}` }; + } + const resolved = await resolveRef(workspaceDir, ref, limit); + if (!resolved.ok) return resolved; + + const target = resolved.target; + if (!target.isMilestone) { + return { + ok: false, + error: `Checkpoint ${target.shortHash} is not marked as a milestone.`, + }; + } + + try { + const manager = new CheckpointManager(workspaceDir); + const success = await manager.unmarkMilestone(target.hash); + return { ok: true, removed: success }; + } catch (e) { + return { + ok: false, + error: `Failed to remove milestone: ${errorMessage(e)}`, + }; + } +} + +/** + * List, mark, or remove milestones in a workspace's local `.boxel-history/` git repo. + * Pure local — does not touch the realm server. + */ +export async function realmMilestone( + workspaceDir: string, + options: MilestoneOptions = {}, +): Promise { + if (options.mark !== undefined && options.remove !== undefined) { + return { + ok: false, + error: 'Only one of --mark or --remove may be specified.', + }; + } + if ( + options.limit !== undefined && + (!Number.isInteger(options.limit) || options.limit <= 0) + ) { + return { ok: false, error: 'limit must be a positive integer.' }; + } + const limit = options.limit ?? DEFAULT_LIMIT; + + if (options.mark !== undefined) { + if (options.name === undefined) { + return { ok: false, error: '--name is required when using --mark.' }; + } + const r = await markMilestoneStep( + workspaceDir, + options.mark, + options.name, + limit, + ); + return r.ok + ? { ok: true, marked: r.marked } + : { ok: false, error: r.error }; + } + + if (options.remove !== undefined) { + const r = await removeMilestoneStep(workspaceDir, options.remove, limit); + return r.ok + ? { ok: true, removed: r.removed } + : { ok: false, error: r.error }; + } + + const r = await listMilestonesStep(workspaceDir); + return r.ok + ? { ok: true, milestones: r.milestones } + : { ok: false, error: r.error }; +} + +function printMilestones(milestones: Checkpoint[], workspaceDir: string): void { + if (milestones.length === 0) { + console.log('\nNo milestones marked yet.\n'); + console.log( + `Use ${FG_CYAN}boxel realm milestone --mark --name ${RESET} to mark a checkpoint.`, + ); + console.log( + `Use ${FG_CYAN}boxel realm history ${RESET} to see available checkpoints.\n`, + ); + return; + } + + console.log(`\n${BOLD}Milestones${RESET} ${DIM}(${workspaceDir})${RESET}\n`); + for (const cp of milestones) { + const sourceIcon = + cp.source === 'local' ? '↑' : cp.source === 'remote' ? '↓' : '●'; + const sourceColor = + cp.source === 'local' + ? FG_GREEN + : cp.source === 'remote' + ? FG_CYAN + : FG_MAGENTA; + console.log( + ` ${FG_YELLOW}⭐${RESET} ` + + `${FG_YELLOW}${cp.shortHash}${RESET} ` + + `${sourceColor}${sourceIcon}${RESET} ` + + `${FG_MAGENTA}[${cp.milestoneName}]${RESET} ` + + `${cp.message}`, + ); + console.log(` ${DIM}${formatRelativeDate(cp.date)}${RESET}`); + } + console.log(); +} + +function parseLimit(raw: string | undefined): number | null { + if (raw === undefined) return DEFAULT_LIMIT; + if (!/^\d+$/.test(raw)) return null; + const n = parseInt(raw, 10); + return n > 0 ? n : null; +} + +function bailout(msg: string): never { + console.error(`${FG_RED}Error:${RESET} ${msg}`); + process.exit(1); +} + +export function registerMilestoneCommand(realm: Command): void { + realm + .command('milestone') + .description( + 'List, mark, or remove milestones in the local .boxel-history/ checkpoint log', + ) + .argument('', 'The local workspace directory') + .option( + '--mark ', + 'Mark a checkpoint as a milestone (1-based index, short hash, or full hash)', + ) + .option('--name ', 'Name for the milestone (required with --mark)') + .option( + '--remove ', + 'Remove the milestone tag from a checkpoint (1-based index, short hash, or full hash)', + ) + .option( + '--limit ', + `Maximum number of checkpoints to consider for ref resolution (default: ${DEFAULT_LIMIT})`, + ) + .option('--json', 'Output result as JSON') + .action(async (localDir: string, opts: MilestoneCliOptions) => { + if (opts.mark !== undefined && opts.remove !== undefined) { + bailout('Only one of --mark or --remove may be specified.'); + } + + const limit = parseLimit(opts.limit); + if (limit === null) { + bailout('--limit must be a positive integer.'); + } + + if (opts.mark !== undefined && opts.name === undefined) { + bailout('--name is required when using --mark.'); + } + + const result = await realmMilestone(localDir, { + mark: opts.mark, + name: opts.name, + remove: opts.remove, + limit, + }); + + if (opts.json) { + cliLog.output(JSON.stringify(result, null, 2)); + if (!result.ok) process.exit(1); + return; + } + + if (!result.ok) { + bailout(result.error!); + } + + if (result.marked) { + const cp = result.marked; + console.log( + `\n${FG_GREEN}✓${RESET} ${FG_YELLOW}⭐${RESET} Milestone created: ${FG_MAGENTA}${cp.milestoneName}${RESET}`, + ); + console.log( + ` Checkpoint: ${FG_YELLOW}${cp.shortHash}${RESET} ${cp.message}`, + ); + console.log(); + return; + } + + if (result.removed !== undefined) { + console.log(`${FG_GREEN}✓${RESET} Milestone removed`); + return; + } + + printMilestones(result.milestones!, localDir); + }); +} diff --git a/packages/boxel-cli/src/lib/checkpoint-manager.ts b/packages/boxel-cli/src/lib/checkpoint-manager.ts index 0d03cea6e6..d269e49506 100644 --- a/packages/boxel-cli/src/lib/checkpoint-manager.ts +++ b/packages/boxel-cli/src/lib/checkpoint-manager.ts @@ -364,41 +364,46 @@ export class CheckpointManager { }); return Promise.all( - lines.map(async (line) => { - const [hash, shortHash, subject, dateStr] = line.split('|'); - - const isMajor = subject.includes('[MAJOR]'); - const source = subject.includes('[local]') - ? ('local' as const) - : subject.includes('[remote]') - ? ('remote' as const) - : ('manual' as const); - - const message = subject - .replace(/\[(MAJOR|minor)\]\s*/i, '') - .replace(/\[(local|remote|manual)\]\s*/i, ''); - - const stats = await this.getCommitStats(hash); - - const milestoneName = milestones.get(hash); - const isMilestone = !!milestoneName; - - return { - hash, - shortHash, - message, - description: '', - date: new Date(dateStr), - isMajor, - source, - isMilestone, - milestoneName, - ...stats, - }; - }), + lines.map((line) => this.parseCheckpointLine(line, milestones)), ); } + private async parseCheckpointLine( + line: string, + milestones: Map, + ): Promise { + const [hash, shortHash, subject, dateStr] = line.split('|'); + + const isMajor = subject.includes('[MAJOR]'); + const source = subject.includes('[local]') + ? ('local' as const) + : subject.includes('[remote]') + ? ('remote' as const) + : ('manual' as const); + + const message = subject + .replace(/\[(MAJOR|minor)\]\s*/i, '') + .replace(/\[(local|remote|manual)\]\s*/i, ''); + + const stats = await this.getCommitStats(hash); + + const milestoneName = milestones.get(hash); + const isMilestone = !!milestoneName; + + return { + hash, + shortHash, + message, + description: '', + date: new Date(dateStr), + isMajor, + source, + isMilestone, + milestoneName, + ...stats, + }; + } + private async getCommitStats(hash: string): Promise<{ filesChanged: number; insertions: number; @@ -575,8 +580,36 @@ export class CheckpointManager { } async getMilestones(): Promise { - const all = await this.getCheckpoints(100); - return all.filter((cp) => cp.isMilestone); + if (!(await this.isInitialized())) { + return []; + } + const milestones = await this.getAllMilestones(); + if (milestones.size === 0) { + return []; + } + + // Enumerate from the milestone tags directly so the result is complete + // regardless of how deep the tagged checkpoints sit in history. `--no-walk` + // limits `git log` to just the listed commits — no traversal, no implicit + // cap. + const format = '%H|%h|%s|%aI|%an'; + const log = await this.git( + 'log', + '--no-walk', + `--format=${format}`, + ...milestones.keys(), + ); + + if (!log.trim()) { + return []; + } + + return Promise.all( + log + .trim() + .split('\n') + .map((line) => this.parseCheckpointLine(line, milestones)), + ); } private git(...args: string[]): Promise { diff --git a/packages/boxel-cli/tests/integration/realm-milestone.test.ts b/packages/boxel-cli/tests/integration/realm-milestone.test.ts new file mode 100644 index 0000000000..a0a0e647a5 --- /dev/null +++ b/packages/boxel-cli/tests/integration/realm-milestone.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { realmMilestone } from '../../src/commands/realm/milestone'; +import { CheckpointManager } from '../../src/lib/checkpoint-manager'; + +let workspaceDir: string; + +function writeFile(relPath: string, content: string): void { + const full = path.join(workspaceDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content, 'utf8'); +} + +async function makeCheckpoint( + cm: CheckpointManager, + file: string, + content: string, + source: 'manual' | 'local' | 'remote' = 'manual', +): Promise { + writeFile(file, content); + const cp = await cm.createCheckpoint(source, [{ file, status: 'added' }]); + return cp!.hash; +} + +beforeEach(() => { + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-milestone-int-')); +}); + +afterEach(() => { + fs.rmSync(workspaceDir, { recursive: true, force: true }); +}); + +describe('realm milestone (integration)', () => { + describe('list mode (no options)', () => { + it('returns empty list for uninitialized workspace', async () => { + const result = await realmMilestone(workspaceDir); + expect(result.ok).toBe(true); + expect(result.milestones).toEqual([]); + }); + + it('returns empty list when no milestones are marked', async () => { + const cm = new CheckpointManager(workspaceDir); + await makeCheckpoint(cm, 'a.gts', 'a'); + + const result = await realmMilestone(workspaceDir); + expect(result.ok).toBe(true); + expect(result.milestones).toEqual([]); + }); + + it('returns milestones with correct names', async () => { + const cm = new CheckpointManager(workspaceDir); + const hash = await makeCheckpoint(cm, 'a.gts', 'a'); + await cm.markMilestone(hash, 'v1.0'); + + const result = await realmMilestone(workspaceDir); + expect(result.ok).toBe(true); + expect(result.milestones).toHaveLength(1); + expect(result.milestones![0].isMilestone).toBe(true); + expect(result.milestones![0].milestoneName).toBe('v1.0'); + }); + + it('returns multiple milestones', async () => { + const cm = new CheckpointManager(workspaceDir); + const h1 = await makeCheckpoint(cm, 'a.gts', 'a'); + const h2 = await makeCheckpoint(cm, 'b.gts', 'b'); + await cm.markMilestone(h1, 'first'); + await cm.markMilestone(h2, 'second'); + + const result = await realmMilestone(workspaceDir); + expect(result.ok).toBe(true); + expect(result.milestones).toHaveLength(2); + }); + + it('returns error for non-existent directory', async () => { + const missing = fs.mkdtempSync( + path.join(os.tmpdir(), 'boxel-milestone-missing-'), + ); + fs.rmSync(missing, { recursive: true, force: true }); + + const result = await realmMilestone(missing); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/directory not found/i); + }); + }); + + describe('mark mode (--mark + --name)', () => { + it('marks a checkpoint by hash and returns it', async () => { + const cm = new CheckpointManager(workspaceDir); + const hash = await makeCheckpoint(cm, 'a.gts', 'a'); + + const result = await realmMilestone(workspaceDir, { + mark: hash, + name: 'release-1', + }); + + expect(result.ok).toBe(true); + expect(result.marked).toBeDefined(); + expect(result.marked!.isMilestone).toBe(true); + expect(result.marked!.milestoneName).toBe('release 1'); + }); + + it('marks a checkpoint by 1-based index', async () => { + const cm = new CheckpointManager(workspaceDir); + await makeCheckpoint(cm, 'a.gts', 'a'); + + const result = await realmMilestone(workspaceDir, { + mark: '1', + name: 'first', + }); + + expect(result.ok).toBe(true); + expect(result.marked).toBeDefined(); + expect(result.marked!.isMilestone).toBe(true); + }); + + it('marks a checkpoint by short hash', async () => { + const cm = new CheckpointManager(workspaceDir); + const hash = await makeCheckpoint(cm, 'a.gts', 'a'); + const shortHash = hash.substring(0, 7); + + const result = await realmMilestone(workspaceDir, { + mark: shortHash, + name: 'short-ref', + }); + + expect(result.ok).toBe(true); + expect(result.marked).toBeDefined(); + }); + + it('returns error when --name is missing', async () => { + const result = await realmMilestone(workspaceDir, { mark: '1' }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/--name is required/i); + }); + + it('returns error when --name is empty', async () => { + const cm = new CheckpointManager(workspaceDir); + await makeCheckpoint(cm, 'a.gts', 'a'); + + const result = await realmMilestone(workspaceDir, { + mark: '1', + name: ' ', + }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/--name must not be empty/i); + }); + + it('returns error for out-of-range index', async () => { + const cm = new CheckpointManager(workspaceDir); + await makeCheckpoint(cm, 'a.gts', 'a'); + + const result = await realmMilestone(workspaceDir, { + mark: '99', + name: 'nope', + }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not found/i); + }); + + it('returns error when no checkpoint history exists', async () => { + const result = await realmMilestone(workspaceDir, { + mark: '1', + name: 'x', + }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/no checkpoint history/i); + }); + }); + + describe('remove mode (--remove)', () => { + it('removes a milestone by hash', async () => { + const cm = new CheckpointManager(workspaceDir); + const hash = await makeCheckpoint(cm, 'a.gts', 'a'); + await cm.markMilestone(hash, 'v1'); + + const result = await realmMilestone(workspaceDir, { remove: hash }); + expect(result.ok).toBe(true); + expect(result.removed).toBe(true); + + const after = await realmMilestone(workspaceDir); + expect(after.milestones).toHaveLength(0); + }); + + it('removes a milestone by index', async () => { + const cm = new CheckpointManager(workspaceDir); + const hash = await makeCheckpoint(cm, 'a.gts', 'a'); + await cm.markMilestone(hash, 'v1'); + + const result = await realmMilestone(workspaceDir, { remove: '1' }); + expect(result.ok).toBe(true); + expect(result.removed).toBe(true); + }); + + it('returns error when checkpoint is not a milestone', async () => { + const cm = new CheckpointManager(workspaceDir); + const hash = await makeCheckpoint(cm, 'a.gts', 'a'); + + const result = await realmMilestone(workspaceDir, { remove: hash }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not marked as a milestone/i); + }); + + it('returns error for unknown ref', async () => { + const cm = new CheckpointManager(workspaceDir); + await makeCheckpoint(cm, 'a.gts', 'a'); + + const result = await realmMilestone(workspaceDir, { remove: '99' }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not found/i); + }); + }); + + describe('validation', () => { + it('returns error when --mark and --remove are both set', async () => { + const result = await realmMilestone(workspaceDir, { + mark: '1', + name: 'x', + remove: '1', + }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/only one of/i); + }); + + it('returns error for non-positive limit', async () => { + const result = await realmMilestone(workspaceDir, { limit: 0 }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/limit/i); + }); + }); +}); diff --git a/packages/boxel-cli/tests/lib/checkpoint-manager.test.ts b/packages/boxel-cli/tests/lib/checkpoint-manager.test.ts index 58b7f13988..5c3d79809c 100644 --- a/packages/boxel-cli/tests/lib/checkpoint-manager.test.ts +++ b/packages/boxel-cli/tests/lib/checkpoint-manager.test.ts @@ -336,6 +336,23 @@ describe('CheckpointManager', () => { expect(milestones[0].milestoneName).toBe('release 1'); }); + it('getMilestones returns milestones beyond the most recent 100 checkpoints', async () => { + // Tag the earliest checkpoint, then bury it under 120 more checkpoints. + // The old implementation walked `getCheckpoints(100)` and would miss it. + await cm.markMilestone(firstHash, 'ancient'); + for (let i = 0; i < 120; i++) { + writeFile(workspaceDir, `f${i}.gts`, String(i)); + await cm.createCheckpoint('manual', [ + { file: `f${i}.gts`, status: 'added' }, + ]); + } + + const milestones = await cm.getMilestones(); + expect(milestones.length).toBe(1); + expect(milestones[0].hash).toBe(firstHash); + expect(milestones[0].milestoneName).toBe('ancient'); + }); + it('unmarkMilestone removes the tag; second call returns false', async () => { await cm.markMilestone(firstHash, 'release-1'); expect(await cm.unmarkMilestone(firstHash)).toBe(true);