@@ -10,6 +10,36 @@ import type {
1010 StoredMessage ,
1111} from '@deepcode/core' ;
1212import { 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
1444export 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+
731852export 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