Skip to content

Commit ac7d47b

Browse files
tclaude
andcommitted
feat(cli): add /diff, /release-notes and /bug slash commands (Codex + Claude parity)
Codex and Claude Code both expose /diff and /release-notes; Claude also has /bug (/feedback). DeepCode had none of them. - /diff: `git status --short` + `git diff HEAD` (truncated to 300 lines) + untracked files, in the session cwd. Clean message outside a git repo. - /release-notes: prints the latest CHANGELOG.md section (walks up from cwd). - /bug (alias /feedback): prints a prefilled github.com/oratis/deepcode issue link, carrying the current model/mode/effort in the body. git is spawned with GIT_* stripped from the env (inlined to keep this PR independent of #149) so a leaked GIT_DIR can't redirect it. Adds 6 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e6c8d36 commit ac7d47b

2 files changed

Lines changed: 205 additions & 1 deletion

File tree

apps/cli/src/commands.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
1-
import { mkdtemp, rm } from 'node:fs/promises';
1+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2+
import { execFile } from 'node:child_process';
3+
import { promisify } from 'node:util';
24
import { tmpdir } from 'node:os';
35
import { join } from 'node:path';
46
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
57
import { SessionManager } from '@deepcode/core';
68
import { CommandRegistry, type SessionContext } from './commands.js';
79

10+
const exec = promisify(execFile);
11+
12+
// Strip inherited GIT_* so this test's `git init` can't be hijacked by a leaked
13+
// GIT_DIR when the suite runs inside a git hook (which would re-init the real
14+
// repo as bare). Mirrors the inline scrub in commands.ts.
15+
function gitEnv(): NodeJS.ProcessEnv {
16+
const env: NodeJS.ProcessEnv = { ...process.env };
17+
for (const k of Object.keys(env)) if (k.startsWith('GIT_')) delete env[k];
18+
return env;
19+
}
20+
821
function makeContext(overrides: Partial<SessionContext> = {}): SessionContext {
922
return {
1023
cwd: '/tmp/x',
@@ -144,6 +157,73 @@ describe('built-in command behavior', () => {
144157
expect(out.join('\n')).toMatch(/Context:/);
145158
});
146159

160+
it('/bug prints a prefilled GitHub issue link', async () => {
161+
const reg = new CommandRegistry();
162+
const out = (
163+
await reg.match('/bug it crashed')!.cmd.run(['it', 'crashed'], makeContext())
164+
).join('\n');
165+
expect(out).toMatch(/github\.com\/oratis\/deepcode\/issues/);
166+
expect(out).toMatch(/title=it\+crashed/);
167+
});
168+
169+
it('/feedback is an alias for /bug', () => {
170+
const reg = new CommandRegistry();
171+
expect(reg.match('/feedback')?.cmd.name).toBe('/bug');
172+
});
173+
174+
it('/release-notes prints the latest CHANGELOG section only', async () => {
175+
const dir = await mkdtemp(join(tmpdir(), 'dc-cl-'));
176+
await writeFile(
177+
join(dir, 'CHANGELOG.md'),
178+
'# Changelog\n\n## 1.2.0\n\n- new thing\n\n## 1.1.0\n\n- old thing\n',
179+
);
180+
const reg = new CommandRegistry();
181+
const out = (await reg.match('/release-notes')!.cmd.run([], makeContext({ cwd: dir }))).join(
182+
'\n',
183+
);
184+
expect(out).toMatch(/## 1\.2\.0/);
185+
expect(out).toMatch(/new thing/);
186+
expect(out).not.toMatch(/old thing/);
187+
await rm(dir, { recursive: true, force: true });
188+
});
189+
190+
it('/release-notes reports a missing CHANGELOG cleanly', async () => {
191+
const dir = await mkdtemp(join(tmpdir(), 'dc-nocl-'));
192+
const reg = new CommandRegistry();
193+
const out = (await reg.match('/release-notes')!.cmd.run([], makeContext({ cwd: dir }))).join(
194+
'\n',
195+
);
196+
expect(out).toMatch(/No CHANGELOG/);
197+
await rm(dir, { recursive: true, force: true });
198+
});
199+
200+
it('/diff shows uncommitted changes (tracked edit + untracked file)', async () => {
201+
const repo = await mkdtemp(join(tmpdir(), 'dc-diff-'));
202+
const GIT = { cwd: repo, env: gitEnv() };
203+
await exec('git', ['init', '-q'], GIT);
204+
await exec('git', ['config', 'user.email', 't@t'], GIT);
205+
await exec('git', ['config', 'user.name', 't'], GIT);
206+
await writeFile(join(repo, 'f.txt'), 'one\n');
207+
await exec('git', ['add', '-A'], GIT);
208+
await exec('git', ['commit', '-qm', 'init'], GIT);
209+
await writeFile(join(repo, 'f.txt'), 'two\n'); // tracked modification
210+
await writeFile(join(repo, 'new.txt'), 'fresh\n'); // untracked
211+
const reg = new CommandRegistry();
212+
const out = (await reg.match('/diff')!.cmd.run([], makeContext({ cwd: repo }))).join('\n');
213+
expect(out).toMatch(/Uncommitted changes/);
214+
expect(out).toMatch(/f\.txt/);
215+
expect(out).toMatch(/new\.txt/);
216+
await rm(repo, { recursive: true, force: true });
217+
});
218+
219+
it('/diff handles a non-git directory', async () => {
220+
const dir = await mkdtemp(join(tmpdir(), 'dc-nogit-'));
221+
const reg = new CommandRegistry();
222+
const out = (await reg.match('/diff')!.cmd.run([], makeContext({ cwd: dir }))).join('\n');
223+
expect(out).toMatch(/Not a git repository/);
224+
await rm(dir, { recursive: true, force: true });
225+
});
226+
147227
it('/config dumps settings', async () => {
148228
const reg = new CommandRegistry();
149229
const ctx = makeContext({ settings: { model: 'deepseek-chat' } });

apps/cli/src/commands.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,36 @@ import type {
1010
StoredMessage,
1111
} from '@deepcode/core';
1212
import { contextWindowFor, redact, type Credentials } from '@deepcode/core';
13+
import { execFile } from 'node:child_process';
14+
import { promisify } from 'node:util';
15+
16+
const execFileAsync = promisify(execFile);
17+
18+
/** Environment for spawning git: strips inherited GIT_* (e.g. a GIT_DIR leaked
19+
* from a parent git hook) so the call targets `cwd`, not the hook's repo. */
20+
function gitEnv(): NodeJS.ProcessEnv {
21+
const env: NodeJS.ProcessEnv = { ...process.env };
22+
for (const k of Object.keys(env)) if (k.startsWith('GIT_')) delete env[k];
23+
return env;
24+
}
25+
26+
/** Run a git subcommand in `cwd`, never throwing. */
27+
async function runGit(
28+
cwd: string,
29+
args: string[],
30+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
31+
try {
32+
const { stdout, stderr } = await execFileAsync('git', args, {
33+
cwd,
34+
env: gitEnv(),
35+
maxBuffer: 8 * 1024 * 1024,
36+
});
37+
return { ok: true, stdout, stderr };
38+
} catch (err) {
39+
const e = err as { stdout?: string; stderr?: string; message?: string };
40+
return { ok: false, stdout: e.stdout ?? '', stderr: e.stderr ?? e.message ?? 'git failed' };
41+
}
42+
}
1343

1444
export interface SessionContext {
1545
cwd: string;
@@ -728,6 +758,97 @@ function historyToMarkdown(history: StoredMessage[]): string {
728758
return out.join('\n');
729759
}
730760

761+
export const DiffCommand: SlashCommand = {
762+
name: '/diff',
763+
description: 'Show uncommitted changes in the working tree (git diff + untracked files).',
764+
async run(_args, ctx) {
765+
const inside = await runGit(ctx.cwd, ['rev-parse', '--is-inside-work-tree']);
766+
if (!inside.ok || inside.stdout.trim() !== 'true') {
767+
return ['Not a git repository (or git is unavailable) — nothing to diff.'];
768+
}
769+
const status = await runGit(ctx.cwd, ['status', '--short']);
770+
if (status.ok && status.stdout.trim() === '') {
771+
return ['Working tree clean — no uncommitted changes.'];
772+
}
773+
const lines: string[] = ['Uncommitted changes:', ''];
774+
if (status.stdout.trim()) {
775+
lines.push(...status.stdout.trimEnd().split('\n'), '');
776+
}
777+
// `diff HEAD` covers staged + unstaged edits to tracked files. It fails in a
778+
// repo with no commits yet (no HEAD) — that's fine, status/untracked still show.
779+
const diff = await runGit(ctx.cwd, ['--no-pager', 'diff', 'HEAD']);
780+
const MAX = 300;
781+
if (diff.ok && diff.stdout.trim()) {
782+
const diffLines = diff.stdout.split('\n');
783+
lines.push(...diffLines.slice(0, MAX));
784+
if (diffLines.length > MAX) {
785+
lines.push(`… (${diffLines.length - MAX} more lines — run \`git diff\` for the full diff)`);
786+
}
787+
}
788+
const untracked = await runGit(ctx.cwd, ['ls-files', '--others', '--exclude-standard']);
789+
if (untracked.ok && untracked.stdout.trim()) {
790+
lines.push('', 'Untracked files:');
791+
for (const f of untracked.stdout.trim().split('\n')) lines.push(` ? ${f}`);
792+
}
793+
return lines;
794+
},
795+
};
796+
797+
export const ReleaseNotesCommand: SlashCommand = {
798+
name: '/release-notes',
799+
description: 'Show the latest CHANGELOG entry.',
800+
async run(_args, ctx) {
801+
const fs = await import('node:fs/promises');
802+
const path = await import('node:path');
803+
// Walk up from cwd looking for CHANGELOG.md (repo root may be above cwd).
804+
let dir = ctx.cwd;
805+
let changelog: string | null = null;
806+
for (let i = 0; i < 8; i++) {
807+
try {
808+
changelog = await fs.readFile(path.join(dir, 'CHANGELOG.md'), 'utf8');
809+
break;
810+
} catch {
811+
const parent = path.dirname(dir);
812+
if (parent === dir) break;
813+
dir = parent;
814+
}
815+
}
816+
if (!changelog) {
817+
return ['No CHANGELOG.md found (searched from cwd up to the filesystem root).'];
818+
}
819+
const all = changelog.split('\n');
820+
const firstH2 = all.findIndex((l) => l.startsWith('## '));
821+
if (firstH2 === -1) return all.slice(0, 40);
822+
const afterFirst = all.slice(firstH2 + 1);
823+
const nextRel = afterFirst.findIndex((l) => l.startsWith('## '));
824+
const end = nextRel === -1 ? all.length : firstH2 + 1 + nextRel;
825+
const section = all.slice(firstH2, end);
826+
while (section.length && section[section.length - 1]!.trim() === '') section.pop();
827+
return section;
828+
},
829+
};
830+
831+
export const BugCommand: SlashCommand = {
832+
name: '/bug',
833+
aliases: ['/feedback'],
834+
description: 'Report a bug or give feedback (prints a prefilled GitHub issue link).',
835+
run(args, ctx) {
836+
const title = args.join(' ').trim();
837+
const params = new URLSearchParams();
838+
if (title) params.set('title', title);
839+
params.set(
840+
'body',
841+
`<!-- Describe the issue above. -->\n\n---\nModel: ${ctx.model} · Mode: ${ctx.mode} · Effort: ${ctx.effort}`,
842+
);
843+
return [
844+
'Report a bug or request a feature:',
845+
` https://github.com/oratis/deepcode/issues/new?${params.toString()}`,
846+
'',
847+
'Or browse existing issues: https://github.com/oratis/deepcode/issues',
848+
];
849+
},
850+
};
851+
731852
export const BUILTIN_COMMANDS: SlashCommand[] = [
732853
HelpCommand,
733854
ClearCommand,
@@ -754,6 +875,9 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
754875
SkillsCommand,
755876
ExportCommand,
756877
CompactCommand,
878+
DiffCommand,
879+
ReleaseNotesCommand,
880+
BugCommand,
757881
];
758882

759883
// ──────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)