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,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,135 @@
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
+ // The git function is called twice: once for clone, once for remote rename
109
+ expect(mockGit).toHaveBeenCalledTimes(2);
110
+ expect(mockGit).toHaveBeenNthCalledWith(1,
111
+ expect.stringMatching(/clone.*my-project\.git.*my-project$/)
112
+ );
113
+ });
114
+
115
+ it('should log success message', () => {
116
+ vol.fromJSON({
117
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
118
+ });
119
+
120
+ restore(['my-project']);
121
+
122
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Restored'));
123
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('my-project'));
124
+ });
125
+
126
+ it('should work with custom target directory', () => {
127
+ vol.fromJSON({
128
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
129
+ });
130
+
131
+ restore(['my-project', '/custom/target']);
132
+
133
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('/custom/target'));
134
+ });
135
+ });
@@ -0,0 +1,199 @@
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 git
15
+ jest.mock('../../git.js', () => ({
16
+ isGitRepo: jest.fn(),
17
+ getProjectName: jest.fn(),
18
+ getRemoteUrl: jest.fn(),
19
+ }));
20
+
21
+ // Mock server
22
+ jest.mock('../../server.js', () => ({
23
+ ensureServerRunning: jest.fn(),
24
+ }));
25
+
26
+ // Mock os
27
+ jest.mock('os', () => ({
28
+ homedir: () => '/home/testuser',
29
+ }));
30
+
31
+ import { getDiskInfo } from 'node-disk-info';
32
+ import { isGitRepo, getProjectName, getRemoteUrl } from '../../git.js';
33
+ import { status } from '../../commands/status.js';
34
+
35
+ const mockGetDiskInfo = getDiskInfo as jest.Mock;
36
+ const mockIsGitRepo = isGitRepo as jest.Mock;
37
+ const mockGetProjectName = getProjectName as jest.Mock;
38
+ const mockGetRemoteUrl = getRemoteUrl as jest.Mock;
39
+
40
+ describe('status command', () => {
41
+ let consoleSpy: jest.SpyInstance;
42
+ const originalPlatform = process.platform;
43
+
44
+ beforeEach(() => {
45
+ jest.clearAllMocks();
46
+ vol.reset();
47
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
48
+ mockIsGitRepo.mockReturnValue(false);
49
+ Object.defineProperty(process, 'platform', { value: 'darwin', writable: true });
50
+ });
51
+
52
+ afterEach(() => {
53
+ consoleSpy.mockRestore();
54
+ Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true });
55
+ });
56
+
57
+ it('should display connected drives section', async () => {
58
+ mockGetDiskInfo.mockResolvedValue([
59
+ { mounted: '/Volumes/MyUSB', filesystem: 'MyUSB', blocks: 32000000, available: 16000000 },
60
+ ]);
61
+
62
+ vol.fromJSON({
63
+ '/home/testuser/.config/git-drive/links.json': '{}',
64
+ '/Volumes/MyUSB/.git-drive/my-project.git/HEAD': '',
65
+ });
66
+
67
+ await status([]);
68
+
69
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Connected Drives'));
70
+ });
71
+
72
+ it('should display no external drives message when none found', async () => {
73
+ mockGetDiskInfo.mockResolvedValue([]);
74
+
75
+ vol.fromJSON({
76
+ '/home/testuser/.config/git-drive/links.json': '{}',
77
+ });
78
+
79
+ await status([]);
80
+
81
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No external drives connected'));
82
+ });
83
+
84
+ it('should display registered repositories section', async () => {
85
+ mockGetDiskInfo.mockResolvedValue([]);
86
+
87
+ vol.fromJSON({
88
+ '/home/testuser/.config/git-drive/links.json': JSON.stringify({
89
+ '/home/user/project1': {
90
+ mountpoint: '/Volumes/MyUSB',
91
+ repoName: 'project1.git',
92
+ linkedAt: '2024-01-01T00:00:00.000Z',
93
+ },
94
+ }),
95
+ '/home/user/project1': '',
96
+ '/Volumes/MyUSB': '',
97
+ });
98
+
99
+ await status([]);
100
+
101
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Registered Repositories'));
102
+ });
103
+
104
+ it('should show NOT CONNECTED status when drive is not mounted', async () => {
105
+ mockGetDiskInfo.mockResolvedValue([]);
106
+
107
+ vol.fromJSON({
108
+ '/home/testuser/.config/git-drive/links.json': JSON.stringify({
109
+ '/home/user/project1': {
110
+ mountpoint: '/Volumes/MyUSB',
111
+ repoName: 'project1.git',
112
+ linkedAt: '2024-01-01T00:00:00.000Z',
113
+ },
114
+ }),
115
+ '/home/user/project1': '',
116
+ });
117
+
118
+ await status([]);
119
+
120
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('NOT CONNECTED'));
121
+ });
122
+
123
+ it('should show NOT FOUND status when local directory does not exist', async () => {
124
+ mockGetDiskInfo.mockResolvedValue([]);
125
+
126
+ vol.fromJSON({
127
+ '/home/testuser/.config/git-drive/links.json': JSON.stringify({
128
+ '/home/user/nonexistent': {
129
+ mountpoint: '/Volumes/MyUSB',
130
+ repoName: 'project.git',
131
+ linkedAt: '2024-01-01T00:00:00.000Z',
132
+ },
133
+ }),
134
+ '/Volumes/MyUSB': '',
135
+ });
136
+
137
+ await status([]);
138
+
139
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('NOT FOUND'));
140
+ });
141
+
142
+ it('should display current repository section when in a git repo', async () => {
143
+ mockGetDiskInfo.mockResolvedValue([]);
144
+ mockIsGitRepo.mockReturnValue(true);
145
+ mockGetProjectName.mockReturnValue('my-project');
146
+ mockGetRemoteUrl.mockReturnValue('/Volumes/MyUSB/.git-drive/my-project.git');
147
+
148
+ vol.fromJSON({
149
+ '/home/testuser/.config/git-drive/links.json': '{}',
150
+ });
151
+
152
+ await status([]);
153
+
154
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Current Repository'));
155
+ });
156
+
157
+ it('should show no gd remote message when not linked', async () => {
158
+ mockGetDiskInfo.mockResolvedValue([]);
159
+ mockIsGitRepo.mockReturnValue(true);
160
+ mockGetProjectName.mockReturnValue('my-project');
161
+ mockGetRemoteUrl.mockReturnValue(null);
162
+
163
+ vol.fromJSON({
164
+ '/home/testuser/.config/git-drive/links.json': '{}',
165
+ });
166
+
167
+ await status([]);
168
+
169
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("No 'gd' remote configured"));
170
+ });
171
+
172
+ it('should display server status section', async () => {
173
+ mockGetDiskInfo.mockResolvedValue([]);
174
+
175
+ vol.fromJSON({
176
+ '/home/testuser/.config/git-drive/links.json': '{}',
177
+ });
178
+
179
+ await status([]);
180
+
181
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Server'));
182
+ });
183
+
184
+ it('should handle errors gracefully', async () => {
185
+ mockGetDiskInfo.mockRejectedValue(new Error('Failed to get disk info'));
186
+
187
+ vol.fromJSON({
188
+ '/home/testuser/.config/git-drive/links.json': '{}',
189
+ });
190
+
191
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation();
192
+
193
+ await status([]);
194
+
195
+ expect(errorSpy).toHaveBeenCalledWith('Error detecting drives:', expect.any(Error));
196
+
197
+ errorSpy.mockRestore();
198
+ });
199
+ });
@@ -0,0 +1,198 @@
1
+ import { vol } from 'memfs';
2
+ import { join } from 'path';
3
+ import {
4
+ loadConfig,
5
+ saveConfig,
6
+ requireConfig,
7
+ assertDriveMounted,
8
+ getDriveStorePath,
9
+ loadLinks,
10
+ saveLink,
11
+ Config,
12
+ LinkRegistry,
13
+ } from '../config.js';
14
+
15
+ // Mock fs and os modules
16
+ jest.mock('fs', () => {
17
+ const { fs } = require('memfs');
18
+ return fs;
19
+ });
20
+
21
+ jest.mock('os', () => ({
22
+ homedir: () => '/home/testuser',
23
+ }));
24
+
25
+ describe('config', () => {
26
+ const configDir = '/home/testuser/.config/git-drive';
27
+ const configFile = join(configDir, 'config.json');
28
+ const linksFile = join(configDir, 'links.json');
29
+
30
+ beforeEach(() => {
31
+ vol.reset();
32
+ });
33
+
34
+ describe('loadConfig', () => {
35
+ it('should return null when config file does not exist', () => {
36
+ const result = loadConfig();
37
+ expect(result).toBeNull();
38
+ });
39
+
40
+ it('should load and parse valid config', () => {
41
+ const config: Config = { drivePath: '/Volumes/TestDrive' };
42
+ vol.fromJSON({
43
+ [configFile]: JSON.stringify(config),
44
+ });
45
+
46
+ const result = loadConfig();
47
+ expect(result).toEqual(config);
48
+ });
49
+
50
+ it('should throw on malformed JSON', () => {
51
+ vol.fromJSON({
52
+ [configFile]: 'not valid json',
53
+ });
54
+
55
+ expect(() => loadConfig()).toThrow();
56
+ });
57
+ });
58
+
59
+ describe('saveConfig', () => {
60
+ it('should create config directory if it does not exist', () => {
61
+ const config: Config = { drivePath: '/Volumes/MyDrive' };
62
+ saveConfig(config);
63
+
64
+ const files = vol.toJSON();
65
+ expect(files[configFile]).toBeDefined();
66
+ });
67
+
68
+ it('should save config with proper formatting', () => {
69
+ const config: Config = { drivePath: '/Volumes/MyDrive' };
70
+ saveConfig(config);
71
+
72
+ const savedContent = vol.toJSON()[configFile];
73
+ expect(savedContent).toBe(JSON.stringify(config, null, 2) + '\n');
74
+ });
75
+
76
+ it('should overwrite existing config', () => {
77
+ const config1: Config = { drivePath: '/Volumes/Drive1' };
78
+ const config2: Config = { drivePath: '/Volumes/Drive2' };
79
+
80
+ saveConfig(config1);
81
+ saveConfig(config2);
82
+
83
+ const result = loadConfig();
84
+ expect(result).toEqual(config2);
85
+ });
86
+ });
87
+
88
+ describe('requireConfig', () => {
89
+ it('should return config when it exists', () => {
90
+ const config: Config = { drivePath: '/Volumes/TestDrive' };
91
+ vol.fromJSON({
92
+ [configFile]: JSON.stringify(config),
93
+ });
94
+
95
+ const result = requireConfig();
96
+ expect(result).toEqual(config);
97
+ });
98
+
99
+ it('should throw GitDriveError when config does not exist', () => {
100
+ expect(() => requireConfig()).toThrow('No drive configured. Run: git drive init <path>');
101
+ });
102
+ });
103
+
104
+ describe('assertDriveMounted', () => {
105
+ it('should not throw when drive path exists', () => {
106
+ vol.fromJSON({
107
+ '/Volumes/TestDrive/.git-drive': '',
108
+ });
109
+
110
+ expect(() => assertDriveMounted('/Volumes/TestDrive')).not.toThrow();
111
+ });
112
+
113
+ it('should throw GitDriveError when drive path does not exist', () => {
114
+ expect(() => assertDriveMounted('/Volumes/NonExistent')).toThrow(
115
+ 'Drive not found at /Volumes/NonExistent. Is it connected?'
116
+ );
117
+ });
118
+ });
119
+
120
+ describe('getDriveStorePath', () => {
121
+ it('should return the .git-drive path for a mountpoint', () => {
122
+ expect(getDriveStorePath('/Volumes/MyDrive')).toBe('/Volumes/MyDrive/.git-drive');
123
+ });
124
+
125
+ it('should handle different path formats', () => {
126
+ expect(getDriveStorePath('/mnt/usb')).toBe('/mnt/usb/.git-drive');
127
+ });
128
+ });
129
+
130
+ describe('loadLinks', () => {
131
+ it('should return empty object when links file does not exist', () => {
132
+ const result = loadLinks();
133
+ expect(result).toEqual({});
134
+ });
135
+
136
+ it('should load and parse valid links', () => {
137
+ const links: LinkRegistry = {
138
+ '/home/user/project1': {
139
+ mountpoint: '/Volumes/Drive1',
140
+ repoName: 'project1.git',
141
+ linkedAt: '2024-01-01T00:00:00.000Z',
142
+ },
143
+ };
144
+
145
+ vol.fromJSON({
146
+ [linksFile]: JSON.stringify(links),
147
+ });
148
+
149
+ const result = loadLinks();
150
+ expect(result).toEqual(links);
151
+ });
152
+
153
+ it('should return empty object on malformed JSON', () => {
154
+ vol.fromJSON({
155
+ [linksFile]: 'invalid json',
156
+ });
157
+
158
+ const result = loadLinks();
159
+ expect(result).toEqual({});
160
+ });
161
+ });
162
+
163
+ describe('saveLink', () => {
164
+ it('should create links directory if it does not exist', () => {
165
+ saveLink('/home/user/project', '/Volumes/Drive', 'project.git');
166
+
167
+ const files = vol.toJSON();
168
+ expect(files[linksFile]).toBeDefined();
169
+ });
170
+
171
+ it('should save a new link', () => {
172
+ saveLink('/home/user/project', '/Volumes/Drive', 'project.git');
173
+
174
+ const result = loadLinks();
175
+ expect(result['/home/user/project']).toEqual({
176
+ mountpoint: '/Volumes/Drive',
177
+ repoName: 'project.git',
178
+ linkedAt: expect.any(String),
179
+ });
180
+ });
181
+
182
+ it('should update existing link', () => {
183
+ saveLink('/home/user/project', '/Volumes/Drive1', 'project.git');
184
+ saveLink('/home/user/project', '/Volumes/Drive2', 'project.git');
185
+
186
+ const result = loadLinks();
187
+ expect(result['/home/user/project'].mountpoint).toBe('/Volumes/Drive2');
188
+ });
189
+
190
+ it('should preserve other links when adding new one', () => {
191
+ saveLink('/home/user/project1', '/Volumes/Drive', 'project1.git');
192
+ saveLink('/home/user/project2', '/Volumes/Drive', 'project2.git');
193
+
194
+ const result = loadLinks();
195
+ expect(Object.keys(result)).toHaveLength(2);
196
+ });
197
+ });
198
+ });