gitmaps 1.0.0
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/README.md +167 -0
- package/app/api/auth/favorites/route.ts +56 -0
- package/app/api/auth/github/callback/route.ts +103 -0
- package/app/api/auth/github/route.ts +32 -0
- package/app/api/auth/me/route.ts +52 -0
- package/app/api/auth/positions/route.ts +50 -0
- package/app/api/chat/route.ts +101 -0
- package/app/api/connections/route.ts +72 -0
- package/app/api/github/repos/route.ts +111 -0
- package/app/api/positions/route.ts +80 -0
- package/app/api/repo/branch-diff/route.ts +201 -0
- package/app/api/repo/branches/route.ts +53 -0
- package/app/api/repo/browse/route.ts +55 -0
- package/app/api/repo/clone/route.ts +78 -0
- package/app/api/repo/clone-stream/route.ts +131 -0
- package/app/api/repo/file-content/route.ts +28 -0
- package/app/api/repo/file-delete/route.ts +62 -0
- package/app/api/repo/file-history/route.ts +45 -0
- package/app/api/repo/file-rename/route.ts +83 -0
- package/app/api/repo/file-save/route.ts +45 -0
- package/app/api/repo/files/route.ts +169 -0
- package/app/api/repo/git-blame/route.ts +86 -0
- package/app/api/repo/git-commit/route.ts +40 -0
- package/app/api/repo/git-heatmap/route.ts +55 -0
- package/app/api/repo/imports/route.ts +154 -0
- package/app/api/repo/load/route.ts +56 -0
- package/app/api/repo/mode/route.ts +14 -0
- package/app/api/repo/search/route.ts +127 -0
- package/app/api/repo/tree/route.ts +104 -0
- package/app/api/repo/upload/route.ts +53 -0
- package/app/api/repo/validate-path.ts +53 -0
- package/app/canvas_users.db +0 -0
- package/app/canvas_users.db-shm +0 -0
- package/app/canvas_users.db-wal +0 -0
- package/app/globals.css +7899 -0
- package/app/layout.tsx +493 -0
- package/app/lib/auth.ts +193 -0
- package/app/lib/auto-save.ts +137 -0
- package/app/lib/branch-compare.ts +443 -0
- package/app/lib/breadcrumbs.ts +170 -0
- package/app/lib/canvas-export.ts +358 -0
- package/app/lib/canvas-text.ts +912 -0
- package/app/lib/canvas.ts +564 -0
- package/app/lib/card-arrangement.ts +188 -0
- package/app/lib/card-context-menu.tsx +453 -0
- package/app/lib/card-diff-markers.ts +270 -0
- package/app/lib/card-expand.ts +189 -0
- package/app/lib/card-groups.ts +246 -0
- package/app/lib/cards.tsx +914 -0
- package/app/lib/chat.tsx +308 -0
- package/app/lib/code-editor.ts +508 -0
- package/app/lib/command-palette.ts +262 -0
- package/app/lib/connections.tsx +1037 -0
- package/app/lib/context.ts +94 -0
- package/app/lib/cursor-sharing.ts +281 -0
- package/app/lib/dependency-graph.ts +438 -0
- package/app/lib/events.tsx +1747 -0
- package/app/lib/file-card-plugin.ts +134 -0
- package/app/lib/file-modal.tsx +849 -0
- package/app/lib/file-preview.ts +400 -0
- package/app/lib/file-tabs.ts +318 -0
- package/app/lib/galaxydraw-bridge.ts +477 -0
- package/app/lib/galaxydraw.test.ts +229 -0
- package/app/lib/global-search.ts +264 -0
- package/app/lib/goto-definition.ts +224 -0
- package/app/lib/heatmap.ts +178 -0
- package/app/lib/hidden-files.tsx +222 -0
- package/app/lib/layers.ts +0 -0
- package/app/lib/layers.tsx +365 -0
- package/app/lib/loading.tsx +45 -0
- package/app/lib/multi-repo.ts +286 -0
- package/app/lib/new-file-dialog.tsx +230 -0
- package/app/lib/onboarding.tsx +213 -0
- package/app/lib/perf-overlay.ts +360 -0
- package/app/lib/positions.ts +176 -0
- package/app/lib/pr-review.ts +374 -0
- package/app/lib/production-mode.ts +47 -0
- package/app/lib/repo.tsx +977 -0
- package/app/lib/settings-modal.tsx +374 -0
- package/app/lib/settings.ts +97 -0
- package/app/lib/shortcuts-panel.ts +141 -0
- package/app/lib/status-bar.ts +128 -0
- package/app/lib/symbol-outline.ts +212 -0
- package/app/lib/syntax.ts +177 -0
- package/app/lib/tab-diff.ts +238 -0
- package/app/lib/user.tsx +133 -0
- package/app/lib/utils.ts +78 -0
- package/app/lib/viewport-culling.ts +728 -0
- package/app/page.client.tsx +215 -0
- package/app/page.tsx +291 -0
- package/app/state/machine.js +196 -0
- package/app/styles/main.css +2168 -0
- package/banner.png +0 -0
- package/cli.ts +44 -0
- package/package.json +75 -0
- package/packages/galaxydraw/README.md +296 -0
- package/packages/galaxydraw/banner.png +0 -0
- package/packages/galaxydraw/demo/build-static.ts +100 -0
- package/packages/galaxydraw/demo/client.ts +154 -0
- package/packages/galaxydraw/demo/dist/client.js +8 -0
- package/packages/galaxydraw/demo/index.html +256 -0
- package/packages/galaxydraw/demo/server.ts +96 -0
- package/packages/galaxydraw/dist/index.js +984 -0
- package/packages/galaxydraw/dist/index.js.map +16 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
- package/packages/galaxydraw/package.json +49 -0
- package/packages/galaxydraw/perf.test.ts +284 -0
- package/packages/galaxydraw/src/core/cards.ts +435 -0
- package/packages/galaxydraw/src/core/engine.ts +339 -0
- package/packages/galaxydraw/src/core/events.ts +81 -0
- package/packages/galaxydraw/src/core/layout.ts +136 -0
- package/packages/galaxydraw/src/core/minimap.ts +216 -0
- package/packages/galaxydraw/src/core/state.ts +177 -0
- package/packages/galaxydraw/src/core/viewport.ts +106 -0
- package/packages/galaxydraw/src/galaxydraw.css +166 -0
- package/packages/galaxydraw/src/index.ts +40 -0
- package/packages/galaxydraw/tsconfig.json +30 -0
- package/server.ts +62 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
|
|
6
|
+
const CLONES_DIR = path.join(process.cwd(), 'git-canvas', 'repos');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /api/repo/clone-stream
|
|
10
|
+
* Body: { url: string }
|
|
11
|
+
* Streams clone progress via SSE, then emits a final "done" or "error" event.
|
|
12
|
+
*
|
|
13
|
+
* SSE events:
|
|
14
|
+
* event: progress\n data: {"message":"Receiving objects: 45%","percent":45}\n\n
|
|
15
|
+
* event: done\n data: {"ok":true,"path":"...","cached":false}\n\n
|
|
16
|
+
* event: error\n data: {"error":"Clone failed"}\n\n
|
|
17
|
+
*/
|
|
18
|
+
export async function POST(req: Request) {
|
|
19
|
+
const { url } = await req.json() as { url: string };
|
|
20
|
+
|
|
21
|
+
if (!url || typeof url !== 'string') {
|
|
22
|
+
return Response.json({ error: 'url is required' }, { status: 400 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isGitUrl = url.startsWith('git@') || url.startsWith('https://') || url.startsWith('http://') || url.endsWith('.git');
|
|
26
|
+
if (!isGitUrl) {
|
|
27
|
+
return Response.json({ error: 'Invalid git URL. Use https:// or git@ format.' }, { status: 400 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const repoName = url
|
|
31
|
+
.replace(/\.git$/, '')
|
|
32
|
+
.split('/')
|
|
33
|
+
.pop()!
|
|
34
|
+
.split(':')
|
|
35
|
+
.pop()!
|
|
36
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
37
|
+
|
|
38
|
+
if (!repoName) {
|
|
39
|
+
return Response.json({ error: 'Could not determine repository name from URL' }, { status: 400 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fs.mkdirSync(CLONES_DIR, { recursive: true });
|
|
43
|
+
const targetPath = path.join(CLONES_DIR, repoName);
|
|
44
|
+
|
|
45
|
+
// If already cloned, do a quick pull and return immediately
|
|
46
|
+
if (fs.existsSync(path.join(targetPath, '.git'))) {
|
|
47
|
+
return measure('api:repo:clone-stream:cached', async () => {
|
|
48
|
+
try {
|
|
49
|
+
const pull = spawn('git', ['pull'], { cwd: targetPath, stdio: 'pipe' });
|
|
50
|
+
await new Promise<void>((resolve) => pull.on('close', resolve));
|
|
51
|
+
console.log(`[clone-stream] Updated existing repo: ${repoName}`);
|
|
52
|
+
} catch {
|
|
53
|
+
console.log(`[clone-stream] Using existing repo (pull skipped): ${repoName}`);
|
|
54
|
+
}
|
|
55
|
+
return Response.json({ ok: true, path: targetPath, cached: true });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Stream clone progress via SSE ──
|
|
60
|
+
console.log(`[clone-stream] Cloning ${url} → ${targetPath}`);
|
|
61
|
+
|
|
62
|
+
const encoder = new TextEncoder();
|
|
63
|
+
const stream = new ReadableStream({
|
|
64
|
+
start(controller) {
|
|
65
|
+
function sendSSE(event: string, data: any) {
|
|
66
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
67
|
+
try { controller.enqueue(encoder.encode(payload)); } catch { /* stream closed */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
sendSSE('progress', { message: `Starting clone of ${repoName}...`, percent: 0 });
|
|
71
|
+
|
|
72
|
+
const gitProc = spawn('git', ['clone', '--depth', '100', '--progress', url, targetPath], {
|
|
73
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Git writes progress to stderr
|
|
77
|
+
let lastPercent = 0;
|
|
78
|
+
|
|
79
|
+
function parseProgress(chunk: Buffer) {
|
|
80
|
+
const lines = chunk.toString().split(/[\r\n]+/);
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const trimmed = line.trim();
|
|
83
|
+
if (!trimmed) continue;
|
|
84
|
+
|
|
85
|
+
// Parse percentage from git output like "Receiving objects: 45% (100/222)"
|
|
86
|
+
const pctMatch = trimmed.match(/(\d+)%/);
|
|
87
|
+
let percent = lastPercent;
|
|
88
|
+
if (pctMatch) {
|
|
89
|
+
percent = parseInt(pctMatch[1], 10);
|
|
90
|
+
// Git has multiple phases — scale the overall progress
|
|
91
|
+
if (trimmed.startsWith('Counting')) percent = Math.round(percent * 0.1);
|
|
92
|
+
else if (trimmed.startsWith('Compressing')) percent = 10 + Math.round(percent * 0.1);
|
|
93
|
+
else if (trimmed.startsWith('Receiving')) percent = 20 + Math.round(percent * 0.6);
|
|
94
|
+
else if (trimmed.startsWith('Resolving')) percent = 80 + Math.round(percent * 0.2);
|
|
95
|
+
lastPercent = percent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
sendSSE('progress', { message: trimmed, percent: Math.min(percent, 99) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
gitProc.stderr.on('data', parseProgress);
|
|
103
|
+
gitProc.stdout.on('data', parseProgress);
|
|
104
|
+
|
|
105
|
+
gitProc.on('close', (code) => {
|
|
106
|
+
if (code === 0) {
|
|
107
|
+
console.log(`[clone-stream] ✅ Cloned ${repoName}`);
|
|
108
|
+
sendSSE('done', { ok: true, path: targetPath, cached: false });
|
|
109
|
+
} else {
|
|
110
|
+
console.error(`[clone-stream] ✗ git clone exited with code ${code}`);
|
|
111
|
+
sendSSE('error', { error: `git clone failed (exit code ${code})` });
|
|
112
|
+
}
|
|
113
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
gitProc.on('error', (err) => {
|
|
117
|
+
console.error('[clone-stream] spawn error:', err);
|
|
118
|
+
sendSSE('error', { error: err.message || 'Failed to start git' });
|
|
119
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return new Response(stream, {
|
|
125
|
+
headers: {
|
|
126
|
+
'Content-Type': 'text/event-stream',
|
|
127
|
+
'Cache-Control': 'no-cache',
|
|
128
|
+
'Connection': 'keep-alive',
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import simpleGit from 'simple-git';
|
|
3
|
+
import { validateRepoPath } from '../validate-path';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: Request) {
|
|
6
|
+
return measure('api:repo:file-content', async () => {
|
|
7
|
+
try {
|
|
8
|
+
const { path: repoPath, commit, filePath } = await req.json();
|
|
9
|
+
|
|
10
|
+
if (!repoPath || !commit || !filePath) {
|
|
11
|
+
return new Response('Repository path, commit, and file path are required', { status: 400 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const blocked = validateRepoPath(repoPath);
|
|
15
|
+
if (blocked) return blocked;
|
|
16
|
+
|
|
17
|
+
const git = simpleGit(repoPath);
|
|
18
|
+
|
|
19
|
+
// Get file content at this commit
|
|
20
|
+
const content = await git.show([`${commit}:${filePath}`]);
|
|
21
|
+
|
|
22
|
+
return Response.json({ content });
|
|
23
|
+
} catch (error: any) {
|
|
24
|
+
console.error('api:repo:file-content:error', error);
|
|
25
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import { validateRepoPath } from '../validate-path';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import simpleGit from 'simple-git';
|
|
6
|
+
|
|
7
|
+
export async function POST(req: Request) {
|
|
8
|
+
return measure('api:repo:file-delete', async () => {
|
|
9
|
+
try {
|
|
10
|
+
const { path: repoPath, filePath, gitRm } = await req.json();
|
|
11
|
+
|
|
12
|
+
if (!repoPath || !filePath) {
|
|
13
|
+
return new Response('Repository path and file path are required', { status: 400 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const blocked = validateRepoPath(repoPath);
|
|
17
|
+
if (blocked) return blocked;
|
|
18
|
+
|
|
19
|
+
// Resolve absolute path and ensure it's within the repo
|
|
20
|
+
const absPath = path.resolve(repoPath, filePath);
|
|
21
|
+
const absRepo = path.resolve(repoPath);
|
|
22
|
+
if (!absPath.startsWith(absRepo)) {
|
|
23
|
+
return new Response('File path must be within the repository', { status: 403 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check file exists
|
|
27
|
+
if (!fs.existsSync(absPath)) {
|
|
28
|
+
return new Response('File not found', { status: 404 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (gitRm) {
|
|
32
|
+
// Use git rm to stage the deletion
|
|
33
|
+
const git = simpleGit(repoPath);
|
|
34
|
+
await git.rm(filePath);
|
|
35
|
+
} else {
|
|
36
|
+
// Just delete the file
|
|
37
|
+
fs.unlinkSync(absPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Clean up empty parent directories
|
|
41
|
+
let dir = path.dirname(absPath);
|
|
42
|
+
while (dir !== absRepo && dir.startsWith(absRepo)) {
|
|
43
|
+
const entries = fs.readdirSync(dir);
|
|
44
|
+
if (entries.length === 0) {
|
|
45
|
+
fs.rmdirSync(dir);
|
|
46
|
+
dir = path.dirname(dir);
|
|
47
|
+
} else {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return Response.json({
|
|
53
|
+
success: true,
|
|
54
|
+
path: filePath,
|
|
55
|
+
gitRm: !!gitRm,
|
|
56
|
+
});
|
|
57
|
+
} catch (error: any) {
|
|
58
|
+
console.error('api:repo:file-delete:error', error);
|
|
59
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import simpleGit from 'simple-git';
|
|
3
|
+
import { validateRepoPath } from '../validate-path';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: Request) {
|
|
6
|
+
return measure('api:repo:file-history', async () => {
|
|
7
|
+
try {
|
|
8
|
+
const { path: repoPath, filePath, limit = 20 } = await req.json();
|
|
9
|
+
|
|
10
|
+
if (!repoPath || !filePath) {
|
|
11
|
+
return new Response('Repository path and file path are required', { status: 400 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const blocked = validateRepoPath(repoPath);
|
|
15
|
+
if (blocked) return blocked;
|
|
16
|
+
|
|
17
|
+
const git = simpleGit(repoPath);
|
|
18
|
+
|
|
19
|
+
// Get commit history for this specific file
|
|
20
|
+
const log = await git.log({
|
|
21
|
+
file: filePath,
|
|
22
|
+
maxCount: limit,
|
|
23
|
+
format: {
|
|
24
|
+
hash: '%H',
|
|
25
|
+
date: '%ai',
|
|
26
|
+
message: '%s',
|
|
27
|
+
author_name: '%an',
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const commits = log.all.map(c => ({
|
|
32
|
+
hash: c.hash,
|
|
33
|
+
shortHash: c.hash.substring(0, 7),
|
|
34
|
+
message: c.message,
|
|
35
|
+
author: c.author_name,
|
|
36
|
+
date: c.date,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
return Response.json({ commits, total: commits.length });
|
|
40
|
+
} catch (error: any) {
|
|
41
|
+
console.error('api:repo:file-history:error', error);
|
|
42
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import { validateRepoPath } from '../validate-path';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import simpleGit from 'simple-git';
|
|
6
|
+
|
|
7
|
+
export async function POST(req: Request) {
|
|
8
|
+
return measure('api:repo:file-rename', async () => {
|
|
9
|
+
try {
|
|
10
|
+
const { path: repoPath, oldPath, newPath } = await req.json();
|
|
11
|
+
|
|
12
|
+
if (!repoPath || !oldPath || !newPath) {
|
|
13
|
+
return new Response('Repository path, old path, and new path are required', { status: 400 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const blocked = validateRepoPath(repoPath);
|
|
17
|
+
if (blocked) return blocked;
|
|
18
|
+
|
|
19
|
+
// Normalize paths
|
|
20
|
+
const normalizedOld = oldPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
21
|
+
const normalizedNew = newPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
22
|
+
|
|
23
|
+
// Validate paths are within repo
|
|
24
|
+
const absOld = path.resolve(repoPath, normalizedOld);
|
|
25
|
+
const absNew = path.resolve(repoPath, normalizedNew);
|
|
26
|
+
const absRepo = path.resolve(repoPath);
|
|
27
|
+
|
|
28
|
+
if (!absOld.startsWith(absRepo) || !absNew.startsWith(absRepo)) {
|
|
29
|
+
return new Response('Paths must be within the repository', { status: 403 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (normalizedNew.includes('..')) {
|
|
33
|
+
return new Response('Invalid path — cannot use ..', { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check source exists
|
|
37
|
+
if (!fs.existsSync(absOld)) {
|
|
38
|
+
return new Response('Source file not found', { status: 404 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check destination doesn't already exist
|
|
42
|
+
if (fs.existsSync(absNew)) {
|
|
43
|
+
return new Response('Destination already exists', { status: 409 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Ensure destination directory exists
|
|
47
|
+
const destDir = path.dirname(absNew);
|
|
48
|
+
if (!fs.existsSync(destDir)) {
|
|
49
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Try git mv first, fall back to fs rename
|
|
53
|
+
try {
|
|
54
|
+
const git = simpleGit(repoPath);
|
|
55
|
+
await git.mv(normalizedOld, normalizedNew);
|
|
56
|
+
} catch {
|
|
57
|
+
// git mv might fail if file is untracked — just use fs
|
|
58
|
+
fs.renameSync(absOld, absNew);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Clean up empty parent directories
|
|
62
|
+
let dir = path.dirname(absOld);
|
|
63
|
+
while (dir !== absRepo && dir.startsWith(absRepo)) {
|
|
64
|
+
const entries = fs.readdirSync(dir);
|
|
65
|
+
if (entries.length === 0) {
|
|
66
|
+
fs.rmdirSync(dir);
|
|
67
|
+
dir = path.dirname(dir);
|
|
68
|
+
} else {
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Response.json({
|
|
74
|
+
success: true,
|
|
75
|
+
oldPath: normalizedOld,
|
|
76
|
+
newPath: normalizedNew,
|
|
77
|
+
});
|
|
78
|
+
} catch (error: any) {
|
|
79
|
+
console.error('api:repo:file-rename:error', error);
|
|
80
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import { validateRepoPath } from '../validate-path';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
|
|
6
|
+
export async function POST(req: Request) {
|
|
7
|
+
return measure('api:repo:file-save', async () => {
|
|
8
|
+
try {
|
|
9
|
+
const { path: repoPath, filePath, content } = await req.json();
|
|
10
|
+
|
|
11
|
+
if (!repoPath || !filePath || content === undefined) {
|
|
12
|
+
return new Response('Repository path, file path, and content are required', { status: 400 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const blocked = validateRepoPath(repoPath);
|
|
16
|
+
if (blocked) return blocked;
|
|
17
|
+
|
|
18
|
+
// Resolve absolute path and ensure it's within the repo
|
|
19
|
+
const absPath = path.resolve(repoPath, filePath);
|
|
20
|
+
const absRepo = path.resolve(repoPath);
|
|
21
|
+
if (!absPath.startsWith(absRepo)) {
|
|
22
|
+
return new Response('File path must be within the repository', { status: 403 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Ensure directory exists
|
|
26
|
+
const dir = path.dirname(absPath);
|
|
27
|
+
if (!fs.existsSync(dir)) {
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Write file
|
|
32
|
+
fs.writeFileSync(absPath, content, 'utf-8');
|
|
33
|
+
|
|
34
|
+
return Response.json({
|
|
35
|
+
success: true,
|
|
36
|
+
path: filePath,
|
|
37
|
+
bytes: Buffer.byteLength(content, 'utf-8'),
|
|
38
|
+
lines: content.split('\n').length,
|
|
39
|
+
});
|
|
40
|
+
} catch (error: any) {
|
|
41
|
+
console.error('api:repo:file-save:error', error);
|
|
42
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import simpleGit from 'simple-git';
|
|
3
|
+
import { validateRepoPath } from '../validate-path';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: Request) {
|
|
6
|
+
return measure('api:repo:files', async () => {
|
|
7
|
+
try {
|
|
8
|
+
const { path: repoPath, commit } = await req.json();
|
|
9
|
+
|
|
10
|
+
if (!repoPath || !commit) {
|
|
11
|
+
return new Response('Repository path and commit are required', { status: 400 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const blocked = validateRepoPath(repoPath);
|
|
15
|
+
if (blocked) return blocked;
|
|
16
|
+
|
|
17
|
+
const git = simpleGit(repoPath);
|
|
18
|
+
|
|
19
|
+
// Get files CHANGED in this specific commit
|
|
20
|
+
// -M detects renames, -C detects copies
|
|
21
|
+
// Use --root for initial commits that have no parent
|
|
22
|
+
let diffResult = '';
|
|
23
|
+
try {
|
|
24
|
+
diffResult = await git.raw(['diff-tree', '--no-commit-id', '--name-status', '-M30%', '-r', commit]);
|
|
25
|
+
} catch (e) { /* ignore */ }
|
|
26
|
+
// If empty (root commit), try with --root
|
|
27
|
+
if (!diffResult.trim()) {
|
|
28
|
+
try {
|
|
29
|
+
diffResult = await git.raw(['diff-tree', '--root', '--no-commit-id', '--name-status', '-M30%', '-r', commit]);
|
|
30
|
+
} catch (e) { /* ignore */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const changedFiles = [];
|
|
34
|
+
const lines = diffResult.trim().split('\n').filter(Boolean);
|
|
35
|
+
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
const parts = line.split('\t');
|
|
38
|
+
const statusCode = parts[0];
|
|
39
|
+
if (!statusCode || parts.length < 2) continue;
|
|
40
|
+
|
|
41
|
+
// Rename/copy: status is R### or C### (e.g. R100, R087, C100)
|
|
42
|
+
// Format: R100\toldPath\tnewPath
|
|
43
|
+
const isRename = statusCode.startsWith('R');
|
|
44
|
+
const isCopy = statusCode.startsWith('C');
|
|
45
|
+
|
|
46
|
+
let filePath: string;
|
|
47
|
+
let oldPath: string | null = null;
|
|
48
|
+
let fileStatus: string;
|
|
49
|
+
let similarity: number | null = null;
|
|
50
|
+
|
|
51
|
+
if (isRename || isCopy) {
|
|
52
|
+
oldPath = parts[1];
|
|
53
|
+
filePath = parts[2];
|
|
54
|
+
fileStatus = isRename ? 'renamed' : 'copied';
|
|
55
|
+
similarity = parseInt(statusCode.substring(1)) || null;
|
|
56
|
+
} else {
|
|
57
|
+
filePath = parts[1];
|
|
58
|
+
fileStatus = statusCode === 'A' ? 'added'
|
|
59
|
+
: statusCode === 'D' ? 'deleted'
|
|
60
|
+
: statusCode === 'M' ? 'modified'
|
|
61
|
+
: statusCode;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const name = filePath.split('/').pop()!;
|
|
65
|
+
|
|
66
|
+
let content = null;
|
|
67
|
+
let hunks: DiffHunk[] = [];
|
|
68
|
+
let error = null;
|
|
69
|
+
|
|
70
|
+
if (fileStatus === 'added') {
|
|
71
|
+
// New file — get full content
|
|
72
|
+
try { content = await git.show([`${commit}:${filePath}`]); } catch (e: any) { error = e.message; }
|
|
73
|
+
|
|
74
|
+
} else if (fileStatus === 'deleted') {
|
|
75
|
+
// Deleted file — get previous content
|
|
76
|
+
try { content = await git.show([`${commit}~1:${filePath}`]); } catch (e: any) { error = e.message; }
|
|
77
|
+
|
|
78
|
+
} else if (fileStatus === 'modified') {
|
|
79
|
+
// Modified — parse unified diff into hunks + get full new content
|
|
80
|
+
try {
|
|
81
|
+
const rawDiff = await git.raw(['diff', '-U3', `${commit}~1`, commit, '--', filePath]);
|
|
82
|
+
hunks = parseHunks(rawDiff);
|
|
83
|
+
} catch (e: any) { error = e.message; }
|
|
84
|
+
try { content = await git.show([`${commit}:${filePath}`]); } catch (e: any) { /* ignore, hunks enough */ }
|
|
85
|
+
|
|
86
|
+
} else if (fileStatus === 'renamed' || fileStatus === 'copied') {
|
|
87
|
+
// Renamed/copied — diff between old path and new path across the commit
|
|
88
|
+
try {
|
|
89
|
+
const rawDiff = await git.raw([
|
|
90
|
+
'diff', '-U3', '-M',
|
|
91
|
+
`${commit}~1`, commit,
|
|
92
|
+
'--', oldPath!, filePath
|
|
93
|
+
]);
|
|
94
|
+
hunks = parseHunks(rawDiff);
|
|
95
|
+
} catch (e: any) { error = e.message; }
|
|
96
|
+
try { content = await git.show([`${commit}:${filePath}`]); } catch (e: any) { /* ignore */ }
|
|
97
|
+
|
|
98
|
+
// If 100% rename with no content changes, hunks will be empty
|
|
99
|
+
// Still include the file so it shows up as renamed
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
changedFiles.push({
|
|
103
|
+
path: filePath,
|
|
104
|
+
name,
|
|
105
|
+
type: 'file',
|
|
106
|
+
status: fileStatus,
|
|
107
|
+
content,
|
|
108
|
+
hunks,
|
|
109
|
+
contentError: error,
|
|
110
|
+
lines: content ? content.split('\n').length : 0,
|
|
111
|
+
// Rename-specific fields
|
|
112
|
+
...(oldPath ? { oldPath } : {}),
|
|
113
|
+
...(similarity != null ? { similarity } : {}),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return Response.json({ files: changedFiles, totalChanged: changedFiles.length });
|
|
118
|
+
} catch (error: any) {
|
|
119
|
+
console.error('api:repo:files:error', error);
|
|
120
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Parse unified diff into structured hunks
|
|
126
|
+
interface DiffLine { type: string; content: string }
|
|
127
|
+
interface DiffHunk { oldStart: number; oldCount: number; newStart: number; newCount: number; context: string; lines: DiffLine[] }
|
|
128
|
+
|
|
129
|
+
function parseHunks(rawDiff: string): DiffHunk[] {
|
|
130
|
+
const allLines = rawDiff.split('\n');
|
|
131
|
+
const hunks: DiffHunk[] = [];
|
|
132
|
+
let currentHunk: DiffHunk | null = null;
|
|
133
|
+
|
|
134
|
+
for (const line of allLines) {
|
|
135
|
+
// Parse hunk header: @@ -old,count +new,count @@ optional context
|
|
136
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/);
|
|
137
|
+
if (hunkMatch) {
|
|
138
|
+
if (currentHunk) hunks.push(currentHunk);
|
|
139
|
+
currentHunk = {
|
|
140
|
+
oldStart: parseInt(hunkMatch[1]),
|
|
141
|
+
oldCount: parseInt(hunkMatch[2] || '1'),
|
|
142
|
+
newStart: parseInt(hunkMatch[3]),
|
|
143
|
+
newCount: parseInt(hunkMatch[4] || '1'),
|
|
144
|
+
context: hunkMatch[5]?.trim() || '',
|
|
145
|
+
lines: []
|
|
146
|
+
};
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Skip diff metadata lines
|
|
151
|
+
if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')
|
|
152
|
+
|| line.startsWith('similarity ') || line.startsWith('rename ') || line.startsWith('copy ')) continue;
|
|
153
|
+
|
|
154
|
+
if (!currentHunk) continue;
|
|
155
|
+
|
|
156
|
+
if (line.startsWith('+')) {
|
|
157
|
+
currentHunk.lines.push({ type: 'add', content: line.substring(1) });
|
|
158
|
+
} else if (line.startsWith('-')) {
|
|
159
|
+
currentHunk.lines.push({ type: 'del', content: line.substring(1) });
|
|
160
|
+
} else if (line.startsWith('\\')) {
|
|
161
|
+
// "" — skip
|
|
162
|
+
} else {
|
|
163
|
+
currentHunk.lines.push({ type: 'ctx', content: line.startsWith(' ') ? line.substring(1) : line });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (currentHunk) hunks.push(currentHunk);
|
|
168
|
+
return hunks;
|
|
169
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import { validateRepoPath } from '../validate-path';
|
|
3
|
+
import simpleGit from 'simple-git';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: Request) {
|
|
6
|
+
return measure('api:repo:git-blame', async () => {
|
|
7
|
+
try {
|
|
8
|
+
const { path: repoPath, filePath, commit } = await req.json();
|
|
9
|
+
|
|
10
|
+
if (!repoPath || !filePath) {
|
|
11
|
+
return new Response('Repository path and file path are required', { status: 400 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const blocked = validateRepoPath(repoPath);
|
|
15
|
+
if (blocked) return blocked;
|
|
16
|
+
|
|
17
|
+
const git = simpleGit(repoPath);
|
|
18
|
+
|
|
19
|
+
// Run git blame with porcelain format for machine-readable output
|
|
20
|
+
const args = ['blame', '--porcelain'];
|
|
21
|
+
if (commit) args.push(commit);
|
|
22
|
+
args.push('--', filePath);
|
|
23
|
+
|
|
24
|
+
const output = await git.raw(args);
|
|
25
|
+
|
|
26
|
+
// Parse porcelain blame output
|
|
27
|
+
const lines = output.split('\n');
|
|
28
|
+
const blameEntries: Array<{
|
|
29
|
+
hash: string;
|
|
30
|
+
shortHash: string;
|
|
31
|
+
author: string;
|
|
32
|
+
authorTime: number;
|
|
33
|
+
summary: string;
|
|
34
|
+
line: number;
|
|
35
|
+
content: string;
|
|
36
|
+
}> = [];
|
|
37
|
+
|
|
38
|
+
let currentHash = '';
|
|
39
|
+
let currentAuthor = '';
|
|
40
|
+
let currentTime = 0;
|
|
41
|
+
let currentSummary = '';
|
|
42
|
+
let currentLine = 0;
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
|
|
47
|
+
// Hash line: <hash> <orig-line> <final-line> [<num-lines>]
|
|
48
|
+
const hashMatch = line.match(/^([0-9a-f]{40})\s+(\d+)\s+(\d+)/);
|
|
49
|
+
if (hashMatch) {
|
|
50
|
+
currentHash = hashMatch[1];
|
|
51
|
+
currentLine = parseInt(hashMatch[3]);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (line.startsWith('author ')) {
|
|
56
|
+
currentAuthor = line.slice(7);
|
|
57
|
+
} else if (line.startsWith('author-time ')) {
|
|
58
|
+
currentTime = parseInt(line.slice(12));
|
|
59
|
+
} else if (line.startsWith('summary ')) {
|
|
60
|
+
currentSummary = line.slice(8);
|
|
61
|
+
} else if (line.startsWith('\t')) {
|
|
62
|
+
// Content line — this is the actual source line
|
|
63
|
+
blameEntries.push({
|
|
64
|
+
hash: currentHash,
|
|
65
|
+
shortHash: currentHash.slice(0, 7),
|
|
66
|
+
author: currentAuthor,
|
|
67
|
+
authorTime: currentTime,
|
|
68
|
+
summary: currentSummary,
|
|
69
|
+
line: currentLine,
|
|
70
|
+
content: line.slice(1), // Remove leading tab
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Response.json({
|
|
76
|
+
success: true,
|
|
77
|
+
path: filePath,
|
|
78
|
+
entries: blameEntries,
|
|
79
|
+
totalLines: blameEntries.length,
|
|
80
|
+
});
|
|
81
|
+
} catch (error: any) {
|
|
82
|
+
console.error('api:repo:git-blame:error', error);
|
|
83
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|