git-drive 0.1.3 → 0.1.5

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 (83) 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 +121 -0
  12. package/docker-compose.yml +48 -0
  13. package/package.json +18 -45
  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/init.test.ts +154 -0
  18. package/packages/cli/src/__tests__/commands/list.test.ts +118 -0
  19. package/packages/cli/src/__tests__/commands/push.test.ts +155 -0
  20. package/packages/cli/src/__tests__/commands/restore.test.ts +134 -0
  21. package/packages/cli/src/__tests__/commands/status.test.ts +195 -0
  22. package/packages/cli/src/__tests__/config.test.ts +198 -0
  23. package/packages/cli/src/__tests__/e2e.test.ts +125 -0
  24. package/packages/cli/src/__tests__/errors.test.ts +66 -0
  25. package/packages/cli/src/__tests__/git.test.ts +226 -0
  26. package/packages/cli/src/__tests__/server.test.ts +368 -0
  27. package/packages/cli/src/commands/archive.ts +39 -0
  28. package/packages/cli/src/commands/init.ts +64 -0
  29. package/packages/cli/src/commands/link.ts +151 -0
  30. package/packages/cli/src/commands/list.ts +94 -0
  31. package/packages/cli/src/commands/push.ts +77 -0
  32. package/packages/cli/src/commands/restore.ts +36 -0
  33. package/packages/cli/src/commands/status.ts +127 -0
  34. package/packages/cli/src/config.ts +73 -0
  35. package/packages/cli/src/errors.ts +23 -0
  36. package/packages/cli/src/git.ts +55 -0
  37. package/packages/cli/src/index.ts +122 -0
  38. package/packages/cli/src/server.ts +573 -0
  39. package/packages/cli/tsconfig.json +13 -0
  40. package/packages/cli/ui/assets/index-Br8xQbJz.js +17 -0
  41. package/{ui → packages/cli/ui}/index.html +1 -1
  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 +242 -0
  54. package/packages/ui/src/App.tsx +755 -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/commands/archive.js +0 -32
  70. package/dist/commands/init.js +0 -55
  71. package/dist/commands/link.js +0 -139
  72. package/dist/commands/list.js +0 -83
  73. package/dist/commands/push.js +0 -99
  74. package/dist/commands/restore.js +0 -30
  75. package/dist/commands/status.js +0 -116
  76. package/dist/config.js +0 -62
  77. package/dist/errors.js +0 -30
  78. package/dist/git.js +0 -60
  79. package/dist/index.js +0 -108
  80. package/dist/server.js +0 -526
  81. /package/{ui → packages/cli/ui}/assets/index-Cc2q1t5k.js +0 -0
  82. /package/{ui → packages/cli/ui}/assets/index-DrL7ojPA.css +0 -0
  83. /package/{ui → packages/cli/ui}/vite.svg +0 -0
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'child_process';
4
+ import { readFileSync } from 'fs';
5
+ import { push } from "./commands/push.js";
6
+ import { list } from "./commands/list.js";
7
+ import { status } from "./commands/status.js";
8
+ import { link } from "./commands/link.js";
9
+ import { init } from "./commands/init.js";
10
+ import { handleError } from "./errors.js";
11
+ import { ensureServerRunning } from "./server.js";
12
+
13
+ // Get version from package.json
14
+ declare const __dirname: string;
15
+ const { version: VERSION } = JSON.parse(readFileSync(__dirname + '/../package.json', 'utf-8'));
16
+
17
+ const commands: Record<string, (args: string[]) => void | Promise<void>> = {
18
+ init,
19
+ push,
20
+ list,
21
+ status,
22
+ link,
23
+ server: startServer,
24
+ start: startServer,
25
+ ui: startServer,
26
+ };
27
+
28
+ // Commands that don't need the server running
29
+ const NO_SERVER_COMMANDS = ['server', 'start', 'ui'];
30
+
31
+ function printUsage(): void {
32
+ console.log(`
33
+ git-drive - Turn any external drive into a git remote backup for your code
34
+
35
+ Usage:
36
+ git-drive <command> [options]
37
+
38
+ Commands:
39
+ init Initialize git-drive on an external drive
40
+ link Link current repo to a drive
41
+ push Push current repo to drive
42
+ list Show connected drives and their status
43
+ status Show detailed status of drives and repos
44
+ server, start, ui Start the git-drive web UI server
45
+
46
+ Options:
47
+ -v, -V, --version Show version number
48
+ -h, --help Show this help message
49
+
50
+ Examples:
51
+ git-drive init /Volumes/MyDrive Initialize git-drive on a drive
52
+ git-drive link Link current repo to a drive
53
+ git-drive push Push current repo to drive
54
+ git-drive list List connected drives
55
+ git-drive status Show detailed status
56
+ git-drive server Start the web UI at http://localhost:4483
57
+
58
+ Environment Variables:
59
+ GIT_DRIVE_PORT Port for the web server (default: 4483)
60
+
61
+ Docker:
62
+ docker run -it --rm -v /Volumes:/Volumes -p 4483:4483 git-drive
63
+
64
+ Documentation:
65
+ https://github.com/josmanvis/git-drive
66
+ `);
67
+ }
68
+
69
+ function startServer(_args: string[]): void {
70
+ console.log('\n 🚀 Starting Git Drive server...\n');
71
+ console.log(' Web UI: http://localhost:4483\n');
72
+ console.log(' Press Ctrl+C to stop\n');
73
+
74
+ const serverPath = require.resolve('./server.js');
75
+ const child = spawn(process.execPath, [serverPath], {
76
+ stdio: 'inherit',
77
+ env: process.env
78
+ });
79
+
80
+ child.on('error', (err) => {
81
+ console.error('Failed to start server:', err.message);
82
+ process.exit(1);
83
+ });
84
+
85
+ child.on('exit', (code) => {
86
+ process.exit(code || 0);
87
+ });
88
+ }
89
+
90
+ const [command, ...args] = process.argv.slice(2);
91
+
92
+ (async () => {
93
+ try {
94
+ if (!command || command === "--help" || command === "-h") {
95
+ printUsage();
96
+ process.exit(0);
97
+ }
98
+
99
+ // Handle version flags
100
+ if (command === "--version" || command === "-v" || command === "-V" || command === "version") {
101
+ console.log(`git-drive v${VERSION}`);
102
+ process.exit(0);
103
+ }
104
+
105
+ const handler = commands[command];
106
+ if (!handler) {
107
+ console.error(`Unknown command: ${command}\n`);
108
+ printUsage();
109
+ process.exit(1);
110
+ }
111
+
112
+ // Ensure server is running for commands that need it
113
+ if (!NO_SERVER_COMMANDS.includes(command)) {
114
+ await ensureServerRunning();
115
+ }
116
+
117
+ await handler(args);
118
+ } catch (err) {
119
+ handleError(err);
120
+ process.exit(1);
121
+ }
122
+ })();
@@ -0,0 +1,573 @@
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 } 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
+ app.use(express.json());
13
+
14
+ // Serve static UI files from the ui directory
15
+ const uiPath = path.join(__dirname, '..', 'ui');
16
+ app.use(express.static(uiPath));
17
+
18
+ // ── Helpers ──────────────────────────────────────────────────────────
19
+
20
+ function git(args: string, cwd?: string): string {
21
+ return execSync(`git ${args}`, {
22
+ cwd,
23
+ encoding: 'utf-8',
24
+ stdio: ['pipe', 'pipe', 'pipe'],
25
+ }).trim();
26
+ }
27
+
28
+ function getGitDrivePath(mountpoint: string): string {
29
+ return path.join(mountpoint, '.git-drive');
30
+ }
31
+
32
+ function ensureGitDriveDir(mountpoint: string): string {
33
+ const gitDrivePath = getGitDrivePath(mountpoint);
34
+ if (!existsSync(gitDrivePath)) {
35
+ try {
36
+ mkdirSync(gitDrivePath, { recursive: true });
37
+ } catch (err: any) {
38
+ throw new Error(`Failed to write to drive. Please ensure Terminal/Node has "Removable Volumes" access in macOS Privacy settings. Details: ${err.message}`);
39
+ }
40
+ }
41
+ return gitDrivePath;
42
+ }
43
+
44
+ function listRepos(gitDrivePath: string): Array<{ name: string; path: string; lastModified: string }> {
45
+ if (!existsSync(gitDrivePath)) return [];
46
+
47
+ return readdirSync(gitDrivePath)
48
+ .filter((entry) => {
49
+ const entryPath = path.join(gitDrivePath, entry);
50
+ return (
51
+ statSync(entryPath).isDirectory() &&
52
+ (entry.endsWith('.git') || existsSync(path.join(entryPath, 'HEAD')))
53
+ );
54
+ })
55
+ .map((entry) => {
56
+ const entryPath = path.join(gitDrivePath, entry);
57
+ const stat = statSync(entryPath);
58
+ return {
59
+ name: entry.replace(/\.git$/, ''),
60
+ path: entryPath,
61
+ lastModified: stat.mtime.toISOString(),
62
+ };
63
+ });
64
+ }
65
+
66
+ function loadLinks(): Record<string, { mountpoint: string; repoName: string; linkedAt: string }> {
67
+ const linksFile = path.join(homedir(), '.config', 'git-drive', 'links.json');
68
+ if (!existsSync(linksFile)) return {};
69
+ try {
70
+ return JSON.parse(readFileSync(linksFile, 'utf-8'));
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+
76
+ // ── Server Health Check Utilities ────────────────────────────────────────────
77
+
78
+ const DEFAULT_PORT = 4483;
79
+
80
+ export function getServerPort(): number {
81
+ return parseInt(process.env.GIT_DRIVE_PORT || String(DEFAULT_PORT), 10);
82
+ }
83
+
84
+ export async function isServerRunning(port?: number): Promise<boolean> {
85
+ const serverPort = port || getServerPort();
86
+ try {
87
+ const response = await fetch(`http://localhost:${serverPort}/api/drives`, {
88
+ method: 'HEAD',
89
+ signal: AbortSignal.timeout(1000),
90
+ });
91
+ return response.ok;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ export async function ensureServerRunning(): Promise<void> {
98
+ const port = getServerPort();
99
+ const running = await isServerRunning(port);
100
+
101
+ if (!running) {
102
+ console.log('\n 🚀 Starting Git Drive server...\n');
103
+
104
+ // Start server in detached/background mode
105
+ const serverPath = require.resolve('./server.js');
106
+ const child = spawn(process.execPath, [serverPath], {
107
+ detached: true,
108
+ stdio: 'ignore',
109
+ env: process.env,
110
+ });
111
+
112
+ // Allow the parent process to exit independently
113
+ child.unref();
114
+
115
+ // Wait a moment for server to start
116
+ let retries = 10;
117
+ while (retries > 0) {
118
+ await new Promise(resolve => setTimeout(resolve, 300));
119
+ if (await isServerRunning(port)) {
120
+ break;
121
+ }
122
+ retries--;
123
+ }
124
+
125
+ if (retries === 0) {
126
+ throw new Error('Failed to start Git Drive server. Please run "git-drive server" manually.');
127
+ }
128
+ }
129
+ }
130
+
131
+ // ── API Routes ───────────────────────────────────────────────────────
132
+
133
+ // Health check endpoint
134
+ app.get('/api/health', (_req: Request, res: Response) => {
135
+ res.json({ status: 'ok', timestamp: new Date().toISOString() });
136
+ });
137
+
138
+ // List all connected drives
139
+ app.get('/api/drives', async (_req: Request, res: Response) => {
140
+ try {
141
+ const drives = await getDiskInfo();
142
+ const result = drives
143
+ .filter((d: any) => {
144
+ const mp = d.mounted;
145
+ if (!mp) return false;
146
+ if (mp === "/" || mp === "100%") return false;
147
+
148
+ if (process.platform === "darwin") {
149
+ return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
150
+ }
151
+
152
+ if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
153
+ if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
154
+
155
+ return true;
156
+ })
157
+ .map((d: any) => ({
158
+ device: d.filesystem,
159
+ description: d.mounted,
160
+ size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
161
+ isRemovable: true,
162
+ isSystem: d.mounted === '/',
163
+ mountpoints: [d.mounted],
164
+ hasGitDrive: existsSync(getGitDrivePath(d.mounted)),
165
+ }));
166
+ res.json(result);
167
+ } catch (err) {
168
+ res.status(500).json({ error: 'Failed to list drives' });
169
+ }
170
+ });
171
+
172
+ // List repos on a specific drive
173
+ app.get('/api/drives/:mountpoint/repos', (req: Request, res: Response) => {
174
+ try {
175
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
176
+ const gitDrivePath = getGitDrivePath(mountpoint);
177
+
178
+ if (!existsSync(mountpoint)) {
179
+ res.status(404).json({ error: 'Drive not found or not mounted' });
180
+ return;
181
+ }
182
+
183
+ const repos = listRepos(gitDrivePath);
184
+ res.json({
185
+ mountpoint,
186
+ gitDrivePath,
187
+ initialized: existsSync(gitDrivePath),
188
+ repos,
189
+ });
190
+ } catch (err) {
191
+ res.status(500).json({ error: 'Failed to list repos' });
192
+ }
193
+ });
194
+
195
+ // Initialize git-drive on a drive
196
+ app.post('/api/drives/:mountpoint/init', (req: Request, res: Response) => {
197
+ try {
198
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
199
+
200
+ if (!existsSync(mountpoint)) {
201
+ res.status(404).json({ error: 'Drive not found or not mounted' });
202
+ return;
203
+ }
204
+
205
+ const gitDrivePath = ensureGitDriveDir(mountpoint);
206
+ res.json({
207
+ mountpoint,
208
+ gitDrivePath,
209
+ message: 'Git Drive initialized on this drive',
210
+ });
211
+ } catch (err: any) {
212
+ console.error("Init Error:", err);
213
+ res.status(500).json({ error: err.message || 'Failed to initialize drive' });
214
+ }
215
+ });
216
+
217
+ // Create a new bare repo on a drive
218
+ app.post('/api/drives/:mountpoint/repos', (req: Request, res: Response) => {
219
+ try {
220
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
221
+ const { name } = req.body;
222
+
223
+ if (!name || typeof name !== 'string') {
224
+ res.status(400).json({ error: 'Repo name is required' });
225
+ return;
226
+ }
227
+
228
+ const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '-');
229
+
230
+ if (!existsSync(mountpoint)) {
231
+ res.status(404).json({ error: 'Drive not found or not mounted' });
232
+ return;
233
+ }
234
+
235
+ const gitDrivePath = ensureGitDriveDir(mountpoint);
236
+ const repoName = safeName.endsWith('.git') ? safeName : `${safeName}.git`;
237
+ const repoPath = path.join(gitDrivePath, repoName);
238
+
239
+ if (existsSync(repoPath)) {
240
+ res.status(409).json({ error: 'Repository already exists' });
241
+ return;
242
+ }
243
+
244
+ git(`init --bare "${repoPath}"`);
245
+
246
+ res.status(201).json({
247
+ name: safeName.replace(/\.git$/, ''),
248
+ path: repoPath,
249
+ message: `Bare repository created: ${repoName}`,
250
+ remoteUrl: repoPath,
251
+ });
252
+ } catch (err) {
253
+ res.status(500).json({ error: 'Failed to create repository' });
254
+ }
255
+ });
256
+
257
+ // Delete a repo from a drive
258
+ app.delete('/api/drives/:mountpoint/repos/:repoName', (req: Request, res: Response) => {
259
+ try {
260
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
261
+ const repoName = decodeURIComponent(req.params.repoName);
262
+ const gitDrivePath = getGitDrivePath(mountpoint);
263
+
264
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
265
+ const repoPath = path.join(gitDrivePath, bareRepoName);
266
+
267
+ if (!existsSync(repoPath)) {
268
+ const altPath = path.join(gitDrivePath, repoName);
269
+ if (!existsSync(altPath)) {
270
+ res.status(404).json({ error: 'Repository not found' });
271
+ return;
272
+ }
273
+ execSync(`rm -rf "${altPath}"`);
274
+ } else {
275
+ execSync(`rm -rf "${repoPath}"`);
276
+ }
277
+
278
+ res.json({ message: `Repository '${repoName}' deleted` });
279
+ } catch (err) {
280
+ res.status(500).json({ error: 'Failed to delete repository' });
281
+ }
282
+ });
283
+
284
+ // Get info about a specific repo
285
+ app.get('/api/drives/:mountpoint/repos/:repoName', (req: Request, res: Response) => {
286
+ try {
287
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
288
+ const repoName = decodeURIComponent(req.params.repoName);
289
+ const gitDrivePath = getGitDrivePath(mountpoint);
290
+
291
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
292
+ let repoPath = path.join(gitDrivePath, bareRepoName);
293
+
294
+ if (!existsSync(repoPath)) {
295
+ repoPath = path.join(gitDrivePath, repoName);
296
+ if (!existsSync(repoPath)) {
297
+ res.status(404).json({ error: 'Repository not found' });
298
+ return;
299
+ }
300
+ }
301
+
302
+ let branches: string[] = [];
303
+ try {
304
+ const branchOutput = git("branch --format='%(refname:short)'", repoPath);
305
+ branches = branchOutput
306
+ .split('\n')
307
+ .map((b) => b.trim().replace(/^'|'$/g, ''))
308
+ .filter(Boolean);
309
+ } catch {}
310
+
311
+ let tags: string[] = [];
312
+ try {
313
+ const tagOutput = git("tag", repoPath);
314
+ tags = tagOutput.split('\n').map((t) => t.trim()).filter(Boolean);
315
+ } catch {}
316
+
317
+ let lastCommit: { hash: string; message: string; date: string } | null = null;
318
+ try {
319
+ const log = git('log -1 --format="%H|%s|%ci" --all', repoPath);
320
+ if (log) {
321
+ const [hash, message, date] = log.replace(/^"|"$/g, '').split('|');
322
+ lastCommit = { hash, message, date };
323
+ }
324
+ } catch {}
325
+
326
+ const stat = statSync(repoPath);
327
+
328
+ res.json({
329
+ name: repoName.replace(/\.git$/, ''),
330
+ path: repoPath,
331
+ branches,
332
+ tags,
333
+ lastCommit,
334
+ lastModified: stat.mtime.toISOString(),
335
+ remoteUrl: repoPath,
336
+ });
337
+ } catch (err) {
338
+ res.status(500).json({ error: 'Failed to get repo info' });
339
+ }
340
+ });
341
+
342
+ // Local status check
343
+ app.get('/api/drives/:mountpoint/repos/:repoName/local-status', (req: Request, res: Response) => {
344
+ try {
345
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
346
+ let repoName = decodeURIComponent(req.params.repoName);
347
+ repoName = repoName.replace(/\.git$/, '');
348
+
349
+ const links = loadLinks();
350
+ let localPath: string | null = null;
351
+
352
+ for (const [p, data] of Object.entries(links)) {
353
+ if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
354
+ if (existsSync(p)) {
355
+ localPath = p;
356
+ break;
357
+ }
358
+ }
359
+ }
360
+
361
+ if (!localPath) {
362
+ res.json({ linked: false });
363
+ return;
364
+ }
365
+
366
+ let hasChanges = false;
367
+ let unpushed = false;
368
+ try {
369
+ const statusOutput = git('status --porcelain', localPath);
370
+ hasChanges = statusOutput.trim().length > 0;
371
+ const unpushedOutput = git('log gd/main..HEAD --oneline', localPath);
372
+ unpushed = unpushedOutput.trim().length > 0;
373
+ } catch {}
374
+
375
+ res.json({ linked: true, localPath, hasChanges, unpushed });
376
+ } catch (err) {
377
+ res.status(500).json({ error: 'Failed to check local status' });
378
+ }
379
+ });
380
+
381
+ // Push to git-drive
382
+ app.post('/api/drives/:mountpoint/repos/:repoName/push', (req: Request, res: Response) => {
383
+ try {
384
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
385
+ let repoName = decodeURIComponent(req.params.repoName);
386
+ repoName = repoName.replace(/\.git$/, '');
387
+
388
+ const links = loadLinks();
389
+ let localPath: string | null = null;
390
+ for (const [p, data] of Object.entries(links)) {
391
+ if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
392
+ if (existsSync(p)) {
393
+ localPath = p;
394
+ break;
395
+ }
396
+ }
397
+ }
398
+
399
+ if (!localPath) {
400
+ res.status(404).json({ error: 'Local linked repository not found.' });
401
+ return;
402
+ }
403
+
404
+ git('push gd --all', localPath);
405
+ git('push gd --tags', localPath);
406
+
407
+ try {
408
+ const gitDrivePath = getGitDrivePath(mountpoint);
409
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
410
+ let repoPath = path.join(gitDrivePath, bareRepoName);
411
+ if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
412
+
413
+ const payload = {
414
+ date: new Date().toISOString(),
415
+ computer: homedir(),
416
+ user: process.env.USER || 'local-user',
417
+ localDir: localPath,
418
+ mode: 'web-ui',
419
+ };
420
+ const logFile = path.join(repoPath, "git-drive-pushlog.json");
421
+ appendFileSync(logFile, JSON.stringify(payload) + "\n", "utf-8");
422
+ } catch {}
423
+
424
+ res.json({ success: true, message: 'Successfully backed up local code to Git Drive!' });
425
+ } catch (err: any) {
426
+ res.status(500).json({ error: err.message || 'Failed to push' });
427
+ }
428
+ });
429
+
430
+ // Browse repository files tree
431
+ app.get('/api/drives/:mountpoint/repos/:repoName/tree', (req: Request, res: Response) => {
432
+ try {
433
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
434
+ const repoName = decodeURIComponent(req.params.repoName);
435
+ const branch = (req.query.branch as string) || 'main';
436
+ const treePath = (req.query.path as string) || '';
437
+
438
+ const gitDrivePath = getGitDrivePath(mountpoint);
439
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
440
+ let repoPath = path.join(gitDrivePath, bareRepoName);
441
+
442
+ if (!existsSync(repoPath)) {
443
+ repoPath = path.join(gitDrivePath, repoName);
444
+ }
445
+
446
+ const target = treePath ? `${branch}:${treePath}` : branch;
447
+ const output = git(`ls-tree ${target}`, repoPath);
448
+
449
+ const files = output.split('\n').filter(Boolean).map((line) => {
450
+ const parts = line.split('\t');
451
+ const meta = parts[0].split(' ');
452
+ return {
453
+ mode: meta[0],
454
+ type: meta[1],
455
+ hash: meta[2],
456
+ path: parts[1],
457
+ name: parts[1].split('/').pop(),
458
+ };
459
+ });
460
+
461
+ res.json({ files });
462
+ } catch (err) {
463
+ res.json({ files: [] });
464
+ }
465
+ });
466
+
467
+ // Get commit history
468
+ app.get('/api/drives/:mountpoint/repos/:repoName/commits', (req: Request, res: Response) => {
469
+ try {
470
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
471
+ const repoName = decodeURIComponent(req.params.repoName);
472
+ const branch = (req.query.branch as string) || 'main';
473
+
474
+ const gitDrivePath = getGitDrivePath(mountpoint);
475
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
476
+ let repoPath = path.join(gitDrivePath, bareRepoName);
477
+
478
+ if (!existsSync(repoPath)) {
479
+ repoPath = path.join(gitDrivePath, repoName);
480
+ }
481
+
482
+ let commits: any[] = [];
483
+ try {
484
+ const logOutput = git(`log ${branch} -n 100 --format="%H|%an|%ae|%s|%ci"`, repoPath);
485
+ commits = logOutput
486
+ .split('\n')
487
+ .filter(Boolean)
488
+ .map((line) => {
489
+ const [hash, author, email, message, date] = line.split('|');
490
+ return { hash, author, email, message, date };
491
+ });
492
+ } catch {}
493
+
494
+ let pushLogs: any[] = [];
495
+ try {
496
+ const logFile = path.join(repoPath, "git-drive-pushlog.json");
497
+ if (existsSync(logFile)) {
498
+ const rawLogs = readFileSync(logFile, "utf-8").trim().split('\n');
499
+ pushLogs = rawLogs.map((l) => {
500
+ try { return JSON.parse(l); } catch { return null; }
501
+ }).filter(Boolean);
502
+ pushLogs.reverse();
503
+ }
504
+ } catch {}
505
+
506
+ res.json({ commits, pushLogs });
507
+ } catch (err) {
508
+ res.status(500).json({ error: 'Failed to retrieve history' });
509
+ }
510
+ });
511
+
512
+ // Get single commit details
513
+ app.get('/api/drives/:mountpoint/repos/:repoName/commits/:hash', (req: Request, res: Response) => {
514
+ try {
515
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
516
+ const repoName = decodeURIComponent(req.params.repoName);
517
+ const hash = req.params.hash;
518
+
519
+ const gitDrivePath = getGitDrivePath(mountpoint);
520
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
521
+ let repoPath = path.join(gitDrivePath, bareRepoName);
522
+ if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
523
+
524
+ const logOutput = git(`log -1 --format="%H|%an|%ae|%s|%ci" ${hash}`, repoPath);
525
+ const [commitHash, author, email, message, date] = logOutput.split('|');
526
+
527
+ const patch = git(`show --format="" ${hash}`, repoPath);
528
+
529
+ res.json({ hash: commitHash, author, email, message, date, patch });
530
+ } catch (err) {
531
+ res.status(500).json({ error: 'Failed to retrieve commit details' });
532
+ }
533
+ });
534
+
535
+ // Read raw file content
536
+ app.get('/api/drives/:mountpoint/repos/:repoName/blob', (req: Request, res: Response) => {
537
+ try {
538
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
539
+ const repoName = decodeURIComponent(req.params.repoName);
540
+ const branch = (req.query.branch as string) || 'main';
541
+ const filePath = req.query.path as string;
542
+
543
+ const gitDrivePath = getGitDrivePath(mountpoint);
544
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
545
+ let repoPath = path.join(gitDrivePath, bareRepoName);
546
+
547
+ if (!existsSync(repoPath)) {
548
+ repoPath = path.join(gitDrivePath, repoName);
549
+ }
550
+
551
+ const content = git(`show ${branch}:${filePath}`, repoPath);
552
+ res.send(content);
553
+ } catch (err) {
554
+ res.status(500).json({ error: 'Failed to read file' });
555
+ }
556
+ });
557
+
558
+ // SPA fallback
559
+ app.get('*', (_req: Request, res: Response) => {
560
+ const indexPath = path.join(uiPath, 'index.html');
561
+ if (existsSync(indexPath)) {
562
+ res.sendFile(indexPath);
563
+ } else {
564
+ res.status(404).send('UI not built. The package may need to be rebuilt.');
565
+ }
566
+ });
567
+
568
+ // Start server only when run directly (not when imported)
569
+ if (require.main === module) {
570
+ app.listen(port, () => {
571
+ console.log(`\n 🚀 Git Drive is running at http://localhost:${port}\n`);
572
+ });
573
+ }
@@ -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
+ }