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
package/package.json CHANGED
@@ -1,64 +1,29 @@
1
1
  {
2
2
  "name": "git-drive",
3
- "version": "0.1.6",
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"
3
+ "version": "0.1.7",
4
+ "description": "Use an external drive as a git bare repository remote",
5
+ "workspaces": [
6
+ "packages/*"
13
7
  ],
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
- "dependencies": {
35
- "express": "^4.19.2",
36
- "node-disk-info": "^1.3.0",
37
- "prompts": "^2.4.2"
8
+ "scripts": {
9
+ "build": "pnpm build:cli && pnpm build:ui && pnpm build:server",
10
+ "build:cli": "tsc -p packages/cli/tsconfig.json",
11
+ "build:ui": "cd packages/ui && pnpm run build",
12
+ "build:server": "tsc -p packages/server/tsconfig.json",
13
+ "dev": "tsc --watch",
14
+ "test": "pnpm -r test",
15
+ "test:cli": "cd packages/cli && pnpm test",
16
+ "test:ui": "cd packages/ui && pnpm test",
17
+ "test:coverage": "pnpm -r test:coverage"
38
18
  },
39
19
  "devDependencies": {
40
- "@types/express": "^4.17.21",
41
20
  "@types/node": "^22.0.0",
42
- "@types/prompts": "^2.4.9",
43
- "@types/jest": "^29.5.14",
44
- "@types/supertest": "^6.0.2",
45
- "jest": "^29.7.0",
46
- "ts-jest": "^29.2.5",
47
- "supertest": "^6.3.4",
48
- "memfs": "^4.14.0",
49
21
  "typescript": "^5.7.0"
50
22
  },
51
- "engines": {
52
- "node": ">=18"
53
- },
54
- "scripts": {
55
- "build": "tsc -p tsconfig.json",
56
- "start": "node dist/index.js",
57
- "start:server": "node dist/server.js",
58
- "docker:build": "docker build -t git-drive .",
59
- "docker:run": "docker run -it --rm -v /Volumes:/Volumes -p 4483:4483 git-drive",
60
- "test": "jest",
61
- "test:watch": "jest --watch",
62
- "test:coverage": "jest --coverage"
23
+ "pnpm": {
24
+ "onlyBuiltDependencies": [
25
+ "drivelist",
26
+ "esbuild"
27
+ ]
63
28
  }
64
- }
29
+ }
@@ -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.6",
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,152 @@
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 child_process
15
+ jest.mock('child_process', () => ({
16
+ spawn: jest.fn(() => ({
17
+ on: jest.fn(),
18
+ kill: jest.fn(),
19
+ })),
20
+ execSync: jest.fn(),
21
+ }));
22
+
23
+ // Mock os
24
+ jest.mock('os', () => ({
25
+ homedir: () => '/home/testuser',
26
+ }));
27
+
28
+ // Mock readline
29
+ jest.mock('readline', () => ({
30
+ createInterface: jest.fn(() => ({
31
+ question: jest.fn((_prompt, callback) => callback()),
32
+ close: jest.fn(),
33
+ })),
34
+ }));
35
+
36
+ // Mock prompts
37
+ jest.mock('prompts', () => jest.fn());
38
+
39
+ import { getDiskInfo } from 'node-disk-info';
40
+ import { getCompanionInfo, isPortAvailable, findAvailablePort } from '../../commands/companion.js';
41
+
42
+ const mockGetDiskInfo = getDiskInfo as jest.Mock;
43
+
44
+ // Mock fetch for port testing
45
+ const mockFetch = jest.fn();
46
+ global.fetch = mockFetch;
47
+
48
+ describe('companion command', () => {
49
+ beforeEach(() => {
50
+ jest.clearAllMocks();
51
+ vol.reset();
52
+ mockFetch.mockReset();
53
+ });
54
+
55
+ describe('getCompanionInfo', () => {
56
+ it('should return installed: false when companion repo does not exist', () => {
57
+ vol.fromJSON({
58
+ '/Volumes/MyUSB/.git-drive': '',
59
+ });
60
+
61
+ const result = getCompanionInfo('/Volumes/MyUSB');
62
+
63
+ expect(result.installed).toBe(false);
64
+ });
65
+
66
+ it('should return installed: true when companion repo exists', () => {
67
+ vol.fromJSON({
68
+ '/Volumes/MyUSB/.git-drive/git-drive.git/HEAD': '',
69
+ '/Volumes/MyUSB/.git-drive/companion.json': JSON.stringify({
70
+ version: '0.1.6',
71
+ installedAt: '2026-02-26T00:00:00Z',
72
+ }),
73
+ });
74
+
75
+ const result = getCompanionInfo('/Volumes/MyUSB');
76
+
77
+ expect(result.installed).toBe(true);
78
+ expect(result.version).toBe('0.1.6');
79
+ });
80
+
81
+ it('should return installed: true even without companion.json', () => {
82
+ vol.fromJSON({
83
+ '/Volumes/MyUSB/.git-drive/git-drive.git/HEAD': '',
84
+ });
85
+
86
+ const result = getCompanionInfo('/Volumes/MyUSB');
87
+
88
+ expect(result.installed).toBe(true);
89
+ });
90
+
91
+ it('should handle malformed companion.json gracefully', () => {
92
+ vol.fromJSON({
93
+ '/Volumes/MyUSB/.git-drive/git-drive.git/HEAD': '',
94
+ '/Volumes/MyUSB/.git-drive/companion.json': 'invalid json{',
95
+ });
96
+
97
+ const result = getCompanionInfo('/Volumes/MyUSB');
98
+
99
+ expect(result.installed).toBe(true);
100
+ expect(result.version).toBeUndefined();
101
+ });
102
+ });
103
+
104
+ describe('isPortAvailable', () => {
105
+ it('should return false when port is in use', async () => {
106
+ mockFetch.mockResolvedValueOnce({ ok: true });
107
+
108
+ const result = await isPortAvailable(4484);
109
+
110
+ expect(result).toBe(false);
111
+ expect(mockFetch).toHaveBeenCalledWith(
112
+ 'http://localhost:4484/api/health',
113
+ expect.objectContaining({ method: 'HEAD' })
114
+ );
115
+ });
116
+
117
+ it('should return true when port is available', async () => {
118
+ mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
119
+
120
+ const result = await isPortAvailable(4484);
121
+
122
+ expect(result).toBe(true);
123
+ });
124
+ });
125
+
126
+ describe('findAvailablePort', () => {
127
+ it('should return the start port if available', async () => {
128
+ mockFetch.mockRejectedValue(new Error('Connection refused'));
129
+
130
+ const result = await findAvailablePort(4484);
131
+
132
+ expect(result).toBe(4484);
133
+ });
134
+
135
+ it('should find next available port', async () => {
136
+ // First port is in use
137
+ mockFetch.mockResolvedValueOnce({ ok: true });
138
+ // Second port is available
139
+ mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
140
+
141
+ const result = await findAvailablePort(4484);
142
+
143
+ expect(result).toBe(4485);
144
+ });
145
+
146
+ it('should throw after max attempts', async () => {
147
+ mockFetch.mockResolvedValue({ ok: true });
148
+
149
+ await expect(findAvailablePort(4484)).rejects.toThrow('Could not find an available port');
150
+ });
151
+ });
152
+ });
@@ -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
+ // Mock child_process for companion installation
29
+ jest.mock('child_process', () => ({
30
+ execSync: jest.fn(),
31
+ }));
32
+
33
+ import prompts from 'prompts';
34
+ import { listDrives } from '../../git.js';
35
+ import { saveConfig, getDriveStorePath } from '../../config.js';
36
+
37
+ const mockPrompts = prompts as unknown as jest.Mock;
38
+ const mockListDrives = listDrives as jest.Mock;
39
+ const mockSaveConfig = saveConfig as jest.Mock;
40
+ const mockGetDriveStorePath = getDriveStorePath as jest.Mock;
41
+
42
+ describe('init command', () => {
43
+ let consoleSpy: jest.SpyInstance;
44
+
45
+ beforeEach(() => {
46
+ jest.clearAllMocks();
47
+ vol.reset();
48
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
49
+ mockGetDriveStorePath.mockImplementation((drivePath: string) => `${drivePath}/.git-drive`);
50
+ });
51
+
52
+ afterEach(() => {
53
+ consoleSpy.mockRestore();
54
+ });
55
+
56
+ describe('with path argument', () => {
57
+ it('should initialize git-drive on specified path', async () => {
58
+ const drivePath = '/Volumes/TestDrive';
59
+ vol.fromJSON({
60
+ [drivePath]: null, // null creates a directory in memfs
61
+ });
62
+
63
+ await init([drivePath]);
64
+
65
+ expect(mockSaveConfig).toHaveBeenCalledWith({ drivePath: expect.stringContaining('TestDrive') });
66
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Git Drive initialized'));
67
+ });
68
+
69
+ it('should throw error if path does not exist', async () => {
70
+ await expect(init(['/Volumes/NonExistent'])).rejects.toThrow(GitDriveError);
71
+ await expect(init(['/Volumes/NonExistent'])).rejects.toThrow('Path not found');
72
+ });
73
+
74
+ it('should throw error if path is not a directory', async () => {
75
+ vol.fromJSON({
76
+ '/Volumes/SomeFile': 'file content',
77
+ });
78
+
79
+ await expect(init(['/Volumes/SomeFile'])).rejects.toThrow('Path is not a directory');
80
+ });
81
+
82
+ it('should resolve relative paths', async () => {
83
+ // Use an absolute path instead since memfs doesn't interact with process.cwd properly
84
+ vol.fromJSON({
85
+ '/Volumes/RelativeDrive': null, // null creates a directory in memfs
86
+ });
87
+
88
+ await init(['/Volumes/RelativeDrive']);
89
+
90
+ expect(mockSaveConfig).toHaveBeenCalled();
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': null, // null creates a directory in memfs
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]: null, // null creates a directory in memfs
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,122 @@
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
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation();
115
+
116
+ await list([]);
117
+
118
+ expect(errorSpy).toHaveBeenCalledWith('Error detecting drives:', expect.any(Error));
119
+
120
+ errorSpy.mockRestore();
121
+ });
122
+ });