plugin-git-manager 1.2.2 → 1.2.3

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 (67) hide show
  1. package/dist/client/228.7588a0707cb3694a.js +9 -0
  2. package/dist/client/597.1698c0124e2f95ce.js +10 -0
  3. package/dist/client/906.bea20db91f34f5e6.js +10 -0
  4. package/dist/client/index.js +10 -1
  5. package/dist/externalVersion.js +15 -5
  6. package/dist/index.js +9 -0
  7. package/dist/locale/en-US.json +8 -1
  8. package/dist/locale/vi-VN.json +8 -1
  9. package/dist/server/actions/git-actions.js +43 -6
  10. package/dist/server/actions/gitlab-api.js +9 -0
  11. package/dist/server/actions/poller.js +9 -0
  12. package/dist/server/actions/review.js +9 -0
  13. package/dist/server/actions/role-permissions.js +145 -0
  14. package/dist/server/ai-tools.js +9 -0
  15. package/dist/server/collections/gitCodeReviews.js +9 -0
  16. package/dist/server/collections/gitRepositories.js +9 -0
  17. package/dist/server/collections/gitReviewFlows.js +9 -0
  18. package/dist/server/index.js +9 -0
  19. package/dist/server/migrations/20260508000000-add-auto-review-flow-id.js +9 -0
  20. package/dist/server/plugin.js +27 -1
  21. package/dist/server/poller.js +9 -0
  22. package/dist/server/utils/gitlab-url.js +9 -0
  23. package/dist/server/utils/redact.js +9 -0
  24. package/package.json +36 -36
  25. package/src/client/components/GitManagerSettings.tsx +1 -11
  26. package/src/client/components/RepositoryPermissions.tsx +130 -0
  27. package/src/client/index.tsx +36 -2
  28. package/src/locale/en-US.json +8 -1
  29. package/src/locale/vi-VN.json +8 -1
  30. package/src/server/__tests__/smoke.test.ts +17 -0
  31. package/src/server/actions/git-actions.ts +430 -430
  32. package/src/server/actions/role-permissions.ts +128 -0
  33. package/src/server/plugin.ts +46 -25
  34. package/dist/client/187.d5545b7cc8b90bfc.js +0 -1
  35. package/dist/client/ai-context.d.ts +0 -8
  36. package/dist/client/components/AIEmployeeSelect.d.ts +0 -13
  37. package/dist/client/components/CommitHistory.d.ts +0 -2
  38. package/dist/client/components/FileExplorer.d.ts +0 -2
  39. package/dist/client/components/GitManagerSettings.d.ts +0 -2
  40. package/dist/client/components/GitOperations.d.ts +0 -2
  41. package/dist/client/components/LLMModelSelect.d.ts +0 -10
  42. package/dist/client/components/LLMServiceSelect.d.ts +0 -9
  43. package/dist/client/components/MarkdownView.d.ts +0 -10
  44. package/dist/client/components/MergeRequests.d.ts +0 -2
  45. package/dist/client/components/PollingStatus.d.ts +0 -2
  46. package/dist/client/components/RepositoryConfig.d.ts +0 -2
  47. package/dist/client/components/ReviewFlows.d.ts +0 -2
  48. package/dist/client/components/ReviewHistory.d.ts +0 -4
  49. package/dist/client/components/RunReviewButton.d.ts +0 -31
  50. package/dist/client/context/GitManagerContext.d.ts +0 -26
  51. package/dist/client/index.d.ts +0 -5
  52. package/dist/client/locale.d.ts +0 -2
  53. package/dist/index.d.ts +0 -2
  54. package/dist/server/actions/git-actions.d.ts +0 -13
  55. package/dist/server/actions/gitlab-api.d.ts +0 -4
  56. package/dist/server/actions/poller.d.ts +0 -8
  57. package/dist/server/actions/review.d.ts +0 -40
  58. package/dist/server/ai-tools.d.ts +0 -10
  59. package/dist/server/collections/gitCodeReviews.d.ts +0 -2
  60. package/dist/server/collections/gitRepositories.d.ts +0 -2
  61. package/dist/server/collections/gitReviewFlows.d.ts +0 -2
  62. package/dist/server/index.d.ts +0 -2
  63. package/dist/server/migrations/20260508000000-add-auto-review-flow-id.d.ts +0 -6
  64. package/dist/server/plugin.d.ts +0 -12
  65. package/dist/server/poller.d.ts +0 -30
  66. package/dist/server/utils/gitlab-url.d.ts +0 -21
  67. package/dist/server/utils/redact.d.ts +0 -13
@@ -1,430 +1,430 @@
1
- import simpleGit, { SimpleGit } from 'simple-git';
2
- import { Context } from '@nocobase/actions';
3
- import * as path from 'path';
4
- import * as fs from 'fs';
5
- import { spawnSync } from 'child_process';
6
- import { redactPat, redactError } from '../utils/redact';
7
-
8
- // Disallow leading `-` to prevent argument-injection (e.g. `--upload-pack=...`)
9
- // when refs are passed as positional args to git.
10
- const REF_PATTERN = /^(?!-)[a-zA-Z0-9._/-]+$/;
11
-
12
- // Per-repo mutex to prevent PAT race conditions in withAuth
13
- const repoLocks = new Map<string, Promise<any>>();
14
- const GIT_BINARY = process.env.GIT_BINARY_PATH || process.env.GIT_EXECUTABLE || 'git';
15
- let gitAvailabilityChecked = false;
16
-
17
- function acquireLock(key: string): { promise: Promise<void>; release: () => void } {
18
- const prev = repoLocks.get(key) || Promise.resolve();
19
- let release = () => {};
20
- const next = new Promise<void>((resolve) => {
21
- release = resolve;
22
- });
23
- const promise = prev.then(() => {});
24
- repoLocks.set(key, next);
25
- return { promise, release };
26
- }
27
-
28
- function validateRef(ref: string): string {
29
- if (!REF_PATTERN.test(ref)) {
30
- throw new Error(`Invalid ref: ${ref}`);
31
- }
32
- return ref;
33
- }
34
-
35
- function validateBranch(branch: string): string {
36
- if (!branch || !REF_PATTERN.test(branch)) {
37
- throw new Error(`Invalid branch name: ${branch}`);
38
- }
39
- return branch;
40
- }
41
-
42
- function validateRepoUrl(repoUrl: string): void {
43
- let parsed: URL;
44
- try {
45
- parsed = new URL(repoUrl);
46
- } catch {
47
- throw new Error('Invalid repository URL');
48
- }
49
- if (parsed.protocol !== 'https:') {
50
- throw new Error('Only HTTPS repository URLs are allowed');
51
- }
52
- }
53
-
54
- async function withAuth(
55
- git: ReturnType<typeof simpleGit>,
56
- localPath: string,
57
- repoUrl: string,
58
- pat: string,
59
- fn: () => Promise<any>,
60
- username?: string,
61
- ) {
62
- // Lock by local working tree — that's what `git.remote('set-url', ...)`
63
- // mutates. Two repo records sharing a `repoUrl` but cloned to different
64
- // paths can run in parallel safely; conversely, two repos pointed at the
65
- // same `localPath` (config error) must NOT run concurrent set-url's.
66
- const lockKey = localPath;
67
- const lock = acquireLock(lockKey);
68
- await lock.promise;
69
- const authUrl = getAuthUrl(repoUrl, pat, username);
70
- await git.remote(['set-url', 'origin', authUrl]);
71
- try {
72
- return await fn();
73
- } catch (err) {
74
- // simple-git often echoes the authenticated URL in stderr — scrub before re-throw
75
- throw redactError(err);
76
- } finally {
77
- // H-2 fix: guard PAT cleanup — if reset fails, the PAT-embedded URL persists on disk
78
- try {
79
- await git.remote(['set-url', 'origin', repoUrl]);
80
- } catch (cleanupErr) {
81
- // Critical: PAT may be persisted in .git/config — attempt one more cleanup
82
- try {
83
- await git.remote(['set-url', 'origin', repoUrl]);
84
- } catch {
85
- // Log but don't throw — the original operation already completed
86
- console.error(
87
- `[plugin-git-manager] CRITICAL: failed to remove PAT from remote URL for ${redactPat(repoUrl)}. ` +
88
- `Manual cleanup of .git/config may be required.`,
89
- redactError(cleanupErr),
90
- );
91
- }
92
- }
93
- lock.release();
94
- }
95
- }
96
-
97
- function getAuthUrl(repoUrl: string, pat: string, username?: string): string {
98
- const url = new URL(repoUrl.trim());
99
- url.username = (username || 'oauth2').trim();
100
- url.password = pat.trim();
101
- return url.toString();
102
- }
103
-
104
- function getGitMissingMessage() {
105
- return `Git executable "${GIT_BINARY}" was not found on the server. Install git in the app/worker container, or set GIT_BINARY_PATH to the absolute git binary path.`;
106
- }
107
-
108
- function assertGitAvailable(ctx: Context) {
109
- if (gitAvailabilityChecked) return;
110
-
111
- const check = spawnSync(GIT_BINARY, ['--version'], { stdio: 'ignore' });
112
- if (check.error || check.status !== 0) {
113
- ctx.throw(503, getGitMissingMessage());
114
- }
115
- gitAvailabilityChecked = true;
116
- }
117
-
118
- function isMissingGitError(error: any) {
119
- const message = error?.message || String(error);
120
- return error?.code === 'ENOENT' || /spawn .*git.*ENOENT/i.test(message);
121
- }
122
-
123
- function createGit(baseDir?: string): SimpleGit {
124
- return simpleGit({ baseDir, binary: GIT_BINARY } as any);
125
- }
126
-
127
- function getGit(ctx: Context, localPath: string): SimpleGit {
128
- if (!fs.existsSync(localPath)) {
129
- ctx.throw(400, 'Repository directory does not exist. Please clone the repository first.');
130
- }
131
- assertGitAvailable(ctx);
132
- return createGit(localPath);
133
- }
134
-
135
- async function getRepo(ctx: Context) {
136
- const { repositoryId } = ctx.action.params;
137
- const repo = await ctx.db.getRepository('gitRepositories').findOne({
138
- filterByTk: repositoryId,
139
- });
140
- if (!repo) {
141
- ctx.throw(404, 'Repository not found');
142
- }
143
- return repo;
144
- }
145
-
146
- // Validate localPath to prevent path traversal
147
- function validateLocalPath(localPath: string): string {
148
- const basePath = process.env.GIT_REPOS_BASE_PATH || path.join(process.cwd(), 'storage', 'git-repos');
149
- const resolved = path.resolve(basePath, localPath);
150
-
151
- // Ensure the resolved path is strictly inside the basePath.
152
- // We add path.sep to prevent partial matches like /storage/git-repo-hack matching /storage/git-repo
153
- const strictBasePath = path.resolve(basePath) + path.sep;
154
-
155
- if (!resolved.startsWith(strictBasePath) && resolved !== path.resolve(basePath)) {
156
- throw new Error('Invalid local path: path traversal detected or path is outside the allowed base directory');
157
- }
158
- return resolved;
159
- }
160
-
161
- export async function clone(ctx: Context, next: () => Promise<void>) {
162
- const repo = await getRepo(ctx);
163
- const localPath = validateLocalPath(repo.get('localPath'));
164
- const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
165
- const pat = ((repo.get('pat') as string) || '').trim();
166
- const username = ((repo.get('username') as string) || '').trim();
167
- const defaultBranch = ((repo.get('defaultBranch') as string) || 'main').trim() || 'main';
168
-
169
- validateRepoUrl(repoUrl);
170
- // Prevent argument-injection through `defaultBranch` (e.g. `--upload-pack=...`)
171
- validateBranch(defaultBranch);
172
-
173
- // Check if directory already exists
174
- if (fs.existsSync(localPath)) {
175
- ctx.throw(400, 'Directory already exists. Remove it before cloning again.');
176
- }
177
-
178
- if (!fs.existsSync(path.dirname(localPath))) {
179
- fs.mkdirSync(path.dirname(localPath), { recursive: true });
180
- }
181
-
182
- const authUrl = getAuthUrl(repoUrl, pat, username);
183
- assertGitAvailable(ctx);
184
- try {
185
- await createGit().clone(authUrl, localPath, ['--branch', defaultBranch]);
186
- // Remove PAT from the cloned repo's remote URL
187
- await createGit(localPath).remote(['set-url', 'origin', repoUrl]);
188
- await ctx.db.getRepository('gitRepositories').update({
189
- filterByTk: repo.get('id'),
190
- values: { status: 'connected' },
191
- });
192
- ctx.body = { success: true, message: 'Repository cloned successfully' };
193
- } catch (err) {
194
- await ctx.db.getRepository('gitRepositories').update({
195
- filterByTk: repo.get('id'),
196
- values: { status: 'error' },
197
- });
198
- // Redact embedded PAT before the error reaches the client / log
199
- if (isMissingGitError(err)) {
200
- ctx.throw(503, getGitMissingMessage());
201
- }
202
- throw redactError(err);
203
- }
204
- await next();
205
- }
206
-
207
- export async function pull(ctx: Context, next: () => Promise<void>) {
208
- const repo = await getRepo(ctx);
209
- const localPath = validateLocalPath(repo.get('localPath'));
210
- const pat = ((repo.get('pat') as string) || '').trim();
211
- const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
212
- const username = ((repo.get('username') as string) || '').trim();
213
-
214
- const git = getGit(ctx, localPath);
215
- const result = await withAuth(git, localPath, repoUrl, pat, () => git.pull(), username);
216
-
217
- ctx.body = { success: true, data: result };
218
- await next();
219
- }
220
-
221
- export async function push(ctx: Context, next: () => Promise<void>) {
222
- const repo = await getRepo(ctx);
223
- const localPath = validateLocalPath(repo.get('localPath'));
224
- const pat = ((repo.get('pat') as string) || '').trim();
225
- const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
226
- const username = ((repo.get('username') as string) || '').trim();
227
-
228
- const git = getGit(ctx, localPath);
229
- const result = await withAuth(git, localPath, repoUrl, pat, () => git.push(), username);
230
-
231
- ctx.body = { success: true, data: result };
232
- await next();
233
- }
234
-
235
- export async function fetch(ctx: Context, next: () => Promise<void>) {
236
- const repo = await getRepo(ctx);
237
- const localPath = validateLocalPath(repo.get('localPath'));
238
- const pat = ((repo.get('pat') as string) || '').trim();
239
- const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
240
- const username = ((repo.get('username') as string) || '').trim();
241
-
242
- const git = getGit(ctx, localPath);
243
- const result = await withAuth(git, localPath, repoUrl, pat, () => git.fetch(), username);
244
-
245
- ctx.body = { success: true, data: result };
246
- await next();
247
- }
248
-
249
- export async function diff(ctx: Context, next: () => Promise<void>) {
250
- const repo = await getRepo(ctx);
251
- const localPath = validateLocalPath(repo.get('localPath'));
252
- const { file, commitHash, compareHash } = ctx.action.params;
253
-
254
- const git = getGit(ctx, localPath);
255
- const args: string[] = [];
256
- if (commitHash && compareHash) {
257
- args.push(validateRef(commitHash), validateRef(compareHash));
258
- } else if (commitHash) {
259
- args.push(validateRef(commitHash) + '^', validateRef(commitHash));
260
- }
261
- if (file) {
262
- if (file.includes('..')) ctx.throw(400, 'Invalid file path');
263
- args.push('--', file);
264
- }
265
-
266
- const result = await git.diff(args);
267
- ctx.body = { success: true, data: result };
268
- await next();
269
- }
270
-
271
- export async function status(ctx: Context, next: () => Promise<void>) {
272
- const repo = await getRepo(ctx);
273
- const localPath = validateLocalPath(repo.get('localPath'));
274
- const result = await getGit(ctx, localPath).status();
275
- ctx.body = { success: true, data: result };
276
- await next();
277
- }
278
-
279
- export async function log(ctx: Context, next: () => Promise<void>) {
280
- const repo = await getRepo(ctx);
281
- const localPath = validateLocalPath(repo.get('localPath'));
282
- const { maxCount = 50, file } = ctx.action.params;
283
-
284
- const parsed = parseInt(maxCount, 10);
285
- const options: Record<string, any> = { maxCount: Math.min(Math.max(parsed || 50, 1), 500) };
286
- if (file) {
287
- if (file.includes('..')) ctx.throw(400, 'Invalid file path');
288
- options.file = file;
289
- }
290
-
291
- const result = await getGit(ctx, localPath).log(options);
292
- ctx.body = { success: true, data: result };
293
- await next();
294
- }
295
-
296
- export async function branches(ctx: Context, next: () => Promise<void>) {
297
- const repo = await getRepo(ctx);
298
- const localPath = validateLocalPath(repo.get('localPath'));
299
- const result = await getGit(ctx, localPath).branch();
300
- ctx.body = { success: true, data: result };
301
- await next();
302
- }
303
-
304
- export async function checkout(ctx: Context, next: () => Promise<void>) {
305
- const repo = await getRepo(ctx);
306
- const localPath = validateLocalPath(repo.get('localPath'));
307
- const { branch } = ctx.action.params;
308
- validateBranch(branch);
309
- await getGit(ctx, localPath).checkout(branch);
310
- ctx.body = { success: true, message: `Switched to branch ${branch}` };
311
- await next();
312
- }
313
-
314
- export async function fileTree(ctx: Context, next: () => Promise<void>) {
315
- const repo = await getRepo(ctx);
316
- const localPath = validateLocalPath(repo.get('localPath'));
317
- const { ref = 'HEAD', treePath = '' } = ctx.action.params;
318
-
319
- const git = getGit(ctx, localPath);
320
- validateRef(ref);
321
- if (treePath && treePath.includes('..')) {
322
- ctx.throw(400, 'Invalid tree path');
323
- }
324
-
325
- const detailArgs = ['ls-tree', '-l', ref];
326
- if (treePath) detailArgs.push(treePath + '/');
327
- const detailedResult = await git.raw(detailArgs);
328
- const items = detailedResult
329
- .trim()
330
- .split('\n')
331
- .filter(Boolean)
332
- .map((line) => {
333
- // format: <mode> <type> <hash> <size>\t<name>
334
- const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\s+(-|\d+)\t(.+)$/);
335
- if (!match) return null;
336
- const fullPath = match[5];
337
- // Extract just the filename from the full path when using treePath prefix
338
- const parts = fullPath.split('/');
339
- const name = fullPath.includes('/') ? parts[parts.length - 1] : fullPath;
340
- return {
341
- mode: match[1],
342
- type: match[2] as 'blob' | 'tree',
343
- hash: match[3],
344
- size: match[4] === '-' ? 0 : parseInt(match[4], 10),
345
- name,
346
- path: treePath ? `${treePath}/${name}` : name,
347
- };
348
- })
349
- .filter(Boolean);
350
-
351
- // Sort: directories first, then files, both alphabetical
352
- items.sort((a, b) => {
353
- if (a.type !== b.type) return a.type === 'tree' ? -1 : 1;
354
- return a.name.localeCompare(b.name);
355
- });
356
-
357
- ctx.body = { success: true, data: items };
358
- await next();
359
- }
360
-
361
- export async function fileContent(ctx: Context, next: () => Promise<void>) {
362
- const repo = await getRepo(ctx);
363
- const localPath = validateLocalPath(repo.get('localPath'));
364
- const { ref = 'HEAD', filePath } = ctx.action.params;
365
-
366
- if (!filePath) {
367
- ctx.throw(400, 'filePath is required');
368
- }
369
- if (filePath.includes('..')) {
370
- ctx.throw(400, 'Invalid file path');
371
- }
372
-
373
- validateRef(ref);
374
- const git = getGit(ctx, localPath);
375
- const content = await git.show([`${ref}:${filePath}`]);
376
- ctx.body = { success: true, data: { content, filePath, ref } };
377
- await next();
378
- }
379
-
380
- export async function commitDetail(ctx: Context, next: () => Promise<void>) {
381
- const repo = await getRepo(ctx);
382
- const localPath = validateLocalPath(repo.get('localPath'));
383
- const { commitHash } = ctx.action.params;
384
-
385
- if (!commitHash) {
386
- ctx.throw(400, 'commitHash is required');
387
- }
388
-
389
- const git = getGit(ctx, localPath);
390
- validateRef(commitHash);
391
-
392
- // Use %x00 in format string to tell git to output null bytes, avoiding null bytes in args
393
- const DELIM_ARG = '%x00';
394
- const DELIM_OUT = '\x00';
395
- const format = `%H${DELIM_ARG}%an${DELIM_ARG}%ae${DELIM_ARG}%aI${DELIM_ARG}%s${DELIM_ARG}%b`;
396
-
397
- // Run show + diff in parallel for better performance
398
- const [show, diffResult] = await Promise.all([
399
- git.show([commitHash, '--stat', `--format=${format}`]),
400
- git.diff([`${commitHash}^`, commitHash, '--name-status']).catch(() =>
401
- // Root commit has no parent — use diff-tree --root instead
402
- git.raw(['diff-tree', '--root', '--name-status', '-r', commitHash]),
403
- ),
404
- ]);
405
-
406
- const parts = show.split(DELIM_OUT);
407
- const files = diffResult
408
- .trim()
409
- .split('\n')
410
- .filter(Boolean)
411
- .map((line) => {
412
- const [statusCode, ...fileParts] = line.split('\t');
413
- return { status: statusCode, file: fileParts.join('\t') };
414
- });
415
-
416
- ctx.body = {
417
- success: true,
418
- data: {
419
- hash: parts[0] || '',
420
- author: parts[1] || '',
421
- email: parts[2] || '',
422
- date: parts[3] || '',
423
- subject: parts[4] || '',
424
- body: (parts[5] || '').split('\n\n')[0].trim(), // body before --stat output
425
- files,
426
- raw: show,
427
- },
428
- };
429
- await next();
430
- }
1
+ import simpleGit, { SimpleGit } from 'simple-git';
2
+ import { Context } from '@nocobase/actions';
3
+ import * as path from 'path';
4
+ import * as fs from 'fs';
5
+ import { spawnSync } from 'child_process';
6
+ import { redactPat, redactError } from '../utils/redact';
7
+
8
+ // Disallow leading `-` to prevent argument-injection (e.g. `--upload-pack=...`)
9
+ // when refs are passed as positional args to git.
10
+ const REF_PATTERN = /^(?!-)[a-zA-Z0-9._/-]+$/;
11
+
12
+ // Per-repo mutex to prevent PAT race conditions in withAuth
13
+ const repoLocks = new Map<string, Promise<any>>();
14
+ const GIT_BINARY = process.env.GIT_BINARY_PATH || process.env.GIT_EXECUTABLE || 'git';
15
+ let gitAvailabilityChecked = false;
16
+
17
+ function acquireLock(key: string): { promise: Promise<void>; release: () => void } {
18
+ const prev = repoLocks.get(key) || Promise.resolve();
19
+ let release = () => {};
20
+ const next = new Promise<void>((resolve) => {
21
+ release = resolve;
22
+ });
23
+ const promise = prev.then(() => {});
24
+ repoLocks.set(key, next);
25
+ return { promise, release };
26
+ }
27
+
28
+ function validateRef(ref: string): string {
29
+ if (!REF_PATTERN.test(ref)) {
30
+ throw new Error(`Invalid ref: ${ref}`);
31
+ }
32
+ return ref;
33
+ }
34
+
35
+ function validateBranch(branch: string): string {
36
+ if (!branch || !REF_PATTERN.test(branch)) {
37
+ throw new Error(`Invalid branch name: ${branch}`);
38
+ }
39
+ return branch;
40
+ }
41
+
42
+ function validateRepoUrl(repoUrl: string): void {
43
+ let parsed: URL;
44
+ try {
45
+ parsed = new URL(repoUrl);
46
+ } catch {
47
+ throw new Error('Invalid repository URL');
48
+ }
49
+ if (parsed.protocol !== 'https:') {
50
+ throw new Error('Only HTTPS repository URLs are allowed');
51
+ }
52
+ }
53
+
54
+ async function withAuth(
55
+ git: ReturnType<typeof simpleGit>,
56
+ localPath: string,
57
+ repoUrl: string,
58
+ pat: string,
59
+ fn: () => Promise<any>,
60
+ username?: string,
61
+ ) {
62
+ // Lock by local working tree — that's what `git.remote('set-url', ...)`
63
+ // mutates. Two repo records sharing a `repoUrl` but cloned to different
64
+ // paths can run in parallel safely; conversely, two repos pointed at the
65
+ // same `localPath` (config error) must NOT run concurrent set-url's.
66
+ const lockKey = localPath;
67
+ const lock = acquireLock(lockKey);
68
+ await lock.promise;
69
+ const authUrl = getAuthUrl(repoUrl, pat, username);
70
+ await git.remote(['set-url', 'origin', authUrl]);
71
+ try {
72
+ return await fn();
73
+ } catch (err) {
74
+ // simple-git often echoes the authenticated URL in stderr — scrub before re-throw
75
+ throw redactError(err);
76
+ } finally {
77
+ // H-2 fix: guard PAT cleanup — if reset fails, the PAT-embedded URL persists on disk
78
+ try {
79
+ await git.remote(['set-url', 'origin', repoUrl]);
80
+ } catch (cleanupErr) {
81
+ // Critical: PAT may be persisted in .git/config — attempt one more cleanup
82
+ try {
83
+ await git.remote(['set-url', 'origin', repoUrl]);
84
+ } catch {
85
+ // Log but don't throw — the original operation already completed
86
+ console.error(
87
+ `[plugin-git-manager] CRITICAL: failed to remove PAT from remote URL for ${redactPat(repoUrl)}. ` +
88
+ `Manual cleanup of .git/config may be required.`,
89
+ redactError(cleanupErr),
90
+ );
91
+ }
92
+ }
93
+ lock.release();
94
+ }
95
+ }
96
+
97
+ function getAuthUrl(repoUrl: string, pat: string, username?: string): string {
98
+ const url = new URL(repoUrl.trim());
99
+ url.username = (username || 'oauth2').trim();
100
+ url.password = pat.trim();
101
+ return url.toString();
102
+ }
103
+
104
+ function getGitMissingMessage() {
105
+ return `Git executable "${GIT_BINARY}" was not found on the server. Install git in the app/worker container, or set GIT_BINARY_PATH to the absolute git binary path.`;
106
+ }
107
+
108
+ function assertGitAvailable(ctx: Context) {
109
+ if (gitAvailabilityChecked) return;
110
+
111
+ const check = spawnSync(GIT_BINARY, ['--version'], { stdio: 'ignore' });
112
+ if (check.error || check.status !== 0) {
113
+ ctx.throw(503, getGitMissingMessage());
114
+ }
115
+ gitAvailabilityChecked = true;
116
+ }
117
+
118
+ function isMissingGitError(error: any) {
119
+ const message = error?.message || String(error);
120
+ return error?.code === 'ENOENT' || /spawn .*git.*ENOENT/i.test(message);
121
+ }
122
+
123
+ function createGit(baseDir?: string): SimpleGit {
124
+ return simpleGit({ baseDir, binary: GIT_BINARY } as any);
125
+ }
126
+
127
+ function getGit(ctx: Context, localPath: string): SimpleGit {
128
+ if (!fs.existsSync(localPath)) {
129
+ ctx.throw(400, 'Repository directory does not exist. Please clone the repository first.');
130
+ }
131
+ assertGitAvailable(ctx);
132
+ return createGit(localPath);
133
+ }
134
+
135
+ async function getRepo(ctx: Context) {
136
+ const { repositoryId } = ctx.action.params;
137
+ const repo = await ctx.db.getRepository('gitRepositories').findOne({
138
+ filterByTk: repositoryId,
139
+ });
140
+ if (!repo) {
141
+ ctx.throw(404, 'Repository not found');
142
+ }
143
+ return repo;
144
+ }
145
+
146
+ // Validate localPath to prevent path traversal
147
+ function validateLocalPath(localPath: string): string {
148
+ const basePath = process.env.GIT_REPOS_BASE_PATH || path.join(process.cwd(), 'storage', 'git-repos');
149
+ const resolved = path.resolve(basePath, localPath);
150
+
151
+ // Ensure the resolved path is strictly inside the basePath.
152
+ // We add path.sep to prevent partial matches like /storage/git-repo-hack matching /storage/git-repo
153
+ const strictBasePath = path.resolve(basePath) + path.sep;
154
+
155
+ if (!resolved.startsWith(strictBasePath) && resolved !== path.resolve(basePath)) {
156
+ throw new Error('Invalid local path: path traversal detected or path is outside the allowed base directory');
157
+ }
158
+ return resolved;
159
+ }
160
+
161
+ export async function clone(ctx: Context, next: () => Promise<void>) {
162
+ const repo = await getRepo(ctx);
163
+ const localPath = validateLocalPath(repo.get('localPath'));
164
+ const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
165
+ const pat = ((repo.get('pat') as string) || '').trim();
166
+ const username = ((repo.get('username') as string) || '').trim();
167
+ const defaultBranch = ((repo.get('defaultBranch') as string) || 'main').trim() || 'main';
168
+
169
+ validateRepoUrl(repoUrl);
170
+ // Prevent argument-injection through `defaultBranch` (e.g. `--upload-pack=...`)
171
+ validateBranch(defaultBranch);
172
+
173
+ // Check if directory already exists
174
+ if (fs.existsSync(localPath)) {
175
+ ctx.throw(400, 'Directory already exists. Remove it before cloning again.');
176
+ }
177
+
178
+ if (!fs.existsSync(path.dirname(localPath))) {
179
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
180
+ }
181
+
182
+ const authUrl = getAuthUrl(repoUrl, pat, username);
183
+ assertGitAvailable(ctx);
184
+ try {
185
+ await createGit().clone(authUrl, localPath, ['--branch', defaultBranch]);
186
+ // Remove PAT from the cloned repo's remote URL
187
+ await createGit(localPath).remote(['set-url', 'origin', repoUrl]);
188
+ await ctx.db.getRepository('gitRepositories').update({
189
+ filterByTk: repo.get('id'),
190
+ values: { status: 'connected' },
191
+ });
192
+ ctx.body = { success: true, message: 'Repository cloned successfully' };
193
+ } catch (err) {
194
+ await ctx.db.getRepository('gitRepositories').update({
195
+ filterByTk: repo.get('id'),
196
+ values: { status: 'error' },
197
+ });
198
+ // Redact embedded PAT before the error reaches the client / log
199
+ if (isMissingGitError(err)) {
200
+ ctx.throw(503, getGitMissingMessage());
201
+ }
202
+ throw redactError(err);
203
+ }
204
+ await next();
205
+ }
206
+
207
+ export async function pull(ctx: Context, next: () => Promise<void>) {
208
+ const repo = await getRepo(ctx);
209
+ const localPath = validateLocalPath(repo.get('localPath'));
210
+ const pat = ((repo.get('pat') as string) || '').trim();
211
+ const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
212
+ const username = ((repo.get('username') as string) || '').trim();
213
+
214
+ const git = getGit(ctx, localPath);
215
+ const result = await withAuth(git, localPath, repoUrl, pat, () => git.pull(), username);
216
+
217
+ ctx.body = { success: true, data: result };
218
+ await next();
219
+ }
220
+
221
+ export async function push(ctx: Context, next: () => Promise<void>) {
222
+ const repo = await getRepo(ctx);
223
+ const localPath = validateLocalPath(repo.get('localPath'));
224
+ const pat = ((repo.get('pat') as string) || '').trim();
225
+ const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
226
+ const username = ((repo.get('username') as string) || '').trim();
227
+
228
+ const git = getGit(ctx, localPath);
229
+ const result = await withAuth(git, localPath, repoUrl, pat, () => git.push(), username);
230
+
231
+ ctx.body = { success: true, data: result };
232
+ await next();
233
+ }
234
+
235
+ export async function fetch(ctx: Context, next: () => Promise<void>) {
236
+ const repo = await getRepo(ctx);
237
+ const localPath = validateLocalPath(repo.get('localPath'));
238
+ const pat = ((repo.get('pat') as string) || '').trim();
239
+ const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
240
+ const username = ((repo.get('username') as string) || '').trim();
241
+
242
+ const git = getGit(ctx, localPath);
243
+ const result = await withAuth(git, localPath, repoUrl, pat, () => git.fetch(), username);
244
+
245
+ ctx.body = { success: true, data: result };
246
+ await next();
247
+ }
248
+
249
+ export async function diff(ctx: Context, next: () => Promise<void>) {
250
+ const repo = await getRepo(ctx);
251
+ const localPath = validateLocalPath(repo.get('localPath'));
252
+ const { file, commitHash, compareHash } = ctx.action.params;
253
+
254
+ const git = getGit(ctx, localPath);
255
+ const args: string[] = [];
256
+ if (commitHash && compareHash) {
257
+ args.push(validateRef(commitHash), validateRef(compareHash));
258
+ } else if (commitHash) {
259
+ args.push(validateRef(commitHash) + '^', validateRef(commitHash));
260
+ }
261
+ if (file) {
262
+ if (file.includes('..')) ctx.throw(400, 'Invalid file path');
263
+ args.push('--', file);
264
+ }
265
+
266
+ const result = await git.diff(args);
267
+ ctx.body = { success: true, data: result };
268
+ await next();
269
+ }
270
+
271
+ export async function status(ctx: Context, next: () => Promise<void>) {
272
+ const repo = await getRepo(ctx);
273
+ const localPath = validateLocalPath(repo.get('localPath'));
274
+ const result = await getGit(ctx, localPath).status();
275
+ ctx.body = { success: true, data: result };
276
+ await next();
277
+ }
278
+
279
+ export async function log(ctx: Context, next: () => Promise<void>) {
280
+ const repo = await getRepo(ctx);
281
+ const localPath = validateLocalPath(repo.get('localPath'));
282
+ const { maxCount = 50, file } = ctx.action.params;
283
+
284
+ const parsed = parseInt(maxCount, 10);
285
+ const options: Record<string, any> = { maxCount: Math.min(Math.max(parsed || 50, 1), 500) };
286
+ if (file) {
287
+ if (file.includes('..')) ctx.throw(400, 'Invalid file path');
288
+ options.file = file;
289
+ }
290
+
291
+ const result = await getGit(ctx, localPath).log(options);
292
+ ctx.body = { success: true, data: result };
293
+ await next();
294
+ }
295
+
296
+ export async function branches(ctx: Context, next: () => Promise<void>) {
297
+ const repo = await getRepo(ctx);
298
+ const localPath = validateLocalPath(repo.get('localPath'));
299
+ const result = await getGit(ctx, localPath).branch();
300
+ ctx.body = { success: true, data: result };
301
+ await next();
302
+ }
303
+
304
+ export async function checkout(ctx: Context, next: () => Promise<void>) {
305
+ const repo = await getRepo(ctx);
306
+ const localPath = validateLocalPath(repo.get('localPath'));
307
+ const { branch } = ctx.action.params;
308
+ validateBranch(branch);
309
+ await getGit(ctx, localPath).checkout(branch);
310
+ ctx.body = { success: true, message: `Switched to branch ${branch}` };
311
+ await next();
312
+ }
313
+
314
+ export async function fileTree(ctx: Context, next: () => Promise<void>) {
315
+ const repo = await getRepo(ctx);
316
+ const localPath = validateLocalPath(repo.get('localPath'));
317
+ const { ref = 'HEAD', treePath = '' } = ctx.action.params;
318
+
319
+ const git = getGit(ctx, localPath);
320
+ validateRef(ref);
321
+ if (treePath && treePath.includes('..')) {
322
+ ctx.throw(400, 'Invalid tree path');
323
+ }
324
+
325
+ const detailArgs = ['ls-tree', '-l', ref];
326
+ if (treePath) detailArgs.push(treePath + '/');
327
+ const detailedResult = await git.raw(detailArgs);
328
+ const items = detailedResult
329
+ .trim()
330
+ .split('\n')
331
+ .filter(Boolean)
332
+ .map((line) => {
333
+ // format: <mode> <type> <hash> <size>\t<name>
334
+ const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\s+(-|\d+)\t(.+)$/);
335
+ if (!match) return null;
336
+ const fullPath = match[5];
337
+ // Extract just the filename from the full path when using treePath prefix
338
+ const parts = fullPath.split('/');
339
+ const name = fullPath.includes('/') ? parts[parts.length - 1] : fullPath;
340
+ return {
341
+ mode: match[1],
342
+ type: match[2] as 'blob' | 'tree',
343
+ hash: match[3],
344
+ size: match[4] === '-' ? 0 : parseInt(match[4], 10),
345
+ name,
346
+ path: treePath ? `${treePath}/${name}` : name,
347
+ };
348
+ })
349
+ .filter(Boolean);
350
+
351
+ // Sort: directories first, then files, both alphabetical
352
+ items.sort((a, b) => {
353
+ if (a.type !== b.type) return a.type === 'tree' ? -1 : 1;
354
+ return a.name.localeCompare(b.name);
355
+ });
356
+
357
+ ctx.body = { success: true, data: items };
358
+ await next();
359
+ }
360
+
361
+ export async function fileContent(ctx: Context, next: () => Promise<void>) {
362
+ const repo = await getRepo(ctx);
363
+ const localPath = validateLocalPath(repo.get('localPath'));
364
+ const { ref = 'HEAD', filePath } = ctx.action.params;
365
+
366
+ if (!filePath) {
367
+ ctx.throw(400, 'filePath is required');
368
+ }
369
+ if (filePath.includes('..')) {
370
+ ctx.throw(400, 'Invalid file path');
371
+ }
372
+
373
+ validateRef(ref);
374
+ const git = getGit(ctx, localPath);
375
+ const content = await git.show([`${ref}:${filePath}`]);
376
+ ctx.body = { success: true, data: { content, filePath, ref } };
377
+ await next();
378
+ }
379
+
380
+ export async function commitDetail(ctx: Context, next: () => Promise<void>) {
381
+ const repo = await getRepo(ctx);
382
+ const localPath = validateLocalPath(repo.get('localPath'));
383
+ const { commitHash } = ctx.action.params;
384
+
385
+ if (!commitHash) {
386
+ ctx.throw(400, 'commitHash is required');
387
+ }
388
+
389
+ const git = getGit(ctx, localPath);
390
+ validateRef(commitHash);
391
+
392
+ // Use %x00 in format string to tell git to output null bytes, avoiding null bytes in args
393
+ const DELIM_ARG = '%x00';
394
+ const DELIM_OUT = '\x00';
395
+ const format = `%H${DELIM_ARG}%an${DELIM_ARG}%ae${DELIM_ARG}%aI${DELIM_ARG}%s${DELIM_ARG}%b`;
396
+
397
+ // Run show + diff in parallel for better performance
398
+ const [show, diffResult] = await Promise.all([
399
+ git.show([commitHash, '--stat', `--format=${format}`]),
400
+ git.diff([`${commitHash}^`, commitHash, '--name-status']).catch(() =>
401
+ // Root commit has no parent — use diff-tree --root instead
402
+ git.raw(['diff-tree', '--root', '--name-status', '-r', commitHash]),
403
+ ),
404
+ ]);
405
+
406
+ const parts = show.split(DELIM_OUT);
407
+ const files = diffResult
408
+ .trim()
409
+ .split('\n')
410
+ .filter(Boolean)
411
+ .map((line) => {
412
+ const [statusCode, ...fileParts] = line.split('\t');
413
+ return { status: statusCode, file: fileParts.join('\t') };
414
+ });
415
+
416
+ ctx.body = {
417
+ success: true,
418
+ data: {
419
+ hash: parts[0] || '',
420
+ author: parts[1] || '',
421
+ email: parts[2] || '',
422
+ date: parts[3] || '',
423
+ subject: parts[4] || '',
424
+ body: (parts[5] || '').split('\n\n')[0].trim(), // body before --stat output
425
+ files,
426
+ raw: show,
427
+ },
428
+ };
429
+ await next();
430
+ }