git-drive 0.1.3 → 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 +18 -45
  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 -108
  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,151 @@
1
+ import { handleError } from "../errors.js";
2
+ import { saveLink } from "../config.js";
3
+ import { git, getProjectName, getRepoRoot, listDrives } from "../git.js";
4
+ import prompts from "prompts";
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+
8
+ export async function link(args: string[]): Promise<void> {
9
+ try {
10
+ const drives = await listDrives();
11
+
12
+ // Only fetch drives that actually have .git-drive configured
13
+ const configuredDrives = drives
14
+ .filter((drive) => drive.mounted && drive.mounted !== "/")
15
+ .filter((drive) => fs.existsSync(path.join(drive.mounted, ".git-drive")));
16
+
17
+ if (configuredDrives.length === 0) {
18
+ console.log("No initialized git-drives found. Please initialize a drive first.");
19
+ return;
20
+ }
21
+
22
+ // Check for --drive flag for non-interactive mode
23
+ const driveFlagIndex = args.findIndex((a) => a === "--drive" || a === "-d");
24
+ const drivePath = driveFlagIndex !== -1 ? args[driveFlagIndex + 1] : null;
25
+ const createNew = args.includes("--create") || args.includes("-c");
26
+
27
+ let drive;
28
+
29
+ if (drivePath) {
30
+ // Non-interactive mode: find the drive by path
31
+ drive = configuredDrives.find((d) => d.mounted === drivePath);
32
+ if (!drive) {
33
+ console.log(`Drive not found at: ${drivePath}`);
34
+ return;
35
+ }
36
+ } else {
37
+ // Interactive mode
38
+ const result = await prompts({
39
+ type: "select",
40
+ name: "drive",
41
+ message: "Select a configured git-drive:",
42
+ choices: configuredDrives.map((d) => ({
43
+ title: `${d.filesystem} (${d.mounted})`,
44
+ value: d,
45
+ })),
46
+ });
47
+
48
+ if (!result.drive) return;
49
+ drive = result.drive;
50
+ }
51
+
52
+ const gitDrivePath = path.join(drive.mounted, ".git-drive");
53
+ const existingRepos = fs.readdirSync(gitDrivePath).filter((entry) => {
54
+ const entryPath = path.join(gitDrivePath, entry);
55
+ return (
56
+ fs.statSync(entryPath).isDirectory() &&
57
+ (entry.endsWith(".git") || fs.existsSync(path.join(entryPath, "HEAD")))
58
+ );
59
+ });
60
+
61
+ const CREATE_NEW = "__CREATE_NEW__";
62
+ let targetRepoName: string | null = null;
63
+
64
+ if (createNew) {
65
+ // Non-interactive mode: create new repo with project name
66
+ const defaultName = getProjectName();
67
+ targetRepoName = defaultName.endsWith(".git") ? defaultName : `${defaultName}.git`;
68
+
69
+ const repoPath = path.join(gitDrivePath, targetRepoName);
70
+ if (fs.existsSync(repoPath)) {
71
+ console.log(`Repository ${targetRepoName} already exists in this drive. Linking to existing.`);
72
+ } else {
73
+ git(`init --bare "${repoPath}"`);
74
+ console.log(`Created new bare repository: ${targetRepoName}`);
75
+ }
76
+ } else {
77
+ // Interactive mode
78
+ const { selectedRepo } = await prompts({
79
+ type: "select",
80
+ name: "selectedRepo",
81
+ message: "Select an existing repository to link, or create a new one:",
82
+ choices: [
83
+ { title: "✨ Create new repository...", value: CREATE_NEW },
84
+ ...existingRepos.map((repo) => ({
85
+ title: `📁 ${repo.replace(/\.git$/, "")}`,
86
+ value: repo,
87
+ })),
88
+ ],
89
+ });
90
+
91
+ if (!selectedRepo) return;
92
+ targetRepoName = selectedRepo;
93
+
94
+ if (selectedRepo === CREATE_NEW) {
95
+ const defaultName = getProjectName();
96
+ const { newRepoName } = await prompts({
97
+ type: "text",
98
+ name: "newRepoName",
99
+ message: "Enter the new repository name:",
100
+ initial: defaultName,
101
+ });
102
+
103
+ if (!newRepoName) return;
104
+ targetRepoName = newRepoName.endsWith(".git") ? newRepoName : `${newRepoName}.git`;
105
+
106
+ const repoPath = path.join(gitDrivePath, targetRepoName as string);
107
+ if (fs.existsSync(repoPath)) {
108
+ console.log(`Repository ${targetRepoName} already exists in this drive.`);
109
+ return;
110
+ }
111
+
112
+ git(`init --bare "${repoPath}"`);
113
+ console.log(`Created new bare repository: ${targetRepoName}`);
114
+ }
115
+ }
116
+
117
+ if (!targetRepoName) return;
118
+
119
+ const repoRoot = getRepoRoot();
120
+ const finalRepoPath = path.join(gitDrivePath, targetRepoName as string);
121
+
122
+ // Check if remote 'gd' already exists
123
+ let gdExists = false;
124
+ try {
125
+ git(`remote get-url gd`, repoRoot);
126
+ gdExists = true;
127
+ } catch {
128
+ // Remote does not exist
129
+ }
130
+
131
+ if (gdExists) {
132
+ console.log("Remote 'gd' already exists. Updating it to point to the new drive.");
133
+ git(`remote set-url gd "${finalRepoPath}"`, repoRoot);
134
+ } else {
135
+ git(`remote add gd "${finalRepoPath}"`, repoRoot);
136
+ }
137
+
138
+ // Persist to global git-drive registry for the Web UI
139
+ saveLink(repoRoot, drive.mounted, targetRepoName as string);
140
+
141
+ console.log(`\n✅ Successfully linked!`);
142
+ console.log(`Repository: ${targetRepoName.replace(/\.git$/, "")}`);
143
+ console.log(`Drive: ${drive.mounted}`);
144
+ console.log(`\nYou can now push to this remote using:`);
145
+ console.log(` git push gd main`);
146
+
147
+ } catch (err) {
148
+ handleError(err);
149
+ process.exit(1);
150
+ }
151
+ }
@@ -0,0 +1,94 @@
1
+ import { readdirSync, statSync, existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { getDiskInfo } from "node-disk-info";
5
+
6
+ interface LinkConfig {
7
+ mountpoint: string;
8
+ repoName: string;
9
+ linkedAt: string;
10
+ }
11
+
12
+ function loadLinks(): Record<string, LinkConfig> {
13
+ const linksFile = join(homedir(), ".config", "git-drive", "links.json");
14
+ if (!existsSync(linksFile)) return {};
15
+ try {
16
+ return JSON.parse(readFileSync(linksFile, "utf-8"));
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function getGitDrivePath(mountpoint: string): string {
23
+ return join(mountpoint, ".git-drive");
24
+ }
25
+
26
+ export async function list(_args: string[]): Promise<void> {
27
+ console.log("Git Drive - Connected Drives\n");
28
+
29
+ // Get all connected drives
30
+ let drives: any[] = [];
31
+ try {
32
+ drives = await getDiskInfo();
33
+ } catch (err) {
34
+ console.error("Error detecting drives:", err);
35
+ return;
36
+ }
37
+
38
+ // Filter to external/removable drives
39
+ const externalDrives = drives.filter((d: any) => {
40
+ const mp = d.mounted;
41
+ if (!mp) return false;
42
+ if (mp === "/" || mp === "100%") return false;
43
+
44
+ if (process.platform === "darwin") {
45
+ return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
46
+ }
47
+
48
+ if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
49
+ if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
50
+
51
+ return true;
52
+ });
53
+
54
+ if (externalDrives.length === 0) {
55
+ console.log("No external drives detected.");
56
+ console.log("\nConnect an external drive and try again.");
57
+ return;
58
+ }
59
+
60
+ // Load links to show which drives are registered
61
+ const links = loadLinks();
62
+ const registeredMountpoints = new Set(Object.values(links).map(l => l.mountpoint));
63
+
64
+ for (const drive of externalDrives) {
65
+ const mp = drive.mounted;
66
+ const gitDrivePath = getGitDrivePath(mp);
67
+ const hasGitDrive = existsSync(gitDrivePath);
68
+ const isRegistered = registeredMountpoints.has(mp);
69
+
70
+ // Count repos on this drive
71
+ let repoCount = 0;
72
+ if (hasGitDrive) {
73
+ try {
74
+ const entries = readdirSync(gitDrivePath).filter(n => n.endsWith(".git") || existsSync(join(gitDrivePath, n, "HEAD")));
75
+ repoCount = entries.length;
76
+ } catch {}
77
+ }
78
+
79
+ // Format size
80
+ const sizeGB = drive.blocks ? ((parseInt(drive.blocks) * 1024) / (1024 * 1024 * 1024)).toFixed(1) : "?";
81
+
82
+ // Status indicator
83
+ const status = hasGitDrive ? "✓ registered" : "○ not registered";
84
+
85
+ console.log(` ${mp}`);
86
+ console.log(` Size: ${sizeGB} GB`);
87
+ console.log(` Status: ${status}`);
88
+ console.log(` Repositories: ${repoCount}`);
89
+ console.log();
90
+ }
91
+
92
+ console.log(`\n${externalDrives.length} drive${externalDrives.length === 1 ? "" : "s"} detected.`);
93
+ console.log("\nRun 'git-drive link' to link a repo to a drive.");
94
+ }
@@ -0,0 +1,77 @@
1
+ import { git, getRemoteUrl, isGitRepo } from "../git.js";
2
+ import { GitDriveError } from "../errors.js";
3
+ import prompts from "prompts";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+
8
+ export async function push(args: string[]): Promise<void> {
9
+ if (!isGitRepo()) {
10
+ throw new GitDriveError("Not in a git repository.");
11
+ }
12
+
13
+ const existingUrl = getRemoteUrl("gd");
14
+ if (!existingUrl) {
15
+ throw new GitDriveError("No git-drive linked for this project. Please run 'git-drive link' first.");
16
+ }
17
+
18
+ try {
19
+ const currentBranch = git("branch --show-current") || "HEAD";
20
+
21
+ // Check for --all or --current flags for non-interactive mode
22
+ const pushAll = args.includes("--all");
23
+ const pushCurrent = args.includes("--current");
24
+
25
+ let pushMode: "current" | "all" | null = null;
26
+
27
+ if (pushAll) {
28
+ pushMode = "all";
29
+ } else if (pushCurrent) {
30
+ pushMode = "current";
31
+ } else {
32
+ const result = await prompts({
33
+ type: "select",
34
+ name: "pushMode",
35
+ message: `Pushing to ${existingUrl}\nSelect what to branch to push:`,
36
+ choices: [
37
+ { title: `Current branch only (${currentBranch})`, value: "current" },
38
+ { title: "All branches & tags", value: "all" }
39
+ ]
40
+ });
41
+ pushMode = result.pushMode;
42
+ }
43
+
44
+ if (!pushMode) return;
45
+
46
+ if (pushMode === "current") {
47
+ console.log(`\nPushing ${currentBranch}...`);
48
+ git(`push gd ${currentBranch}`);
49
+ console.log(`✅ Successfully pushed ${currentBranch} to git-drive.`);
50
+ } else {
51
+ console.log("\nPushing all branches and tags...");
52
+ git("push gd --all");
53
+ git("push gd --tags");
54
+ console.log(`✅ Successfully pushed all branches and tags to git-drive.`);
55
+ }
56
+
57
+ // Write context to a central pushlog safely inside the git-drive repo folder
58
+ try {
59
+ if (fs.existsSync(existingUrl)) {
60
+ const payload = {
61
+ date: new Date().toISOString(),
62
+ computer: os.hostname(),
63
+ user: os.userInfo().username,
64
+ localDir: process.cwd(),
65
+ mode: pushMode,
66
+ };
67
+ const logFile = path.join(existingUrl, "git-drive-pushlog.json");
68
+ fs.appendFileSync(logFile, JSON.stringify(payload) + "\n", "utf-8");
69
+ }
70
+ } catch {
71
+ // Intentionally swallow telemetry tracking errors
72
+ }
73
+
74
+ } catch (err: any) {
75
+ throw new GitDriveError(`Failed to push to drive. Make sure the drive is connected.\n${err.message}`);
76
+ }
77
+ }
@@ -0,0 +1,36 @@
1
+ import { existsSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { requireConfig, assertDriveMounted, getDriveStorePath } from "../config.js";
4
+ import { git } from "../git.js";
5
+ import { GitDriveError } from "../errors.js";
6
+
7
+ export function restore(args: string[]): void {
8
+ const projectName = args[0];
9
+ const targetDir = args[1] || projectName;
10
+
11
+ if (!projectName) {
12
+ throw new GitDriveError("Usage: git drive restore <project-name> [target-dir]");
13
+ }
14
+
15
+ const config = requireConfig();
16
+ assertDriveMounted(config.drivePath);
17
+
18
+ const storePath = getDriveStorePath(config.drivePath);
19
+ const bareRepoPath = join(storePath, `${projectName}.git`);
20
+
21
+ if (!existsSync(bareRepoPath)) {
22
+ throw new GitDriveError(`Project '${projectName}' not found on drive.`);
23
+ }
24
+
25
+ const targetPath = resolve(targetDir);
26
+ if (existsSync(targetPath)) {
27
+ throw new GitDriveError(`Directory already exists: ${targetPath}`);
28
+ }
29
+
30
+ git(`clone ${bareRepoPath} ${targetPath}`);
31
+
32
+ // Rename origin to drive so the remote stays consistent
33
+ git("remote rename origin drive", targetPath);
34
+
35
+ console.log(`Restored ${projectName} into ${targetPath}`);
36
+ }
@@ -0,0 +1,127 @@
1
+ import { existsSync, readdirSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { getDiskInfo } from "node-disk-info";
5
+ import { isGitRepo, getProjectName, getRemoteUrl } from "../git.js";
6
+
7
+ interface LinkConfig {
8
+ mountpoint: string;
9
+ repoName: string;
10
+ linkedAt: string;
11
+ }
12
+
13
+ function loadLinks(): Record<string, LinkConfig> {
14
+ const linksFile = join(homedir(), ".config", "git-drive", "links.json");
15
+ if (!existsSync(linksFile)) return {};
16
+ try {
17
+ return JSON.parse(readFileSync(linksFile, "utf-8"));
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ function getGitDrivePath(mountpoint: string): string {
24
+ return join(mountpoint, ".git-drive");
25
+ }
26
+
27
+ export async function status(_args: string[]): Promise<void> {
28
+ console.log("Git Drive Status\n");
29
+
30
+ // Get all connected drives
31
+ let drives: any[] = [];
32
+ try {
33
+ drives = await getDiskInfo();
34
+ } catch (err) {
35
+ console.error("Error detecting drives:", err);
36
+ return;
37
+ }
38
+
39
+ // Filter to external/removable drives
40
+ const externalDrives = drives.filter((d: any) => {
41
+ const mp = d.mounted;
42
+ if (!mp) return false;
43
+ if (mp === "/" || mp === "100%") return false;
44
+
45
+ if (process.platform === "darwin") {
46
+ return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
47
+ }
48
+
49
+ if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
50
+ if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
51
+
52
+ return true;
53
+ });
54
+
55
+ // Load links
56
+ const links = loadLinks();
57
+ const linkEntries = Object.entries(links);
58
+
59
+ // Show connected drives with git-drive
60
+ console.log("=== Connected Drives ===\n");
61
+
62
+ if (externalDrives.length === 0) {
63
+ console.log("No external drives connected.\n");
64
+ } else {
65
+ for (const drive of externalDrives) {
66
+ const mp = drive.mounted;
67
+ const gitDrivePath = getGitDrivePath(mp);
68
+ const hasGitDrive = existsSync(gitDrivePath);
69
+
70
+ if (hasGitDrive) {
71
+ const entries = readdirSync(gitDrivePath).filter(n => n.endsWith(".git") || existsSync(join(gitDrivePath, n, "HEAD")));
72
+ console.log(`✓ ${mp}`);
73
+ console.log(` ${entries.length} repo${entries.length === 1 ? "" : "s"} backed up`);
74
+ } else {
75
+ console.log(`○ ${mp} (not initialized)`);
76
+ }
77
+ }
78
+ console.log();
79
+ }
80
+
81
+ // Show registered drives (from links)
82
+ console.log("=== Registered Repositories ===\n");
83
+
84
+ if (linkEntries.length === 0) {
85
+ console.log("No repositories linked to drives yet.");
86
+ console.log("Run 'git-drive link' to link a repository.\n");
87
+ } else {
88
+ for (const [localPath, link] of linkEntries) {
89
+ const stillConnected = existsSync(link.mountpoint);
90
+ const localExists = existsSync(localPath);
91
+
92
+ console.log(`${localPath}`);
93
+ console.log(` → ${link.mountpoint} (${link.repoName})`);
94
+ console.log(` Drive: ${stillConnected ? "connected" : "NOT CONNECTED"}`);
95
+ console.log(` Local: ${localExists ? "exists" : "NOT FOUND"}`);
96
+ console.log();
97
+ }
98
+ }
99
+
100
+ // Show current repo status if in a git repo
101
+ if (isGitRepo()) {
102
+ console.log("=== Current Repository ===\n");
103
+ const name = getProjectName();
104
+ const remoteUrl = getRemoteUrl("gd");
105
+
106
+ console.log(`Repository: ${name}`);
107
+ if (remoteUrl) {
108
+ console.log(`Remote 'gd': ${remoteUrl}`);
109
+
110
+ // Check if this repo is linked
111
+ const cwd = process.cwd();
112
+ const link = links[cwd];
113
+ if (link) {
114
+ console.log(`Linked to: ${link.mountpoint}`);
115
+ }
116
+ } else {
117
+ console.log(`No 'gd' remote configured.`);
118
+ console.log("Run 'git-drive link' to set up backup.");
119
+ }
120
+ console.log();
121
+ }
122
+
123
+ // Server status hint
124
+ console.log("=== Server ===\n");
125
+ console.log("Web UI: http://localhost:4483");
126
+ console.log("Run 'git-drive server' to start the web interface.\n");
127
+ }
@@ -0,0 +1,73 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
4
+ import { GitDriveError } from "./errors.js";
5
+
6
+ const CONFIG_DIR = join(homedir(), ".config", "git-drive");
7
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
8
+
9
+ export interface Config {
10
+ drivePath: string;
11
+ }
12
+
13
+ export function loadConfig(): Config | null {
14
+ if (!existsSync(CONFIG_FILE)) return null;
15
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
16
+ return JSON.parse(raw) as Config;
17
+ }
18
+
19
+ export function saveConfig(config: Config): void {
20
+ mkdirSync(CONFIG_DIR, { recursive: true });
21
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
22
+ }
23
+
24
+ export function requireConfig(): Config {
25
+ const config = loadConfig();
26
+ if (!config) {
27
+ throw new GitDriveError("No drive configured. Run: git drive init <path>");
28
+ }
29
+ return config;
30
+ }
31
+
32
+ export function assertDriveMounted(drivePath: string): void {
33
+ if (!existsSync(drivePath)) {
34
+ throw new GitDriveError(
35
+ `Drive not found at ${drivePath}. Is it connected?`
36
+ );
37
+ }
38
+ }
39
+
40
+ export function getDriveStorePath(drivePath: string): string {
41
+ return join(drivePath, ".git-drive");
42
+ }
43
+
44
+ export interface LinkRegistry {
45
+ [localPath: string]: {
46
+ mountpoint: string;
47
+ repoName: string;
48
+ linkedAt: string;
49
+ };
50
+ }
51
+
52
+ const LINKS_FILE = join(CONFIG_DIR, "links.json");
53
+
54
+ export function loadLinks(): LinkRegistry {
55
+ if (!existsSync(LINKS_FILE)) return {};
56
+ try {
57
+ const raw = readFileSync(LINKS_FILE, "utf-8");
58
+ return JSON.parse(raw) as LinkRegistry;
59
+ } catch {
60
+ return {};
61
+ }
62
+ }
63
+
64
+ export function saveLink(localPath: string, mountpoint: string, repoName: string): void {
65
+ mkdirSync(CONFIG_DIR, { recursive: true });
66
+ const links = loadLinks();
67
+ links[localPath] = {
68
+ mountpoint,
69
+ repoName,
70
+ linkedAt: new Date().toISOString(),
71
+ };
72
+ writeFileSync(LINKS_FILE, JSON.stringify(links, null, 2) + "\n");
73
+ }
@@ -0,0 +1,23 @@
1
+ export class GitDriveError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = "GitDriveError";
5
+ }
6
+ }
7
+
8
+ export function handleError(err: unknown): void {
9
+ if (err instanceof GitDriveError) {
10
+ console.error(`error: ${err.message}`);
11
+ } else if (err instanceof Error) {
12
+ const msg = err.message;
13
+ // execSync errors include stderr in the message
14
+ const stderrMatch = msg.match(/stderr:\s*([\s\S]*)/);
15
+ if (stderrMatch) {
16
+ console.error(`error: ${stderrMatch[1].trim()}`);
17
+ } else {
18
+ console.error(`error: ${msg}`);
19
+ }
20
+ } else {
21
+ console.error("An unexpected error occurred.");
22
+ }
23
+ }
@@ -0,0 +1,55 @@
1
+ import { execSync } from "child_process";
2
+ import { basename } from "path";
3
+ import { getDiskInfo } from "node-disk-info";
4
+
5
+ export function git(args: string, cwd?: string): string {
6
+ return execSync(`git ${args}`, {
7
+ cwd,
8
+ encoding: "utf-8",
9
+ stdio: ["pipe", "pipe", "pipe"],
10
+ }).trim();
11
+ }
12
+
13
+ export async function listDrives(): Promise<any[]> {
14
+ const drives = await getDiskInfo();
15
+ return drives.filter((d: any) => {
16
+ const mp = d.mounted;
17
+ if (!mp) return false;
18
+ if (mp === "/" || mp === "100%") return false;
19
+
20
+ if (process.platform === "darwin") {
21
+ return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
22
+ }
23
+
24
+ if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
25
+ if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
26
+
27
+ return true;
28
+ });
29
+ }
30
+
31
+ export function getRepoRoot(): string {
32
+ return git("rev-parse --show-toplevel");
33
+ }
34
+
35
+ export function getProjectName(): string {
36
+ const root = getRepoRoot();
37
+ return basename(root);
38
+ }
39
+
40
+ export function getRemoteUrl(remoteName: string): string | null {
41
+ try {
42
+ return git(`remote get-url ${remoteName}`);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export function isGitRepo(): boolean {
49
+ try {
50
+ git("rev-parse --is-inside-work-tree");
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }