Skip to content

Commit 6c05681

Browse files
oratistclaude
authored
feat(cli): add /diff, /release-notes and /bug slash commands (Codex + Claude parity) (#150)
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: t <t@t> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 36136c4 commit 6c05681

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',
@@ -158,6 +171,73 @@ describe('built-in command behavior', () => {
158171
expect(out.join('\n')).toMatch(/Context:/);
159172
});
160173

174+
it('/bug prints a prefilled GitHub issue link', async () => {
175+
const reg = new CommandRegistry();
176+
const out = (
177+
await reg.match('/bug it crashed')!.cmd.run(['it', 'crashed'], makeContext())
178+
).join('\n');
179+
expect(out).toMatch(/github\.com\/oratis\/deepcode\/issues/);
180+
expect(out).toMatch(/title=it\+crashed/);
181+
});
182+
183+
it('/feedback is an alias for /bug', () => {
184+
const reg = new CommandRegistry();
185+
expect(reg.match('/feedback')?.cmd.name).toBe('/bug');
186+
});
187+
188+
it('/release-notes prints the latest CHANGELOG section only', async () => {
189+
const dir = await mkdtemp(join(tmpdir(), 'dc-cl-'));
190+
await writeFile(
191+
join(dir, 'CHANGELOG.md'),
192+
'# Changelog\n\n## 1.2.0\n\n- new thing\n\n## 1.1.0\n\n- old thing\n',
193+
);
194+
const reg = new CommandRegistry();
195+
const out = (await reg.match('/release-notes')!.cmd.run([], makeContext({ cwd: dir }))).join(
196+
'\n',
197+
);
198+
expect(out).toMatch(/## 1\.2\.0/);
199+
expect(out).toMatch(/new thing/);
200+
expect(out).not.toMatch(/old thing/);
201+
await rm(dir, { recursive: true, force: true });
202+
});
203+
204+
it('/release-notes reports a missing CHANGELOG cleanly', async () => {
205+
const dir = await mkdtemp(join(tmpdir(), 'dc-nocl-'));
206+
const reg = new CommandRegistry();
207+
const out = (await reg.match('/release-notes')!.cmd.run([], makeContext({ cwd: dir }))).join(
208+
'\n',
209+
);
210+
expect(out).toMatch(/No CHANGELOG/);
211+
await rm(dir, { recursive: true, force: true });
212+
});
213+
214+
it('/diff shows uncommitted changes (tracked edit + untracked file)', async () => {
215+
const repo = await mkdtemp(join(tmpdir(), 'dc-diff-'));
216+
const GIT = { cwd: repo, env: gitEnv() };
217+
await exec('git', ['init', '-q'], GIT);
218+
await exec('git', ['config', 'user.email', 't@t'], GIT);
219+
await exec('git', ['config', 'user.name', 't'], GIT);
220+
await writeFile(join(repo, 'f.txt'), 'one\n');
221+
await exec('git', ['add', '-A'], GIT);
222+
await exec('git', ['commit', '-qm', 'init'], GIT);
223+
await writeFile(join(repo, 'f.txt'), 'two\n'); // tracked modification
224+
await writeFile(join(repo, 'new.txt'), 'fresh\n'); // untracked
225+
const reg = new CommandRegistry();
226+
const out = (await reg.match('/diff')!.cmd.run([], makeContext({ cwd: repo }))).join('\n');
227+
expect(out).toMatch(/Uncommitted changes/);
228+
expect(out).toMatch(/f\.txt/);
229+
expect(out).toMatch(/new\.txt/);
230+
await rm(repo, { recursive: true, force: true });
231+
});
232+
233+
it('/diff handles a non-git directory', async () => {
234+
const dir = await mkdtemp(join(tmpdir(), 'dc-nogit-'));
235+
const reg = new CommandRegistry();
236+
const out = (await reg.match('/diff')!.cmd.run([], makeContext({ cwd: dir }))).join('\n');
237+
expect(out).toMatch(/Not a git repository/);
238+
await rm(dir, { recursive: true, force: true });
239+
});
240+
161241
it('/config dumps settings', async () => {
162242
const reg = new CommandRegistry();
163243
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
@@ -16,6 +16,36 @@ import {
1616
type Credentials,
1717
type Effort,
1818
} from '@deepcode/core';
19+
import { execFile } from 'node:child_process';
20+
import { promisify } from 'node:util';
21+
22+
const execFileAsync = promisify(execFile);
23+
24+
/** Environment for spawning git: strips inherited GIT_* (e.g. a GIT_DIR leaked
25+
* from a parent git hook) so the call targets `cwd`, not the hook's repo. */
26+
function gitEnv(): NodeJS.ProcessEnv {
27+
const env: NodeJS.ProcessEnv = { ...process.env };
28+
for (const k of Object.keys(env)) if (k.startsWith('GIT_')) delete env[k];
29+
return env;
30+
}
31+
32+
/** Run a git subcommand in `cwd`, never throwing. */
33+
async function runGit(
34+
cwd: string,
35+
args: string[],
36+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
37+
try {
38+
const { stdout, stderr } = await execFileAsync('git', args, {
39+
cwd,
40+
env: gitEnv(),
41+
maxBuffer: 8 * 1024 * 1024,
42+
});
43+
return { ok: true, stdout, stderr };
44+
} catch (err) {
45+
const e = err as { stdout?: string; stderr?: string; message?: string };
46+
return { ok: false, stdout: e.stdout ?? '', stderr: e.stderr ?? e.message ?? 'git failed' };
47+
}
48+
}
1949

2050
export interface SessionContext {
2151
cwd: string;
@@ -749,6 +779,97 @@ function historyToMarkdown(history: StoredMessage[]): string {
749779
return out.join('\n');
750780
}
751781

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

780904
// ──────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)