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,26 @@
1
+ # Git Drive - Docker Image
2
+ # Includes CLI, server, and web UI
3
+
4
+ FROM node:20-alpine
5
+
6
+ # Install git and util-linux (for lsblk)
7
+ RUN apk add --no-cache git util-linux
8
+
9
+ WORKDIR /app
10
+
11
+ # Copy package files
12
+ COPY package.json ./
13
+ COPY dist ./dist/
14
+ COPY ui ./ui/
15
+
16
+ # Install production dependencies only
17
+ RUN npm install --omit=dev
18
+
19
+ # Expose the web UI port
20
+ EXPOSE 4483
21
+
22
+ # Set environment
23
+ ENV GIT_DRIVE_PORT=4483
24
+
25
+ # Default command starts the server
26
+ CMD ["node", "dist/server.js"]
@@ -0,0 +1,26 @@
1
+ /** @type {import('jest').Config} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ roots: ['<rootDir>/src'],
6
+ testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'],
7
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8
+ collectCoverageFrom: [
9
+ 'src/**/*.ts',
10
+ '!src/**/*.d.ts',
11
+ '!src/__tests__/**',
12
+ ],
13
+ coverageDirectory: 'coverage',
14
+ coverageReporters: ['text', 'lcov', 'html'],
15
+ verbose: true,
16
+ clearMocks: true,
17
+ restoreMocks: true,
18
+ moduleNameMapper: {
19
+ '^(\\.{1,2}/.*)\\.js$': '$1',
20
+ },
21
+ transform: {
22
+ '^.+\\.tsx?$': ['ts-jest', {
23
+ useESM: false,
24
+ }],
25
+ },
26
+ };
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "git-drive",
3
+ "version": "0.1.4",
4
+ "description": "Turn any external drive into a git remote backup for your code - CLI, server, and web UI",
5
+ "keywords": [
6
+ "git",
7
+ "backup",
8
+ "external-drive",
9
+ "usb",
10
+ "remote",
11
+ "cli",
12
+ "docker"
13
+ ],
14
+ "author": "",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/josmanvis/git-drive.git",
19
+ "directory": "packages/cli"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/josmanvis/git-drive/issues"
23
+ },
24
+ "homepage": "https://github.com/josmanvis/git-drive#readme",
25
+ "type": "commonjs",
26
+ "bin": {
27
+ "git-drive": "dist/index.js"
28
+ },
29
+ "main": "./dist/index.js",
30
+ "files": [
31
+ "dist",
32
+ "ui"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "start": "node dist/index.js",
37
+ "start:server": "node dist/server.js",
38
+ "docker:build": "docker build -t git-drive .",
39
+ "docker:run": "docker run -it --rm -v /Volumes:/Volumes -p 4483:4483 git-drive",
40
+ "prepublishOnly": "npm run build",
41
+ "test": "jest",
42
+ "test:watch": "jest --watch",
43
+ "test:coverage": "jest --coverage"
44
+ },
45
+ "dependencies": {
46
+ "express": "^4.19.2",
47
+ "node-disk-info": "^1.3.0",
48
+ "prompts": "^2.4.2"
49
+ },
50
+ "devDependencies": {
51
+ "@types/express": "^4.17.21",
52
+ "@types/node": "^22.0.0",
53
+ "@types/prompts": "^2.4.9",
54
+ "@types/jest": "^29.5.14",
55
+ "@types/supertest": "^6.0.2",
56
+ "jest": "^29.7.0",
57
+ "ts-jest": "^29.2.5",
58
+ "supertest": "^6.3.4",
59
+ "memfs": "^4.14.0",
60
+ "typescript": "^5.7.0"
61
+ },
62
+ "engines": {
63
+ "node": ">=18"
64
+ }
65
+ }
@@ -0,0 +1,154 @@
1
+ import { init } from '../../commands/init.js';
2
+ import { GitDriveError } from '../../errors.js';
3
+ import { vol } from 'memfs';
4
+
5
+ // Mock fs and path modules
6
+ jest.mock('fs', () => {
7
+ const { fs } = require('memfs');
8
+ return fs;
9
+ });
10
+
11
+ // Mock prompts
12
+ jest.mock('prompts', () => ({
13
+ __esModule: true,
14
+ default: jest.fn(),
15
+ }));
16
+
17
+ // Mock config
18
+ jest.mock('../../config.js', () => ({
19
+ saveConfig: jest.fn(),
20
+ getDriveStorePath: jest.fn((drivePath: string) => `${drivePath}/.git-drive`),
21
+ }));
22
+
23
+ // Mock git
24
+ jest.mock('../../git.js', () => ({
25
+ listDrives: jest.fn(),
26
+ }));
27
+
28
+ import prompts from 'prompts';
29
+ import { listDrives } from '../../git.js';
30
+ import { saveConfig, getDriveStorePath } from '../../config.js';
31
+
32
+ const mockPrompts = prompts as unknown as jest.Mock;
33
+ const mockListDrives = listDrives as jest.Mock;
34
+ const mockSaveConfig = saveConfig as jest.Mock;
35
+ const mockGetDriveStorePath = getDriveStorePath as jest.Mock;
36
+
37
+ describe('init command', () => {
38
+ let consoleSpy: jest.SpyInstance;
39
+
40
+ beforeEach(() => {
41
+ jest.clearAllMocks();
42
+ vol.reset();
43
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
44
+ mockGetDriveStorePath.mockImplementation((drivePath: string) => `${drivePath}/.git-drive`);
45
+ });
46
+
47
+ afterEach(() => {
48
+ consoleSpy.mockRestore();
49
+ });
50
+
51
+ describe('with path argument', () => {
52
+ it('should initialize git-drive on specified path', async () => {
53
+ const drivePath = '/Volumes/TestDrive';
54
+ vol.fromJSON({
55
+ [drivePath]: '',
56
+ });
57
+
58
+ await init([drivePath]);
59
+
60
+ expect(mockSaveConfig).toHaveBeenCalledWith({ drivePath: expect.stringContaining('TestDrive') });
61
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Git Drive initialized'));
62
+ });
63
+
64
+ it('should throw error if path does not exist', async () => {
65
+ await expect(init(['/Volumes/NonExistent'])).rejects.toThrow(GitDriveError);
66
+ await expect(init(['/Volumes/NonExistent'])).rejects.toThrow('Path not found');
67
+ });
68
+
69
+ it('should throw error if path is not a directory', async () => {
70
+ vol.fromJSON({
71
+ '/Volumes/SomeFile': 'file content',
72
+ });
73
+
74
+ await expect(init(['/Volumes/SomeFile'])).rejects.toThrow('Path is not a directory');
75
+ });
76
+
77
+ it('should resolve relative paths', async () => {
78
+ vol.fromJSON({
79
+ '/current/dir/TestDrive': '',
80
+ });
81
+
82
+ // Mock cwd to return a specific directory
83
+ const originalCwd = process.cwd;
84
+ process.cwd = jest.fn(() => '/current/dir');
85
+
86
+ await init(['./TestDrive']);
87
+
88
+ expect(mockSaveConfig).toHaveBeenCalled();
89
+
90
+ process.cwd = originalCwd;
91
+ });
92
+ });
93
+
94
+ describe('without path argument (interactive)', () => {
95
+ it('should throw error when no drives found', async () => {
96
+ mockListDrives.mockResolvedValue([]);
97
+
98
+ await expect(init([])).rejects.toThrow('No external drives found');
99
+ });
100
+
101
+ it('should prompt user to select a drive', async () => {
102
+ mockListDrives.mockResolvedValue([
103
+ { mounted: '/Volumes/Drive1', filesystem: 'Drive1', blocks: 1000000, available: 500000 },
104
+ { mounted: '/Volumes/Drive2', filesystem: 'Drive2', blocks: 2000000, available: 1000000 },
105
+ ]);
106
+
107
+ mockPrompts.mockResolvedValue({
108
+ selectedDrive: '/Volumes/Drive1',
109
+ });
110
+
111
+ vol.fromJSON({
112
+ '/Volumes/Drive1': '',
113
+ });
114
+
115
+ await init([]);
116
+
117
+ expect(mockPrompts).toHaveBeenCalledWith(
118
+ expect.objectContaining({
119
+ type: 'select',
120
+ name: 'selectedDrive',
121
+ })
122
+ );
123
+ });
124
+
125
+ it('should handle user cancellation', async () => {
126
+ mockListDrives.mockResolvedValue([
127
+ { mounted: '/Volumes/Drive1', filesystem: 'Drive1', blocks: 1000000, available: 500000 },
128
+ ]);
129
+
130
+ mockPrompts.mockResolvedValue({
131
+ selectedDrive: undefined,
132
+ });
133
+
134
+ await init([]);
135
+
136
+ expect(consoleSpy).toHaveBeenCalledWith('Operation cancelled.');
137
+ expect(mockSaveConfig).not.toHaveBeenCalled();
138
+ });
139
+ });
140
+
141
+ describe('store directory creation', () => {
142
+ it('should create .git-drive directory if it does not exist', async () => {
143
+ const drivePath = '/Volumes/TestDrive';
144
+ vol.fromJSON({
145
+ [drivePath]: '',
146
+ });
147
+
148
+ await init([drivePath]);
149
+
150
+ // The store path should have been requested
151
+ expect(mockGetDriveStorePath).toHaveBeenCalled();
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,118 @@
1
+ import { vol } from 'memfs';
2
+
3
+ // Mock fs
4
+ jest.mock('fs', () => {
5
+ const { fs } = require('memfs');
6
+ return fs;
7
+ });
8
+
9
+ // Mock node-disk-info
10
+ jest.mock('node-disk-info', () => ({
11
+ getDiskInfo: jest.fn(),
12
+ }));
13
+
14
+ // Mock server
15
+ jest.mock('../../server.js', () => ({
16
+ ensureServerRunning: jest.fn(),
17
+ }));
18
+
19
+ // Mock os
20
+ jest.mock('os', () => ({
21
+ homedir: () => '/home/testuser',
22
+ }));
23
+
24
+ import { getDiskInfo } from 'node-disk-info';
25
+ import { list } from '../../commands/list.js';
26
+
27
+ const mockGetDiskInfo = getDiskInfo as jest.Mock;
28
+
29
+ describe('list command', () => {
30
+ let consoleSpy: jest.SpyInstance;
31
+ const originalPlatform = process.platform;
32
+
33
+ beforeEach(() => {
34
+ jest.clearAllMocks();
35
+ vol.reset();
36
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
37
+ Object.defineProperty(process, 'platform', { value: 'darwin', writable: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ consoleSpy.mockRestore();
42
+ Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true });
43
+ });
44
+
45
+ it('should display no drives message when no external drives found', async () => {
46
+ mockGetDiskInfo.mockResolvedValue([]);
47
+
48
+ vol.fromJSON({
49
+ '/home/testuser/.config/git-drive/links.json': '{}',
50
+ });
51
+
52
+ await list([]);
53
+
54
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No external drives detected'));
55
+ });
56
+
57
+ it('should list connected drives with git-drive status', async () => {
58
+ mockGetDiskInfo.mockResolvedValue([
59
+ { mounted: '/Volumes/MyUSB', filesystem: 'MyUSB', blocks: 32000000, available: 16000000 },
60
+ { mounted: '/Volumes/External', filesystem: 'External', blocks: 1000000000, available: 500000000 },
61
+ ]);
62
+
63
+ vol.fromJSON({
64
+ '/home/testuser/.config/git-drive/links.json': '{}',
65
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
66
+ '/Volumes/External': '',
67
+ });
68
+
69
+ await list([]);
70
+
71
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('/Volumes/MyUSB'));
72
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('/Volumes/External'));
73
+ });
74
+
75
+ it('should count repositories on initialized drives', async () => {
76
+ mockGetDiskInfo.mockResolvedValue([
77
+ { mounted: '/Volumes/MyUSB', filesystem: 'MyUSB', blocks: 32000000, available: 16000000 },
78
+ ]);
79
+
80
+ vol.fromJSON({
81
+ '/home/testuser/.config/git-drive/links.json': '{}',
82
+ '/Volumes/MyUSB/.git-drive/project1.git/HEAD': '',
83
+ '/Volumes/MyUSB/.git-drive/project2.git/HEAD': '',
84
+ '/Volumes/MyUSB/.git-drive/project3.git/HEAD': '',
85
+ });
86
+
87
+ await list([]);
88
+
89
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Repositories: 3'));
90
+ });
91
+
92
+ it('should display drive size in GB', async () => {
93
+ mockGetDiskInfo.mockResolvedValue([
94
+ { mounted: '/Volumes/MyUSB', filesystem: 'MyUSB', blocks: 97656250, available: 50000000 },
95
+ ]);
96
+
97
+ vol.fromJSON({
98
+ '/home/testuser/.config/git-drive/links.json': '{}',
99
+ '/Volumes/MyUSB': '',
100
+ });
101
+
102
+ await list([]);
103
+
104
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('GB'));
105
+ });
106
+
107
+ it('should handle errors when detecting drives', async () => {
108
+ mockGetDiskInfo.mockRejectedValue(new Error('Failed to detect drives'));
109
+
110
+ vol.fromJSON({
111
+ '/home/testuser/.config/git-drive/links.json': '{}',
112
+ });
113
+
114
+ await list([]);
115
+
116
+ expect(consoleSpy).toHaveBeenCalledWith('Error detecting drives:', expect.any(Error));
117
+ });
118
+ });
@@ -0,0 +1,155 @@
1
+ import { push } from '../../commands/push.js';
2
+ import { GitDriveError } from '../../errors.js';
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 prompts
12
+ jest.mock('prompts', () => ({
13
+ __esModule: true,
14
+ default: jest.fn(),
15
+ }));
16
+
17
+ // Mock git
18
+ jest.mock('../../git.js', () => ({
19
+ git: jest.fn(),
20
+ getRemoteUrl: jest.fn(),
21
+ isGitRepo: jest.fn(),
22
+ }));
23
+
24
+ // Mock server
25
+ jest.mock('../../server.js', () => ({
26
+ ensureServerRunning: jest.fn(),
27
+ }));
28
+
29
+ import prompts from 'prompts';
30
+ import { git, getRemoteUrl, isGitRepo } from '../../git.js';
31
+
32
+ const mockPrompts = prompts as unknown as jest.Mock;
33
+ const mockGit = git as jest.Mock;
34
+ const mockGetRemoteUrl = getRemoteUrl as jest.Mock;
35
+ const mockIsGitRepo = isGitRepo as jest.Mock;
36
+
37
+ describe('push command', () => {
38
+ let consoleSpy: jest.SpyInstance;
39
+
40
+ beforeEach(() => {
41
+ jest.clearAllMocks();
42
+ vol.reset();
43
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
44
+ mockIsGitRepo.mockReturnValue(true);
45
+ mockGetRemoteUrl.mockReturnValue('/Volumes/TestDrive/.git-drive/my-repo.git');
46
+ mockGit.mockImplementation((cmd: string) => {
47
+ if (cmd.includes('branch --show-current')) return 'main';
48
+ return '';
49
+ });
50
+ });
51
+
52
+ afterEach(() => {
53
+ consoleSpy.mockRestore();
54
+ });
55
+
56
+ it('should throw error when not in a git repository', async () => {
57
+ mockIsGitRepo.mockReturnValue(false);
58
+
59
+ await expect(push([])).rejects.toThrow(GitDriveError);
60
+ await expect(push([])).rejects.toThrow('Not in a git repository');
61
+ });
62
+
63
+ it('should throw error when no git-drive linked', async () => {
64
+ mockIsGitRepo.mockReturnValue(true);
65
+ mockGetRemoteUrl.mockReturnValue(null);
66
+
67
+ await expect(push([])).rejects.toThrow("No git-drive linked for this project");
68
+ });
69
+
70
+ describe('push modes', () => {
71
+ beforeEach(() => {
72
+ vol.fromJSON({
73
+ '/Volumes/TestDrive/.git-drive/my-repo.git/HEAD': '',
74
+ });
75
+ });
76
+
77
+ it('should push current branch with --current flag', async () => {
78
+ await push(['--current']);
79
+
80
+ expect(mockGit).toHaveBeenCalledWith(expect.stringContaining('push gd main'));
81
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Successfully pushed'));
82
+ });
83
+
84
+ it('should push all branches and tags with --all flag', async () => {
85
+ await push(['--all']);
86
+
87
+ expect(mockGit).toHaveBeenCalledWith('push gd --all');
88
+ expect(mockGit).toHaveBeenCalledWith('push gd --tags');
89
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('all branches and tags'));
90
+ });
91
+
92
+ it('should prompt for push mode when no flags provided', async () => {
93
+ mockPrompts.mockResolvedValue({ pushMode: 'current' });
94
+
95
+ await push([]);
96
+
97
+ expect(mockPrompts).toHaveBeenCalledWith(
98
+ expect.objectContaining({
99
+ type: 'select',
100
+ name: 'pushMode',
101
+ })
102
+ );
103
+ });
104
+
105
+ it('should handle user cancellation in interactive mode', async () => {
106
+ mockPrompts.mockResolvedValue({ pushMode: undefined });
107
+
108
+ await push([]);
109
+
110
+ // Should not have pushed anything beyond the prompt
111
+ expect(mockGit).not.toHaveBeenCalledWith(expect.stringContaining('push gd'));
112
+ });
113
+ });
114
+
115
+ describe('pushlog', () => {
116
+ it('should write pushlog on successful push', async () => {
117
+ vol.fromJSON({
118
+ '/Volumes/TestDrive/.git-drive/my-repo.git/HEAD': '',
119
+ });
120
+
121
+ await push(['--current']);
122
+
123
+ // Check that the pushlog file was created
124
+ const files = vol.toJSON();
125
+ const pushlogPath = '/Volumes/TestDrive/.git-drive/my-repo.git/git-drive-pushlog.json';
126
+ // The pushlog should exist if the remote URL path exists
127
+ // This is tested indirectly through the code path
128
+ });
129
+
130
+ it('should handle pushlog write errors silently', async () => {
131
+ // Create a scenario where writing the pushlog would fail
132
+ vol.fromJSON({
133
+ '/Volumes/TestDrive/.git-drive/my-repo.git/HEAD': '',
134
+ });
135
+
136
+ // This should not throw even if pushlog writing fails
137
+ await expect(push(['--current'])).resolves.not.toThrow();
138
+ });
139
+ });
140
+
141
+ describe('error handling', () => {
142
+ it('should throw GitDriveError when git push fails', async () => {
143
+ vol.fromJSON({
144
+ '/Volumes/TestDrive/.git-drive/my-repo.git/HEAD': '',
145
+ });
146
+
147
+ mockGit.mockImplementation((cmd: string) => {
148
+ if (cmd.includes('branch --show-current')) return 'main';
149
+ throw new Error('fatal: failed to push');
150
+ });
151
+
152
+ await expect(push(['--current'])).rejects.toThrow(GitDriveError);
153
+ });
154
+ });
155
+ });
@@ -0,0 +1,134 @@
1
+ import { restore } from '../../commands/restore.js';
2
+ import { GitDriveError } from '../../errors.js';
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 config
12
+ jest.mock('../../config.js', () => ({
13
+ requireConfig: jest.fn(),
14
+ assertDriveMounted: jest.fn(),
15
+ getDriveStorePath: jest.fn((drivePath: string) => `${drivePath}/.git-drive`),
16
+ }));
17
+
18
+ // Mock git
19
+ jest.mock('../../git.js', () => ({
20
+ git: jest.fn(),
21
+ }));
22
+
23
+ // Mock server
24
+ jest.mock('../../server.js', () => ({
25
+ ensureServerRunning: jest.fn(),
26
+ }));
27
+
28
+ import { git } from '../../git.js';
29
+ import { requireConfig, assertDriveMounted } from '../../config.js';
30
+
31
+ const mockGit = git as jest.Mock;
32
+ const mockRequireConfig = requireConfig as jest.Mock;
33
+ const mockAssertDriveMounted = assertDriveMounted as jest.Mock;
34
+
35
+ describe('restore command', () => {
36
+ let consoleSpy: jest.SpyInstance;
37
+
38
+ beforeEach(() => {
39
+ jest.clearAllMocks();
40
+ vol.reset();
41
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
42
+ mockRequireConfig.mockReturnValue({ drivePath: '/Volumes/MyUSB' });
43
+ mockAssertDriveMounted.mockImplementation(() => {});
44
+ mockGit.mockImplementation(() => {});
45
+ });
46
+
47
+ afterEach(() => {
48
+ consoleSpy.mockRestore();
49
+ });
50
+
51
+ it('should throw error when no project name provided', () => {
52
+ expect(() => restore([])).toThrow(GitDriveError);
53
+ expect(() => restore([])).toThrow('Usage: git drive restore <project-name> [target-dir]');
54
+ });
55
+
56
+ it('should throw error when project not found on drive', () => {
57
+ vol.fromJSON({
58
+ '/Volumes/MyUSB/.git-drive': '',
59
+ });
60
+
61
+ expect(() => restore(['nonexistent-project'])).toThrow(GitDriveError);
62
+ expect(() => restore(['nonexistent-project'])).toThrow("Project 'nonexistent-project' not found on drive.");
63
+ });
64
+
65
+ it('should throw error when target directory already exists', () => {
66
+ vol.fromJSON({
67
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
68
+ '/home/user/my-project': '',
69
+ });
70
+
71
+ expect(() => restore(['my-project', '/home/user/my-project'])).toThrow(GitDriveError);
72
+ expect(() => restore(['my-project', '/home/user/my-project'])).toThrow('Directory already exists');
73
+ });
74
+
75
+ it('should clone the bare repo to target directory', () => {
76
+ vol.fromJSON({
77
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
78
+ });
79
+
80
+ restore(['my-project', '/home/user/restored-project']);
81
+
82
+ expect(mockGit).toHaveBeenCalledWith(
83
+ expect.stringContaining('clone /Volumes/MyUSB/.git-drive/my-project.git')
84
+ );
85
+ });
86
+
87
+ it('should rename origin remote to drive', () => {
88
+ vol.fromJSON({
89
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
90
+ });
91
+
92
+ restore(['my-project', '/home/user/restored-project']);
93
+
94
+ expect(mockGit).toHaveBeenCalledWith(
95
+ expect.stringContaining('remote rename origin drive'),
96
+ expect.any(String)
97
+ );
98
+ });
99
+
100
+ it('should use project name as target directory if not specified', () => {
101
+ vol.fromJSON({
102
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
103
+ });
104
+
105
+ restore(['my-project']);
106
+
107
+ // Should call git clone with the project name
108
+ expect(mockGit).toHaveBeenCalledWith(
109
+ expect.stringMatching(/clone.*my-project\.git/),
110
+ undefined
111
+ );
112
+ });
113
+
114
+ it('should log success message', () => {
115
+ vol.fromJSON({
116
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
117
+ });
118
+
119
+ restore(['my-project']);
120
+
121
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Restored'));
122
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('my-project'));
123
+ });
124
+
125
+ it('should work with custom target directory', () => {
126
+ vol.fromJSON({
127
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
128
+ });
129
+
130
+ restore(['my-project', '/custom/target']);
131
+
132
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('/custom/target'));
133
+ });
134
+ });