trident-git 0.2.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 (64) hide show
  1. package/README.md +198 -0
  2. package/bin/trident-git.mjs +153 -0
  3. package/eslint.config.mjs +18 -0
  4. package/next.config.ts +30 -0
  5. package/package.json +60 -0
  6. package/postcss.config.mjs +7 -0
  7. package/public/favicon.png +0 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/next.svg +1 -0
  11. package/public/vercel.svg +1 -0
  12. package/public/window.svg +1 -0
  13. package/src/app/api/credentials/route.ts +113 -0
  14. package/src/app/api/custom-scripts/route.ts +203 -0
  15. package/src/app/api/fs/route.ts +75 -0
  16. package/src/app/api/git/action/route.ts +383 -0
  17. package/src/app/api/git/branches/route.ts +20 -0
  18. package/src/app/api/git/diff/route.ts +104 -0
  19. package/src/app/api/git/log/route.ts +28 -0
  20. package/src/app/api/git/status/route.ts +28 -0
  21. package/src/app/api/repos/route.ts +84 -0
  22. package/src/app/api/settings/route.ts +37 -0
  23. package/src/app/credentials/page.tsx +408 -0
  24. package/src/app/globals.css +109 -0
  25. package/src/app/icon.png +0 -0
  26. package/src/app/layout.tsx +38 -0
  27. package/src/app/page.tsx +10 -0
  28. package/src/app/providers.tsx +21 -0
  29. package/src/app/workspace/changes/page.tsx +27 -0
  30. package/src/app/workspace/custom-scripts/page.tsx +247 -0
  31. package/src/app/workspace/history/page.tsx +27 -0
  32. package/src/app/workspace/layout.tsx +26 -0
  33. package/src/app/workspace/page.tsx +27 -0
  34. package/src/app/workspace/settings/page.tsx +233 -0
  35. package/src/app/workspace/stashes/page.tsx +395 -0
  36. package/src/components/command-palette.tsx +178 -0
  37. package/src/components/context-menu.tsx +200 -0
  38. package/src/components/fs-browser.tsx +154 -0
  39. package/src/components/git/diff-view.tsx +137 -0
  40. package/src/components/git/git-graph.tsx +489 -0
  41. package/src/components/git/grouped-diff-viewer.tsx +332 -0
  42. package/src/components/git/history-view.tsx +4862 -0
  43. package/src/components/git/image-diff-view.tsx +342 -0
  44. package/src/components/git/status-view.tsx +597 -0
  45. package/src/components/home-settings-modal.tsx +192 -0
  46. package/src/components/layout/sidebar.tsx +256 -0
  47. package/src/components/repo-list.tsx +206 -0
  48. package/src/components/theme-toggle.tsx +37 -0
  49. package/src/components/toaster.tsx +36 -0
  50. package/src/components/workspace-repo-open-tracker.tsx +39 -0
  51. package/src/hooks/use-credentials.ts +123 -0
  52. package/src/hooks/use-escape-dismiss.ts +72 -0
  53. package/src/hooks/use-git.ts +448 -0
  54. package/src/hooks/use-toast.ts +280 -0
  55. package/src/hooks/use-workspace-title.ts +23 -0
  56. package/src/lib/api-utils.ts +24 -0
  57. package/src/lib/branch-colors.ts +98 -0
  58. package/src/lib/credentials.ts +404 -0
  59. package/src/lib/git.ts +1510 -0
  60. package/src/lib/graph-utils.ts +253 -0
  61. package/src/lib/store.ts +145 -0
  62. package/src/lib/types.ts +95 -0
  63. package/src/lib/utils.ts +266 -0
  64. package/tsconfig.json +34 -0
@@ -0,0 +1,203 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import { z } from 'zod';
5
+ import { GitService } from '@/lib/git';
6
+
7
+ export const runtime = 'nodejs';
8
+
9
+ type ExecutionStatus = 'running' | 'completed' | 'failed' | 'canceled';
10
+
11
+ interface ScriptExecution {
12
+ id: string;
13
+ status: ExecutionStatus;
14
+ cancelRequested: boolean;
15
+ output: string;
16
+ exitCode: number | null;
17
+ signal: NodeJS.Signals | null;
18
+ startedAt: string;
19
+ finishedAt: string | null;
20
+ process: ChildProcessWithoutNullStreams | null;
21
+ }
22
+
23
+ const executions = new Map<string, ScriptExecution>();
24
+ const MAX_OUTPUT_LENGTH = 500_000;
25
+ const FINISHED_EXECUTION_TTL_MS = 30 * 60 * 1000;
26
+
27
+ const startSchema = z.object({
28
+ command: z.literal('start'),
29
+ repoPath: z.string().min(1),
30
+ branchRef: z.string().min(1),
31
+ scriptContent: z.string(),
32
+ });
33
+
34
+ const statusSchema = z.object({
35
+ command: z.literal('status'),
36
+ executionId: z.string().min(1),
37
+ });
38
+
39
+ const cancelSchema = z.object({
40
+ command: z.literal('cancel'),
41
+ executionId: z.string().min(1),
42
+ });
43
+
44
+ const requestSchema = z.discriminatedUnion('command', [startSchema, statusSchema, cancelSchema]);
45
+
46
+ function normalizeBranchForCheckout(branchRef: string): string {
47
+ return branchRef.startsWith('remotes/') ? branchRef.slice('remotes/'.length) : branchRef;
48
+ }
49
+
50
+ function appendOutput(execution: ScriptExecution, text: string) {
51
+ execution.output += text;
52
+ if (execution.output.length > MAX_OUTPUT_LENGTH) {
53
+ execution.output = execution.output.slice(execution.output.length - MAX_OUTPUT_LENGTH);
54
+ }
55
+ }
56
+
57
+ function toResponsePayload(execution: ScriptExecution) {
58
+ return {
59
+ executionId: execution.id,
60
+ status: execution.status,
61
+ cancelRequested: execution.cancelRequested,
62
+ output: execution.output,
63
+ exitCode: execution.exitCode,
64
+ signal: execution.signal,
65
+ startedAt: execution.startedAt,
66
+ finishedAt: execution.finishedAt,
67
+ };
68
+ }
69
+
70
+ function cleanupFinishedExecutions() {
71
+ const now = Date.now();
72
+ for (const [id, execution] of executions.entries()) {
73
+ if (execution.status === 'running') continue;
74
+ if (!execution.finishedAt) continue;
75
+ const finishedAtMs = new Date(execution.finishedAt).getTime();
76
+ if (Number.isNaN(finishedAtMs)) continue;
77
+ if (now - finishedAtMs > FINISHED_EXECUTION_TTL_MS) {
78
+ executions.delete(id);
79
+ }
80
+ }
81
+ }
82
+
83
+ export async function POST(request: Request) {
84
+ try {
85
+ cleanupFinishedExecutions();
86
+
87
+ const body = await request.json();
88
+ const payload = requestSchema.parse(body);
89
+
90
+ if (payload.command === 'status') {
91
+ const execution = executions.get(payload.executionId);
92
+ if (!execution) {
93
+ return NextResponse.json({ error: 'Execution not found' }, { status: 404 });
94
+ }
95
+
96
+ return NextResponse.json({ success: true, ...toResponsePayload(execution) });
97
+ }
98
+
99
+ if (payload.command === 'cancel') {
100
+ const execution = executions.get(payload.executionId);
101
+ if (!execution) {
102
+ return NextResponse.json({ error: 'Execution not found' }, { status: 404 });
103
+ }
104
+
105
+ if (execution.status === 'running' && execution.process) {
106
+ execution.cancelRequested = true;
107
+ appendOutput(execution, '\n[info] Cancel requested...\n');
108
+ execution.process.kill('SIGTERM');
109
+
110
+ setTimeout(() => {
111
+ if (execution.process && execution.cancelRequested) {
112
+ execution.process.kill('SIGKILL');
113
+ }
114
+ }, 1500);
115
+ }
116
+
117
+ return NextResponse.json({ success: true, ...toResponsePayload(execution) });
118
+ }
119
+
120
+ const { repoPath, branchRef, scriptContent } = payload;
121
+
122
+ if (!fs.existsSync(repoPath)) {
123
+ return NextResponse.json({ error: `Path not found: ${repoPath}` }, { status: 404 });
124
+ }
125
+
126
+ if (!fs.statSync(repoPath).isDirectory()) {
127
+ return NextResponse.json({ error: 'Repository path must be a directory' }, { status: 400 });
128
+ }
129
+
130
+ const git = new GitService(repoPath);
131
+ const branches = await git.getBranches();
132
+ const checkoutBranch = normalizeBranchForCheckout(branchRef);
133
+ const currentBranch = branches.current;
134
+
135
+ if (!currentBranch || currentBranch !== checkoutBranch) {
136
+ await git.checkout(checkoutBranch);
137
+ }
138
+
139
+ const executionId = crypto.randomUUID();
140
+ const child = spawn('bash', ['-s'], {
141
+ cwd: repoPath,
142
+ env: process.env,
143
+ stdio: 'pipe',
144
+ });
145
+
146
+ const execution: ScriptExecution = {
147
+ id: executionId,
148
+ status: 'running',
149
+ cancelRequested: false,
150
+ output: '',
151
+ exitCode: null,
152
+ signal: null,
153
+ startedAt: new Date().toISOString(),
154
+ finishedAt: null,
155
+ process: child,
156
+ };
157
+
158
+ executions.set(executionId, execution);
159
+
160
+ const onData = (chunk: Buffer | string) => {
161
+ appendOutput(execution, typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
162
+ };
163
+
164
+ child.stdout.on('data', onData);
165
+ child.stderr.on('data', onData);
166
+
167
+ child.on('error', (error) => {
168
+ appendOutput(execution, `\n[error] ${error.message}\n`);
169
+ execution.status = execution.cancelRequested ? 'canceled' : 'failed';
170
+ execution.finishedAt = new Date().toISOString();
171
+ execution.process = null;
172
+ });
173
+
174
+ child.on('close', (code, signal) => {
175
+ execution.exitCode = code;
176
+ execution.signal = signal;
177
+ execution.finishedAt = new Date().toISOString();
178
+ execution.process = null;
179
+
180
+ if (execution.cancelRequested) {
181
+ execution.status = 'canceled';
182
+ return;
183
+ }
184
+
185
+ execution.status = code === 0 ? 'completed' : 'failed';
186
+ });
187
+
188
+ child.stdin.write(scriptContent);
189
+ child.stdin.end();
190
+
191
+ return NextResponse.json({
192
+ success: true,
193
+ ...toResponsePayload(execution),
194
+ checkedOutBranch: checkoutBranch,
195
+ previousBranch: currentBranch,
196
+ });
197
+ } catch (error) {
198
+ if (error instanceof z.ZodError) {
199
+ return NextResponse.json({ error: error.issues }, { status: 400 });
200
+ }
201
+ return NextResponse.json({ error: (error as Error).message }, { status: 500 });
202
+ }
203
+ }
@@ -0,0 +1,75 @@
1
+ import { NextResponse } from 'next/server';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ export async function GET(request: Request) {
7
+ const { searchParams } = new URL(request.url);
8
+ const requestedPath = searchParams.get('path');
9
+
10
+ // Default to home directory
11
+ const currentPath = requestedPath ? requestedPath : os.homedir();
12
+
13
+ try {
14
+ // Security check: though this is local app, basic sanity check
15
+ // For now, allow reading anywhere as it's a dev tool/local tool.
16
+
17
+ const stats = await fs.promises.stat(currentPath);
18
+ if (!stats.isDirectory()) {
19
+ return NextResponse.json({ error: 'Not a directory' }, { status: 400 });
20
+ }
21
+
22
+ const items = await fs.promises.readdir(currentPath, { withFileTypes: true });
23
+
24
+ // Let's verify if they are git repos
25
+ const directories = items.filter(item => item.isDirectory());
26
+
27
+ const contents = [];
28
+ const BATCH_SIZE = 50;
29
+
30
+ for (let i = 0; i < directories.length; i += BATCH_SIZE) {
31
+ const batch = directories.slice(i, i + BATCH_SIZE);
32
+ const batchResults = await Promise.all(batch.map(async (item) => {
33
+ const itemPath = path.join(currentPath, item.name);
34
+ let isRepo = false;
35
+ try {
36
+ // Check for .git directory inside
37
+ await fs.promises.access(path.join(itemPath, '.git'), fs.constants.F_OK);
38
+ isRepo = true;
39
+ } catch (e) {}
40
+
41
+ return {
42
+ name: item.name,
43
+ path: itemPath,
44
+ isRepo
45
+ };
46
+ }));
47
+ contents.push(...batchResults);
48
+ }
49
+
50
+ // Sort: Visible folders first, then Repos first within that group, then alphabetical
51
+ contents.sort((a, b) => {
52
+ const aHidden = a.name.startsWith('.');
53
+ const bHidden = b.name.startsWith('.');
54
+
55
+ // Visible first
56
+ if (!aHidden && bHidden) return -1;
57
+ if (aHidden && !bHidden) return 1;
58
+
59
+ // Repos first
60
+ if (a.isRepo && !b.isRepo) return -1;
61
+ if (!a.isRepo && b.isRepo) return 1;
62
+
63
+ return a.name.localeCompare(b.name);
64
+ });
65
+
66
+ return NextResponse.json({
67
+ path: currentPath,
68
+ folders: contents,
69
+ parent: path.dirname(currentPath)
70
+ });
71
+
72
+ } catch (error) {
73
+ return NextResponse.json({ error: (error as Error).message }, { status: 500 });
74
+ }
75
+ }
@@ -0,0 +1,383 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { GitService } from '@/lib/git';
3
+ import { getRepositories } from '@/lib/store';
4
+ import { getCredentialById, getCredentialToken, findCredentialForRemote } from '@/lib/credentials';
5
+ import { getImageMimeType, isImageFile } from '@/lib/utils';
6
+ import { handleGitError } from '@/lib/api-utils';
7
+ import { z } from 'zod';
8
+ import fs from 'node:fs';
9
+
10
+ const actionSchema = z.object({
11
+ repoPath: z.string(),
12
+ action: z.enum(['commit', 'push', 'pull', 'stage', 'unstage', 'fetch', 'checkout', 'checkout-to-local', 'branch', 'create-tag', 'delete-branch', 'delete-remote-branch', 'delete-remote', 'delete-tag', 'delete-remote-tag', 'rename-branch', 'rename-remote-branch', 'rename-remote', 'add-remote', 'reset', 'revert', 'cherry-pick', 'cherry-pick-multiple', 'cherry-pick-abort', 'rebase', 'merge', 'check-merge-conflicts', 'check-rebase-conflicts', 'get-remotes', 'get-remote-branches', 'get-tracking-branch', 'get-latest-commit-message', 'push-to-remote', 'pull-from-remote', 'stash', 'stash-list', 'stash-apply', 'stash-drop', 'stash-pop', 'stash-files', 'stash-file-diff', 'reword', 'discard', 'cleanup-lock-file']),
13
+ data: z.any().optional(), // Payload depends on action
14
+ });
15
+
16
+ async function resolveCredentials(repoPath: string, git: GitService, remoteName?: string) {
17
+ const repos = getRepositories();
18
+ const repoConfig = repos.find(r => r.path === repoPath);
19
+
20
+ // 1. Check for explicitly associated credential
21
+ if (repoConfig?.credentialId) {
22
+ const cred = await getCredentialById(repoConfig.credentialId);
23
+ if (cred) {
24
+ const token = await getCredentialToken(cred.id);
25
+ if (token) {
26
+ return { username: cred.username, token };
27
+ }
28
+ }
29
+ }
30
+
31
+ // 2. Fallback: try to find matching credential by URL
32
+ if (remoteName) {
33
+ const remoteUrl = await git.getRemoteUrl(remoteName);
34
+ if (remoteUrl) {
35
+ const result = await findCredentialForRemote(remoteUrl);
36
+ if (result) {
37
+ return { username: result.credential.username, token: result.token };
38
+ }
39
+ }
40
+ }
41
+
42
+ return undefined;
43
+ }
44
+
45
+ function toImageSide(buffer: Buffer | null, mimeType: string) {
46
+ if (!buffer) return null;
47
+ return {
48
+ mimeType,
49
+ base64: buffer.toString('base64'),
50
+ };
51
+ }
52
+
53
+ export async function POST(request: Request) {
54
+ try {
55
+ const body = await request.json();
56
+ const { repoPath, action, data } = actionSchema.parse(body);
57
+
58
+ // Check if path exists
59
+ if (!fs.existsSync(repoPath)) {
60
+ return NextResponse.json({ error: `Path not found: ${repoPath}` }, { status: 404 });
61
+ }
62
+
63
+ const git = new GitService(repoPath);
64
+
65
+ switch (action) {
66
+ case 'commit':
67
+ if (!data?.message) throw new Error('Commit message is required');
68
+ await git.commit(data.message, data.files);
69
+ break;
70
+ case 'push':
71
+ // Try to resolve credentials
72
+ let pushCredentials = await resolveCredentials(repoPath, git, undefined);
73
+
74
+ if (!pushCredentials) {
75
+ // If no associated credential, try to infer from upstream
76
+ try {
77
+ const status = await git.getBranches();
78
+ const current = status.current;
79
+ const tracking = status.trackingInfo[current];
80
+ if (tracking && tracking.upstream) {
81
+ const slashIndex = tracking.upstream.indexOf('/');
82
+ if (slashIndex > 0) {
83
+ const remoteName = tracking.upstream.slice(0, slashIndex);
84
+ pushCredentials = await resolveCredentials(repoPath, git, remoteName);
85
+ }
86
+ }
87
+ } catch (e) {
88
+ // Ignore errors finding upstream, just proceed without creds
89
+ console.warn('[API] Failed to resolve upstream for push credentials:', e);
90
+ }
91
+ }
92
+
93
+ await git.push({ credentials: pushCredentials });
94
+ break;
95
+ case 'pull':
96
+ await git.pull();
97
+ break;
98
+ case 'fetch':
99
+ if (data?.allRemotes) {
100
+ await git.fetchAllRemotes();
101
+ } else if (data?.remote) {
102
+ await git.fetchRemote(data.remote);
103
+ } else {
104
+ await git.fetch();
105
+ }
106
+ break;
107
+ case 'stage':
108
+ if (!data?.files) throw new Error('Files are required for staging');
109
+ await git.stage(data.files);
110
+ break;
111
+ case 'unstage':
112
+ if (!data?.files) throw new Error('Files are required for unstaging');
113
+ await git.unstage(data.files);
114
+ break;
115
+ case 'discard':
116
+ await git.discardUnstagedChanges({
117
+ includeUntracked: data?.includeUntracked ?? true,
118
+ });
119
+ break;
120
+ case 'checkout':
121
+ if (!data?.branch) throw new Error('Branch name is required for checkout');
122
+ await git.checkout(data.branch);
123
+ break;
124
+ case 'checkout-to-local':
125
+ if (!data?.remoteBranch) throw new Error('Remote branch is required for checkout-to-local');
126
+ if (!data?.localBranch) throw new Error('Local branch name is required for checkout-to-local');
127
+ await git.checkoutRemoteToLocal(data.remoteBranch, data.localBranch);
128
+ break;
129
+ case 'branch':
130
+ if (!data?.branch) throw new Error('Branch name is required to create branch');
131
+ await git.createBranch(data.branch, data?.fromRef);
132
+ break;
133
+ case 'create-tag':
134
+ if (!data?.tagName) throw new Error('Tag name is required to create tag');
135
+ if (!data?.commitHash) throw new Error('Commit hash is required to create tag');
136
+ if (data?.pushToRemote) {
137
+ let remoteForTag = typeof data?.remote === 'string' && data.remote.trim() ? data.remote.trim() : undefined;
138
+ if (!remoteForTag) {
139
+ const branches = await git.getBranches();
140
+ const current = branches.current;
141
+ const trackingUpstream = current ? branches.trackingInfo?.[current]?.upstream : undefined;
142
+ if (trackingUpstream) {
143
+ const slashIndex = trackingUpstream.indexOf('/');
144
+ if (slashIndex > 0) {
145
+ remoteForTag = trackingUpstream.slice(0, slashIndex);
146
+ }
147
+ }
148
+ }
149
+ if (!remoteForTag) {
150
+ const remotes = await git.getRemotes();
151
+ if (remotes.includes('origin')) {
152
+ remoteForTag = 'origin';
153
+ } else {
154
+ remoteForTag = remotes[0];
155
+ }
156
+ }
157
+
158
+ const tagCreds = remoteForTag ? await resolveCredentials(repoPath, git, remoteForTag) : undefined;
159
+ await git.createTag(data.tagName, data.commitHash, {
160
+ pushToRemote: true,
161
+ remote: remoteForTag ?? undefined,
162
+ credentials: tagCreds,
163
+ });
164
+ } else {
165
+ await git.createTag(data.tagName, data.commitHash, { pushToRemote: false });
166
+ }
167
+ break;
168
+ case 'delete-branch':
169
+ if (!data?.branch) throw new Error('Branch name is required to delete branch');
170
+ await git.deleteBranch(data.branch);
171
+ break;
172
+ case 'delete-remote-branch':
173
+ if (!data?.remote) throw new Error('Remote name is required to delete remote branch');
174
+ if (!data?.branch) throw new Error('Branch name is required to delete remote branch');
175
+
176
+ const deleteCreds = await resolveCredentials(repoPath, git, data.remote);
177
+ await git.deleteRemoteBranch(data.remote, data.branch, deleteCreds);
178
+ break;
179
+ case 'delete-remote':
180
+ if (!data?.name) throw new Error('Remote name is required to delete remote');
181
+ await git.deleteRemote(data.name);
182
+ break;
183
+ case 'delete-tag':
184
+ if (!data?.tag) throw new Error('Tag name is required to delete tag');
185
+ await git.deleteTag(data.tag);
186
+ break;
187
+ case 'delete-remote-tag':
188
+ if (!data?.remote) throw new Error('Remote name is required to delete remote tag');
189
+ if (!data?.tag) throw new Error('Tag name is required to delete remote tag');
190
+
191
+ const deleteTagCreds = await resolveCredentials(repoPath, git, data.remote);
192
+ await git.deleteRemoteTag(data.remote, data.tag, deleteTagCreds);
193
+ break;
194
+ case 'rename-branch':
195
+ if (!data?.oldName) throw new Error('Old branch name is required to rename branch');
196
+ if (!data?.newName) throw new Error('New branch name is required to rename branch');
197
+ if (data?.renameTrackingRemote) {
198
+ const tracking = await git.getTrackingBranch(data.oldName);
199
+ const renameTrackingCreds = tracking
200
+ ? await resolveCredentials(repoPath, git, tracking.remote)
201
+ : undefined;
202
+ await git.renameBranch(data.oldName, data.newName, {
203
+ renameTrackingRemote: true,
204
+ credentials: renameTrackingCreds,
205
+ });
206
+ } else {
207
+ await git.renameBranch(data.oldName, data.newName);
208
+ }
209
+ break;
210
+ case 'rename-remote-branch':
211
+ if (!data?.remote) throw new Error('Remote name is required to rename remote branch');
212
+ if (!data?.oldName) throw new Error('Old branch name is required to rename remote branch');
213
+ if (!data?.newName) throw new Error('New branch name is required to rename remote branch');
214
+
215
+ const renameCreds = await resolveCredentials(repoPath, git, data.remote);
216
+ await git.renameRemoteBranch(data.remote, data.oldName, data.newName, renameCreds);
217
+ break;
218
+ case 'rename-remote':
219
+ if (!data?.oldName) throw new Error('Old remote name is required to rename remote');
220
+ if (!data?.newName) throw new Error('New remote name is required to rename remote');
221
+ await git.renameRemote(data.oldName, data.newName);
222
+ break;
223
+ case 'add-remote':
224
+ if (!data?.name) throw new Error('Remote name is required to add remote');
225
+ if (!data?.url) throw new Error('Remote URL is required to add remote');
226
+ await git.addRemote(data.name, data.url);
227
+ break;
228
+ case 'reset':
229
+ if (!data?.commitHash) throw new Error('Commit hash is required for reset');
230
+ await git.reset(data.commitHash, data.mode ?? 'hard');
231
+ break;
232
+ case 'revert':
233
+ if (!data?.commitHash) throw new Error('Commit hash is required for revert');
234
+ await git.revert(data.commitHash);
235
+ break;
236
+ case 'cherry-pick':
237
+ if (!data?.commitHash) throw new Error('Commit hash is required for cherry-pick');
238
+ await git.cherryPick(data.commitHash);
239
+ break;
240
+ case 'cherry-pick-multiple':
241
+ if (!Array.isArray(data?.commitHashes) || data.commitHashes.length === 0) {
242
+ throw new Error('Commit hashes are required for multi cherry-pick');
243
+ }
244
+ if (!data.commitHashes.every((hash: unknown) => typeof hash === 'string' && hash.trim().length > 0)) {
245
+ throw new Error('All commit hashes must be non-empty strings');
246
+ }
247
+ await git.cherryPickMultiple(data.commitHashes);
248
+ break;
249
+ case 'cherry-pick-abort':
250
+ await git.abortCherryPick();
251
+ break;
252
+ case 'rebase':
253
+ if (!data?.ontoBranch) throw new Error('Target branch is required for rebase');
254
+ await git.rebase(data.ontoBranch, data.stashChanges ?? true);
255
+ break;
256
+ case 'reword':
257
+ if (!data?.commitHash) throw new Error('Commit hash is required for reword');
258
+ if (!data?.message) throw new Error('New message is required for reword');
259
+ await git.reword(data.commitHash, data.message, data.branch);
260
+ break;
261
+ case 'merge':
262
+ if (!data?.targetBranch) throw new Error('Target branch is required for merge');
263
+ await git.merge(data.targetBranch, {
264
+ rebaseBeforeMerge: data.rebaseBeforeMerge ?? false,
265
+ squash: data.squash ?? false,
266
+ fastForward: data.fastForward ?? false,
267
+ squashMessage: data.squashMessage,
268
+ });
269
+ break;
270
+ case 'check-merge-conflicts':
271
+ if (!data?.sourceBranch) throw new Error('Source branch is required for merge conflict check');
272
+ const hasConflicts = await git.willMergeHaveConflicts(data.sourceBranch, data.targetBranch);
273
+ return NextResponse.json({ success: true, hasConflicts });
274
+ case 'check-rebase-conflicts':
275
+ if (!data?.ontoBranch) throw new Error('Target branch is required for rebase conflict check');
276
+ if (!data?.sourceBranch) throw new Error('Source branch is required for rebase conflict check');
277
+ const hasRebaseConflicts = await git.willRebaseHaveConflicts(data.ontoBranch, data.sourceBranch);
278
+ return NextResponse.json({ success: true, hasConflicts: hasRebaseConflicts });
279
+ case 'get-remotes':
280
+ const remotes = await git.getRemotes();
281
+ return NextResponse.json({ success: true, remotes });
282
+ case 'get-remote-branches':
283
+ if (!data?.remote) throw new Error('Remote name is required');
284
+ const remoteBranches = await git.getRemoteBranches(data.remote);
285
+ return NextResponse.json({ success: true, branches: remoteBranches });
286
+ case 'get-tracking-branch':
287
+ if (!data?.branch) throw new Error('Branch name is required');
288
+ const tracking = await git.getTrackingBranch(data.branch);
289
+ return NextResponse.json({ success: true, tracking });
290
+ case 'get-latest-commit-message':
291
+ if (!data?.branch) throw new Error('Branch name is required');
292
+ const message = await git.getLatestCommitMessage(data.branch);
293
+ return NextResponse.json({ success: true, message });
294
+ case 'push-to-remote':
295
+ console.log('[API] push-to-remote action received:', data);
296
+ if (!data?.localBranch) throw new Error('Local branch is required');
297
+ if (!data?.remote) throw new Error('Remote is required');
298
+ if (!data?.remoteBranch) throw new Error('Remote branch is required');
299
+
300
+ const creds = await resolveCredentials(repoPath, git, data.remote);
301
+
302
+ console.log('[API] Calling git.pushToRemote...');
303
+ await git.pushToRemote(data.localBranch, data.remote, data.remoteBranch, {
304
+ rebaseFirst: data.rebaseFirst ?? !(data.forcePush ?? false),
305
+ forcePush: data.forcePush ?? false,
306
+ pushLocalOnlyTags: data.pushLocalOnlyTags ?? true,
307
+ setUpstream: data.setUpstream ?? false,
308
+ squash: data.squash ?? false,
309
+ squashMessage: data.squashMessage,
310
+ credentials: creds,
311
+ });
312
+ console.log('[API] git.pushToRemote completed');
313
+ break;
314
+ case 'pull-from-remote':
315
+ console.log('[API] pull-from-remote action received:', data);
316
+ if (!data?.localBranch) throw new Error('Local branch is required');
317
+ if (!data?.remote) throw new Error('Remote is required');
318
+ if (!data?.remoteBranch) throw new Error('Remote branch is required');
319
+ console.log('[API] Calling git.pullFromRemote...');
320
+ await git.pullFromRemote(data.localBranch, data.remote, data.remoteBranch, {
321
+ rebase: data.rebase ?? true,
322
+ });
323
+ console.log('[API] git.pullFromRemote completed');
324
+ break;
325
+ case 'stash':
326
+ await git.stash(data?.message);
327
+ break;
328
+ case 'stash-list':
329
+ const stashes = await git.getStashes();
330
+ return NextResponse.json({ success: true, stashes });
331
+ case 'stash-apply':
332
+ if (data?.index === undefined) throw new Error('Stash index is required');
333
+ await git.applyStash(data.index);
334
+ break;
335
+ case 'stash-drop':
336
+ if (data?.index === undefined) throw new Error('Stash index is required');
337
+ await git.dropStash(data.index);
338
+ break;
339
+ case 'stash-pop':
340
+ if (data?.index === undefined) throw new Error('Stash index is required');
341
+ await git.popStash(data.index);
342
+ break;
343
+ case 'stash-files':
344
+ if (data?.index === undefined) throw new Error('Stash index is required');
345
+ const stashFiles = await git.getStashFiles(data.index);
346
+ return NextResponse.json({ success: true, files: stashFiles });
347
+ case 'stash-file-diff':
348
+ if (data?.index === undefined) throw new Error('Stash index is required');
349
+ if (!data?.file) throw new Error('File path is required');
350
+ if (isImageFile(data.file)) {
351
+ const mimeType = getImageMimeType(data.file);
352
+ const [leftBuffer, rightBuffer, diff] = await Promise.all([
353
+ git.getFileContentBuffer(data.file, `stash@{${data.index}}^1`),
354
+ git.getFileContentBuffer(data.file, `stash@{${data.index}}`),
355
+ git.getStashFilePatch(data.index, data.file),
356
+ ]);
357
+
358
+ return NextResponse.json({
359
+ success: true,
360
+ left: '',
361
+ right: '',
362
+ diff,
363
+ imageDiff: {
364
+ left: toImageSide(leftBuffer, mimeType),
365
+ right: toImageSide(rightBuffer, mimeType),
366
+ },
367
+ });
368
+ }
369
+
370
+ const stashFileDiff = await git.getStashFileDiff(data.index, data.file);
371
+ return NextResponse.json({ success: true, ...stashFileDiff });
372
+ case 'cleanup-lock-file':
373
+ const cleaned = await git.cleanupLockFile();
374
+ return NextResponse.json({ success: true, cleaned });
375
+ default:
376
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
377
+ }
378
+
379
+ return NextResponse.json({ success: true });
380
+ } catch (error) {
381
+ return handleGitError(error);
382
+ }
383
+ }
@@ -0,0 +1,20 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { GitService } from '@/lib/git';
3
+ import { handleGitError } from '@/lib/api-utils';
4
+
5
+ export async function GET(req: Request) {
6
+ const { searchParams } = new URL(req.url);
7
+ const path = searchParams.get('path');
8
+
9
+ if (!path) {
10
+ return NextResponse.json({ error: 'Path is required' }, { status: 400 });
11
+ }
12
+
13
+ try {
14
+ const git = new GitService(path);
15
+ const branches = await git.getBranches();
16
+ return NextResponse.json(branches);
17
+ } catch (err) {
18
+ return handleGitError(err);
19
+ }
20
+ }