git-drive 0.1.0
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/.github/workflows/ci.yml +77 -0
- package/.planning/codebase/ARCHITECTURE.md +151 -0
- package/.planning/codebase/CONCERNS.md +191 -0
- package/.planning/codebase/CONVENTIONS.md +169 -0
- package/.planning/codebase/INTEGRATIONS.md +94 -0
- package/.planning/codebase/STACK.md +77 -0
- package/.planning/codebase/STRUCTURE.md +157 -0
- package/.planning/codebase/TESTING.md +156 -0
- package/Dockerfile.cli +30 -0
- package/Dockerfile.server +32 -0
- package/README.md +95 -0
- package/docker-compose.yml +48 -0
- package/package.json +25 -0
- package/packages/cli/Dockerfile +26 -0
- package/packages/cli/package.json +57 -0
- package/packages/cli/src/commands/archive.ts +39 -0
- package/packages/cli/src/commands/init.ts +34 -0
- package/packages/cli/src/commands/link.ts +115 -0
- package/packages/cli/src/commands/list.ts +94 -0
- package/packages/cli/src/commands/push.ts +64 -0
- package/packages/cli/src/commands/restore.ts +36 -0
- package/packages/cli/src/commands/status.ts +127 -0
- package/packages/cli/src/config.ts +73 -0
- package/packages/cli/src/errors.ts +23 -0
- package/packages/cli/src/git.ts +55 -0
- package/packages/cli/src/index.ts +97 -0
- package/packages/cli/src/server.ts +514 -0
- package/packages/cli/tsconfig.json +13 -0
- package/packages/cli/ui/assets/index-Cc2q1t5k.js +17 -0
- package/packages/cli/ui/assets/index-DrL7ojPA.css +1 -0
- package/packages/cli/ui/index.html +14 -0
- package/packages/cli/ui/vite.svg +1 -0
- package/packages/git-drive-docker/package.json +15 -0
- package/packages/server/package.json +44 -0
- package/packages/server/src/index.ts +569 -0
- package/packages/server/tsconfig.json +9 -0
- package/packages/ui/README.md +73 -0
- package/packages/ui/eslint.config.js +23 -0
- package/packages/ui/index.html +13 -0
- package/packages/ui/package.json +42 -0
- package/packages/ui/postcss.config.js +6 -0
- package/packages/ui/public/vite.svg +1 -0
- package/packages/ui/src/App.css +23 -0
- package/packages/ui/src/App.tsx +726 -0
- package/packages/ui/src/assets/react.svg +8 -0
- package/packages/ui/src/assets/vite.svg +3 -0
- package/packages/ui/src/index.css +37 -0
- package/packages/ui/src/main.tsx +14 -0
- package/packages/ui/tailwind.config.js +11 -0
- package/packages/ui/tsconfig.app.json +28 -0
- package/packages/ui/tsconfig.json +26 -0
- package/packages/ui/tsconfig.node.json +12 -0
- package/packages/ui/vite.config.ts +7 -0
- package/pnpm-workspace.yaml +4 -0
- package/rewrite_app.js +731 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "git-drive",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"git-drive-server": "./dist/server.js"
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"ui"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.json",
|
|
37
|
+
"start": "node dist/index.js",
|
|
38
|
+
"start:server": "node dist/server.js",
|
|
39
|
+
"docker:build": "docker build -t git-drive .",
|
|
40
|
+
"docker:run": "docker run -it --rm -v /Volumes:/Volumes -p 4483:4483 git-drive",
|
|
41
|
+
"prepublishOnly": "npm run build"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"express": "^4.19.2",
|
|
45
|
+
"node-disk-info": "^1.3.0",
|
|
46
|
+
"prompts": "^2.4.2"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/express": "^4.17.21",
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"@types/prompts": "^2.4.9",
|
|
52
|
+
"typescript": "^5.7.0"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=18"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { rmSync } from "fs";
|
|
2
|
+
import { requireConfig, assertDriveMounted } from "../config.js";
|
|
3
|
+
import { git, getRepoRoot, getProjectName, isGitRepo } from "../git.js";
|
|
4
|
+
import { push } from "./push.js";
|
|
5
|
+
import { GitDriveError } from "../errors.js";
|
|
6
|
+
|
|
7
|
+
export function archive(args: string[]): void {
|
|
8
|
+
if (!isGitRepo()) {
|
|
9
|
+
throw new GitDriveError("Not in a git repository.");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const force = args.includes("--force");
|
|
13
|
+
|
|
14
|
+
// Check for uncommitted changes
|
|
15
|
+
if (!force) {
|
|
16
|
+
const status = git("status --porcelain");
|
|
17
|
+
if (status) {
|
|
18
|
+
throw new GitDriveError(
|
|
19
|
+
"Working tree has uncommitted changes.\nCommit first or use --force to archive anyway."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const config = requireConfig();
|
|
25
|
+
assertDriveMounted(config.drivePath);
|
|
26
|
+
|
|
27
|
+
const projectName = getProjectName();
|
|
28
|
+
const repoRoot = getRepoRoot();
|
|
29
|
+
|
|
30
|
+
// Push first
|
|
31
|
+
push([]);
|
|
32
|
+
|
|
33
|
+
// Remove local copy
|
|
34
|
+
process.chdir("..");
|
|
35
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
36
|
+
|
|
37
|
+
console.log(`Archived: ${projectName}`);
|
|
38
|
+
console.log(`Restore with: git drive restore ${projectName}`);
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, statSync, mkdirSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { saveConfig, getDriveStorePath } from "../config.js";
|
|
4
|
+
import { GitDriveError } from "../errors.js";
|
|
5
|
+
|
|
6
|
+
export function init(args: string[]): void {
|
|
7
|
+
const rawPath = args[0];
|
|
8
|
+
|
|
9
|
+
if (!rawPath) {
|
|
10
|
+
throw new GitDriveError("Usage: git drive init <path>");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const drivePath = resolve(rawPath);
|
|
14
|
+
|
|
15
|
+
if (!existsSync(drivePath)) {
|
|
16
|
+
throw new GitDriveError(
|
|
17
|
+
`Path not found: ${drivePath}\nIs the drive mounted?`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const stat = statSync(drivePath);
|
|
22
|
+
if (!stat.isDirectory()) {
|
|
23
|
+
throw new GitDriveError(`Path is not a directory: ${drivePath}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const storePath = getDriveStorePath(drivePath);
|
|
27
|
+
if (!existsSync(storePath)) {
|
|
28
|
+
mkdirSync(storePath, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
saveConfig({ drivePath });
|
|
32
|
+
|
|
33
|
+
console.log(`Drive configured: ${storePath}`);
|
|
34
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
const { drive } = await prompts({
|
|
23
|
+
type: "select",
|
|
24
|
+
name: "drive",
|
|
25
|
+
message: "Select a configured git-drive:",
|
|
26
|
+
choices: configuredDrives.map((d) => ({
|
|
27
|
+
title: `${d.filesystem} (${d.mounted})`,
|
|
28
|
+
value: d,
|
|
29
|
+
})),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!drive) return;
|
|
33
|
+
|
|
34
|
+
const gitDrivePath = path.join(drive.mounted, ".git-drive");
|
|
35
|
+
const existingRepos = fs.readdirSync(gitDrivePath).filter((entry) => {
|
|
36
|
+
const entryPath = path.join(gitDrivePath, entry);
|
|
37
|
+
return (
|
|
38
|
+
fs.statSync(entryPath).isDirectory() &&
|
|
39
|
+
(entry.endsWith(".git") || fs.existsSync(path.join(entryPath, "HEAD")))
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const CREATE_NEW = "__CREATE_NEW__";
|
|
44
|
+
const { selectedRepo } = await prompts({
|
|
45
|
+
type: "select",
|
|
46
|
+
name: "selectedRepo",
|
|
47
|
+
message: "Select an existing repository to link, or create a new one:",
|
|
48
|
+
choices: [
|
|
49
|
+
{ title: "✨ Create new repository...", value: CREATE_NEW },
|
|
50
|
+
...existingRepos.map((repo) => ({
|
|
51
|
+
title: `📁 ${repo.replace(/\.git$/, "")}`,
|
|
52
|
+
value: repo,
|
|
53
|
+
})),
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!selectedRepo) return;
|
|
58
|
+
|
|
59
|
+
let targetRepoName = selectedRepo;
|
|
60
|
+
|
|
61
|
+
if (selectedRepo === CREATE_NEW) {
|
|
62
|
+
const defaultName = getProjectName();
|
|
63
|
+
const { newRepoName } = await prompts({
|
|
64
|
+
type: "text",
|
|
65
|
+
name: "newRepoName",
|
|
66
|
+
message: "Enter the new repository name:",
|
|
67
|
+
initial: defaultName,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!newRepoName) return;
|
|
71
|
+
targetRepoName = newRepoName.endsWith(".git") ? newRepoName : `${newRepoName}.git`;
|
|
72
|
+
|
|
73
|
+
const repoPath = path.join(gitDrivePath, targetRepoName);
|
|
74
|
+
if (fs.existsSync(repoPath)) {
|
|
75
|
+
console.log(`Repository ${targetRepoName} already exists in this drive.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
git(`init --bare "${repoPath}"`);
|
|
80
|
+
console.log(`Created new bare repository: ${targetRepoName}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const repoRoot = getRepoRoot();
|
|
84
|
+
const finalRepoPath = path.join(gitDrivePath, targetRepoName);
|
|
85
|
+
|
|
86
|
+
// Check if remote 'gd' already exists
|
|
87
|
+
let gdExists = false;
|
|
88
|
+
try {
|
|
89
|
+
git(`remote get-url gd`, repoRoot);
|
|
90
|
+
gdExists = true;
|
|
91
|
+
} catch {
|
|
92
|
+
// Remote does not exist
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (gdExists) {
|
|
96
|
+
console.log("Remote 'gd' already exists. Updating it to point to the new drive.");
|
|
97
|
+
git(`remote set-url gd "${finalRepoPath}"`, repoRoot);
|
|
98
|
+
} else {
|
|
99
|
+
git(`remote add gd "${finalRepoPath}"`, repoRoot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Persist to global git-drive registry for the Web UI
|
|
103
|
+
saveLink(repoRoot, drive.mounted, targetRepoName);
|
|
104
|
+
|
|
105
|
+
console.log(`\n✅ Successfully linked!`);
|
|
106
|
+
console.log(`Repository: ${targetRepoName.replace(/\.git$/, "")}`);
|
|
107
|
+
console.log(`Drive: ${drive.mounted}`);
|
|
108
|
+
console.log(`\nYou can now push to this remote using:`);
|
|
109
|
+
console.log(` git push gd main`);
|
|
110
|
+
|
|
111
|
+
} catch (err) {
|
|
112
|
+
handleError(err);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -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,64 @@
|
|
|
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
|
+
const { pushMode } = await prompts({
|
|
22
|
+
type: "select",
|
|
23
|
+
name: "pushMode",
|
|
24
|
+
message: `Pushing to ${existingUrl}\nSelect what to branch to push:`,
|
|
25
|
+
choices: [
|
|
26
|
+
{ title: `Current branch only (${currentBranch})`, value: "current" },
|
|
27
|
+
{ title: "All branches & tags", value: "all" }
|
|
28
|
+
]
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!pushMode) return;
|
|
32
|
+
|
|
33
|
+
if (pushMode === "current") {
|
|
34
|
+
console.log(`\nPushing ${currentBranch}...`);
|
|
35
|
+
git(`push gd ${currentBranch}`);
|
|
36
|
+
console.log(`✅ Successfully pushed ${currentBranch} to git-drive.`);
|
|
37
|
+
} else {
|
|
38
|
+
console.log("\nPushing all branches and tags...");
|
|
39
|
+
git("push gd --all");
|
|
40
|
+
git("push gd --tags");
|
|
41
|
+
console.log(`✅ Successfully pushed all branches and tags to git-drive.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Write context to a central pushlog safely inside the git-drive repo folder
|
|
45
|
+
try {
|
|
46
|
+
if (fs.existsSync(existingUrl)) {
|
|
47
|
+
const payload = {
|
|
48
|
+
date: new Date().toISOString(),
|
|
49
|
+
computer: os.hostname(),
|
|
50
|
+
user: os.userInfo().username,
|
|
51
|
+
localDir: process.cwd(),
|
|
52
|
+
mode: pushMode,
|
|
53
|
+
};
|
|
54
|
+
const logFile = path.join(existingUrl, "git-drive-pushlog.json");
|
|
55
|
+
fs.appendFileSync(logFile, JSON.stringify(payload) + "\n", "utf-8");
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Intentionally swallow telemetry tracking errors
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
} catch (err: any) {
|
|
62
|
+
throw new GitDriveError(`Failed to push to drive. Make sure the drive is connected.\n${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|