git-drive 0.1.5 → 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.
package/README.md CHANGED
@@ -59,9 +59,45 @@ git-drive server
59
59
  | `push` | Push current repo to the linked drive |
60
60
  | `list` | Show connected drives and their status |
61
61
  | `status` | Show detailed status of drives and repos |
62
+ | `companion [path]` | Run git-drive from a drive (companion mode) - see below |
62
63
  | `server`, `start`, `ui` | Start the git-drive web UI server at http://localhost:4483 |
63
64
  | `-v`, `-V`, `--version`, `version` | Show the installed version |
64
65
 
66
+ ## Companion Mode
67
+
68
+ Companion mode allows you to run git-drive directly from an external drive, making it portable and self-contained. This is useful when:
69
+
70
+ - You want to use git-drive on multiple computers without installing it
71
+ - You're at a different machine and need to access your backups
72
+ - You want the drive to be completely self-contained
73
+
74
+ ### Using Companion Mode
75
+
76
+ When you run `git-drive init` on a drive, it automatically installs a companion copy of git-drive on that drive. To use it:
77
+
78
+ ```bash
79
+ # Interactive selection - shows drives with git-drive initialized
80
+ git-drive companion
81
+
82
+ # Direct path - run companion mode from a specific drive
83
+ git-drive companion /Volumes/MyDrive
84
+ ```
85
+
86
+ This will:
87
+ 1. Find an available port (starting from 4484)
88
+ 2. Start the git-drive server in companion mode
89
+ 3. Open your browser to the web UI
90
+ 4. Show only the companion drive in the UI
91
+
92
+ Press Enter to stop the companion server.
93
+
94
+ ### Companion Mode Features
95
+
96
+ - **Portable**: The companion is stored on the drive itself
97
+ - **Self-updating**: The companion can be updated from the web UI
98
+ - **Version tracking**: Shows companion version and update availability
99
+ - **Port detection**: Automatically finds an available port if the default is in use
100
+
65
101
  ## How it works
66
102
 
67
103
  Run the git-drive (available as a docker container). In the web ui (localhost:4483) just select the drive you want to use (this will create a `.git-drive/` directory in that drive if it doesnt already have it). Here you can see a list of existing repos in this drive (`.git-drive/*`) or add new ones so you can push your code to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-drive",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Use an external drive as a git bare repository remote",
5
5
  "workspaces": [
6
6
  "packages/*"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-drive",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Turn any external drive into a git remote backup for your code - CLI, server, and web UI",
5
5
  "keywords": [
6
6
  "git",
@@ -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
+ });
@@ -25,6 +25,11 @@ jest.mock('../../git.js', () => ({
25
25
  listDrives: jest.fn(),
26
26
  }));
27
27
 
28
+ // Mock child_process for companion installation
29
+ jest.mock('child_process', () => ({
30
+ execSync: jest.fn(),
31
+ }));
32
+
28
33
  import prompts from 'prompts';
29
34
  import { listDrives } from '../../git.js';
30
35
  import { saveConfig, getDriveStorePath } from '../../config.js';
@@ -52,7 +57,7 @@ describe('init command', () => {
52
57
  it('should initialize git-drive on specified path', async () => {
53
58
  const drivePath = '/Volumes/TestDrive';
54
59
  vol.fromJSON({
55
- [drivePath]: '',
60
+ [drivePath]: null, // null creates a directory in memfs
56
61
  });
57
62
 
58
63
  await init([drivePath]);
@@ -75,19 +80,14 @@ describe('init command', () => {
75
80
  });
76
81
 
77
82
  it('should resolve relative paths', async () => {
83
+ // Use an absolute path instead since memfs doesn't interact with process.cwd properly
78
84
  vol.fromJSON({
79
- '/current/dir/TestDrive': '',
85
+ '/Volumes/RelativeDrive': null, // null creates a directory in memfs
80
86
  });
81
87
 
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']);
88
+ await init(['/Volumes/RelativeDrive']);
87
89
 
88
90
  expect(mockSaveConfig).toHaveBeenCalled();
89
-
90
- process.cwd = originalCwd;
91
91
  });
92
92
  });
93
93
 
@@ -109,7 +109,7 @@ describe('init command', () => {
109
109
  });
110
110
 
111
111
  vol.fromJSON({
112
- '/Volumes/Drive1': '',
112
+ '/Volumes/Drive1': null, // null creates a directory in memfs
113
113
  });
114
114
 
115
115
  await init([]);
@@ -142,7 +142,7 @@ describe('init command', () => {
142
142
  it('should create .git-drive directory if it does not exist', async () => {
143
143
  const drivePath = '/Volumes/TestDrive';
144
144
  vol.fromJSON({
145
- [drivePath]: '',
145
+ [drivePath]: null, // null creates a directory in memfs
146
146
  });
147
147
 
148
148
  await init([drivePath]);
@@ -111,8 +111,12 @@ describe('list command', () => {
111
111
  '/home/testuser/.config/git-drive/links.json': '{}',
112
112
  });
113
113
 
114
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation();
115
+
114
116
  await list([]);
115
117
 
116
- expect(consoleSpy).toHaveBeenCalledWith('Error detecting drives:', expect.any(Error));
118
+ expect(errorSpy).toHaveBeenCalledWith('Error detecting drives:', expect.any(Error));
119
+
120
+ errorSpy.mockRestore();
117
121
  });
118
122
  });
@@ -104,10 +104,11 @@ describe('restore command', () => {
104
104
 
105
105
  restore(['my-project']);
106
106
 
107
- // Should call git clone with the project name
108
- expect(mockGit).toHaveBeenCalledWith(
109
- expect.stringMatching(/clone.*my-project\.git/),
110
- undefined
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$/)
111
112
  );
112
113
  });
113
114
 
@@ -188,8 +188,12 @@ describe('status command', () => {
188
188
  '/home/testuser/.config/git-drive/links.json': '{}',
189
189
  });
190
190
 
191
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation();
192
+
191
193
  await status([]);
192
194
 
193
- expect(consoleSpy).toHaveBeenCalledWith('Error detecting drives:', expect.any(Error));
195
+ expect(errorSpy).toHaveBeenCalledWith('Error detecting drives:', expect.any(Error));
196
+
197
+ errorSpy.mockRestore();
194
198
  });
195
199
  });
@@ -152,6 +152,30 @@ describe('git', () => {
152
152
 
153
153
  expect(result).toHaveLength(0);
154
154
  });
155
+
156
+ it('should filter out temporary paths with TemporaryItems', async () => {
157
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
158
+
159
+ mockGetDiskInfo.mockResolvedValue([
160
+ { mounted: '/var/folders/2b/b7kfzb0s2v55m89_k9qvpj9w0000gn/T/TemporaryItems/NSIRD_screencaptureui_g99XDe', filesystem: 'tmpfs', blocks: 1000, available: 500 },
161
+ ]);
162
+
163
+ const result = await listDrives();
164
+
165
+ expect(result).toHaveLength(0);
166
+ });
167
+
168
+ it('should filter out /var/folders paths', async () => {
169
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
170
+
171
+ mockGetDiskInfo.mockResolvedValue([
172
+ { mounted: '/var/folders/abc', filesystem: 'tmpfs', blocks: 1000, available: 500 },
173
+ ]);
174
+
175
+ const result = await listDrives();
176
+
177
+ expect(result).toHaveLength(0);
178
+ });
155
179
  });
156
180
 
157
181
  describe('getRepoRoot', () => {
@@ -280,16 +280,19 @@ describe('Server API', () => {
280
280
  describe('POST /api/drives/:mountpoint/init', () => {
281
281
  it('should initialize git-drive on a drive', async () => {
282
282
  vol.fromJSON({
283
- '/Volumes/MyUSB': '',
283
+ '/Volumes/MyUSB': null,
284
284
  });
285
285
 
286
286
  const response = await request(app).post('/api/drives/%2FVolumes%2FMyUSB/init');
287
287
 
288
288
  expect(response.status).toBe(200);
289
289
  expect(response.body).toHaveProperty('message', 'Git Drive initialized on this drive');
290
+ expect(response.body).toHaveProperty('gitDrivePath', '/Volumes/MyUSB/.git-drive');
290
291
  });
291
292
 
292
293
  it('should return 404 for non-existent drive', async () => {
294
+ vol.fromJSON({});
295
+
293
296
  const response = await request(app).post('/api/drives/%2FVolumes%2FNonExistent/init');
294
297
 
295
298
  expect(response.status).toBe(404);
@@ -299,7 +302,7 @@ describe('Server API', () => {
299
302
  describe('POST /api/drives/:mountpoint/repos', () => {
300
303
  it('should create a new repository', async () => {
301
304
  vol.fromJSON({
302
- '/Volumes/MyUSB/.git-drive': '',
305
+ '/Volumes/MyUSB/.git-drive': null,
303
306
  });
304
307
 
305
308
  const response = await request(app)
@@ -312,7 +315,7 @@ describe('Server API', () => {
312
315
 
313
316
  it('should sanitize repository name', async () => {
314
317
  vol.fromJSON({
315
- '/Volumes/MyUSB/.git-drive': '',
318
+ '/Volumes/MyUSB/.git-drive': null,
316
319
  });
317
320
 
318
321
  const response = await request(app)
@@ -0,0 +1,205 @@
1
+ import { spawn } from "child_process";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { resolve, join } from "path";
4
+ import { createInterface } from "readline";
5
+ import prompts from "prompts";
6
+ import { listDrives } from "../git.js";
7
+ import { GitDriveError } from "../errors.js";
8
+ import { getDriveStorePath } from "../config.js";
9
+
10
+ const DEFAULT_PORT = 4484;
11
+ const MAX_PORT_ATTEMPTS = 20;
12
+
13
+ // Check if a port is available
14
+ async function isPortAvailable(port: number): Promise<boolean> {
15
+ try {
16
+ const response = await fetch(`http://localhost:${port}/api/health`, {
17
+ method: 'HEAD',
18
+ signal: AbortSignal.timeout(500),
19
+ });
20
+ return false; // Port is in use
21
+ } catch {
22
+ return true; // Port is available
23
+ }
24
+ }
25
+
26
+ // Find the next available port starting from DEFAULT_PORT
27
+ async function findAvailablePort(startPort: number = DEFAULT_PORT): Promise<number> {
28
+ for (let port = startPort; port < startPort + MAX_PORT_ATTEMPTS; port++) {
29
+ if (await isPortAvailable(port)) {
30
+ return port;
31
+ }
32
+ }
33
+ throw new GitDriveError(`Could not find an available port after ${MAX_PORT_ATTEMPTS} attempts.`);
34
+ }
35
+
36
+ // Get companion info from a drive
37
+ function getCompanionInfo(drivePath: string): { installed: boolean; version?: string; installedAt?: string } {
38
+ const storePath = getDriveStorePath(drivePath);
39
+ const companionVersionPath = join(storePath, 'companion.json');
40
+ const companionRepoPath = join(storePath, 'git-drive.git');
41
+
42
+ if (!existsSync(companionRepoPath)) {
43
+ return { installed: false };
44
+ }
45
+
46
+ try {
47
+ if (existsSync(companionVersionPath)) {
48
+ const companionInfo = JSON.parse(readFileSync(companionVersionPath, 'utf-8'));
49
+ return {
50
+ installed: true,
51
+ version: companionInfo.version,
52
+ installedAt: companionInfo.installedAt,
53
+ };
54
+ }
55
+ return { installed: true }; // Repo exists but no version file
56
+ } catch {
57
+ return { installed: true };
58
+ }
59
+ }
60
+
61
+ // Open browser to URL
62
+ function openBrowser(url: string): void {
63
+ const platform = process.platform;
64
+ let command: string;
65
+
66
+ if (platform === 'darwin') {
67
+ command = `open "${url}"`;
68
+ } else if (platform === 'win32') {
69
+ command = `start "" "${url}"`;
70
+ } else {
71
+ command = `xdg-open "${url}"`;
72
+ }
73
+
74
+ try {
75
+ spawn(command, { shell: true, detached: true, stdio: 'ignore' });
76
+ } catch (err) {
77
+ console.log(`Please open your browser to: ${url}`);
78
+ }
79
+ }
80
+
81
+ // Wait for Enter key
82
+ async function waitForEnter(): Promise<void> {
83
+ const rl = createInterface({
84
+ input: process.stdin,
85
+ output: process.stdout,
86
+ });
87
+
88
+ return new Promise((resolve) => {
89
+ rl.question('\nPress Enter to stop the companion server...', () => {
90
+ rl.close();
91
+ resolve();
92
+ });
93
+ });
94
+ }
95
+
96
+ export async function companion(args: string[]): Promise<void> {
97
+ let drivePath: string;
98
+
99
+ const rawPath = args[0];
100
+
101
+ if (!rawPath) {
102
+ // No argument provided - prompt user to select a drive
103
+ const drives = await listDrives();
104
+
105
+ // Filter to only drives with git-drive initialized
106
+ const initializedDrives = drives.filter((d: any) => {
107
+ const storePath = getDriveStorePath(d.mounted);
108
+ return existsSync(storePath);
109
+ });
110
+
111
+ if (initializedDrives.length === 0) {
112
+ throw new GitDriveError(
113
+ "No drives with git-drive initialized found.\nRun 'git-drive init' on a drive first."
114
+ );
115
+ }
116
+
117
+ const { selectedDrive } = await prompts({
118
+ type: "select",
119
+ name: "selectedDrive",
120
+ message: "Select a drive to run in companion mode:",
121
+ choices: initializedDrives.map((d: any) => {
122
+ const companionInfo = getCompanionInfo(d.mounted);
123
+ const companionStatus = companionInfo.installed
124
+ ? ` (Companion v${companionInfo.version || 'unknown'})`
125
+ : ' (No companion)';
126
+ return {
127
+ title: `${d.filesystem} (${d.mounted})${companionStatus}`,
128
+ value: d.mounted,
129
+ };
130
+ }),
131
+ });
132
+
133
+ if (!selectedDrive) {
134
+ console.log("Operation cancelled.");
135
+ return;
136
+ }
137
+
138
+ drivePath = resolve(selectedDrive);
139
+ } else {
140
+ drivePath = resolve(rawPath);
141
+ }
142
+
143
+ // Verify the drive has git-drive initialized
144
+ const storePath = getDriveStorePath(drivePath);
145
+ if (!existsSync(storePath)) {
146
+ throw new GitDriveError(
147
+ `Git Drive not initialized on ${drivePath}.\nRun 'git-drive init ${drivePath}' first.`
148
+ );
149
+ }
150
+
151
+ // Check companion status
152
+ const companionInfo = getCompanionInfo(drivePath);
153
+ if (!companionInfo.installed) {
154
+ console.log(`\n⚠️ Warning: Companion not installed on this drive.`);
155
+ console.log(` Run 'git-drive init ${drivePath}' to install the companion.`);
156
+ console.log(` Continuing in standard mode...\n`);
157
+ }
158
+
159
+ // Find available port
160
+ const port = await findAvailablePort();
161
+
162
+ console.log(`\n🔌 Starting Git Drive in Companion Mode...`);
163
+ console.log(` Drive: ${drivePath}`);
164
+ console.log(` Port: ${port}`);
165
+ if (companionInfo.version) {
166
+ console.log(` Companion: v${companionInfo.version}`);
167
+ }
168
+
169
+ // Start the server with companion mode environment variables
170
+ const serverPath = require.resolve('../server.js');
171
+ const env = {
172
+ ...process.env,
173
+ GIT_DRIVE_PORT: String(port),
174
+ GIT_DRIVE_COMPANION_MODE: 'true',
175
+ GIT_DRIVE_COMPANION_DRIVE: drivePath,
176
+ };
177
+
178
+ const child = spawn(process.execPath, [serverPath], {
179
+ stdio: 'inherit',
180
+ env,
181
+ });
182
+
183
+ child.on('error', (err) => {
184
+ console.error('Failed to start companion server:', err.message);
185
+ process.exit(1);
186
+ });
187
+
188
+ // Wait a moment for server to start, then open browser
189
+ setTimeout(() => {
190
+ const url = `http://localhost:${port}`;
191
+ console.log(`\n 🌐 Opening browser: ${url}`);
192
+ openBrowser(url);
193
+ console.log(`\n Companion mode is running.`);
194
+ }, 1000);
195
+
196
+ // Wait for Enter to stop
197
+ await waitForEnter();
198
+
199
+ console.log('\n Stopping companion server...');
200
+ child.kill();
201
+ console.log(' 👋 Companion mode stopped.');
202
+ }
203
+
204
+ // Export helper functions for testing
205
+ export { getCompanionInfo, findAvailablePort, isPortAvailable };
@@ -1,10 +1,61 @@
1
- import { existsSync, statSync, mkdirSync } from "fs";
2
- import { resolve } from "path";
1
+ import { existsSync, statSync, mkdirSync, writeFileSync } from "fs";
2
+ import { execSync } from "child_process";
3
+ import { resolve, join } from "path";
3
4
  import prompts from "prompts";
4
5
  import { saveConfig, getDriveStorePath } from "../config.js";
5
6
  import { listDrives } from "../git.js";
6
7
  import { GitDriveError } from "../errors.js";
7
8
 
9
+ // Companion repository URL
10
+ const COMPANION_REPO_URL = "https://github.com/josmanvis/git-drive.git";
11
+
12
+ // Get the current version from package.json
13
+ function getCurrentVersion(): string {
14
+ try {
15
+ const packageJsonPath = join(__dirname, '..', '..', 'package.json');
16
+ const packageJson = require(packageJsonPath);
17
+ return packageJson.version;
18
+ } catch {
19
+ return 'unknown';
20
+ }
21
+ }
22
+
23
+ // Install the companion (clone git-drive repo to the drive)
24
+ function installCompanion(storePath: string): { installed: boolean; version: string; error?: string } {
25
+ const companionRepoPath = join(storePath, 'git-drive.git');
26
+ const companionVersionPath = join(storePath, 'companion.json');
27
+ const currentVersion = getCurrentVersion();
28
+
29
+ try {
30
+ // If companion already exists, update it
31
+ if (existsSync(companionRepoPath)) {
32
+ try {
33
+ execSync(`git -C "${companionRepoPath}" fetch origin`, { stdio: 'pipe' });
34
+ execSync(`git -C "${companionRepoPath}" reset --hard origin/main`, { stdio: 'pipe' });
35
+ } catch {
36
+ // If update fails, remove and re-clone
37
+ execSync(`rm -rf "${companionRepoPath}"`, { stdio: 'pipe' });
38
+ execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
39
+ }
40
+ } else {
41
+ // Clone the companion
42
+ execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
43
+ }
44
+
45
+ // Write companion version info
46
+ const companionInfo = {
47
+ version: currentVersion,
48
+ installedAt: new Date().toISOString(),
49
+ repoUrl: COMPANION_REPO_URL,
50
+ };
51
+ writeFileSync(companionVersionPath, JSON.stringify(companionInfo, null, 2));
52
+
53
+ return { installed: true, version: currentVersion };
54
+ } catch (err: any) {
55
+ return { installed: false, version: currentVersion, error: err.message };
56
+ }
57
+ }
58
+
8
59
  export async function init(args: string[]): Promise<void> {
9
60
  let drivePath: string;
10
61
 
@@ -61,4 +112,19 @@ export async function init(args: string[]): Promise<void> {
61
112
  console.log(`\n✅ Git Drive initialized!`);
62
113
  console.log(` Drive: ${drivePath}`);
63
114
  console.log(` Store: ${storePath}`);
64
- }
115
+
116
+ // Install companion
117
+ console.log(`\n📦 Installing Drive Companion...`);
118
+ const companionResult = installCompanion(storePath);
119
+
120
+ if (companionResult.installed) {
121
+ console.log(` ✅ Companion v${companionResult.version} installed!`);
122
+ console.log(`\n You can now use 'git-drive companion ${drivePath}' on any machine.`);
123
+ } else {
124
+ console.log(` ⚠️ Failed to install companion: ${companionResult.error}`);
125
+ console.log(` You can still use git-drive, but companion mode is not available.`);
126
+ }
127
+ }
128
+
129
+ // Export for use in other modules
130
+ export { installCompanion, getCurrentVersion };
@@ -17,6 +17,11 @@ export async function listDrives(): Promise<any[]> {
17
17
  if (!mp) return false;
18
18
  if (mp === "/" || mp === "100%") return false;
19
19
 
20
+ // Exclude temporary and system paths on all platforms
21
+ if (mp.startsWith("/var/") || mp.startsWith("/private/var/") || mp.startsWith("/tmp") || mp.startsWith("/private/tmp")) return false;
22
+ if (mp.includes("TemporaryItems") || mp.includes("NSIRD_")) return false;
23
+ if (mp.startsWith("/System/") || mp.startsWith("/Library/")) return false;
24
+
20
25
  if (process.platform === "darwin") {
21
26
  return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
22
27
  }
@@ -7,6 +7,7 @@ import { list } from "./commands/list.js";
7
7
  import { status } from "./commands/status.js";
8
8
  import { link } from "./commands/link.js";
9
9
  import { init } from "./commands/init.js";
10
+ import { companion } from "./commands/companion.js";
10
11
  import { handleError } from "./errors.js";
11
12
  import { ensureServerRunning } from "./server.js";
12
13
 
@@ -20,13 +21,14 @@ const commands: Record<string, (args: string[]) => void | Promise<void>> = {
20
21
  list,
21
22
  status,
22
23
  link,
24
+ companion,
23
25
  server: startServer,
24
26
  start: startServer,
25
27
  ui: startServer,
26
28
  };
27
29
 
28
30
  // Commands that don't need the server running
29
- const NO_SERVER_COMMANDS = ['server', 'start', 'ui'];
31
+ const NO_SERVER_COMMANDS = ['server', 'start', 'ui', 'companion'];
30
32
 
31
33
  function printUsage(): void {
32
34
  console.log(`
@@ -41,6 +43,7 @@ Commands:
41
43
  push Push current repo to drive
42
44
  list Show connected drives and their status
43
45
  status Show detailed status of drives and repos
46
+ companion [path] Run git-drive from a drive (companion mode)
44
47
  server, start, ui Start the git-drive web UI server
45
48
 
46
49
  Options:
@@ -53,10 +56,14 @@ Examples:
53
56
  git-drive push Push current repo to drive
54
57
  git-drive list List connected drives
55
58
  git-drive status Show detailed status
59
+ git-drive companion Run companion mode (interactive)
60
+ git-drive companion /Volumes/USB Run companion mode for specific drive
56
61
  git-drive server Start the web UI at http://localhost:4483
57
62
 
58
63
  Environment Variables:
59
- GIT_DRIVE_PORT Port for the web server (default: 4483)
64
+ GIT_DRIVE_PORT Port for the web server (default: 4483)
65
+ GIT_DRIVE_COMPANION_MODE Set to 'true' for companion mode
66
+ GIT_DRIVE_COMPANION_DRIVE Drive path in companion mode
60
67
 
61
68
  Docker:
62
69
  docker run -it --rm -v /Volumes:/Volumes -p 4483:4483 git-drive
@@ -1,7 +1,7 @@
1
1
  import express, { Request, Response } from 'express';
2
2
  import { spawn } from 'child_process';
3
3
  import path from 'path';
4
- import { readdirSync, existsSync, mkdirSync, statSync, readFileSync, appendFileSync } from 'fs';
4
+ import { readdirSync, existsSync, mkdirSync, statSync, readFileSync, appendFileSync, writeFileSync } from 'fs';
5
5
  import { execSync } from 'child_process';
6
6
  import { getDiskInfo } from 'node-disk-info';
7
7
  import { homedir } from 'os';
@@ -9,6 +9,13 @@ import { homedir } from 'os';
9
9
  const app = express();
10
10
  const port = process.env.GIT_DRIVE_PORT || 4483;
11
11
 
12
+ // Companion mode configuration
13
+ const COMPANION_MODE = process.env.GIT_DRIVE_COMPANION_MODE === 'true';
14
+ const COMPANION_DRIVE = process.env.GIT_DRIVE_COMPANION_DRIVE;
15
+
16
+ // Companion repository URL
17
+ const COMPANION_REPO_URL = "https://github.com/josmanvis/git-drive.git";
18
+
12
19
  app.use(express.json());
13
20
 
14
21
  // Serve static UI files from the ui directory
@@ -135,16 +142,123 @@ app.get('/api/health', (_req: Request, res: Response) => {
135
142
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
136
143
  });
137
144
 
145
+ // Companion info endpoint - returns info about companion mode
146
+ app.get('/api/companion-info', (_req: Request, res: Response) => {
147
+ res.json({
148
+ companionMode: COMPANION_MODE,
149
+ companionDrive: COMPANION_DRIVE || null,
150
+ });
151
+ });
152
+
153
+ // Get current version from package.json
154
+ function getCurrentVersion(): string {
155
+ try {
156
+ const packageJsonPath = path.join(__dirname, '..', 'package.json');
157
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
158
+ return packageJson.version;
159
+ } catch {
160
+ return 'unknown';
161
+ }
162
+ }
163
+
164
+ // Get companion info for a specific drive
165
+ function getCompanionInfo(mountpoint: string): { installed: boolean; version?: string; installedAt?: string; outdated?: boolean } {
166
+ const gitDrivePath = getGitDrivePath(mountpoint);
167
+ const companionVersionPath = path.join(gitDrivePath, 'companion.json');
168
+ const companionRepoPath = path.join(gitDrivePath, 'git-drive.git');
169
+
170
+ if (!existsSync(companionRepoPath)) {
171
+ return { installed: false };
172
+ }
173
+
174
+ try {
175
+ if (existsSync(companionVersionPath)) {
176
+ const companionInfo = JSON.parse(readFileSync(companionVersionPath, 'utf-8'));
177
+ const currentVersion = getCurrentVersion();
178
+ return {
179
+ installed: true,
180
+ version: companionInfo.version,
181
+ installedAt: companionInfo.installedAt,
182
+ outdated: companionInfo.version !== currentVersion,
183
+ };
184
+ }
185
+ return { installed: true };
186
+ } catch {
187
+ return { installed: true };
188
+ }
189
+ }
190
+
191
+ // Install/update companion on a drive
192
+ app.post('/api/drives/:mountpoint/install-companion', (req: Request, res: Response) => {
193
+ try {
194
+ const mountpoint = decodeURIComponent(req.params.mountpoint);
195
+ const gitDrivePath = getGitDrivePath(mountpoint);
196
+
197
+ if (!existsSync(mountpoint)) {
198
+ res.status(404).json({ error: 'Drive not found or not mounted' });
199
+ return;
200
+ }
201
+
202
+ if (!existsSync(gitDrivePath)) {
203
+ mkdirSync(gitDrivePath, { recursive: true });
204
+ }
205
+
206
+ const companionRepoPath = path.join(gitDrivePath, 'git-drive.git');
207
+ const companionVersionPath = path.join(gitDrivePath, 'companion.json');
208
+ const currentVersion = getCurrentVersion();
209
+
210
+ try {
211
+ // If companion already exists, update it
212
+ if (existsSync(companionRepoPath)) {
213
+ try {
214
+ execSync(`git -C "${companionRepoPath}" fetch origin`, { stdio: 'pipe' });
215
+ execSync(`git -C "${companionRepoPath}" reset --hard origin/main`, { stdio: 'pipe' });
216
+ } catch {
217
+ // If update fails, remove and re-clone
218
+ execSync(`rm -rf "${companionRepoPath}"`, { stdio: 'pipe' });
219
+ execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
220
+ }
221
+ } else {
222
+ // Clone the companion
223
+ execSync(`git clone --bare "${COMPANION_REPO_URL}" "${companionRepoPath}"`, { stdio: 'pipe' });
224
+ }
225
+
226
+ // Write companion version info
227
+ const companionInfo = {
228
+ version: currentVersion,
229
+ installedAt: new Date().toISOString(),
230
+ repoUrl: COMPANION_REPO_URL,
231
+ };
232
+ writeFileSync(companionVersionPath, JSON.stringify(companionInfo, null, 2));
233
+
234
+ res.json({
235
+ success: true,
236
+ version: currentVersion,
237
+ message: 'Companion installed successfully',
238
+ });
239
+ } catch (err: any) {
240
+ res.status(500).json({ error: `Failed to install companion: ${err.message}` });
241
+ }
242
+ } catch (err: any) {
243
+ res.status(500).json({ error: err.message || 'Failed to install companion' });
244
+ }
245
+ });
246
+
138
247
  // List all connected drives
139
248
  app.get('/api/drives', async (_req: Request, res: Response) => {
140
249
  try {
141
250
  const drives = await getDiskInfo();
142
- const result = drives
251
+ let result = drives
143
252
  .filter((d: any) => {
144
253
  const mp = d.mounted;
145
254
  if (!mp) return false;
146
255
  if (mp === "/" || mp === "100%") return false;
147
256
 
257
+ // Exclude temporary and system paths on all platforms
258
+ if (mp.startsWith("/var/") || mp.startsWith("/private/var/") || mp.startsWith("/tmp") || mp.startsWith("/private/tmp")) return false;
259
+ if (mp.includes("TemporaryItems") || mp.includes("NSIRD_")) return false;
260
+ if (mp.startsWith("/System/") || mp.startsWith("/Library/")) return false;
261
+
148
262
  if (process.platform === "darwin") {
149
263
  return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
150
264
  }
@@ -154,15 +268,28 @@ app.get('/api/drives', async (_req: Request, res: Response) => {
154
268
 
155
269
  return true;
156
270
  })
157
- .map((d: any) => ({
158
- device: d.filesystem,
159
- description: d.mounted,
160
- size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
161
- isRemovable: true,
162
- isSystem: d.mounted === '/',
163
- mountpoints: [d.mounted],
164
- hasGitDrive: existsSync(getGitDrivePath(d.mounted)),
165
- }));
271
+ .map((d: any) => {
272
+ const mountpoint = d.mounted;
273
+ const companionInfo = getCompanionInfo(mountpoint);
274
+ return {
275
+ device: d.filesystem,
276
+ description: mountpoint,
277
+ size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
278
+ isRemovable: true,
279
+ isSystem: mountpoint === '/',
280
+ mountpoints: [mountpoint],
281
+ hasGitDrive: existsSync(getGitDrivePath(mountpoint)),
282
+ hasCompanion: companionInfo.installed,
283
+ companionVersion: companionInfo.version,
284
+ companionOutdated: companionInfo.outdated,
285
+ };
286
+ });
287
+
288
+ // In companion mode, filter to only show the companion drive
289
+ if (COMPANION_MODE && COMPANION_DRIVE) {
290
+ result = result.filter((d: any) => d.mountpoints[0] === COMPANION_DRIVE);
291
+ }
292
+
166
293
  res.json(result);
167
294
  } catch (err) {
168
295
  res.status(500).json({ error: 'Failed to list drives' });
@@ -28,17 +28,23 @@ const renderWithRouter = (initialRoute = '/') => {
28
28
  describe('App', () => {
29
29
  beforeEach(() => {
30
30
  vi.clearAllMocks();
31
+ // Mock companion-info endpoint
32
+ mockAxios.get.mockResolvedValue({ data: { companionMode: false, companionDrive: null } });
31
33
  });
32
34
 
33
- it('should render the header with title', () => {
35
+ it('should render the header with title', async () => {
34
36
  renderWithRouter();
35
- expect(screen.getByText('Git Drive')).toBeInTheDocument();
36
- expect(screen.getByText('Turn any drive into a git remote.')).toBeInTheDocument();
37
+ await waitFor(() => {
38
+ expect(screen.getByText('Git Drive')).toBeInTheDocument();
39
+ expect(screen.getByText('Turn any drive into a git remote.')).toBeInTheDocument();
40
+ });
37
41
  });
38
42
 
39
- it('should render drive list on home route', () => {
43
+ it('should render drive list on home route', async () => {
40
44
  renderWithRouter();
41
- expect(screen.getByText('Connected Drives')).toBeInTheDocument();
45
+ await waitFor(() => {
46
+ expect(screen.getByText('Connected Drives')).toBeInTheDocument();
47
+ });
42
48
  });
43
49
  });
44
50
 
@@ -1,7 +1,7 @@
1
1
  import React, { useState, useEffect, useMemo } from 'react';
2
2
  import axios from 'axios';
3
3
  import { Routes, Route, Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
4
- import { HardDrive, Search, FolderGit2, Trash2, Plus, ArrowLeft, File as FileIcon, Folder as FolderIcon, ChevronRight } from 'lucide-react';
4
+ import { HardDrive, Search, FolderGit2, Trash2, Plus, ArrowLeft, File as FileIcon, Folder as FolderIcon, ChevronRight, Plug } from 'lucide-react';
5
5
  import Fuse from 'fuse.js';
6
6
 
7
7
  type Drive = {
@@ -12,6 +12,14 @@ type Drive = {
12
12
  isSystem: boolean;
13
13
  mountpoints: string[];
14
14
  hasGitDrive: boolean;
15
+ hasCompanion?: boolean;
16
+ companionVersion?: string;
17
+ companionOutdated?: boolean;
18
+ };
19
+
20
+ type CompanionInfo = {
21
+ companionMode: boolean;
22
+ companionDrive: string | null;
15
23
  };
16
24
 
17
25
  type Repo = {
@@ -29,6 +37,14 @@ type TreeItem = {
29
37
  };
30
38
 
31
39
  export default function App() {
40
+ const [companionInfo, setCompanionInfo] = useState<CompanionInfo>({ companionMode: false, companionDrive: null });
41
+
42
+ useEffect(() => {
43
+ axios.get('/api/companion-info')
44
+ .then(({ data }) => setCompanionInfo(data))
45
+ .catch(() => {});
46
+ }, []);
47
+
32
48
  return (
33
49
  <div className="min-h-screen bg-[#0d1117] text-gray-200 p-8 font-sans">
34
50
  <div className="max-w-5xl mx-auto space-y-8">
@@ -38,8 +54,19 @@ export default function App() {
38
54
  <FolderGit2 className="w-8 h-8 text-blue-400" />
39
55
  </div>
40
56
  <div>
41
- <h1 className="text-2xl font-bold text-white tracking-tight">Git Drive</h1>
42
- <p className="text-gray-400 text-sm">Turn any drive into a git remote.</p>
57
+ <div className="flex items-center gap-2">
58
+ <h1 className="text-2xl font-bold text-white tracking-tight">Git Drive</h1>
59
+ {companionInfo.companionMode && (
60
+ <span className="px-2 py-0.5 bg-purple-500/10 text-purple-400 text-xs font-semibold rounded-full border border-purple-500/20 flex items-center gap-1">
61
+ <Plug className="w-3 h-3" /> Companion
62
+ </span>
63
+ )}
64
+ </div>
65
+ <p className="text-gray-400 text-sm">
66
+ {companionInfo.companionMode && companionInfo.companionDrive
67
+ ? `Running from ${companionInfo.companionDrive}`
68
+ : 'Turn any drive into a git remote.'}
69
+ </p>
43
70
  </div>
44
71
  </Link>
45
72
  </header>
@@ -139,6 +166,27 @@ function DriveList() {
139
166
  <span>{(drive.size / 1024 / 1024 / 1024).toFixed(1)} GB</span>
140
167
  </div>
141
168
 
169
+ {/* Companion status indicator */}
170
+ {drive.hasGitDrive && (
171
+ <div className="mt-3 flex items-center gap-2">
172
+ {drive.hasCompanion ? (
173
+ drive.companionOutdated ? (
174
+ <span className="px-2 py-1 text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 rounded-md flex items-center gap-1">
175
+ <Plug className="w-3 h-3" /> Update Available (v{drive.companionVersion})
176
+ </span>
177
+ ) : (
178
+ <span className="px-2 py-1 text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20 rounded-md flex items-center gap-1">
179
+ <Plug className="w-3 h-3" /> Companion v{drive.companionVersion}
180
+ </span>
181
+ )
182
+ ) : (
183
+ <span className="px-2 py-1 text-xs font-medium bg-gray-800 text-gray-400 border border-gray-700 rounded-md">
184
+ No Companion
185
+ </span>
186
+ )}
187
+ </div>
188
+ )}
189
+
142
190
  {!drive.hasGitDrive && (
143
191
  <button
144
192
  onClick={(e) => {