git-drive 0.1.2 → 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 +19 -46
  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 -100
  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,226 @@
1
+ import { git, listDrives, getRepoRoot, getProjectName, getRemoteUrl, isGitRepo } from '../git.js';
2
+
3
+ // Mock child_process
4
+ jest.mock('child_process', () => ({
5
+ execSync: jest.fn(),
6
+ }));
7
+
8
+ // Mock node-disk-info
9
+ jest.mock('node-disk-info', () => ({
10
+ getDiskInfo: jest.fn(),
11
+ }));
12
+
13
+ import { execSync } from 'child_process';
14
+ import { getDiskInfo } from 'node-disk-info';
15
+
16
+ const mockExecSync = execSync as jest.Mock;
17
+ const mockGetDiskInfo = getDiskInfo as jest.Mock;
18
+
19
+ describe('git', () => {
20
+ beforeEach(() => {
21
+ jest.clearAllMocks();
22
+ });
23
+
24
+ describe('git function', () => {
25
+ it('should execute git command and return trimmed output', () => {
26
+ mockExecSync.mockReturnValue(' output from git \n');
27
+
28
+ const result = git('status --short');
29
+
30
+ expect(mockExecSync).toHaveBeenCalledWith('git status --short', {
31
+ cwd: undefined,
32
+ encoding: 'utf-8',
33
+ stdio: ['pipe', 'pipe', 'pipe'],
34
+ });
35
+ expect(result).toBe('output from git');
36
+ });
37
+
38
+ it('should execute git command with cwd option', () => {
39
+ mockExecSync.mockReturnValue('output');
40
+
41
+ const result = git('status', '/path/to/repo');
42
+
43
+ expect(mockExecSync).toHaveBeenCalledWith('git status', {
44
+ cwd: '/path/to/repo',
45
+ encoding: 'utf-8',
46
+ stdio: ['pipe', 'pipe', 'pipe'],
47
+ });
48
+ expect(result).toBe('output');
49
+ });
50
+
51
+ it('should propagate errors from git command', () => {
52
+ mockExecSync.mockImplementation(() => {
53
+ throw new Error('fatal: not a git repository');
54
+ });
55
+
56
+ expect(() => git('status')).toThrow('fatal: not a git repository');
57
+ });
58
+ });
59
+
60
+ describe('listDrives', () => {
61
+ const originalPlatform = process.platform;
62
+
63
+ afterEach(() => {
64
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
65
+ });
66
+
67
+ it('should filter drives correctly on macOS', async () => {
68
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
69
+
70
+ mockGetDiskInfo.mockResolvedValue([
71
+ { mounted: '/', filesystem: 'Macintosh HD', blocks: 500000000, available: 100000000 },
72
+ { mounted: '/Volumes/MyUSB', filesystem: 'MyUSB', blocks: 32000000, available: 16000000 },
73
+ { mounted: '/Volumes/Recovery', filesystem: 'Recovery', blocks: 1000000, available: 500000 },
74
+ { mounted: '/Volumes/External', filesystem: 'External', blocks: 1000000000, available: 500000000 },
75
+ ]);
76
+
77
+ const result = await listDrives();
78
+
79
+ expect(result).toHaveLength(2);
80
+ expect(result.map((d: any) => d.mounted)).toEqual(['/Volumes/MyUSB', '/Volumes/External']);
81
+ });
82
+
83
+ it('should filter drives correctly on Linux', async () => {
84
+ Object.defineProperty(process, 'platform', { value: 'linux' });
85
+
86
+ mockGetDiskInfo.mockResolvedValue([
87
+ { mounted: '/', filesystem: 'root', blocks: 500000000, available: 100000000 },
88
+ { mounted: '/mnt/usb', filesystem: 'usbdrive', blocks: 32000000, available: 16000000 },
89
+ { mounted: '/sys', filesystem: 'sysfs', blocks: 0, available: 0 },
90
+ { mounted: '/proc', filesystem: 'proc', blocks: 0, available: 0 },
91
+ { mounted: '/run', filesystem: 'tmpfs', blocks: 1000000, available: 500000 },
92
+ { mounted: '/media/user/external', filesystem: 'external', blocks: 1000000000, available: 500000000 },
93
+ ]);
94
+
95
+ const result = await listDrives();
96
+
97
+ expect(result).toHaveLength(2);
98
+ expect(result.map((d: any) => d.mounted)).toEqual(['/mnt/usb', '/media/user/external']);
99
+ });
100
+
101
+ it('should filter out tmpfs and overlay filesystems', async () => {
102
+ Object.defineProperty(process, 'platform', { value: 'linux' });
103
+
104
+ mockGetDiskInfo.mockResolvedValue([
105
+ { mounted: '/mnt/real', filesystem: 'ext4', blocks: 1000000, available: 500000 },
106
+ { mounted: '/mnt/tmpfs', filesystem: 'tmpfs', blocks: 1000000, available: 500000 },
107
+ { mounted: '/mnt/devtmpfs', filesystem: 'devtmpfs', blocks: 1000000, available: 500000 },
108
+ { mounted: '/mnt/overlay', filesystem: 'overlay', blocks: 1000000, available: 500000 },
109
+ { mounted: '/mnt/udev', filesystem: 'udev', blocks: 1000000, available: 500000 },
110
+ ]);
111
+
112
+ const result = await listDrives();
113
+
114
+ expect(result).toHaveLength(1);
115
+ expect(result[0].mounted).toBe('/mnt/real');
116
+ });
117
+
118
+ it('should filter out drives without mountpoint', async () => {
119
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
120
+
121
+ mockGetDiskInfo.mockResolvedValue([
122
+ { mounted: null, filesystem: 'nomount', blocks: 1000000, available: 500000 },
123
+ { mounted: '/Volumes/Valid', filesystem: 'valid', blocks: 1000000, available: 500000 },
124
+ ]);
125
+
126
+ const result = await listDrives();
127
+
128
+ expect(result).toHaveLength(1);
129
+ });
130
+
131
+ it('should filter out drives with mounted value of "100%"', async () => {
132
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
133
+
134
+ mockGetDiskInfo.mockResolvedValue([
135
+ { mounted: '100%', filesystem: 'weird', blocks: 1000000, available: 500000 },
136
+ { mounted: '/Volumes/Valid', filesystem: 'valid', blocks: 1000000, available: 500000 },
137
+ ]);
138
+
139
+ const result = await listDrives();
140
+
141
+ expect(result).toHaveLength(1);
142
+ });
143
+
144
+ it('should return empty array when no drives match', async () => {
145
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
146
+
147
+ mockGetDiskInfo.mockResolvedValue([
148
+ { mounted: '/', filesystem: 'system', blocks: 500000000, available: 100000000 },
149
+ ]);
150
+
151
+ const result = await listDrives();
152
+
153
+ expect(result).toHaveLength(0);
154
+ });
155
+ });
156
+
157
+ describe('getRepoRoot', () => {
158
+ it('should return the repo root path', () => {
159
+ mockExecSync.mockReturnValue('/path/to/repo\n');
160
+
161
+ const result = getRepoRoot();
162
+
163
+ expect(mockExecSync).toHaveBeenCalledWith('git rev-parse --show-toplevel', expect.any(Object));
164
+ expect(result).toBe('/path/to/repo');
165
+ });
166
+ });
167
+
168
+ describe('getProjectName', () => {
169
+ it('should return the basename of the repo root', () => {
170
+ mockExecSync.mockReturnValue('/path/to/my-project');
171
+
172
+ const result = getProjectName();
173
+
174
+ expect(result).toBe('my-project');
175
+ });
176
+
177
+ it('should handle nested paths', () => {
178
+ mockExecSync.mockReturnValue('/Users/developer/projects/awesome-app');
179
+
180
+ const result = getProjectName();
181
+
182
+ expect(result).toBe('awesome-app');
183
+ });
184
+ });
185
+
186
+ describe('getRemoteUrl', () => {
187
+ it('should return the remote URL if it exists', () => {
188
+ mockExecSync.mockReturnValue('/Volumes/MyDrive/.git-drive/my-project.git');
189
+
190
+ const result = getRemoteUrl('gd');
191
+
192
+ expect(mockExecSync).toHaveBeenCalledWith('git remote get-url gd', expect.any(Object));
193
+ expect(result).toBe('/Volumes/MyDrive/.git-drive/my-project.git');
194
+ });
195
+
196
+ it('should return null if remote does not exist', () => {
197
+ mockExecSync.mockImplementation(() => {
198
+ throw new Error('fatal: No such remote');
199
+ });
200
+
201
+ const result = getRemoteUrl('nonexistent');
202
+
203
+ expect(result).toBeNull();
204
+ });
205
+ });
206
+
207
+ describe('isGitRepo', () => {
208
+ it('should return true when in a git repository', () => {
209
+ mockExecSync.mockReturnValue('true');
210
+
211
+ const result = isGitRepo();
212
+
213
+ expect(result).toBe(true);
214
+ });
215
+
216
+ it('should return false when not in a git repository', () => {
217
+ mockExecSync.mockImplementation(() => {
218
+ throw new Error('fatal: not a git repository');
219
+ });
220
+
221
+ const result = isGitRepo();
222
+
223
+ expect(result).toBe(false);
224
+ });
225
+ });
226
+ });
@@ -0,0 +1,368 @@
1
+ import request from 'supertest';
2
+ import express, { Application } from 'express';
3
+ import { vol } from 'memfs';
4
+
5
+ // Mock fs
6
+ jest.mock('fs', () => {
7
+ const { fs } = require('memfs');
8
+ return fs;
9
+ });
10
+
11
+ // Mock child_process
12
+ jest.mock('child_process', () => ({
13
+ execSync: jest.fn(),
14
+ }));
15
+
16
+ // Mock node-disk-info
17
+ jest.mock('node-disk-info', () => ({
18
+ getDiskInfo: jest.fn(),
19
+ }));
20
+
21
+ import { execSync } from 'child_process';
22
+ import { getDiskInfo } from 'node-disk-info';
23
+
24
+ const mockExecSync = execSync as jest.Mock;
25
+ const mockGetDiskInfo = getDiskInfo as jest.Mock;
26
+
27
+ // Create a test app with the same routes as server.ts
28
+ function createTestApp(): Application {
29
+ const app = express();
30
+ app.use(express.json());
31
+
32
+ // Health check endpoint
33
+ app.get('/api/health', (_req, res) => {
34
+ res.json({ status: 'ok', timestamp: new Date().toISOString() });
35
+ });
36
+
37
+ // List all connected drives
38
+ app.get('/api/drives', async (_req, res) => {
39
+ try {
40
+ const drives = await mockGetDiskInfo();
41
+ const result = drives
42
+ .filter((d: any) => {
43
+ const mp = d.mounted;
44
+ if (!mp) return false;
45
+ if (mp === '/' || mp === '100%') return false;
46
+
47
+ if (process.platform === 'darwin') {
48
+ return mp.startsWith('/Volumes/') && !mp.startsWith('/Volumes/Recovery');
49
+ }
50
+
51
+ if (mp.startsWith('/sys') || mp.startsWith('/proc') || mp.startsWith('/run') || mp.startsWith('/snap') || mp.startsWith('/boot')) return false;
52
+ if (d.filesystem === 'tmpfs' || d.filesystem === 'devtmpfs' || d.filesystem === 'udev' || d.filesystem === 'overlay') return false;
53
+
54
+ return true;
55
+ })
56
+ .map((d: any) => ({
57
+ device: d.filesystem,
58
+ description: d.mounted,
59
+ size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
60
+ isRemovable: true,
61
+ isSystem: d.mounted === '/',
62
+ mountpoints: [d.mounted],
63
+ hasGitDrive: vol.existsSync(`${d.mounted}/.git-drive`),
64
+ }));
65
+ res.json(result);
66
+ } catch (err) {
67
+ res.status(500).json({ error: 'Failed to list drives' });
68
+ }
69
+ });
70
+
71
+ // List repos on a specific drive
72
+ app.get('/api/drives/:mountpoint/repos', (req, res) => {
73
+ try {
74
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
75
+ const gitDrivePath = `${mountpoint}/.git-drive`;
76
+
77
+ if (!vol.existsSync(mountpoint)) {
78
+ res.status(404).json({ error: 'Drive not found or not mounted' });
79
+ return;
80
+ }
81
+
82
+ const entries = vol.existsSync(gitDrivePath) ? vol.readdirSync(gitDrivePath) as string[] : [];
83
+ const repos = entries
84
+ .filter((entry: string) => {
85
+ const entryPath = `${gitDrivePath}/${entry}`;
86
+ const stat = vol.statSync(entryPath);
87
+ const isDir = stat.isDirectory();
88
+ return isDir && (entry.endsWith('.git') || vol.existsSync(`${entryPath}/HEAD`));
89
+ })
90
+ .map((entry: string) => {
91
+ const entryPath = `${gitDrivePath}/${entry}`;
92
+ const stat = vol.statSync(entryPath);
93
+ return {
94
+ name: entry.replace(/\.git$/, ''),
95
+ path: entryPath,
96
+ lastModified: stat.mtime.toISOString(),
97
+ };
98
+ });
99
+
100
+ res.json({
101
+ mountpoint,
102
+ gitDrivePath,
103
+ initialized: vol.existsSync(gitDrivePath),
104
+ repos,
105
+ });
106
+ } catch (err) {
107
+ res.status(500).json({ error: 'Failed to list repos' });
108
+ }
109
+ });
110
+
111
+ // Initialize git-drive on a drive
112
+ app.post('/api/drives/:mountpoint/init', (req, res) => {
113
+ try {
114
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
115
+
116
+ if (!vol.existsSync(mountpoint)) {
117
+ res.status(404).json({ error: 'Drive not found or not mounted' });
118
+ return;
119
+ }
120
+
121
+ const gitDrivePath = `${mountpoint}/.git-drive`;
122
+ vol.mkdirSync(gitDrivePath, { recursive: true });
123
+
124
+ res.json({
125
+ mountpoint,
126
+ gitDrivePath,
127
+ message: 'Git Drive initialized on this drive',
128
+ });
129
+ } catch (err: any) {
130
+ res.status(500).json({ error: err.message || 'Failed to initialize drive' });
131
+ }
132
+ });
133
+
134
+ // Create a new bare repo on a drive
135
+ app.post('/api/drives/:mountpoint/repos', (req, res) => {
136
+ try {
137
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
138
+ const { name } = req.body;
139
+
140
+ if (!name || typeof name !== 'string') {
141
+ res.status(400).json({ error: 'Repo name is required' });
142
+ return;
143
+ }
144
+
145
+ const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '-');
146
+
147
+ if (!vol.existsSync(mountpoint)) {
148
+ res.status(404).json({ error: 'Drive not found or not mounted' });
149
+ return;
150
+ }
151
+
152
+ const gitDrivePath = `${mountpoint}/.git-drive`;
153
+ if (!vol.existsSync(gitDrivePath)) {
154
+ vol.mkdirSync(gitDrivePath, { recursive: true });
155
+ }
156
+
157
+ const repoName = safeName.endsWith('.git') ? safeName : `${safeName}.git`;
158
+ const repoPath = `${gitDrivePath}/${repoName}`;
159
+
160
+ if (vol.existsSync(repoPath)) {
161
+ res.status(409).json({ error: 'Repository already exists' });
162
+ return;
163
+ }
164
+
165
+ // Mock git init --bare
166
+ vol.mkdirSync(repoPath, { recursive: true });
167
+ vol.writeFileSync(`${repoPath}/HEAD`, 'ref: refs/heads/main');
168
+
169
+ res.status(201).json({
170
+ name: safeName.replace(/\.git$/, ''),
171
+ path: repoPath,
172
+ message: `Bare repository created: ${repoName}`,
173
+ remoteUrl: repoPath,
174
+ });
175
+ } catch (err) {
176
+ res.status(500).json({ error: 'Failed to create repository' });
177
+ }
178
+ });
179
+
180
+ // Delete a repo from a drive
181
+ app.delete('/api/drives/:mountpoint/repos/:repoName', (req, res) => {
182
+ try {
183
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
184
+ const repoName = decodeURIComponent(req.params.repoName);
185
+ const gitDrivePath = `${mountpoint}/.git-drive`;
186
+
187
+ const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
188
+ const repoPath = `${gitDrivePath}/${bareRepoName}`;
189
+
190
+ if (!vol.existsSync(repoPath)) {
191
+ res.status(404).json({ error: 'Repository not found' });
192
+ return;
193
+ }
194
+
195
+ // Delete the repo directory
196
+ vol.rmSync(repoPath, { recursive: true, force: true });
197
+
198
+ res.json({ message: `Repository '${repoName}' deleted` });
199
+ } catch (err) {
200
+ res.status(500).json({ error: 'Failed to delete repository' });
201
+ }
202
+ });
203
+
204
+ return app;
205
+ }
206
+
207
+ describe('Server API', () => {
208
+ let app: Application;
209
+
210
+ beforeEach(() => {
211
+ jest.clearAllMocks();
212
+ vol.reset();
213
+ app = createTestApp();
214
+ });
215
+
216
+ describe('GET /api/health', () => {
217
+ it('should return health status', async () => {
218
+ const response = await request(app).get('/api/health');
219
+
220
+ expect(response.status).toBe(200);
221
+ expect(response.body).toHaveProperty('status', 'ok');
222
+ expect(response.body).toHaveProperty('timestamp');
223
+ });
224
+ });
225
+
226
+ describe('GET /api/drives', () => {
227
+ it('should return list of drives', async () => {
228
+ mockGetDiskInfo.mockResolvedValue([
229
+ { mounted: '/Volumes/MyUSB', filesystem: 'MyUSB', blocks: 32000000, available: 16000000 },
230
+ { mounted: '/Volumes/External', filesystem: 'External', blocks: 1000000000, available: 500000000 },
231
+ ]);
232
+
233
+ vol.fromJSON({
234
+ '/Volumes/MyUSB/.git-drive': '',
235
+ '/Volumes/External': '',
236
+ });
237
+
238
+ const response = await request(app).get('/api/drives');
239
+
240
+ expect(response.status).toBe(200);
241
+ expect(response.body).toHaveLength(2);
242
+ expect(response.body[0]).toHaveProperty('device');
243
+ expect(response.body[0]).toHaveProperty('mountpoints');
244
+ expect(response.body[0]).toHaveProperty('hasGitDrive');
245
+ });
246
+
247
+ it('should handle errors gracefully', async () => {
248
+ mockGetDiskInfo.mockRejectedValue(new Error('Failed to get drives'));
249
+
250
+ const response = await request(app).get('/api/drives');
251
+
252
+ expect(response.status).toBe(500);
253
+ expect(response.body).toHaveProperty('error', 'Failed to list drives');
254
+ });
255
+ });
256
+
257
+ describe('GET /api/drives/:mountpoint/repos', () => {
258
+ it('should return repos on a drive', async () => {
259
+ vol.fromJSON({
260
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': 'ref: refs/heads/main',
261
+ '/Volumes/MyUSB/.git-drive/another-repo.git/HEAD': 'ref: refs/heads/main',
262
+ });
263
+
264
+ const response = await request(app).get('/api/drives/%2FVolumes%2FMyUSB/repos');
265
+
266
+ expect(response.status).toBe(200);
267
+ expect(response.body).toHaveProperty('mountpoint', '/Volumes/MyUSB');
268
+ expect(response.body).toHaveProperty('initialized', true);
269
+ expect(response.body.repos).toHaveLength(2);
270
+ });
271
+
272
+ it('should return 404 for non-existent drive', async () => {
273
+ const response = await request(app).get('/api/drives/%2FVolumes%2FNonExistent/repos');
274
+
275
+ expect(response.status).toBe(404);
276
+ expect(response.body).toHaveProperty('error', 'Drive not found or not mounted');
277
+ });
278
+ });
279
+
280
+ describe('POST /api/drives/:mountpoint/init', () => {
281
+ it('should initialize git-drive on a drive', async () => {
282
+ vol.fromJSON({
283
+ '/Volumes/MyUSB': '',
284
+ });
285
+
286
+ const response = await request(app).post('/api/drives/%2FVolumes%2FMyUSB/init');
287
+
288
+ expect(response.status).toBe(200);
289
+ expect(response.body).toHaveProperty('message', 'Git Drive initialized on this drive');
290
+ });
291
+
292
+ it('should return 404 for non-existent drive', async () => {
293
+ const response = await request(app).post('/api/drives/%2FVolumes%2FNonExistent/init');
294
+
295
+ expect(response.status).toBe(404);
296
+ });
297
+ });
298
+
299
+ describe('POST /api/drives/:mountpoint/repos', () => {
300
+ it('should create a new repository', async () => {
301
+ vol.fromJSON({
302
+ '/Volumes/MyUSB/.git-drive': '',
303
+ });
304
+
305
+ const response = await request(app)
306
+ .post('/api/drives/%2FVolumes%2FMyUSB/repos')
307
+ .send({ name: 'new-project' });
308
+
309
+ expect(response.status).toBe(201);
310
+ expect(response.body).toHaveProperty('name', 'new-project');
311
+ });
312
+
313
+ it('should sanitize repository name', async () => {
314
+ vol.fromJSON({
315
+ '/Volumes/MyUSB/.git-drive': '',
316
+ });
317
+
318
+ const response = await request(app)
319
+ .post('/api/drives/%2FVolumes%2FMyUSB/repos')
320
+ .send({ name: 'my project with spaces!' });
321
+
322
+ expect(response.status).toBe(201);
323
+ expect(response.body.name).toBe('my-project-with-spaces-');
324
+ });
325
+
326
+ it('should return 400 if name is missing', async () => {
327
+ const response = await request(app)
328
+ .post('/api/drives/%2FVolumes%2FMyUSB/repos')
329
+ .send({});
330
+
331
+ expect(response.status).toBe(400);
332
+ });
333
+
334
+ it('should return 409 if repository already exists', async () => {
335
+ vol.fromJSON({
336
+ '/Volumes/MyUSB/.git-drive/existing-project.git/HEAD': '',
337
+ });
338
+
339
+ const response = await request(app)
340
+ .post('/api/drives/%2FVolumes%2FMyUSB/repos')
341
+ .send({ name: 'existing-project' });
342
+
343
+ expect(response.status).toBe(409);
344
+ });
345
+ });
346
+
347
+ describe('DELETE /api/drives/:mountpoint/repos/:repoName', () => {
348
+ it('should delete a repository', async () => {
349
+ vol.fromJSON({
350
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
351
+ });
352
+
353
+ const response = await request(app).delete('/api/drives/%2FVolumes%2FMyUSB/repos/my-project');
354
+
355
+ expect(response.status).toBe(200);
356
+ });
357
+
358
+ it('should return 404 for non-existent repository', async () => {
359
+ vol.fromJSON({
360
+ '/Volumes/MyUSB/.git-drive': '',
361
+ });
362
+
363
+ const response = await request(app).delete('/api/drives/%2FVolumes%2FMyUSB/repos/nonexistent');
364
+
365
+ expect(response.status).toBe(404);
366
+ });
367
+ });
368
+ });
@@ -0,0 +1,39 @@
1
+ import { rmSync } from "fs";
2
+ import { requireConfig, assertDriveMounted } from "../config.js";
3
+ import { git, getRepoRoot, getProjectName, isGitRepo } from "../git.js";
4
+ import { push } from "./push.js";
5
+ import { GitDriveError } from "../errors.js";
6
+
7
+ export function archive(args: string[]): void {
8
+ if (!isGitRepo()) {
9
+ throw new GitDriveError("Not in a git repository.");
10
+ }
11
+
12
+ const force = args.includes("--force");
13
+
14
+ // Check for uncommitted changes
15
+ if (!force) {
16
+ const status = git("status --porcelain");
17
+ if (status) {
18
+ throw new GitDriveError(
19
+ "Working tree has uncommitted changes.\nCommit first or use --force to archive anyway."
20
+ );
21
+ }
22
+ }
23
+
24
+ const config = requireConfig();
25
+ assertDriveMounted(config.drivePath);
26
+
27
+ const projectName = getProjectName();
28
+ const repoRoot = getRepoRoot();
29
+
30
+ // Push first
31
+ push([]);
32
+
33
+ // Remove local copy
34
+ process.chdir("..");
35
+ rmSync(repoRoot, { recursive: true, force: true });
36
+
37
+ console.log(`Archived: ${projectName}`);
38
+ console.log(`Restore with: git drive restore ${projectName}`);
39
+ }
@@ -0,0 +1,64 @@
1
+ import { existsSync, statSync, mkdirSync } from "fs";
2
+ import { resolve } from "path";
3
+ import prompts from "prompts";
4
+ import { saveConfig, getDriveStorePath } from "../config.js";
5
+ import { listDrives } from "../git.js";
6
+ import { GitDriveError } from "../errors.js";
7
+
8
+ export async function init(args: string[]): Promise<void> {
9
+ let drivePath: string;
10
+
11
+ const rawPath = args[0];
12
+
13
+ if (!rawPath) {
14
+ // No argument provided - prompt user to select a drive
15
+ const drives = await listDrives();
16
+
17
+ if (drives.length === 0) {
18
+ throw new GitDriveError(
19
+ "No external drives found. Please connect a drive and try again."
20
+ );
21
+ }
22
+
23
+ const { selectedDrive } = await prompts({
24
+ type: "select",
25
+ name: "selectedDrive",
26
+ message: "Select a drive to initialize git-drive:",
27
+ choices: drives.map((d: any) => ({
28
+ title: `${d.filesystem} (${d.mounted}) - ${Math.round((d.available / d.blocks) * 100)}% free`,
29
+ value: d.mounted,
30
+ })),
31
+ });
32
+
33
+ if (!selectedDrive) {
34
+ console.log("Operation cancelled.");
35
+ return;
36
+ }
37
+
38
+ drivePath = resolve(selectedDrive);
39
+ } else {
40
+ drivePath = resolve(rawPath);
41
+ }
42
+
43
+ if (!existsSync(drivePath)) {
44
+ throw new GitDriveError(
45
+ `Path not found: ${drivePath}\nIs the drive mounted?`
46
+ );
47
+ }
48
+
49
+ const stat = statSync(drivePath);
50
+ if (!stat.isDirectory()) {
51
+ throw new GitDriveError(`Path is not a directory: ${drivePath}`);
52
+ }
53
+
54
+ const storePath = getDriveStorePath(drivePath);
55
+ if (!existsSync(storePath)) {
56
+ mkdirSync(storePath, { recursive: true });
57
+ }
58
+
59
+ saveConfig({ drivePath });
60
+
61
+ console.log(`\n✅ Git Drive initialized!`);
62
+ console.log(` Drive: ${drivePath}`);
63
+ console.log(` Store: ${storePath}`);
64
+ }