@ottocode/server 0.1.173

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/package.json +42 -0
  2. package/src/events/bus.ts +43 -0
  3. package/src/events/types.ts +32 -0
  4. package/src/index.ts +281 -0
  5. package/src/openapi/helpers.ts +64 -0
  6. package/src/openapi/paths/ask.ts +70 -0
  7. package/src/openapi/paths/config.ts +218 -0
  8. package/src/openapi/paths/files.ts +72 -0
  9. package/src/openapi/paths/git.ts +457 -0
  10. package/src/openapi/paths/messages.ts +92 -0
  11. package/src/openapi/paths/sessions.ts +90 -0
  12. package/src/openapi/paths/setu.ts +154 -0
  13. package/src/openapi/paths/stream.ts +26 -0
  14. package/src/openapi/paths/terminals.ts +226 -0
  15. package/src/openapi/schemas.ts +345 -0
  16. package/src/openapi/spec.ts +49 -0
  17. package/src/presets.ts +85 -0
  18. package/src/routes/ask.ts +113 -0
  19. package/src/routes/auth.ts +592 -0
  20. package/src/routes/branch.ts +106 -0
  21. package/src/routes/config/agents.ts +44 -0
  22. package/src/routes/config/cwd.ts +21 -0
  23. package/src/routes/config/defaults.ts +45 -0
  24. package/src/routes/config/index.ts +16 -0
  25. package/src/routes/config/main.ts +73 -0
  26. package/src/routes/config/models.ts +139 -0
  27. package/src/routes/config/providers.ts +46 -0
  28. package/src/routes/config/utils.ts +120 -0
  29. package/src/routes/files.ts +218 -0
  30. package/src/routes/git/branch.ts +75 -0
  31. package/src/routes/git/commit.ts +209 -0
  32. package/src/routes/git/diff.ts +137 -0
  33. package/src/routes/git/index.ts +18 -0
  34. package/src/routes/git/push.ts +160 -0
  35. package/src/routes/git/schemas.ts +48 -0
  36. package/src/routes/git/staging.ts +208 -0
  37. package/src/routes/git/status.ts +83 -0
  38. package/src/routes/git/types.ts +31 -0
  39. package/src/routes/git/utils.ts +249 -0
  40. package/src/routes/openapi.ts +6 -0
  41. package/src/routes/research.ts +392 -0
  42. package/src/routes/root.ts +5 -0
  43. package/src/routes/session-approval.ts +63 -0
  44. package/src/routes/session-files.ts +387 -0
  45. package/src/routes/session-messages.ts +170 -0
  46. package/src/routes/session-stream.ts +61 -0
  47. package/src/routes/sessions.ts +814 -0
  48. package/src/routes/setu.ts +346 -0
  49. package/src/routes/terminals.ts +227 -0
  50. package/src/runtime/agent/registry.ts +351 -0
  51. package/src/runtime/agent/runner-reasoning.ts +108 -0
  52. package/src/runtime/agent/runner-setup.ts +257 -0
  53. package/src/runtime/agent/runner.ts +375 -0
  54. package/src/runtime/agent-registry.ts +6 -0
  55. package/src/runtime/ask/service.ts +369 -0
  56. package/src/runtime/context/environment.ts +202 -0
  57. package/src/runtime/debug/index.ts +117 -0
  58. package/src/runtime/debug/state.ts +140 -0
  59. package/src/runtime/errors/api-error.ts +192 -0
  60. package/src/runtime/errors/handling.ts +199 -0
  61. package/src/runtime/message/compaction-auto.ts +154 -0
  62. package/src/runtime/message/compaction-context.ts +101 -0
  63. package/src/runtime/message/compaction-detect.ts +26 -0
  64. package/src/runtime/message/compaction-limits.ts +37 -0
  65. package/src/runtime/message/compaction-mark.ts +111 -0
  66. package/src/runtime/message/compaction-prune.ts +75 -0
  67. package/src/runtime/message/compaction.ts +21 -0
  68. package/src/runtime/message/history-builder.ts +266 -0
  69. package/src/runtime/message/service.ts +468 -0
  70. package/src/runtime/message/tool-history-tracker.ts +204 -0
  71. package/src/runtime/prompt/builder.ts +167 -0
  72. package/src/runtime/provider/anthropic.ts +50 -0
  73. package/src/runtime/provider/copilot.ts +12 -0
  74. package/src/runtime/provider/google.ts +8 -0
  75. package/src/runtime/provider/index.ts +60 -0
  76. package/src/runtime/provider/moonshot.ts +8 -0
  77. package/src/runtime/provider/oauth-adapter.ts +237 -0
  78. package/src/runtime/provider/openai.ts +18 -0
  79. package/src/runtime/provider/opencode.ts +7 -0
  80. package/src/runtime/provider/openrouter.ts +7 -0
  81. package/src/runtime/provider/selection.ts +118 -0
  82. package/src/runtime/provider/setu.ts +126 -0
  83. package/src/runtime/provider/zai.ts +16 -0
  84. package/src/runtime/session/branch.ts +280 -0
  85. package/src/runtime/session/db-operations.ts +285 -0
  86. package/src/runtime/session/manager.ts +99 -0
  87. package/src/runtime/session/queue.ts +243 -0
  88. package/src/runtime/stream/abort-handler.ts +65 -0
  89. package/src/runtime/stream/error-handler.ts +371 -0
  90. package/src/runtime/stream/finish-handler.ts +101 -0
  91. package/src/runtime/stream/handlers.ts +5 -0
  92. package/src/runtime/stream/step-finish.ts +93 -0
  93. package/src/runtime/stream/types.ts +25 -0
  94. package/src/runtime/tools/approval.ts +180 -0
  95. package/src/runtime/tools/context.ts +83 -0
  96. package/src/runtime/tools/mapping.ts +154 -0
  97. package/src/runtime/tools/setup.ts +44 -0
  98. package/src/runtime/topup/manager.ts +110 -0
  99. package/src/runtime/utils/cwd.ts +69 -0
  100. package/src/runtime/utils/token.ts +35 -0
  101. package/src/tools/adapter.ts +634 -0
  102. package/src/tools/database/get-parent-session.ts +183 -0
  103. package/src/tools/database/get-session-context.ts +161 -0
  104. package/src/tools/database/index.ts +42 -0
  105. package/src/tools/database/present-session-links.ts +47 -0
  106. package/src/tools/database/query-messages.ts +160 -0
  107. package/src/tools/database/query-sessions.ts +126 -0
  108. package/src/tools/database/search-history.ts +135 -0
  109. package/src/types/sql-imports.d.ts +5 -0
  110. package/sst-env.d.ts +8 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,218 @@
1
+ import type { Hono } from 'hono';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { join, relative } from 'node:path';
4
+ import { exec } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { serializeError } from '../runtime/errors/api-error.ts';
7
+ import { logger } from '@ottocode/sdk';
8
+
9
+ const execAsync = promisify(exec);
10
+
11
+ const EXCLUDED_PATTERNS = [
12
+ 'node_modules',
13
+ '.git',
14
+ 'dist',
15
+ 'build',
16
+ '.next',
17
+ '.nuxt',
18
+ '.turbo',
19
+ 'coverage',
20
+ '.cache',
21
+ '.DS_Store',
22
+ 'bun.lockb',
23
+ '.env',
24
+ '.env.local',
25
+ '.env.production',
26
+ '.env.development',
27
+ ];
28
+
29
+ function shouldExclude(name: string): boolean {
30
+ for (const pattern of EXCLUDED_PATTERNS) {
31
+ if (pattern.includes('*')) {
32
+ const regex = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`);
33
+ if (regex.test(name)) return true;
34
+ } else if (name === pattern || name.endsWith(pattern)) {
35
+ return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ async function parseGitignore(projectRoot: string): Promise<Set<string>> {
42
+ const patterns = new Set<string>();
43
+ try {
44
+ const gitignorePath = join(projectRoot, '.gitignore');
45
+ const content = await readFile(gitignorePath, 'utf-8');
46
+ for (const line of content.split('\n')) {
47
+ const trimmed = line.trim();
48
+ if (trimmed && !trimmed.startsWith('#')) {
49
+ patterns.add(trimmed);
50
+ }
51
+ }
52
+ } catch (_err) {}
53
+ return patterns;
54
+ }
55
+
56
+ function matchesGitignorePattern(
57
+ relativePath: string,
58
+ patterns: Set<string>,
59
+ ): boolean {
60
+ for (const pattern of patterns) {
61
+ const cleanPattern = pattern.replace(/^\//, '').replace(/\/$/, '');
62
+ const pathParts = relativePath.split('/');
63
+
64
+ if (pattern.endsWith('/')) {
65
+ if (pathParts[0] === cleanPattern) return true;
66
+ if (relativePath.startsWith(`${cleanPattern}/`)) return true;
67
+ }
68
+
69
+ if (pattern.includes('*')) {
70
+ const regex = new RegExp(
71
+ `^${cleanPattern.replace(/\*/g, '.*').replace(/\?/g, '.')}$`,
72
+ );
73
+ if (regex.test(relativePath)) return true;
74
+ for (const part of pathParts) {
75
+ if (regex.test(part)) return true;
76
+ }
77
+ } else {
78
+ if (relativePath === cleanPattern) return true;
79
+ if (pathParts.includes(cleanPattern)) return true;
80
+ if (relativePath.startsWith(`${cleanPattern}/`)) return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+
86
+ async function traverseDirectory(
87
+ dir: string,
88
+ projectRoot: string,
89
+ maxDepth: number,
90
+ currentDepth = 0,
91
+ limit: number,
92
+ collected: string[] = [],
93
+ gitignorePatterns?: Set<string>,
94
+ ): Promise<{ files: string[]; truncated: boolean }> {
95
+ if (currentDepth >= maxDepth || collected.length >= limit) {
96
+ return { files: collected, truncated: collected.length >= limit };
97
+ }
98
+
99
+ try {
100
+ const entries = await readdir(dir, { withFileTypes: true });
101
+
102
+ for (const entry of entries) {
103
+ if (collected.length >= limit) {
104
+ return { files: collected, truncated: true };
105
+ }
106
+
107
+ if (shouldExclude(entry.name)) {
108
+ continue;
109
+ }
110
+
111
+ const fullPath = join(dir, entry.name);
112
+ const relativePath = relative(projectRoot, fullPath);
113
+
114
+ if (
115
+ gitignorePatterns &&
116
+ matchesGitignorePattern(relativePath, gitignorePatterns)
117
+ ) {
118
+ continue;
119
+ }
120
+
121
+ if (entry.isDirectory()) {
122
+ const result = await traverseDirectory(
123
+ fullPath,
124
+ projectRoot,
125
+ maxDepth,
126
+ currentDepth + 1,
127
+ limit,
128
+ collected,
129
+ gitignorePatterns,
130
+ );
131
+ if (result.truncated) {
132
+ return result;
133
+ }
134
+ } else if (entry.isFile()) {
135
+ collected.push(relativePath);
136
+ }
137
+ }
138
+ } catch (err) {
139
+ logger.warn(`Failed to read directory ${dir}:`, err);
140
+ }
141
+
142
+ return { files: collected, truncated: false };
143
+ }
144
+
145
+ async function getChangedFiles(
146
+ projectRoot: string,
147
+ ): Promise<Map<string, string>> {
148
+ try {
149
+ const { stdout } = await execAsync('git status --porcelain', {
150
+ cwd: projectRoot,
151
+ });
152
+ const changedFiles = new Map<string, string>();
153
+ for (const line of stdout.split('\n')) {
154
+ if (line.length > 3) {
155
+ const statusCode = line.substring(0, 2).trim();
156
+ const filePath = line.substring(3).trim();
157
+
158
+ let status = 'modified';
159
+ if (statusCode.includes('A')) status = 'added';
160
+ else if (statusCode.includes('M')) status = 'modified';
161
+ else if (statusCode.includes('D')) status = 'deleted';
162
+ else if (statusCode.includes('R')) status = 'renamed';
163
+ else if (statusCode.includes('?')) status = 'untracked';
164
+
165
+ changedFiles.set(filePath, status);
166
+ }
167
+ }
168
+ return changedFiles;
169
+ } catch (_err) {
170
+ return new Set();
171
+ }
172
+ }
173
+
174
+ export function registerFilesRoutes(app: Hono) {
175
+ app.get('/v1/files', async (c) => {
176
+ try {
177
+ const projectRoot = c.req.query('project') || process.cwd();
178
+ const maxDepth = Number.parseInt(c.req.query('maxDepth') || '10', 10);
179
+ const limit = Number.parseInt(c.req.query('limit') || '1000', 10);
180
+
181
+ const gitignorePatterns = await parseGitignore(projectRoot);
182
+
183
+ const result = await traverseDirectory(
184
+ projectRoot,
185
+ projectRoot,
186
+ maxDepth,
187
+ 0,
188
+ limit,
189
+ [],
190
+ gitignorePatterns,
191
+ );
192
+
193
+ const changedFiles = await getChangedFiles(projectRoot);
194
+
195
+ result.files.sort((a, b) => {
196
+ const aChanged = changedFiles.has(a);
197
+ const bChanged = changedFiles.has(b);
198
+ if (aChanged && !bChanged) return -1;
199
+ if (!aChanged && bChanged) return 1;
200
+ return a.localeCompare(b);
201
+ });
202
+
203
+ return c.json({
204
+ files: result.files,
205
+ changedFiles: Array.from(changedFiles.entries()).map(
206
+ ([path, status]) => ({
207
+ path,
208
+ status,
209
+ }),
210
+ ),
211
+ truncated: result.truncated,
212
+ });
213
+ } catch (err) {
214
+ logger.error('Files route error:', err);
215
+ return c.json({ error: serializeError(err) }, 500);
216
+ }
217
+ });
218
+ }
@@ -0,0 +1,75 @@
1
+ import type { Hono } from 'hono';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { gitStatusSchema } from './schemas.ts';
5
+ import {
6
+ validateAndGetGitRoot,
7
+ getAheadBehind,
8
+ getCurrentBranch,
9
+ } from './utils.ts';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ export function registerBranchRoute(app: Hono) {
14
+ app.get('/v1/git/branch', async (c) => {
15
+ try {
16
+ const query = gitStatusSchema.parse({
17
+ project: c.req.query('project'),
18
+ });
19
+
20
+ const requestedPath = query.project || process.cwd();
21
+
22
+ const validation = await validateAndGetGitRoot(requestedPath);
23
+ if ('error' in validation) {
24
+ return c.json(
25
+ { status: 'error', error: validation.error, code: validation.code },
26
+ 400,
27
+ );
28
+ }
29
+
30
+ const { gitRoot } = validation;
31
+
32
+ const branch = await getCurrentBranch(gitRoot);
33
+
34
+ const { ahead, behind } = await getAheadBehind(gitRoot);
35
+
36
+ try {
37
+ const { stdout: remotes } = await execFileAsync('git', ['remote'], {
38
+ cwd: gitRoot,
39
+ });
40
+ const remoteList = remotes.trim().split('\n').filter(Boolean);
41
+
42
+ return c.json({
43
+ status: 'ok',
44
+ data: {
45
+ branch,
46
+ ahead,
47
+ behind,
48
+ remotes: remoteList,
49
+ },
50
+ });
51
+ } catch {
52
+ return c.json({
53
+ status: 'ok',
54
+ data: {
55
+ branch,
56
+ ahead,
57
+ behind,
58
+ remotes: [],
59
+ },
60
+ });
61
+ }
62
+ } catch (error) {
63
+ return c.json(
64
+ {
65
+ status: 'error',
66
+ error:
67
+ error instanceof Error
68
+ ? error.message
69
+ : 'Failed to get branch info',
70
+ },
71
+ 500,
72
+ );
73
+ }
74
+ });
75
+ }
@@ -0,0 +1,209 @@
1
+ import type { Hono } from 'hono';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { generateText, streamText } from 'ai';
5
+ import { eq } from 'drizzle-orm';
6
+ import type { ProviderId } from '@ottocode/sdk';
7
+ import { loadConfig, getAuth, getFastModelForAuth } from '@ottocode/sdk';
8
+ import { getDb } from '@ottocode/database';
9
+ import { sessions } from '@ottocode/database/schema';
10
+ import { gitCommitSchema, gitGenerateCommitMessageSchema } from './schemas.ts';
11
+ import { validateAndGetGitRoot, parseGitStatus } from './utils.ts';
12
+ import { resolveModel } from '../../runtime/provider/index.ts';
13
+ import { debugLog } from '../../runtime/debug/index.ts';
14
+ import {
15
+ detectOAuth,
16
+ adaptSimpleCall,
17
+ } from '../../runtime/provider/oauth-adapter.ts';
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ export function registerCommitRoutes(app: Hono) {
22
+ app.post('/v1/git/commit', async (c) => {
23
+ try {
24
+ const body = await c.req.json();
25
+ const { message, project } = gitCommitSchema.parse(body);
26
+
27
+ const requestedPath = project || process.cwd();
28
+
29
+ const validation = await validateAndGetGitRoot(requestedPath);
30
+ if ('error' in validation) {
31
+ return c.json(
32
+ { status: 'error', error: validation.error, code: validation.code },
33
+ 400,
34
+ );
35
+ }
36
+
37
+ const { gitRoot } = validation;
38
+
39
+ const { stdout } = await execFileAsync('git', ['commit', '-m', message], {
40
+ cwd: gitRoot,
41
+ });
42
+
43
+ return c.json({
44
+ status: 'ok',
45
+ data: {
46
+ message: stdout.trim(),
47
+ },
48
+ });
49
+ } catch (error) {
50
+ return c.json(
51
+ {
52
+ status: 'error',
53
+ error: error instanceof Error ? error.message : 'Failed to commit',
54
+ },
55
+ 500,
56
+ );
57
+ }
58
+ });
59
+
60
+ app.post('/v1/git/generate-commit-message', async (c) => {
61
+ try {
62
+ const body = await c.req.json();
63
+ const { project, sessionId } = gitGenerateCommitMessageSchema.parse(body);
64
+
65
+ const requestedPath = project || process.cwd();
66
+
67
+ const validation = await validateAndGetGitRoot(requestedPath);
68
+ if ('error' in validation) {
69
+ return c.json(
70
+ { status: 'error', error: validation.error, code: validation.code },
71
+ 400,
72
+ );
73
+ }
74
+
75
+ const { gitRoot } = validation;
76
+
77
+ const { stdout: diff } = await execFileAsync(
78
+ 'git',
79
+ ['diff', '--cached'],
80
+ {
81
+ cwd: gitRoot,
82
+ },
83
+ );
84
+
85
+ if (!diff.trim()) {
86
+ return c.json(
87
+ {
88
+ status: 'error',
89
+ error: 'No staged changes to generate message from',
90
+ },
91
+ 400,
92
+ );
93
+ }
94
+
95
+ const { stdout: statusOutput } = await execFileAsync(
96
+ 'git',
97
+ ['status', '--porcelain=v2'],
98
+ { cwd: gitRoot },
99
+ );
100
+ const { staged } = parseGitStatus(statusOutput, gitRoot);
101
+ const fileList = staged.map((f) => `${f.status}: ${f.path}`).join('\n');
102
+
103
+ const config = await loadConfig();
104
+
105
+ let provider = (config.defaults?.provider || 'anthropic') as ProviderId;
106
+
107
+ if (sessionId) {
108
+ const db = await getDb();
109
+ const [session] = await db
110
+ .select({ provider: sessions.provider })
111
+ .from(sessions)
112
+ .where(eq(sessions.id, sessionId));
113
+ if (session?.provider) {
114
+ provider = session.provider as ProviderId;
115
+ }
116
+ }
117
+
118
+ const auth = await getAuth(provider, config.projectRoot);
119
+ const oauth = detectOAuth(provider, auth);
120
+
121
+ const modelId =
122
+ getFastModelForAuth(provider, auth?.type) ??
123
+ config.defaults?.model ??
124
+ 'claude-3-5-sonnet-20241022';
125
+ const model = await resolveModel(provider, modelId, config);
126
+
127
+ const userPrompt = `Generate a commit message for these git changes.
128
+
129
+ Staged files:
130
+ ${fileList}
131
+
132
+ Diff (first 4000 chars):
133
+ ${diff.slice(0, 4000)}
134
+
135
+ Guidelines:
136
+ - CAREFULLY READ the diff above - describe what ACTUALLY changed
137
+ - Use conventional commits format: type(scope): description
138
+ - First line under 72 characters
139
+ - Add a blank line, then 2-4 short bullet points
140
+ - Each bullet describes ONE specific change you see in the diff
141
+ - Be ACCURATE - don't invent changes that aren't in the diff
142
+ - Keep bullets short (under 80 chars each)
143
+ - Do not include markdown code blocks or backticks
144
+ - Return ONLY the commit message text, nothing else
145
+
146
+ Example (for a diff that adds boolean returns to functions):
147
+ refactor(auth): return success status from login functions
148
+
149
+ - Add boolean return type to auth functions
150
+ - Return false on user cancellation or failure
151
+ - Check return value before proceeding with auth flow
152
+
153
+ Commit message:`;
154
+
155
+ const commitInstructions =
156
+ 'You are a helpful assistant that generates accurate git commit messages based on the actual diff content.';
157
+
158
+ const adapted = adaptSimpleCall(oauth, {
159
+ instructions: commitInstructions,
160
+ userContent: userPrompt,
161
+ maxOutputTokens: 500,
162
+ });
163
+
164
+ if (adapted.forceStream) {
165
+ debugLog('[COMMIT] Using streamText for OpenAI OAuth');
166
+ const result = streamText({
167
+ model,
168
+ system: adapted.system,
169
+ messages: adapted.messages,
170
+ providerOptions: adapted.providerOptions,
171
+ });
172
+ let text = '';
173
+ for await (const chunk of result.textStream) {
174
+ text += chunk;
175
+ }
176
+ const message = text.trim();
177
+ debugLog(`[COMMIT] OAuth result: "${message.slice(0, 80)}..."`);
178
+ return c.json({ status: 'ok', data: { message } });
179
+ }
180
+
181
+ const { text } = await generateText({
182
+ model,
183
+ system: adapted.system,
184
+ messages: adapted.messages,
185
+ maxOutputTokens: adapted.maxOutputTokens,
186
+ });
187
+
188
+ const message = text.trim();
189
+
190
+ return c.json({
191
+ status: 'ok',
192
+ data: {
193
+ message,
194
+ },
195
+ });
196
+ } catch (error) {
197
+ return c.json(
198
+ {
199
+ status: 'error',
200
+ error:
201
+ error instanceof Error
202
+ ? error.message
203
+ : 'Failed to generate commit message',
204
+ },
205
+ 500,
206
+ );
207
+ }
208
+ });
209
+ }
@@ -0,0 +1,137 @@
1
+ import type { Hono } from 'hono';
2
+ import { execFile } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { promisify } from 'node:util';
6
+ import { gitDiffSchema } from './schemas.ts';
7
+ import {
8
+ validateAndGetGitRoot,
9
+ checkIfNewFile,
10
+ inferLanguage,
11
+ summarizeDiff,
12
+ } from './utils.ts';
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ export function registerDiffRoute(app: Hono) {
17
+ app.get('/v1/git/diff', async (c) => {
18
+ try {
19
+ const query = gitDiffSchema.parse({
20
+ project: c.req.query('project'),
21
+ file: c.req.query('file'),
22
+ staged: c.req.query('staged'),
23
+ });
24
+
25
+ const requestedPath = query.project || process.cwd();
26
+
27
+ const validation = await validateAndGetGitRoot(requestedPath);
28
+ if ('error' in validation) {
29
+ return c.json(
30
+ { status: 'error', error: validation.error, code: validation.code },
31
+ 400,
32
+ );
33
+ }
34
+
35
+ const { gitRoot } = validation;
36
+ const absPath = join(gitRoot, query.file);
37
+
38
+ const isNewFile = await checkIfNewFile(gitRoot, query.file);
39
+
40
+ if (isNewFile) {
41
+ try {
42
+ const content = await readFile(absPath, 'utf-8');
43
+ const lineCount = content.split('\n').length;
44
+ const language = inferLanguage(query.file);
45
+
46
+ return c.json({
47
+ status: 'ok',
48
+ data: {
49
+ file: query.file,
50
+ absPath,
51
+ diff: '',
52
+ content,
53
+ isNewFile: true,
54
+ isBinary: false,
55
+ insertions: lineCount,
56
+ deletions: 0,
57
+ language,
58
+ staged: !!query.staged,
59
+ },
60
+ });
61
+ } catch (error) {
62
+ return c.json(
63
+ {
64
+ status: 'error',
65
+ error:
66
+ error instanceof Error ? error.message : 'Failed to read file',
67
+ },
68
+ 500,
69
+ );
70
+ }
71
+ }
72
+
73
+ const diffArgs = query.staged
74
+ ? ['diff', '--cached', '--', query.file]
75
+ : ['diff', '--', query.file];
76
+ const numstatArgs = query.staged
77
+ ? ['diff', '--cached', '--numstat', '--', query.file]
78
+ : ['diff', '--numstat', '--', query.file];
79
+
80
+ const [{ stdout: diffOutput }, { stdout: numstatOutput }] =
81
+ await Promise.all([
82
+ execFileAsync('git', diffArgs, { cwd: gitRoot }),
83
+ execFileAsync('git', numstatArgs, { cwd: gitRoot }),
84
+ ]);
85
+
86
+ let insertions = 0;
87
+ let deletions = 0;
88
+ let binary = false;
89
+
90
+ const numstatLine = numstatOutput.trim().split('\n').find(Boolean);
91
+ if (numstatLine) {
92
+ const [rawInsertions, rawDeletions] = numstatLine.split('\t');
93
+ if (rawInsertions === '-' || rawDeletions === '-') {
94
+ binary = true;
95
+ } else {
96
+ insertions = Number.parseInt(rawInsertions, 10) || 0;
97
+ deletions = Number.parseInt(rawDeletions, 10) || 0;
98
+ }
99
+ }
100
+
101
+ const diffText = diffOutput ?? '';
102
+ if (!binary) {
103
+ const summary = summarizeDiff(diffText);
104
+ binary = summary.binary;
105
+ if (insertions === 0 && deletions === 0) {
106
+ insertions = summary.insertions;
107
+ deletions = summary.deletions;
108
+ }
109
+ }
110
+
111
+ const language = inferLanguage(query.file);
112
+
113
+ return c.json({
114
+ status: 'ok',
115
+ data: {
116
+ file: query.file,
117
+ absPath,
118
+ diff: diffText,
119
+ isNewFile: false,
120
+ isBinary: binary,
121
+ insertions,
122
+ deletions,
123
+ language,
124
+ staged: !!query.staged,
125
+ },
126
+ });
127
+ } catch (error) {
128
+ return c.json(
129
+ {
130
+ status: 'error',
131
+ error: error instanceof Error ? error.message : 'Failed to get diff',
132
+ },
133
+ 500,
134
+ );
135
+ }
136
+ });
137
+ }
@@ -0,0 +1,18 @@
1
+ import type { Hono } from 'hono';
2
+ import { registerStatusRoute } from './status.ts';
3
+ import { registerBranchRoute } from './branch.ts';
4
+ import { registerDiffRoute } from './diff.ts';
5
+ import { registerStagingRoutes } from './staging.ts';
6
+ import { registerCommitRoutes } from './commit.ts';
7
+ import { registerPushRoute } from './push.ts';
8
+
9
+ export type { GitFile } from './types.ts';
10
+
11
+ export function registerGitRoutes(app: Hono) {
12
+ registerStatusRoute(app);
13
+ registerBranchRoute(app);
14
+ registerDiffRoute(app);
15
+ registerStagingRoutes(app);
16
+ registerCommitRoutes(app);
17
+ registerPushRoute(app);
18
+ }