git-drive 0.1.6 → 0.1.7

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 (95) hide show
  1. package/.github/workflows/ci.yml +77 -0
  2. package/.planning/codebase/ARCHITECTURE.md +151 -0
  3. package/.planning/codebase/CONCERNS.md +191 -0
  4. package/.planning/codebase/CONVENTIONS.md +169 -0
  5. package/.planning/codebase/INTEGRATIONS.md +94 -0
  6. package/.planning/codebase/STACK.md +77 -0
  7. package/.planning/codebase/STRUCTURE.md +157 -0
  8. package/.planning/codebase/TESTING.md +156 -0
  9. package/Dockerfile.cli +30 -0
  10. package/Dockerfile.server +32 -0
  11. package/README.md +157 -0
  12. package/docker-compose.yml +48 -0
  13. package/package.json +20 -55
  14. package/packages/cli/Dockerfile +26 -0
  15. package/packages/cli/jest.config.js +26 -0
  16. package/packages/cli/package.json +65 -0
  17. package/packages/cli/src/__tests__/commands/companion.test.ts +152 -0
  18. package/packages/cli/src/__tests__/commands/init.test.ts +154 -0
  19. package/packages/cli/src/__tests__/commands/list.test.ts +122 -0
  20. package/packages/cli/src/__tests__/commands/push.test.ts +155 -0
  21. package/packages/cli/src/__tests__/commands/restore.test.ts +135 -0
  22. package/packages/cli/src/__tests__/commands/status.test.ts +199 -0
  23. package/packages/cli/src/__tests__/config.test.ts +198 -0
  24. package/packages/cli/src/__tests__/e2e.test.ts +125 -0
  25. package/packages/cli/src/__tests__/errors.test.ts +66 -0
  26. package/packages/cli/src/__tests__/git.test.ts +250 -0
  27. package/packages/cli/src/__tests__/server.test.ts +371 -0
  28. package/packages/cli/src/commands/archive.ts +39 -0
  29. package/packages/cli/src/commands/companion.ts +205 -0
  30. package/packages/cli/src/commands/init.ts +130 -0
  31. package/packages/cli/src/commands/link.ts +151 -0
  32. package/packages/cli/src/commands/list.ts +94 -0
  33. package/packages/cli/src/commands/push.ts +77 -0
  34. package/packages/cli/src/commands/restore.ts +36 -0
  35. package/packages/cli/src/commands/status.ts +127 -0
  36. package/packages/cli/src/config.ts +73 -0
  37. package/packages/cli/src/errors.ts +23 -0
  38. package/packages/cli/src/git.ts +60 -0
  39. package/packages/cli/src/index.ts +129 -0
  40. package/packages/cli/src/server.ts +700 -0
  41. package/packages/cli/tsconfig.json +13 -0
  42. package/packages/git-drive-docker/package.json +15 -0
  43. package/packages/server/package.json +44 -0
  44. package/packages/server/src/index.ts +569 -0
  45. package/packages/server/tsconfig.json +9 -0
  46. package/packages/ui/README.md +73 -0
  47. package/packages/ui/eslint.config.js +23 -0
  48. package/packages/ui/index.html +13 -0
  49. package/packages/ui/package.json +52 -0
  50. package/packages/ui/postcss.config.js +6 -0
  51. package/packages/ui/public/vite.svg +1 -0
  52. package/packages/ui/src/App.css +23 -0
  53. package/packages/ui/src/App.test.tsx +248 -0
  54. package/packages/ui/src/App.tsx +803 -0
  55. package/packages/ui/src/assets/react.svg +8 -0
  56. package/packages/ui/src/assets/vite.svg +3 -0
  57. package/packages/ui/src/index.css +37 -0
  58. package/packages/ui/src/main.tsx +14 -0
  59. package/packages/ui/src/test/setup.ts +1 -0
  60. package/packages/ui/tailwind.config.js +11 -0
  61. package/packages/ui/tsconfig.app.json +28 -0
  62. package/packages/ui/tsconfig.json +26 -0
  63. package/packages/ui/tsconfig.node.json +12 -0
  64. package/packages/ui/vite.config.ts +7 -0
  65. package/packages/ui/vitest.config.ts +20 -0
  66. package/pnpm-workspace.yaml +4 -0
  67. package/rewrite_app.js +731 -0
  68. package/tsconfig.json +14 -0
  69. package/dist/__tests__/commands/init.test.js +0 -123
  70. package/dist/__tests__/commands/list.test.js +0 -91
  71. package/dist/__tests__/commands/push.test.js +0 -128
  72. package/dist/__tests__/commands/restore.test.js +0 -99
  73. package/dist/__tests__/commands/status.test.js +0 -151
  74. package/dist/__tests__/config.test.js +0 -150
  75. package/dist/__tests__/e2e.test.js +0 -107
  76. package/dist/__tests__/errors.test.js +0 -56
  77. package/dist/__tests__/git.test.js +0 -184
  78. package/dist/__tests__/server.test.js +0 -310
  79. package/dist/commands/archive.js +0 -32
  80. package/dist/commands/init.js +0 -55
  81. package/dist/commands/link.js +0 -175
  82. package/dist/commands/list.js +0 -83
  83. package/dist/commands/push.js +0 -112
  84. package/dist/commands/restore.js +0 -30
  85. package/dist/commands/status.js +0 -116
  86. package/dist/config.js +0 -62
  87. package/dist/errors.js +0 -30
  88. package/dist/git.js +0 -67
  89. package/dist/index.js +0 -108
  90. package/dist/server.js +0 -535
  91. /package/{ui → packages/cli/ui}/assets/index-Br8xQbJz.js +0 -0
  92. /package/{ui → packages/cli/ui}/assets/index-Cc2q1t5k.js +0 -0
  93. /package/{ui → packages/cli/ui}/assets/index-DrL7ojPA.css +0 -0
  94. /package/{ui → packages/cli/ui}/index.html +0 -0
  95. /package/{ui → packages/cli/ui}/vite.svg +0 -0
@@ -0,0 +1,700 @@
1
+ import express, { Request, Response } from 'express';
2
+ import { spawn } from 'child_process';
3
+ import path from 'path';
4
+ import { readdirSync, existsSync, mkdirSync, statSync, readFileSync, appendFileSync, writeFileSync } from 'fs';
5
+ import { execSync } from 'child_process';
6
+ import { getDiskInfo } from 'node-disk-info';
7
+ import { homedir } from 'os';
8
+
9
+ const app = express();
10
+ const port = process.env.GIT_DRIVE_PORT || 4483;
11
+
12
+ // Companion mode configuration
13
+ const COMPANION_MODE = process.env.GIT_DRIVE_COMPANION_MODE === 'true';
14
+ const COMPANION_DRIVE = process.env.GIT_DRIVE_COMPANION_DRIVE;
15
+
16
+ // Companion repository URL
17
+ const COMPANION_REPO_URL = "https://github.com/josmanvis/git-drive.git";
18
+
19
+ app.use(express.json());
20
+
21
+ // Serve static UI files from the ui directory
22
+ const uiPath = path.join(__dirname, '..', 'ui');
23
+ app.use(express.static(uiPath));
24
+
25
+ // ── Helpers ──────────────────────────────────────────────────────────
26
+
27
+ function git(args: string, cwd?: string): string {
28
+ return execSync(`git ${args}`, {
29
+ cwd,
30
+ encoding: 'utf-8',
31
+ stdio: ['pipe', 'pipe', 'pipe'],
32
+ }).trim();
33
+ }
34
+
35
+ function getGitDrivePath(mountpoint: string): string {
36
+ return path.join(mountpoint, '.git-drive');
37
+ }
38
+
39
+ function ensureGitDriveDir(mountpoint: string): string {
40
+ const gitDrivePath = getGitDrivePath(mountpoint);
41
+ if (!existsSync(gitDrivePath)) {
42
+ try {
43
+ mkdirSync(gitDrivePath, { recursive: true });
44
+ } catch (err: any) {
45
+ throw new Error(`Failed to write to drive. Please ensure Terminal/Node has "Removable Volumes" access in macOS Privacy settings. Details: ${err.message}`);
46
+ }
47
+ }
48
+ return gitDrivePath;
49
+ }
50
+
51
+ function listRepos(gitDrivePath: string): Array<{ name: string; path: string; lastModified: string }> {
52
+ if (!existsSync(gitDrivePath)) return [];
53
+
54
+ return readdirSync(gitDrivePath)
55
+ .filter((entry) => {
56
+ const entryPath = path.join(gitDrivePath, entry);
57
+ return (
58
+ statSync(entryPath).isDirectory() &&
59
+ (entry.endsWith('.git') || existsSync(path.join(entryPath, 'HEAD')))
60
+ );
61
+ })
62
+ .map((entry) => {
63
+ const entryPath = path.join(gitDrivePath, entry);
64
+ const stat = statSync(entryPath);
65
+ return {
66
+ name: entry.replace(/\.git$/, ''),
67
+ path: entryPath,
68
+ lastModified: stat.mtime.toISOString(),
69
+ };
70
+ });
71
+ }
72
+
73
+ function loadLinks(): Record<string, { mountpoint: string; repoName: string; linkedAt: string }> {
74
+ const linksFile = path.join(homedir(), '.config', 'git-drive', 'links.json');
75
+ if (!existsSync(linksFile)) return {};
76
+ try {
77
+ return JSON.parse(readFileSync(linksFile, 'utf-8'));
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ // ── Server Health Check Utilities ────────────────────────────────────────────
84
+
85
+ const DEFAULT_PORT = 4483;
86
+
87
+ export function getServerPort(): number {
88
+ return parseInt(process.env.GIT_DRIVE_PORT || String(DEFAULT_PORT), 10);
89
+ }
90
+
91
+ export async function isServerRunning(port?: number): Promise<boolean> {
92
+ const serverPort = port || getServerPort();
93
+ try {
94
+ const response = await fetch(`http://localhost:${serverPort}/api/drives`, {
95
+ method: 'HEAD',
96
+ signal: AbortSignal.timeout(1000),
97
+ });
98
+ return response.ok;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ export async function ensureServerRunning(): Promise<void> {
105
+ const port = getServerPort();
106
+ const running = await isServerRunning(port);
107
+
108
+ if (!running) {
109
+ console.log('\n 🚀 Starting Git Drive server...\n');
110
+
111
+ // Start server in detached/background mode
112
+ const serverPath = require.resolve('./server.js');
113
+ const child = spawn(process.execPath, [serverPath], {
114
+ detached: true,
115
+ stdio: 'ignore',
116
+ env: process.env,
117
+ });
118
+
119
+ // Allow the parent process to exit independently
120
+ child.unref();
121
+
122
+ // Wait a moment for server to start
123
+ let retries = 10;
124
+ while (retries > 0) {
125
+ await new Promise(resolve => setTimeout(resolve, 300));
126
+ if (await isServerRunning(port)) {
127
+ break;
128
+ }
129
+ retries--;
130
+ }
131
+
132
+ if (retries === 0) {
133
+ throw new Error('Failed to start Git Drive server. Please run "git-drive server" manually.');
134
+ }
135
+ }
136
+ }
137
+
138
+ // ── API Routes ───────────────────────────────────────────────────────
139
+
140
+ // Health check endpoint
141
+ app.get('/api/health', (_req: Request, res: Response) => {
142
+ res.json({ status: 'ok', timestamp: new Date().toISOString() });
143
+ });
144
+
145
+ // Companion info endpoint - returns info about companion mode
146
+ app.get('/api/companion-info', (_req: Request, res: Response) => {
147
+ res.json({
148
+ companionMode: COMPANION_MODE,
149
+ companionDrive: COMPANION_DRIVE || null,
150
+ });
151
+ });
152
+
153
+ // Get current version from package.json
154
+ function getCurrentVersion(): string {
155
+ try {
156
+ const packageJsonPath = path.join(__dirname, '..', 'package.json');
157
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
158
+ return packageJson.version;
159
+ } catch {
160
+ return 'unknown';
161
+ }
162
+ }
163
+
164
+ // Get companion info for a specific drive
165
+ function getCompanionInfo(mountpoint: string): { installed: boolean; version?: string; installedAt?: string; outdated?: boolean } {
166
+ const gitDrivePath = getGitDrivePath(mountpoint);
167
+ const companionVersionPath = path.join(gitDrivePath, 'companion.json');
168
+ const companionRepoPath = path.join(gitDrivePath, 'git-drive.git');
169
+
170
+ if (!existsSync(companionRepoPath)) {
171
+ return { installed: false };
172
+ }
173
+
174
+ try {
175
+ if (existsSync(companionVersionPath)) {
176
+ const companionInfo = JSON.parse(readFileSync(companionVersionPath, 'utf-8'));
177
+ const currentVersion = getCurrentVersion();
178
+ return {
179
+ installed: true,
180
+ version: companionInfo.version,
181
+ installedAt: companionInfo.installedAt,
182
+ outdated: companionInfo.version !== currentVersion,
183
+ };
184
+ }
185
+ return { installed: true };
186
+ } catch {
187
+ return { installed: true };
188
+ }
189
+ }
190
+
191
+ // Install/update companion on a drive
192
+ app.post('/api/drives/:mountpoint/install-companion', (req: Request, res: Response) => {
193
+ try {
194
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
195
+ const gitDrivePath = getGitDrivePath(mountpoint);
196
+
197
+ if (!existsSync(mountpoint)) {
198
+ res.status(404).json({ error: 'Drive not found or not mounted' });
199
+ return;
200
+ }
201
+
202
+ if (!existsSync(gitDrivePath)) {
203
+ mkdirSync(gitDrivePath, { recursive: true });
204
+ }
205
+
206
+ const companionRepoPath = path.join(gitDrivePath, 'git-drive.git');
207
+ const companionVersionPath = path.join(gitDrivePath, 'companion.json');
208
+ const currentVersion = getCurrentVersion();
209
+
210
+ try {
211
+ // If companion already exists, update it
212
+ if (existsSync(companionRepoPath)) {
213
+ try {
214
+ execSync(`git -C "${companionRepoPath}" fetch origin`, { stdio: 'pipe' });
215
+ execSync(`git -C "${companionRepoPath}" reset --hard origin/main`, { stdio: 'pipe' });
216
+ } catch {
217
+ // If update fails, remove and re-clone
218
+ execSync(`rm -rf "${companionRepoPath}"`, { stdio: 'pipe' });
219
+ execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
220
+ }
221
+ } else {
222
+ // Clone the companion
223
+ execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
224
+ }
225
+
226
+ // Write companion version info
227
+ const companionInfo = {
228
+ version: currentVersion,
229
+ installedAt: new Date().toISOString(),
230
+ repoUrl: COMPANION_REPO_URL,
231
+ };
232
+ writeFileSync(companionVersionPath, JSON.stringify(companionInfo, null, 2));
233
+
234
+ res.json({
235
+ success: true,
236
+ version: currentVersion,
237
+ message: 'Companion installed successfully',
238
+ });
239
+ } catch (err: any) {
240
+ res.status(500).json({ error: `Failed to install companion: ${err.message}` });
241
+ }
242
+ } catch (err: any) {
243
+ res.status(500).json({ error: err.message || 'Failed to install companion' });
244
+ }
245
+ });
246
+
247
+ // List all connected drives
248
+ app.get('/api/drives', async (_req: Request, res: Response) => {
249
+ try {
250
+ const drives = await getDiskInfo();
251
+ let result = drives
252
+ .filter((d: any) => {
253
+ const mp = d.mounted;
254
+ if (!mp) return false;
255
+ if (mp === "/" || mp === "100%") return false;
256
+
257
+ // Exclude temporary and system paths on all platforms
258
+ if (mp.startsWith("/var/") || mp.startsWith("/private/var/") || mp.startsWith("/tmp") || mp.startsWith("/private/tmp")) return false;
259
+ if (mp.includes("TemporaryItems") || mp.includes("NSIRD_")) return false;
260
+ if (mp.startsWith("/System/") || mp.startsWith("/Library/")) return false;
261
+
262
+ if (process.platform === "darwin") {
263
+ return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
264
+ }
265
+
266
+ if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
267
+ if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
268
+
269
+ return true;
270
+ })
271
+ .map((d: any) => {
272
+ const mountpoint = d.mounted;
273
+ const companionInfo = getCompanionInfo(mountpoint);
274
+ return {
275
+ device: d.filesystem,
276
+ description: mountpoint,
277
+ size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
278
+ isRemovable: true,
279
+ isSystem: mountpoint === '/',
280
+ mountpoints: [mountpoint],
281
+ hasGitDrive: existsSync(getGitDrivePath(mountpoint)),
282
+ hasCompanion: companionInfo.installed,
283
+ companionVersion: companionInfo.version,
284
+ companionOutdated: companionInfo.outdated,
285
+ };
286
+ });
287
+
288
+ // In companion mode, filter to only show the companion drive
289
+ if (COMPANION_MODE && COMPANION_DRIVE) {
290
+ result = result.filter((d: any) => d.mountpoints[0] === COMPANION_DRIVE);
291
+ }
292
+
293
+ res.json(result);
294
+ } catch (err) {
295
+ res.status(500).json({ error: 'Failed to list drives' });
296
+ }
297
+ });
298
+
299
+ // List repos on a specific drive
300
+ app.get('/api/drives/:mountpoint/repos', (req: Request, res: Response) => {
301
+ try {
302
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
303
+ const gitDrivePath = getGitDrivePath(mountpoint);
304
+
305
+ if (!existsSync(mountpoint)) {
306
+ res.status(404).json({ error: 'Drive not found or not mounted' });
307
+ return;
308
+ }
309
+
310
+ const repos = listRepos(gitDrivePath);
311
+ res.json({
312
+ mountpoint,
313
+ gitDrivePath,
314
+ initialized: existsSync(gitDrivePath),
315
+ repos,
316
+ });
317
+ } catch (err) {
318
+ res.status(500).json({ error: 'Failed to list repos' });
319
+ }
320
+ });
321
+
322
+ // Initialize git-drive on a drive
323
+ app.post('/api/drives/:mountpoint/init', (req: Request, res: Response) => {
324
+ try {
325
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
326
+
327
+ if (!existsSync(mountpoint)) {
328
+ res.status(404).json({ error: 'Drive not found or not mounted' });
329
+ return;
330
+ }
331
+
332
+ const gitDrivePath = ensureGitDriveDir(mountpoint);
333
+ res.json({
334
+ mountpoint,
335
+ gitDrivePath,
336
+ message: 'Git Drive initialized on this drive',
337
+ });
338
+ } catch (err: any) {
339
+ console.error("Init Error:", err);
340
+ res.status(500).json({ error: err.message || 'Failed to initialize drive' });
341
+ }
342
+ });
343
+
344
+ // Create a new bare repo on a drive
345
+ app.post('/api/drives/:mountpoint/repos', (req: Request, res: Response) => {
346
+ try {
347
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
348
+ const { name } = req.body;
349
+
350
+ if (!name || typeof name !== 'string') {
351
+ res.status(400).json({ error: 'Repo name is required' });
352
+ return;
353
+ }
354
+
355
+ const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '-');
356
+
357
+ if (!existsSync(mountpoint)) {
358
+ res.status(404).json({ error: 'Drive not found or not mounted' });
359
+ return;
360
+ }
361
+
362
+ const gitDrivePath = ensureGitDriveDir(mountpoint);
363
+ const repoName = safeName.endsWith('.git') ? safeName : `${safeName}.git`;
364
+ const repoPath = path.join(gitDrivePath, repoName);
365
+
366
+ if (existsSync(repoPath)) {
367
+ res.status(409).json({ error: 'Repository already exists' });
368
+ return;
369
+ }
370
+
371
+ git(`init --bare "${repoPath}"`);
372
+
373
+ res.status(201).json({
374
+ name: safeName.replace(/\.git$/, ''),
375
+ path: repoPath,
376
+ message: `Bare repository created: ${repoName}`,
377
+ remoteUrl: repoPath,
378
+ });
379
+ } catch (err) {
380
+ res.status(500).json({ error: 'Failed to create repository' });
381
+ }
382
+ });
383
+
384
+ // Delete a repo from a drive
385
+ app.delete('/api/drives/:mountpoint/repos/:repoName', (req: Request, res: Response) => {
386
+ try {
387
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
388
+ const repoName = decodeURIComponent(req.params.repoName);
389
+ const gitDrivePath = getGitDrivePath(mountpoint);
390
+
391
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
392
+ const repoPath = path.join(gitDrivePath, bareRepoName);
393
+
394
+ if (!existsSync(repoPath)) {
395
+ const altPath = path.join(gitDrivePath, repoName);
396
+ if (!existsSync(altPath)) {
397
+ res.status(404).json({ error: 'Repository not found' });
398
+ return;
399
+ }
400
+ execSync(`rm -rf "${altPath}"`);
401
+ } else {
402
+ execSync(`rm -rf "${repoPath}"`);
403
+ }
404
+
405
+ res.json({ message: `Repository '${repoName}' deleted` });
406
+ } catch (err) {
407
+ res.status(500).json({ error: 'Failed to delete repository' });
408
+ }
409
+ });
410
+
411
+ // Get info about a specific repo
412
+ app.get('/api/drives/:mountpoint/repos/:repoName', (req: Request, res: Response) => {
413
+ try {
414
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
415
+ const repoName = decodeURIComponent(req.params.repoName);
416
+ const gitDrivePath = getGitDrivePath(mountpoint);
417
+
418
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
419
+ let repoPath = path.join(gitDrivePath, bareRepoName);
420
+
421
+ if (!existsSync(repoPath)) {
422
+ repoPath = path.join(gitDrivePath, repoName);
423
+ if (!existsSync(repoPath)) {
424
+ res.status(404).json({ error: 'Repository not found' });
425
+ return;
426
+ }
427
+ }
428
+
429
+ let branches: string[] = [];
430
+ try {
431
+ const branchOutput = git("branch --format='%(refname:short)'", repoPath);
432
+ branches = branchOutput
433
+ .split('\n')
434
+ .map((b) => b.trim().replace(/^'|'$/g, ''))
435
+ .filter(Boolean);
436
+ } catch {}
437
+
438
+ let tags: string[] = [];
439
+ try {
440
+ const tagOutput = git("tag", repoPath);
441
+ tags = tagOutput.split('\n').map((t) => t.trim()).filter(Boolean);
442
+ } catch {}
443
+
444
+ let lastCommit: { hash: string; message: string; date: string } | null = null;
445
+ try {
446
+ const log = git('log -1 --format="%H|%s|%ci" --all', repoPath);
447
+ if (log) {
448
+ const [hash, message, date] = log.replace(/^"|"$/g, '').split('|');
449
+ lastCommit = { hash, message, date };
450
+ }
451
+ } catch {}
452
+
453
+ const stat = statSync(repoPath);
454
+
455
+ res.json({
456
+ name: repoName.replace(/\.git$/, ''),
457
+ path: repoPath,
458
+ branches,
459
+ tags,
460
+ lastCommit,
461
+ lastModified: stat.mtime.toISOString(),
462
+ remoteUrl: repoPath,
463
+ });
464
+ } catch (err) {
465
+ res.status(500).json({ error: 'Failed to get repo info' });
466
+ }
467
+ });
468
+
469
+ // Local status check
470
+ app.get('/api/drives/:mountpoint/repos/:repoName/local-status', (req: Request, res: Response) => {
471
+ try {
472
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
473
+ let repoName = decodeURIComponent(req.params.repoName);
474
+ repoName = repoName.replace(/\.git$/, '');
475
+
476
+ const links = loadLinks();
477
+ let localPath: string | null = null;
478
+
479
+ for (const [p, data] of Object.entries(links)) {
480
+ if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
481
+ if (existsSync(p)) {
482
+ localPath = p;
483
+ break;
484
+ }
485
+ }
486
+ }
487
+
488
+ if (!localPath) {
489
+ res.json({ linked: false });
490
+ return;
491
+ }
492
+
493
+ let hasChanges = false;
494
+ let unpushed = false;
495
+ try {
496
+ const statusOutput = git('status --porcelain', localPath);
497
+ hasChanges = statusOutput.trim().length > 0;
498
+ const unpushedOutput = git('log gd/main..HEAD --oneline', localPath);
499
+ unpushed = unpushedOutput.trim().length > 0;
500
+ } catch {}
501
+
502
+ res.json({ linked: true, localPath, hasChanges, unpushed });
503
+ } catch (err) {
504
+ res.status(500).json({ error: 'Failed to check local status' });
505
+ }
506
+ });
507
+
508
+ // Push to git-drive
509
+ app.post('/api/drives/:mountpoint/repos/:repoName/push', (req: Request, res: Response) => {
510
+ try {
511
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
512
+ let repoName = decodeURIComponent(req.params.repoName);
513
+ repoName = repoName.replace(/\.git$/, '');
514
+
515
+ const links = loadLinks();
516
+ let localPath: string | null = null;
517
+ for (const [p, data] of Object.entries(links)) {
518
+ if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
519
+ if (existsSync(p)) {
520
+ localPath = p;
521
+ break;
522
+ }
523
+ }
524
+ }
525
+
526
+ if (!localPath) {
527
+ res.status(404).json({ error: 'Local linked repository not found.' });
528
+ return;
529
+ }
530
+
531
+ git('push gd --all', localPath);
532
+ git('push gd --tags', localPath);
533
+
534
+ try {
535
+ const gitDrivePath = getGitDrivePath(mountpoint);
536
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
537
+ let repoPath = path.join(gitDrivePath, bareRepoName);
538
+ if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
539
+
540
+ const payload = {
541
+ date: new Date().toISOString(),
542
+ computer: homedir(),
543
+ user: process.env.USER || 'local-user',
544
+ localDir: localPath,
545
+ mode: 'web-ui',
546
+ };
547
+ const logFile = path.join(repoPath, "git-drive-pushlog.json");
548
+ appendFileSync(logFile, JSON.stringify(payload) + "\n", "utf-8");
549
+ } catch {}
550
+
551
+ res.json({ success: true, message: 'Successfully backed up local code to Git Drive!' });
552
+ } catch (err: any) {
553
+ res.status(500).json({ error: err.message || 'Failed to push' });
554
+ }
555
+ });
556
+
557
+ // Browse repository files tree
558
+ app.get('/api/drives/:mountpoint/repos/:repoName/tree', (req: Request, res: Response) => {
559
+ try {
560
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
561
+ const repoName = decodeURIComponent(req.params.repoName);
562
+ const branch = (req.query.branch as string) || 'main';
563
+ const treePath = (req.query.path as string) || '';
564
+
565
+ const gitDrivePath = getGitDrivePath(mountpoint);
566
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
567
+ let repoPath = path.join(gitDrivePath, bareRepoName);
568
+
569
+ if (!existsSync(repoPath)) {
570
+ repoPath = path.join(gitDrivePath, repoName);
571
+ }
572
+
573
+ const target = treePath ? `${branch}:${treePath}` : branch;
574
+ const output = git(`ls-tree ${target}`, repoPath);
575
+
576
+ const files = output.split('\n').filter(Boolean).map((line) => {
577
+ const parts = line.split('\t');
578
+ const meta = parts[0].split(' ');
579
+ return {
580
+ mode: meta[0],
581
+ type: meta[1],
582
+ hash: meta[2],
583
+ path: parts[1],
584
+ name: parts[1].split('/').pop(),
585
+ };
586
+ });
587
+
588
+ res.json({ files });
589
+ } catch (err) {
590
+ res.json({ files: [] });
591
+ }
592
+ });
593
+
594
+ // Get commit history
595
+ app.get('/api/drives/:mountpoint/repos/:repoName/commits', (req: Request, res: Response) => {
596
+ try {
597
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
598
+ const repoName = decodeURIComponent(req.params.repoName);
599
+ const branch = (req.query.branch as string) || 'main';
600
+
601
+ const gitDrivePath = getGitDrivePath(mountpoint);
602
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
603
+ let repoPath = path.join(gitDrivePath, bareRepoName);
604
+
605
+ if (!existsSync(repoPath)) {
606
+ repoPath = path.join(gitDrivePath, repoName);
607
+ }
608
+
609
+ let commits: any[] = [];
610
+ try {
611
+ const logOutput = git(`log ${branch} -n 100 --format="%H|%an|%ae|%s|%ci"`, repoPath);
612
+ commits = logOutput
613
+ .split('\n')
614
+ .filter(Boolean)
615
+ .map((line) => {
616
+ const [hash, author, email, message, date] = line.split('|');
617
+ return { hash, author, email, message, date };
618
+ });
619
+ } catch {}
620
+
621
+ let pushLogs: any[] = [];
622
+ try {
623
+ const logFile = path.join(repoPath, "git-drive-pushlog.json");
624
+ if (existsSync(logFile)) {
625
+ const rawLogs = readFileSync(logFile, "utf-8").trim().split('\n');
626
+ pushLogs = rawLogs.map((l) => {
627
+ try { return JSON.parse(l); } catch { return null; }
628
+ }).filter(Boolean);
629
+ pushLogs.reverse();
630
+ }
631
+ } catch {}
632
+
633
+ res.json({ commits, pushLogs });
634
+ } catch (err) {
635
+ res.status(500).json({ error: 'Failed to retrieve history' });
636
+ }
637
+ });
638
+
639
+ // Get single commit details
640
+ app.get('/api/drives/:mountpoint/repos/:repoName/commits/:hash', (req: Request, res: Response) => {
641
+ try {
642
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
643
+ const repoName = decodeURIComponent(req.params.repoName);
644
+ const hash = req.params.hash;
645
+
646
+ const gitDrivePath = getGitDrivePath(mountpoint);
647
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
648
+ let repoPath = path.join(gitDrivePath, bareRepoName);
649
+ if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
650
+
651
+ const logOutput = git(`log -1 --format="%H|%an|%ae|%s|%ci" ${hash}`, repoPath);
652
+ const [commitHash, author, email, message, date] = logOutput.split('|');
653
+
654
+ const patch = git(`show --format="" ${hash}`, repoPath);
655
+
656
+ res.json({ hash: commitHash, author, email, message, date, patch });
657
+ } catch (err) {
658
+ res.status(500).json({ error: 'Failed to retrieve commit details' });
659
+ }
660
+ });
661
+
662
+ // Read raw file content
663
+ app.get('/api/drives/:mountpoint/repos/:repoName/blob', (req: Request, res: Response) => {
664
+ try {
665
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
666
+ const repoName = decodeURIComponent(req.params.repoName);
667
+ const branch = (req.query.branch as string) || 'main';
668
+ const filePath = req.query.path as string;
669
+
670
+ const gitDrivePath = getGitDrivePath(mountpoint);
671
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
672
+ let repoPath = path.join(gitDrivePath, bareRepoName);
673
+
674
+ if (!existsSync(repoPath)) {
675
+ repoPath = path.join(gitDrivePath, repoName);
676
+ }
677
+
678
+ const content = git(`show ${branch}:${filePath}`, repoPath);
679
+ res.send(content);
680
+ } catch (err) {
681
+ res.status(500).json({ error: 'Failed to read file' });
682
+ }
683
+ });
684
+
685
+ // SPA fallback
686
+ app.get('*', (_req: Request, res: Response) => {
687
+ const indexPath = path.join(uiPath, 'index.html');
688
+ if (existsSync(indexPath)) {
689
+ res.sendFile(indexPath);
690
+ } else {
691
+ res.status(404).send('UI not built. The package may need to be rebuilt.');
692
+ }
693
+ });
694
+
695
+ // Start server only when run directly (not when imported)
696
+ if (require.main === module) {
697
+ app.listen(port, () => {
698
+ console.log(`\n 🚀 Git Drive is running at http://localhost:${port}\n`);
699
+ });
700
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": [
8
+ "**/*.ts"
9
+ ],
10
+ "exclude": [
11
+ "node_modules"
12
+ ]
13
+ }