@ottocode/server 0.1.272 → 0.1.273

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/server",
3
- "version": "0.1.272",
3
+ "version": "0.1.273",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -61,8 +61,8 @@
61
61
  "typecheck": "tsc --noEmit"
62
62
  },
63
63
  "dependencies": {
64
- "@ottocode/database": "0.1.272",
65
- "@ottocode/sdk": "0.1.272",
64
+ "@ottocode/database": "0.1.273",
65
+ "@ottocode/sdk": "0.1.273",
66
66
  "@hono/zod-openapi": "^1.1.5",
67
67
  "ai-sdk-ollama": "^3.8.3",
68
68
  "drizzle-orm": "^0.44.5",
@@ -267,6 +267,13 @@ export const schemas = {
267
267
  type: 'object',
268
268
  properties: {
269
269
  branch: { type: 'string' },
270
+ headSha: { type: 'string' },
271
+ shortHeadSha: { type: 'string' },
272
+ isDetached: { type: 'boolean' },
273
+ operation: {
274
+ $ref: '#/components/schemas/GitOperation',
275
+ nullable: true,
276
+ },
270
277
  ahead: { type: 'integer' },
271
278
  behind: { type: 'integer' },
272
279
  staged: {
@@ -295,6 +302,10 @@ export const schemas = {
295
302
  },
296
303
  required: [
297
304
  'branch',
305
+ 'headSha',
306
+ 'shortHeadSha',
307
+ 'isDetached',
308
+ 'operation',
298
309
  'ahead',
299
310
  'behind',
300
311
  'staged',
@@ -307,6 +318,28 @@ export const schemas = {
307
318
  'remotes',
308
319
  ],
309
320
  },
321
+ GitOperation: {
322
+ type: 'object',
323
+ properties: {
324
+ type: {
325
+ type: 'string',
326
+ enum: [
327
+ 'rebase',
328
+ 'rebase-interactive',
329
+ 'merge',
330
+ 'cherry-pick',
331
+ 'revert',
332
+ 'bisect',
333
+ ],
334
+ },
335
+ label: { type: 'string' },
336
+ current: { type: 'integer' },
337
+ total: { type: 'integer' },
338
+ headName: { type: 'string' },
339
+ onto: { type: 'string' },
340
+ },
341
+ required: ['type', 'label'],
342
+ },
310
343
  GitFile: {
311
344
  type: 'object',
312
345
  properties: {
@@ -6,6 +6,7 @@ import { registerStagingRoutes } from './staging.ts';
6
6
  import { registerCommitRoutes } from './commit.ts';
7
7
  import { registerPushRoute } from './push.ts';
8
8
  import { registerPullRoute } from './pull.ts';
9
+ import { registerRebaseRoute } from './rebase.ts';
9
10
  import { registerInitRoute } from './init.ts';
10
11
  import { registerRemoteRoutes } from './remote.ts';
11
12
 
@@ -19,6 +20,7 @@ export function registerGitRoutes(app: Hono) {
19
20
  registerCommitRoutes(app);
20
21
  registerPushRoute(app);
21
22
  registerPullRoute(app);
23
+ registerRebaseRoute(app);
22
24
  registerInitRoute(app);
23
25
  registerRemoteRoutes(app);
24
26
  }
@@ -1,7 +1,10 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { execFile } from 'node:child_process';
3
+ import { realpath, stat } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
3
5
  import { promisify } from 'node:util';
4
6
  import { gitStatusSchema } from './schemas.ts';
7
+ import { validateAndGetGitRoot } from './utils.ts';
5
8
  import { openApiRoute } from '../../openapi/route.ts';
6
9
 
7
10
  const execFileAsync = promisify(execFile);
@@ -89,7 +92,27 @@ export function registerInitRoute(app: Hono) {
89
92
  try {
90
93
  const body = await c.req.json().catch(() => ({}));
91
94
  const { project } = gitStatusSchema.parse(body);
92
- const requestedPath = project || process.cwd();
95
+ const requestedPath = await realpath(resolve(project || process.cwd()));
96
+
97
+ const pathStats = await stat(requestedPath);
98
+ if (!pathStats.isDirectory()) {
99
+ return c.json(
100
+ {
101
+ status: 'error',
102
+ error: 'Git repository can only be initialized in a directory',
103
+ code: 'NOT_A_DIRECTORY',
104
+ },
105
+ 400,
106
+ );
107
+ }
108
+
109
+ const existing = await validateAndGetGitRoot(requestedPath);
110
+ if (!('error' in existing)) {
111
+ return c.json({
112
+ status: 'ok',
113
+ data: { initialized: false, path: existing.gitRoot },
114
+ });
115
+ }
93
116
 
94
117
  await execFileAsync('git', ['init'], { cwd: requestedPath });
95
118
 
@@ -0,0 +1,178 @@
1
+ import type { Hono } from 'hono';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { gitRebaseSchema } from './schemas.ts';
5
+ import { getGitOperationState, validateAndGetGitRoot } from './utils.ts';
6
+ import { openApiRoute } from '../../openapi/route.ts';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ const REBASE_ACTION_ARGS = {
11
+ continue: '--continue',
12
+ abort: '--abort',
13
+ skip: '--skip',
14
+ } as const;
15
+
16
+ export function registerRebaseRoute(app: Hono) {
17
+ openApiRoute(
18
+ app,
19
+ {
20
+ method: 'post',
21
+ path: '/v1/git/rebase',
22
+ tags: ['git'],
23
+ operationId: 'performGitRebaseAction',
24
+ summary: 'Perform a git rebase action',
25
+ description:
26
+ 'Runs git rebase --continue, --abort, or --skip for an in-progress rebase.',
27
+ requestBody: {
28
+ required: true,
29
+ content: {
30
+ 'application/json': {
31
+ schema: {
32
+ type: 'object',
33
+ properties: {
34
+ project: { type: 'string' },
35
+ action: {
36
+ type: 'string',
37
+ enum: ['continue', 'abort', 'skip'],
38
+ },
39
+ },
40
+ required: ['action'],
41
+ },
42
+ },
43
+ },
44
+ },
45
+ responses: {
46
+ '200': {
47
+ description: 'OK',
48
+ content: {
49
+ 'application/json': {
50
+ schema: {
51
+ type: 'object',
52
+ properties: {
53
+ status: { type: 'string', enum: ['ok'] },
54
+ data: {
55
+ type: 'object',
56
+ properties: {
57
+ action: {
58
+ type: 'string',
59
+ enum: ['continue', 'abort', 'skip'],
60
+ },
61
+ output: { type: 'string' },
62
+ },
63
+ required: ['action', 'output'],
64
+ },
65
+ },
66
+ required: ['status', 'data'],
67
+ },
68
+ },
69
+ },
70
+ },
71
+ '400': {
72
+ description: 'Error',
73
+ content: {
74
+ 'application/json': {
75
+ schema: {
76
+ type: 'object',
77
+ properties: {
78
+ status: { type: 'string', enum: ['error'] },
79
+ error: { type: 'string' },
80
+ code: { type: 'string' },
81
+ },
82
+ required: ['status', 'error'],
83
+ },
84
+ },
85
+ },
86
+ },
87
+ '409': {
88
+ description: 'No rebase is currently in progress',
89
+ content: {
90
+ 'application/json': {
91
+ schema: {
92
+ type: 'object',
93
+ properties: {
94
+ status: { type: 'string', enum: ['error'] },
95
+ error: { type: 'string' },
96
+ code: { type: 'string' },
97
+ },
98
+ required: ['status', 'error'],
99
+ },
100
+ },
101
+ },
102
+ },
103
+ '500': {
104
+ description: 'Error',
105
+ content: {
106
+ 'application/json': {
107
+ schema: {
108
+ type: 'object',
109
+ properties: {
110
+ status: { type: 'string', enum: ['error'] },
111
+ error: { type: 'string' },
112
+ code: { type: 'string' },
113
+ },
114
+ required: ['status', 'error'],
115
+ },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ },
121
+ async (c) => {
122
+ try {
123
+ const body = await c.req.json().catch(() => ({}));
124
+ const { project, action } = gitRebaseSchema.parse(body);
125
+ const requestedPath = project || process.cwd();
126
+
127
+ const validation = await validateAndGetGitRoot(requestedPath);
128
+ if ('error' in validation) {
129
+ return c.json(
130
+ { status: 'error', error: validation.error, code: validation.code },
131
+ 400,
132
+ );
133
+ }
134
+
135
+ const operation = await getGitOperationState(validation.gitRoot);
136
+ if (!operation?.type.startsWith('rebase')) {
137
+ return c.json(
138
+ {
139
+ status: 'error',
140
+ error: 'No rebase is currently in progress',
141
+ code: 'NO_REBASE_IN_PROGRESS',
142
+ },
143
+ 409,
144
+ );
145
+ }
146
+
147
+ const { stdout, stderr } = await execFileAsync(
148
+ 'git',
149
+ ['rebase', REBASE_ACTION_ARGS[action]],
150
+ {
151
+ cwd: validation.gitRoot,
152
+ env: {
153
+ ...process.env,
154
+ GIT_EDITOR: 'true',
155
+ GIT_SEQUENCE_EDITOR: 'true',
156
+ },
157
+ },
158
+ );
159
+
160
+ return c.json({
161
+ status: 'ok',
162
+ data: { action, output: `${stdout}${stderr}`.trim() },
163
+ });
164
+ } catch (error) {
165
+ return c.json(
166
+ {
167
+ status: 'error',
168
+ error:
169
+ error instanceof Error
170
+ ? error.message
171
+ : 'Failed to perform rebase action',
172
+ },
173
+ 500,
174
+ );
175
+ }
176
+ },
177
+ );
178
+ }
@@ -51,6 +51,11 @@ export const gitPullSchema = z.object({
51
51
  project: z.string().optional(),
52
52
  });
53
53
 
54
+ export const gitRebaseSchema = z.object({
55
+ project: z.string().optional(),
56
+ action: z.enum(['continue', 'abort', 'skip']),
57
+ });
58
+
54
59
  export const gitRemoteAddSchema = z.object({
55
60
  project: z.string().optional(),
56
61
  name: z.string().min(1),
@@ -24,7 +24,10 @@ const actionConfig: Record<
24
24
  > = {
25
25
  stage: {
26
26
  schema: gitStageSchema,
27
- command: (files) => ['add', ...files],
27
+ command: (files) =>
28
+ files.length === 1 && files[0] === '.'
29
+ ? ['add', '-A']
30
+ : ['add', '--', ...files],
28
31
  dataKey: 'staged',
29
32
  fallbackError: 'Failed to stage files',
30
33
  },
@@ -6,7 +6,8 @@ import {
6
6
  validateAndGetGitRoot,
7
7
  parseGitStatus,
8
8
  getAheadBehind,
9
- getCurrentBranch,
9
+ getHeadInfo,
10
+ getGitOperationState,
10
11
  } from './utils.ts';
11
12
  import { openApiRoute } from '../../openapi/route.ts';
12
13
 
@@ -133,9 +134,11 @@ export function registerStatusRoute(app: Hono) {
133
134
  gitRoot,
134
135
  );
135
136
 
136
- const { ahead, behind } = await getAheadBehind(gitRoot);
137
-
138
- const branch = await getCurrentBranch(gitRoot);
137
+ const [{ ahead, behind }, headInfo, operation] = await Promise.all([
138
+ getAheadBehind(gitRoot),
139
+ getHeadInfo(gitRoot),
140
+ getGitOperationState(gitRoot),
141
+ ]);
139
142
 
140
143
  let hasUpstream = false;
141
144
  try {
@@ -168,7 +171,11 @@ export function registerStatusRoute(app: Hono) {
168
171
  return c.json({
169
172
  status: 'ok',
170
173
  data: {
171
- branch,
174
+ branch: headInfo.branch,
175
+ headSha: headInfo.headSha,
176
+ shortHeadSha: headInfo.shortHeadSha,
177
+ isDetached: headInfo.isDetached,
178
+ operation,
172
179
  ahead,
173
180
  behind,
174
181
  hasUpstream,
@@ -21,6 +21,23 @@ export interface GitFile {
21
21
  | 'both-deleted';
22
22
  }
23
23
 
24
+ export type GitOperationType =
25
+ | 'rebase'
26
+ | 'rebase-interactive'
27
+ | 'merge'
28
+ | 'cherry-pick'
29
+ | 'revert'
30
+ | 'bisect';
31
+
32
+ export interface GitOperationState {
33
+ type: GitOperationType;
34
+ label: string;
35
+ current?: number;
36
+ total?: number;
37
+ headName?: string;
38
+ onto?: string;
39
+ }
40
+
24
41
  export interface GitRoot {
25
42
  gitRoot: string;
26
43
  }
@@ -1,7 +1,8 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { extname, join } from 'node:path';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { extname, isAbsolute, join } from 'node:path';
3
4
  import { promisify } from 'node:util';
4
- import type { GitFile, GitRoot, GitError } from './types.ts';
5
+ import type { GitFile, GitRoot, GitError, GitOperationState } from './types.ts';
5
6
 
6
7
  const execFileAsync = promisify(execFile);
7
8
 
@@ -163,7 +164,9 @@ export function parseGitStatus(
163
164
  const xy = parts[1];
164
165
  const x = xy[0];
165
166
  const y = xy[1];
166
- const path = parts.slice(8).join(' ');
167
+ const pathStartIndex = line.startsWith('2 ') ? 9 : 8;
168
+ const rawPath = parts.slice(pathStartIndex).join(' ');
169
+ const [path, oldPath] = rawPath.split('\t');
167
170
  const absPath = join(gitRoot, path);
168
171
 
169
172
  if (x !== '.') {
@@ -173,6 +176,7 @@ export function parseGitStatus(
173
176
  status: getStatusFromCodeV2(x),
174
177
  staged: true,
175
178
  isNew: x === 'A',
179
+ oldPath,
176
180
  });
177
181
  }
178
182
 
@@ -183,6 +187,7 @@ export function parseGitStatus(
183
187
  status: getStatusFromCodeV2(y),
184
188
  staged: false,
185
189
  isNew: false,
190
+ oldPath,
186
191
  });
187
192
  }
188
193
  } else if (line.startsWith('? ')) {
@@ -247,3 +252,98 @@ export async function getCurrentBranch(gitRoot: string): Promise<string> {
247
252
  return 'unknown';
248
253
  }
249
254
  }
255
+
256
+ export async function getHeadInfo(gitRoot: string): Promise<{
257
+ branch: string;
258
+ headSha: string;
259
+ shortHeadSha: string;
260
+ isDetached: boolean;
261
+ }> {
262
+ const [branchResult, headResult, shortHeadResult] = await Promise.allSettled([
263
+ execFileAsync('git', ['branch', '--show-current'], { cwd: gitRoot }),
264
+ execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: gitRoot }),
265
+ execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: gitRoot }),
266
+ ]);
267
+
268
+ const branch =
269
+ branchResult.status === 'fulfilled' ? branchResult.value.stdout.trim() : '';
270
+ const headSha =
271
+ headResult.status === 'fulfilled' ? headResult.value.stdout.trim() : '';
272
+ const shortHeadSha =
273
+ shortHeadResult.status === 'fulfilled'
274
+ ? shortHeadResult.value.stdout.trim()
275
+ : headSha.slice(0, 7);
276
+
277
+ return {
278
+ branch: branch || 'HEAD',
279
+ headSha,
280
+ shortHeadSha,
281
+ isDetached: !branch,
282
+ };
283
+ }
284
+
285
+ async function getGitDir(gitRoot: string): Promise<string | null> {
286
+ try {
287
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--git-dir'], {
288
+ cwd: gitRoot,
289
+ });
290
+ const gitDir = stdout.trim();
291
+ return isAbsolute(gitDir) ? gitDir : join(gitRoot, gitDir);
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ function readGitStateFile(dir: string, file: string): string | undefined {
298
+ const path = join(dir, file);
299
+ if (!existsSync(path)) return undefined;
300
+ return readFileSync(path, 'utf8').trim() || undefined;
301
+ }
302
+
303
+ function readRebaseState(
304
+ gitDir: string,
305
+ dirName: 'rebase-merge' | 'rebase-apply',
306
+ ): GitOperationState | null {
307
+ const rebaseDir = join(gitDir, dirName);
308
+ if (!existsSync(rebaseDir)) return null;
309
+
310
+ const current = Number(readGitStateFile(rebaseDir, 'msgnum')) || undefined;
311
+ const total = Number(readGitStateFile(rebaseDir, 'end')) || undefined;
312
+ const isInteractive = existsSync(join(rebaseDir, 'interactive'));
313
+
314
+ return {
315
+ type: isInteractive ? 'rebase-interactive' : 'rebase',
316
+ label: isInteractive ? 'Interactive rebase' : 'Rebase',
317
+ current,
318
+ total,
319
+ headName: readGitStateFile(rebaseDir, 'head-name'),
320
+ onto: readGitStateFile(rebaseDir, 'onto'),
321
+ };
322
+ }
323
+
324
+ export async function getGitOperationState(
325
+ gitRoot: string,
326
+ ): Promise<GitOperationState | null> {
327
+ const gitDir = await getGitDir(gitRoot);
328
+ if (!gitDir) return null;
329
+
330
+ const rebaseState =
331
+ readRebaseState(gitDir, 'rebase-merge') ??
332
+ readRebaseState(gitDir, 'rebase-apply');
333
+ if (rebaseState) return rebaseState;
334
+
335
+ if (existsSync(join(gitDir, 'MERGE_HEAD'))) {
336
+ return { type: 'merge', label: 'Merge' };
337
+ }
338
+ if (existsSync(join(gitDir, 'CHERRY_PICK_HEAD'))) {
339
+ return { type: 'cherry-pick', label: 'Cherry-pick' };
340
+ }
341
+ if (existsSync(join(gitDir, 'REVERT_HEAD'))) {
342
+ return { type: 'revert', label: 'Revert' };
343
+ }
344
+ if (existsSync(join(gitDir, 'BISECT_LOG'))) {
345
+ return { type: 'bisect', label: 'Bisect' };
346
+ }
347
+
348
+ return null;
349
+ }