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.
Files changed (121) hide show
  1. package/README.md +167 -0
  2. package/app/api/auth/favorites/route.ts +56 -0
  3. package/app/api/auth/github/callback/route.ts +103 -0
  4. package/app/api/auth/github/route.ts +32 -0
  5. package/app/api/auth/me/route.ts +52 -0
  6. package/app/api/auth/positions/route.ts +50 -0
  7. package/app/api/chat/route.ts +101 -0
  8. package/app/api/connections/route.ts +72 -0
  9. package/app/api/github/repos/route.ts +111 -0
  10. package/app/api/positions/route.ts +80 -0
  11. package/app/api/repo/branch-diff/route.ts +201 -0
  12. package/app/api/repo/branches/route.ts +53 -0
  13. package/app/api/repo/browse/route.ts +55 -0
  14. package/app/api/repo/clone/route.ts +78 -0
  15. package/app/api/repo/clone-stream/route.ts +131 -0
  16. package/app/api/repo/file-content/route.ts +28 -0
  17. package/app/api/repo/file-delete/route.ts +62 -0
  18. package/app/api/repo/file-history/route.ts +45 -0
  19. package/app/api/repo/file-rename/route.ts +83 -0
  20. package/app/api/repo/file-save/route.ts +45 -0
  21. package/app/api/repo/files/route.ts +169 -0
  22. package/app/api/repo/git-blame/route.ts +86 -0
  23. package/app/api/repo/git-commit/route.ts +40 -0
  24. package/app/api/repo/git-heatmap/route.ts +55 -0
  25. package/app/api/repo/imports/route.ts +154 -0
  26. package/app/api/repo/load/route.ts +56 -0
  27. package/app/api/repo/mode/route.ts +14 -0
  28. package/app/api/repo/search/route.ts +127 -0
  29. package/app/api/repo/tree/route.ts +104 -0
  30. package/app/api/repo/upload/route.ts +53 -0
  31. package/app/api/repo/validate-path.ts +53 -0
  32. package/app/canvas_users.db +0 -0
  33. package/app/canvas_users.db-shm +0 -0
  34. package/app/canvas_users.db-wal +0 -0
  35. package/app/globals.css +7899 -0
  36. package/app/layout.tsx +493 -0
  37. package/app/lib/auth.ts +193 -0
  38. package/app/lib/auto-save.ts +137 -0
  39. package/app/lib/branch-compare.ts +443 -0
  40. package/app/lib/breadcrumbs.ts +170 -0
  41. package/app/lib/canvas-export.ts +358 -0
  42. package/app/lib/canvas-text.ts +912 -0
  43. package/app/lib/canvas.ts +564 -0
  44. package/app/lib/card-arrangement.ts +188 -0
  45. package/app/lib/card-context-menu.tsx +453 -0
  46. package/app/lib/card-diff-markers.ts +270 -0
  47. package/app/lib/card-expand.ts +189 -0
  48. package/app/lib/card-groups.ts +246 -0
  49. package/app/lib/cards.tsx +914 -0
  50. package/app/lib/chat.tsx +308 -0
  51. package/app/lib/code-editor.ts +508 -0
  52. package/app/lib/command-palette.ts +262 -0
  53. package/app/lib/connections.tsx +1037 -0
  54. package/app/lib/context.ts +94 -0
  55. package/app/lib/cursor-sharing.ts +281 -0
  56. package/app/lib/dependency-graph.ts +438 -0
  57. package/app/lib/events.tsx +1747 -0
  58. package/app/lib/file-card-plugin.ts +134 -0
  59. package/app/lib/file-modal.tsx +849 -0
  60. package/app/lib/file-preview.ts +400 -0
  61. package/app/lib/file-tabs.ts +318 -0
  62. package/app/lib/galaxydraw-bridge.ts +477 -0
  63. package/app/lib/galaxydraw.test.ts +229 -0
  64. package/app/lib/global-search.ts +264 -0
  65. package/app/lib/goto-definition.ts +224 -0
  66. package/app/lib/heatmap.ts +178 -0
  67. package/app/lib/hidden-files.tsx +222 -0
  68. package/app/lib/layers.ts +0 -0
  69. package/app/lib/layers.tsx +365 -0
  70. package/app/lib/loading.tsx +45 -0
  71. package/app/lib/multi-repo.ts +286 -0
  72. package/app/lib/new-file-dialog.tsx +230 -0
  73. package/app/lib/onboarding.tsx +213 -0
  74. package/app/lib/perf-overlay.ts +360 -0
  75. package/app/lib/positions.ts +176 -0
  76. package/app/lib/pr-review.ts +374 -0
  77. package/app/lib/production-mode.ts +47 -0
  78. package/app/lib/repo.tsx +977 -0
  79. package/app/lib/settings-modal.tsx +374 -0
  80. package/app/lib/settings.ts +97 -0
  81. package/app/lib/shortcuts-panel.ts +141 -0
  82. package/app/lib/status-bar.ts +128 -0
  83. package/app/lib/symbol-outline.ts +212 -0
  84. package/app/lib/syntax.ts +177 -0
  85. package/app/lib/tab-diff.ts +238 -0
  86. package/app/lib/user.tsx +133 -0
  87. package/app/lib/utils.ts +78 -0
  88. package/app/lib/viewport-culling.ts +728 -0
  89. package/app/page.client.tsx +215 -0
  90. package/app/page.tsx +291 -0
  91. package/app/state/machine.js +196 -0
  92. package/app/styles/main.css +2168 -0
  93. package/banner.png +0 -0
  94. package/cli.ts +44 -0
  95. package/package.json +75 -0
  96. package/packages/galaxydraw/README.md +296 -0
  97. package/packages/galaxydraw/banner.png +0 -0
  98. package/packages/galaxydraw/demo/build-static.ts +100 -0
  99. package/packages/galaxydraw/demo/client.ts +154 -0
  100. package/packages/galaxydraw/demo/dist/client.js +8 -0
  101. package/packages/galaxydraw/demo/index.html +256 -0
  102. package/packages/galaxydraw/demo/server.ts +96 -0
  103. package/packages/galaxydraw/dist/index.js +984 -0
  104. package/packages/galaxydraw/dist/index.js.map +16 -0
  105. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  106. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  109. package/packages/galaxydraw/package.json +49 -0
  110. package/packages/galaxydraw/perf.test.ts +284 -0
  111. package/packages/galaxydraw/src/core/cards.ts +435 -0
  112. package/packages/galaxydraw/src/core/engine.ts +339 -0
  113. package/packages/galaxydraw/src/core/events.ts +81 -0
  114. package/packages/galaxydraw/src/core/layout.ts +136 -0
  115. package/packages/galaxydraw/src/core/minimap.ts +216 -0
  116. package/packages/galaxydraw/src/core/state.ts +177 -0
  117. package/packages/galaxydraw/src/core/viewport.ts +106 -0
  118. package/packages/galaxydraw/src/galaxydraw.css +166 -0
  119. package/packages/galaxydraw/src/index.ts +40 -0
  120. package/packages/galaxydraw/tsconfig.json +30 -0
  121. 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
+ }