Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/chatgpt-coding-workflow.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,6 +192,16 @@ that changes files. It shows the combined changes for that turn and advances
the review point automatically. Reusing a workspace does not change this
workflow.

The model-facing result stays compact: DevSpace returns the workspace ID, a
Git-backed `reviewRef`, and the summary text. MCP Apps hosts receive the full
file list and patch in result metadata for immediate rendering. If a host later
restores only the structured result, the review card can reopen that exact
`reviewRef` from DevSpace's Git review history without advancing the current
review point.

For local inspection, run `devspace show-changes <review-ref>`. Add `--json` to
include the parsed summary, file list, and patch.

## Shell Use

The shell tool is for commands that belong in a terminal:
Expand Down
5 changes: 5 additions & 0 deletions docs/gotchas.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,3 +251,8 @@ metadata and only show text results; `show_changes` remains available there.

If both cards are missing in ChatGPT, confirm that `ui.enabled` is not `false`
in `~/.devspace/config.jsonc` and reconnect the MCP server.

Historical `show_changes` cards use the `reviewRef` in their structured result
to recover the exact Git-backed review when a host reloads the app without its
original result metadata. `open_workspace` can rebuild its card directly from
its structured result.
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"schema:config": "tsx scripts/generate-config-schema.ts",
"start": "node dist/cli.js serve",
"test": "tsx src/user-config.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"test": "tsx src/user-config.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/tool-result.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-show-changes.test.ts && tsx src/cli.test.ts",
"typecheck": "tsx src/config-schema.test.ts && tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
108 changes: 108 additions & 0 deletions src/cli-show-changes.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { readFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { createReviewCheckpointManager } from "./review-checkpoints.js";
import { writeTestDevspaceConfig } from "./test-support/config.test.js";

const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const packageJsonPath = fileURLToPath(new URL("../package.json", import.meta.url));
const repoRoot = dirname(packageJsonPath);
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
bin: { devspace: string };
};
// This verifies the compiled entrypoint declared for the installed `devspace`
// command. npm's package-install shim itself is outside this focused test.
const cliPath = join(repoRoot, packageJson.bin.devspace);
const tscPath = require.resolve("typescript/bin/tsc");

test("show-changes prints a Git-backed historical review", async (t) => {
await execFileAsync(process.execPath, [tscPath, "-p", join(repoRoot, "tsconfig.build.json")], {
cwd: repoRoot,
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const root = await mkdtemp(join(tmpdir(), "devspace-cli-show-changes-"));
t.after(() => rm(root, { recursive: true, force: true }));
const project = join(root, "project");
await execFileAsync("git", ["init", project]);
await git(project, ["config", "user.email", "devspace@example.com"]);
await git(project, ["config", "user.name", "DevSpace Test"]);
await writeFile(join(project, "README.md"), "hello\n");
await git(project, ["add", "README.md"]);
await git(project, ["commit", "-m", "Initial commit"]);

const manager = createReviewCheckpointManager();
await manager.initializeWorkspace({ workspaceId: "ws_cli", root: project });
await writeFile(join(project, "README.md"), "hello\nreview me\n");
const review = await manager.reviewChanges({ workspaceId: "ws_cli", root: project });

const configDir = join(root, ".devspace");
const env = writeTestDevspaceConfig(configDir, {
workspaces: { allowedRoots: [project] },
storage: { stateDir: join(root, ".state") },
});
const cliArgs = [cliPath, "show-changes", review.reviewRef];
const plain = await execFileAsync("node", cliArgs, {
cwd: project,
env: {
...process.env,
...env,
DEVSPACE_WORKSPACE_ID: "",
DEVSPACE_WORKSPACE_ROOT: "",
},
encoding: "utf8",
});
assert.match(plain.stdout, /\+review me/);

const json = await execFileAsync("node", [...cliArgs, "--json"], {
cwd: project,
env: {
...process.env,
...env,
DEVSPACE_WORKSPACE_ID: "",
DEVSPACE_WORKSPACE_ROOT: "",
},
encoding: "utf8",
});
const parsed = JSON.parse(json.stdout) as {
reviewRef: string;
patch: string;
};
assert.equal(parsed.reviewRef, review.reviewRef);
assert.equal(parsed.patch, review.patch);

const head = (await execFileAsync("git", ["rev-parse", "HEAD"], {
cwd: project,
encoding: "utf8",
})).stdout.trim();
await assert.rejects(
execFileAsync("node", [cliPath, "show-changes", head], {
cwd: project,
env: {
...process.env,
...env,
DEVSPACE_WORKSPACE_ID: "",
DEVSPACE_WORKSPACE_ROOT: "",
},
encoding: "utf8",
}),
(error: unknown) => {
assert.match(
(error as { stderr?: string }).stderr ?? "",
/Unknown DevSpace review reference/,
);
return true;
},
);
});

async function git(cwd: string, args: string[]): Promise<void> {
await execFileAsync("git", args, { cwd });
}
40 changes: 38 additions & 2 deletions src/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,9 +50,18 @@ import {
writeDevspaceAuth,
} from "./user-config.js";
import { expandHomePath } from "./roots.js";
import { readReviewRef } from "./review-checkpoints.js";
import { shutdownHttpServer } from "./server-shutdown.js";

type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version";
type Command =
| "serve"
| "init"
| "doctor"
| "config"
| "agents"
| "show-changes"
| "help"
| "version";
const require = createRequire(import.meta.url);
const SUPPORTED_NODE_RANGE = ">=20.12 <27";

Expand All@@ -79,6 +88,9 @@ async function main(argv: string[]): Promise<void> {
case "agents":
await runAgentsCommand(args);
return;
case "show-changes":
await runShowChanges(args);
return;
case "help":
printHelp();
return;
Expand All@@ -90,7 +102,13 @@ async function main(argv: string[]): Promise<void> {

function normalizeCommand(command: string | undefined): Command {
if (!command || command === "serve" || command === "start") return "serve";
if (command === "init" || command === "doctor" || command === "config" || command === "agents") return command;
if (
command === "init"
|| command === "doctor"
|| command === "config"
|| command === "agents"
|| command === "show-changes"
) return command;
if (command === "help" || command === "--help" || command === "-h") return "help";
if (command === "version" || command === "--version" || command === "-v") return "version";
throw new Error(`Unknown command: ${command}`);
Expand DownExpand Up@@ -391,6 +409,7 @@ function printHelp(): void {
" devspace doctor Show config, runtime, and native dependency status",
" devspace config get Print persisted config",
" devspace config set publicBaseUrl <url|null>",
" devspace show-changes <review-ref> [--json]",
" devspace agents ls List subagent sessions",
" devspace agents run <profile-or-provider> [--model <model>] [--effort <level>] <prompt>",
" devspace agents continue <id> [--model <model>] [--effort <level>] <prompt>",
Expand All@@ -405,6 +424,23 @@ function printHelp(): void {
);
}

async function runShowChanges(args: string[]): Promise<void> {
const { args: commandArgs, json } = extractJsonOption(args);
const [reviewRef, ...extra] = commandArgs;
if (!reviewRef || extra.length > 0) {
throw new Error("Usage: devspace show-changes <review-ref> [--json]");
}

const config = loadConfig();
const scope = resolveCliWorkspaceContext(config.allowedRoots);
const review = await readReviewRef(scope.workspaceRoot, reviewRef);
if (json) {
printJson(review);
return;
}
console.log(review.patch || review.result);
}

async function runAgentsCommand(args: string[]): Promise<void> {
const [subcommand, ...rest] = args;
const { args: commandArgs, json } = extractJsonOption(rest);
Expand Down
56 changes: 55 additions & 1 deletion src/review-checkpoints.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import test, { type TestContext } from "node:test";
import { promisify } from "node:util";
import { createReviewCheckpointManager } from "./review-checkpoints.js";
import { createReviewCheckpointManager, readReviewRef } from "./review-checkpoints.js";

const execFileAsync = promisify(execFile);

Expand DownExpand Up@@ -62,12 +62,62 @@ test("show_changes reports and advances the last-shown checkpoint", async (t) =>
markReviewed: true,
});
assert.equal(markedReviewed.summary.files, 2);
assert.match(markedReviewed.reviewRef, /^[0-9a-f]{40,64}$/);

const restored = await manager.reviewByRef({
workspaceId: "ws_incremental",
root,
reviewRef: markedReviewed.reviewRef,
});
assert.deepEqual(restored.summary, markedReviewed.summary);
assert.deepEqual(restored.files, markedReviewed.files);
assert.equal(restored.patch, markedReviewed.patch);

const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_incremental", root });
assert.equal(afterReviewed.summary.files, 0);
assert.equal(afterReviewed.patch, "");
});

test("historical review refs survive later reviews and manager restarts", async (t) => {
const root = await committedRepository(t);
const manager = createReviewCheckpointManager();
await manager.initializeWorkspace({ workspaceId: "ws_history", root });

await writeFile(join(root, "README.md"), "hello\nfirst\n");
const first = await manager.reviewChanges({ workspaceId: "ws_history", root });

await writeFile(join(root, "README.md"), "hello\nfirst\nsecond\n");
const second = await manager.reviewChanges({ workspaceId: "ws_history", root });
assert.notEqual(first.reviewRef, second.reviewRef);

const restarted = createReviewCheckpointManager();
const restoredFirst = await restarted.reviewByRef({
workspaceId: "ws_history",
root,
reviewRef: first.reviewRef,
});
assert.deepEqual(restoredFirst.summary, first.summary);
assert.equal(restoredFirst.patch, first.patch);
assert.match(restoredFirst.patch, /\+first/);
assert.doesNotMatch(restoredFirst.patch, /\+second/);
});

test("review refs are scoped to the workspace review history", async (t) => {
const root = await committedRepository(t);
const manager = createReviewCheckpointManager();
await manager.initializeWorkspace({ workspaceId: "ws_scoped", root });

const head = await gitOutput(root, ["rev-parse", "HEAD"]);
await assert.rejects(
() => manager.reviewByRef({ workspaceId: "ws_scoped", root, reviewRef: head }),
/Unknown review reference/,
);
await assert.rejects(
() => readReviewRef(root, head),
/Unknown DevSpace review reference/,
);
});

test("review checkpoints survive a manager restart", async (t) => {
const root = await committedRepository(t);
const manager = createReviewCheckpointManager();
Expand DownExpand Up@@ -246,3 +296,7 @@ async function deleteReviewRef(
async function git(cwd: string, args: string[]): Promise<void> {
await execFileAsync("git", args, { cwd });
}

async function gitOutput(cwd: string, args: string[]): Promise<string> {
return (await execFileAsync("git", args, { cwd })).stdout.trim();
}
Loading
Loading