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,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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { push } from "./commands/push.js";
|
|
5
|
+
import { list } from "./commands/list.js";
|
|
6
|
+
import { status } from "./commands/status.js";
|
|
7
|
+
import { link } from "./commands/link.js";
|
|
8
|
+
import { handleError } from "./errors.js";
|
|
9
|
+
|
|
10
|
+
const commands: Record<string, (args: string[]) => void | Promise<void>> = {
|
|
11
|
+
push,
|
|
12
|
+
list,
|
|
13
|
+
status,
|
|
14
|
+
link,
|
|
15
|
+
server: startServer,
|
|
16
|
+
start: startServer,
|
|
17
|
+
ui: startServer,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function printUsage(): void {
|
|
21
|
+
console.log(`
|
|
22
|
+
git-drive - Turn any external drive into a git remote backup for your code
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
git-drive <command> [options]
|
|
26
|
+
|
|
27
|
+
Commands:
|
|
28
|
+
link Link current repo to a drive
|
|
29
|
+
push Push current repo to drive
|
|
30
|
+
list Show connected drives and their status
|
|
31
|
+
status Show detailed status of drives and repos
|
|
32
|
+
server, start, ui Start the git-drive web UI server
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
-h, --help Show this help message
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
git-drive link Link current repo to a drive
|
|
39
|
+
git-drive push Push current repo to drive
|
|
40
|
+
git-drive list List connected drives
|
|
41
|
+
git-drive status Show detailed status
|
|
42
|
+
git-drive server Start the web UI at http://localhost:4483
|
|
43
|
+
|
|
44
|
+
Environment Variables:
|
|
45
|
+
GIT_DRIVE_PORT Port for the web server (default: 4483)
|
|
46
|
+
|
|
47
|
+
Docker:
|
|
48
|
+
docker run -it --rm -v /Volumes:/Volumes -p 4483:4483 git-drive
|
|
49
|
+
|
|
50
|
+
Documentation:
|
|
51
|
+
https://github.com/josmanvis/git-drive
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function startServer(_args: string[]): void {
|
|
56
|
+
console.log('\n 🚀 Starting Git Drive server...\n');
|
|
57
|
+
console.log(' Web UI: http://localhost:4483\n');
|
|
58
|
+
console.log(' Press Ctrl+C to stop\n');
|
|
59
|
+
|
|
60
|
+
const serverPath = require.resolve('./server.js');
|
|
61
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
62
|
+
stdio: 'inherit',
|
|
63
|
+
env: process.env
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
child.on('error', (err) => {
|
|
67
|
+
console.error('Failed to start server:', err.message);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
child.on('exit', (code) => {
|
|
72
|
+
process.exit(code || 0);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const [command, ...args] = process.argv.slice(2);
|
|
77
|
+
|
|
78
|
+
(async () => {
|
|
79
|
+
try {
|
|
80
|
+
if (!command || command === "--help" || command === "-h") {
|
|
81
|
+
printUsage();
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handler = commands[command];
|
|
86
|
+
if (!handler) {
|
|
87
|
+
console.error(`Unknown command: ${command}\n`);
|
|
88
|
+
printUsage();
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await handler(args);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
handleError(err);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import express, { Request, Response } from 'express';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { readdirSync, existsSync, mkdirSync, statSync, readFileSync, appendFileSync } from 'fs';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { getDiskInfo } from 'node-disk-info';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const app = express();
|
|
13
|
+
const port = process.env.GIT_DRIVE_PORT || 4483;
|
|
14
|
+
|
|
15
|
+
app.use(express.json());
|
|
16
|
+
|
|
17
|
+
// Serve static UI files from the ui directory
|
|
18
|
+
const uiPath = path.join(__dirname, '..', 'ui');
|
|
19
|
+
app.use(express.static(uiPath));
|
|
20
|
+
|
|
21
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function git(args: string, cwd?: string): string {
|
|
24
|
+
return execSync(`git ${args}`, {
|
|
25
|
+
cwd,
|
|
26
|
+
encoding: 'utf-8',
|
|
27
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
28
|
+
}).trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getGitDrivePath(mountpoint: string): string {
|
|
32
|
+
return path.join(mountpoint, '.git-drive');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ensureGitDriveDir(mountpoint: string): string {
|
|
36
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
37
|
+
if (!existsSync(gitDrivePath)) {
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(gitDrivePath, { recursive: true });
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
throw new Error(`Failed to write to drive. Please ensure Terminal/Node has "Removable Volumes" access in macOS Privacy settings. Details: ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return gitDrivePath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function listRepos(gitDrivePath: string): Array<{ name: string; path: string; lastModified: string }> {
|
|
48
|
+
if (!existsSync(gitDrivePath)) return [];
|
|
49
|
+
|
|
50
|
+
return readdirSync(gitDrivePath)
|
|
51
|
+
.filter((entry) => {
|
|
52
|
+
const entryPath = path.join(gitDrivePath, entry);
|
|
53
|
+
return (
|
|
54
|
+
statSync(entryPath).isDirectory() &&
|
|
55
|
+
(entry.endsWith('.git') || existsSync(path.join(entryPath, 'HEAD')))
|
|
56
|
+
);
|
|
57
|
+
})
|
|
58
|
+
.map((entry) => {
|
|
59
|
+
const entryPath = path.join(gitDrivePath, entry);
|
|
60
|
+
const stat = statSync(entryPath);
|
|
61
|
+
return {
|
|
62
|
+
name: entry.replace(/\.git$/, ''),
|
|
63
|
+
path: entryPath,
|
|
64
|
+
lastModified: stat.mtime.toISOString(),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadLinks(): Record<string, { mountpoint: string; repoName: string; linkedAt: string }> {
|
|
70
|
+
const linksFile = path.join(homedir(), '.config', 'git-drive', 'links.json');
|
|
71
|
+
if (!existsSync(linksFile)) return {};
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(readFileSync(linksFile, 'utf-8'));
|
|
74
|
+
} catch {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── API Routes ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
// List all connected drives
|
|
82
|
+
app.get('/api/drives', async (_req: Request, res: Response) => {
|
|
83
|
+
try {
|
|
84
|
+
const drives = await getDiskInfo();
|
|
85
|
+
const result = drives
|
|
86
|
+
.filter((d: any) => {
|
|
87
|
+
const mp = d.mounted;
|
|
88
|
+
if (!mp) return false;
|
|
89
|
+
if (mp === "/" || mp === "100%") return false;
|
|
90
|
+
|
|
91
|
+
if (process.platform === "darwin") {
|
|
92
|
+
return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
|
|
96
|
+
if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
|
|
97
|
+
|
|
98
|
+
return true;
|
|
99
|
+
})
|
|
100
|
+
.map((d: any) => ({
|
|
101
|
+
device: d.filesystem,
|
|
102
|
+
description: d.mounted,
|
|
103
|
+
size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
|
|
104
|
+
isRemovable: true,
|
|
105
|
+
isSystem: d.mounted === '/',
|
|
106
|
+
mountpoints: [d.mounted],
|
|
107
|
+
hasGitDrive: existsSync(getGitDrivePath(d.mounted)),
|
|
108
|
+
}));
|
|
109
|
+
res.json(result);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
res.status(500).json({ error: 'Failed to list drives' });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// List repos on a specific drive
|
|
116
|
+
app.get('/api/drives/:mountpoint/repos', (req: Request, res: Response) => {
|
|
117
|
+
try {
|
|
118
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
119
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
120
|
+
|
|
121
|
+
if (!existsSync(mountpoint)) {
|
|
122
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const repos = listRepos(gitDrivePath);
|
|
127
|
+
res.json({
|
|
128
|
+
mountpoint,
|
|
129
|
+
gitDrivePath,
|
|
130
|
+
initialized: existsSync(gitDrivePath),
|
|
131
|
+
repos,
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
res.status(500).json({ error: 'Failed to list repos' });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Initialize git-drive on a drive
|
|
139
|
+
app.post('/api/drives/:mountpoint/init', (req: Request, res: Response) => {
|
|
140
|
+
try {
|
|
141
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
142
|
+
|
|
143
|
+
if (!existsSync(mountpoint)) {
|
|
144
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const gitDrivePath = ensureGitDriveDir(mountpoint);
|
|
149
|
+
res.json({
|
|
150
|
+
mountpoint,
|
|
151
|
+
gitDrivePath,
|
|
152
|
+
message: 'Git Drive initialized on this drive',
|
|
153
|
+
});
|
|
154
|
+
} catch (err: any) {
|
|
155
|
+
console.error("Init Error:", err);
|
|
156
|
+
res.status(500).json({ error: err.message || 'Failed to initialize drive' });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Create a new bare repo on a drive
|
|
161
|
+
app.post('/api/drives/:mountpoint/repos', (req: Request, res: Response) => {
|
|
162
|
+
try {
|
|
163
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
164
|
+
const { name } = req.body;
|
|
165
|
+
|
|
166
|
+
if (!name || typeof name !== 'string') {
|
|
167
|
+
res.status(400).json({ error: 'Repo name is required' });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
172
|
+
|
|
173
|
+
if (!existsSync(mountpoint)) {
|
|
174
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const gitDrivePath = ensureGitDriveDir(mountpoint);
|
|
179
|
+
const repoName = safeName.endsWith('.git') ? safeName : `${safeName}.git`;
|
|
180
|
+
const repoPath = path.join(gitDrivePath, repoName);
|
|
181
|
+
|
|
182
|
+
if (existsSync(repoPath)) {
|
|
183
|
+
res.status(409).json({ error: 'Repository already exists' });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
git(`init --bare "${repoPath}"`);
|
|
188
|
+
|
|
189
|
+
res.status(201).json({
|
|
190
|
+
name: safeName.replace(/\.git$/, ''),
|
|
191
|
+
path: repoPath,
|
|
192
|
+
message: `Bare repository created: ${repoName}`,
|
|
193
|
+
remoteUrl: repoPath,
|
|
194
|
+
});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
res.status(500).json({ error: 'Failed to create repository' });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Delete a repo from a drive
|
|
201
|
+
app.delete('/api/drives/:mountpoint/repos/:repoName', (req: Request, res: Response) => {
|
|
202
|
+
try {
|
|
203
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
204
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
205
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
206
|
+
|
|
207
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
208
|
+
const repoPath = path.join(gitDrivePath, bareRepoName);
|
|
209
|
+
|
|
210
|
+
if (!existsSync(repoPath)) {
|
|
211
|
+
const altPath = path.join(gitDrivePath, repoName);
|
|
212
|
+
if (!existsSync(altPath)) {
|
|
213
|
+
res.status(404).json({ error: 'Repository not found' });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
execSync(`rm -rf "${altPath}"`);
|
|
217
|
+
} else {
|
|
218
|
+
execSync(`rm -rf "${repoPath}"`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
res.json({ message: `Repository '${repoName}' deleted` });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
res.status(500).json({ error: 'Failed to delete repository' });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Get info about a specific repo
|
|
228
|
+
app.get('/api/drives/:mountpoint/repos/:repoName', (req: Request, res: Response) => {
|
|
229
|
+
try {
|
|
230
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
231
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
232
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
233
|
+
|
|
234
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
235
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
236
|
+
|
|
237
|
+
if (!existsSync(repoPath)) {
|
|
238
|
+
repoPath = path.join(gitDrivePath, repoName);
|
|
239
|
+
if (!existsSync(repoPath)) {
|
|
240
|
+
res.status(404).json({ error: 'Repository not found' });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let branches: string[] = [];
|
|
246
|
+
try {
|
|
247
|
+
const branchOutput = git("branch --format='%(refname:short)'", repoPath);
|
|
248
|
+
branches = branchOutput
|
|
249
|
+
.split('\n')
|
|
250
|
+
.map((b) => b.trim().replace(/^'|'$/g, ''))
|
|
251
|
+
.filter(Boolean);
|
|
252
|
+
} catch {}
|
|
253
|
+
|
|
254
|
+
let tags: string[] = [];
|
|
255
|
+
try {
|
|
256
|
+
const tagOutput = git("tag", repoPath);
|
|
257
|
+
tags = tagOutput.split('\n').map((t) => t.trim()).filter(Boolean);
|
|
258
|
+
} catch {}
|
|
259
|
+
|
|
260
|
+
let lastCommit: { hash: string; message: string; date: string } | null = null;
|
|
261
|
+
try {
|
|
262
|
+
const log = git('log -1 --format="%H|%s|%ci" --all', repoPath);
|
|
263
|
+
if (log) {
|
|
264
|
+
const [hash, message, date] = log.replace(/^"|"$/g, '').split('|');
|
|
265
|
+
lastCommit = { hash, message, date };
|
|
266
|
+
}
|
|
267
|
+
} catch {}
|
|
268
|
+
|
|
269
|
+
const stat = statSync(repoPath);
|
|
270
|
+
|
|
271
|
+
res.json({
|
|
272
|
+
name: repoName.replace(/\.git$/, ''),
|
|
273
|
+
path: repoPath,
|
|
274
|
+
branches,
|
|
275
|
+
tags,
|
|
276
|
+
lastCommit,
|
|
277
|
+
lastModified: stat.mtime.toISOString(),
|
|
278
|
+
remoteUrl: repoPath,
|
|
279
|
+
});
|
|
280
|
+
} catch (err) {
|
|
281
|
+
res.status(500).json({ error: 'Failed to get repo info' });
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Local status check
|
|
286
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/local-status', (req: Request, res: Response) => {
|
|
287
|
+
try {
|
|
288
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
289
|
+
let repoName = decodeURIComponent(req.params.repoName);
|
|
290
|
+
repoName = repoName.replace(/\.git$/, '');
|
|
291
|
+
|
|
292
|
+
const links = loadLinks();
|
|
293
|
+
let localPath: string | null = null;
|
|
294
|
+
|
|
295
|
+
for (const [p, data] of Object.entries(links)) {
|
|
296
|
+
if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
|
|
297
|
+
if (existsSync(p)) {
|
|
298
|
+
localPath = p;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!localPath) {
|
|
305
|
+
res.json({ linked: false });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let hasChanges = false;
|
|
310
|
+
let unpushed = false;
|
|
311
|
+
try {
|
|
312
|
+
const statusOutput = git('status --porcelain', localPath);
|
|
313
|
+
hasChanges = statusOutput.trim().length > 0;
|
|
314
|
+
const unpushedOutput = git('log gd/main..HEAD --oneline', localPath);
|
|
315
|
+
unpushed = unpushedOutput.trim().length > 0;
|
|
316
|
+
} catch {}
|
|
317
|
+
|
|
318
|
+
res.json({ linked: true, localPath, hasChanges, unpushed });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
res.status(500).json({ error: 'Failed to check local status' });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Push to git-drive
|
|
325
|
+
app.post('/api/drives/:mountpoint/repos/:repoName/push', (req: Request, res: Response) => {
|
|
326
|
+
try {
|
|
327
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
328
|
+
let repoName = decodeURIComponent(req.params.repoName);
|
|
329
|
+
repoName = repoName.replace(/\.git$/, '');
|
|
330
|
+
|
|
331
|
+
const links = loadLinks();
|
|
332
|
+
let localPath: string | null = null;
|
|
333
|
+
for (const [p, data] of Object.entries(links)) {
|
|
334
|
+
if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
|
|
335
|
+
if (existsSync(p)) {
|
|
336
|
+
localPath = p;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!localPath) {
|
|
343
|
+
res.status(404).json({ error: 'Local linked repository not found.' });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
git('push gd --all', localPath);
|
|
348
|
+
git('push gd --tags', localPath);
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
352
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
353
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
354
|
+
if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
|
|
355
|
+
|
|
356
|
+
const payload = {
|
|
357
|
+
date: new Date().toISOString(),
|
|
358
|
+
computer: homedir(),
|
|
359
|
+
user: process.env.USER || 'local-user',
|
|
360
|
+
localDir: localPath,
|
|
361
|
+
mode: 'web-ui',
|
|
362
|
+
};
|
|
363
|
+
const logFile = path.join(repoPath, "git-drive-pushlog.json");
|
|
364
|
+
appendFileSync(logFile, JSON.stringify(payload) + "\n", "utf-8");
|
|
365
|
+
} catch {}
|
|
366
|
+
|
|
367
|
+
res.json({ success: true, message: 'Successfully backed up local code to Git Drive!' });
|
|
368
|
+
} catch (err: any) {
|
|
369
|
+
res.status(500).json({ error: err.message || 'Failed to push' });
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Browse repository files tree
|
|
374
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/tree', (req: Request, res: Response) => {
|
|
375
|
+
try {
|
|
376
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
377
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
378
|
+
const branch = (req.query.branch as string) || 'main';
|
|
379
|
+
const treePath = (req.query.path as string) || '';
|
|
380
|
+
|
|
381
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
382
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
383
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
384
|
+
|
|
385
|
+
if (!existsSync(repoPath)) {
|
|
386
|
+
repoPath = path.join(gitDrivePath, repoName);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const target = treePath ? `${branch}:${treePath}` : branch;
|
|
390
|
+
const output = git(`ls-tree ${target}`, repoPath);
|
|
391
|
+
|
|
392
|
+
const files = output.split('\n').filter(Boolean).map((line) => {
|
|
393
|
+
const parts = line.split('\t');
|
|
394
|
+
const meta = parts[0].split(' ');
|
|
395
|
+
return {
|
|
396
|
+
mode: meta[0],
|
|
397
|
+
type: meta[1],
|
|
398
|
+
hash: meta[2],
|
|
399
|
+
path: parts[1],
|
|
400
|
+
name: parts[1].split('/').pop(),
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
res.json({ files });
|
|
405
|
+
} catch (err) {
|
|
406
|
+
res.json({ files: [] });
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Get commit history
|
|
411
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/commits', (req: Request, res: Response) => {
|
|
412
|
+
try {
|
|
413
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
414
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
415
|
+
const branch = (req.query.branch as string) || 'main';
|
|
416
|
+
|
|
417
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
418
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
419
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
420
|
+
|
|
421
|
+
if (!existsSync(repoPath)) {
|
|
422
|
+
repoPath = path.join(gitDrivePath, repoName);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let commits: any[] = [];
|
|
426
|
+
try {
|
|
427
|
+
const logOutput = git(`log ${branch} -n 100 --format="%H|%an|%ae|%s|%ci"`, repoPath);
|
|
428
|
+
commits = logOutput
|
|
429
|
+
.split('\n')
|
|
430
|
+
.filter(Boolean)
|
|
431
|
+
.map((line) => {
|
|
432
|
+
const [hash, author, email, message, date] = line.split('|');
|
|
433
|
+
return { hash, author, email, message, date };
|
|
434
|
+
});
|
|
435
|
+
} catch {}
|
|
436
|
+
|
|
437
|
+
let pushLogs: any[] = [];
|
|
438
|
+
try {
|
|
439
|
+
const logFile = path.join(repoPath, "git-drive-pushlog.json");
|
|
440
|
+
if (existsSync(logFile)) {
|
|
441
|
+
const rawLogs = readFileSync(logFile, "utf-8").trim().split('\n');
|
|
442
|
+
pushLogs = rawLogs.map((l) => {
|
|
443
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
444
|
+
}).filter(Boolean);
|
|
445
|
+
pushLogs.reverse();
|
|
446
|
+
}
|
|
447
|
+
} catch {}
|
|
448
|
+
|
|
449
|
+
res.json({ commits, pushLogs });
|
|
450
|
+
} catch (err) {
|
|
451
|
+
res.status(500).json({ error: 'Failed to retrieve history' });
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Get single commit details
|
|
456
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/commits/:hash', (req: Request, res: Response) => {
|
|
457
|
+
try {
|
|
458
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
459
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
460
|
+
const hash = req.params.hash;
|
|
461
|
+
|
|
462
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
463
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
464
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
465
|
+
if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
|
|
466
|
+
|
|
467
|
+
const logOutput = git(`log -1 --format="%H|%an|%ae|%s|%ci" ${hash}`, repoPath);
|
|
468
|
+
const [commitHash, author, email, message, date] = logOutput.split('|');
|
|
469
|
+
|
|
470
|
+
const patch = git(`show --format="" ${hash}`, repoPath);
|
|
471
|
+
|
|
472
|
+
res.json({ hash: commitHash, author, email, message, date, patch });
|
|
473
|
+
} catch (err) {
|
|
474
|
+
res.status(500).json({ error: 'Failed to retrieve commit details' });
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Read raw file content
|
|
479
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/blob', (req: Request, res: Response) => {
|
|
480
|
+
try {
|
|
481
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
482
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
483
|
+
const branch = (req.query.branch as string) || 'main';
|
|
484
|
+
const filePath = req.query.path as string;
|
|
485
|
+
|
|
486
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
487
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
488
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
489
|
+
|
|
490
|
+
if (!existsSync(repoPath)) {
|
|
491
|
+
repoPath = path.join(gitDrivePath, repoName);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const content = git(`show ${branch}:${filePath}`, repoPath);
|
|
495
|
+
res.send(content);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
res.status(500).json({ error: 'Failed to read file' });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// SPA fallback
|
|
502
|
+
app.get('*', (_req: Request, res: Response) => {
|
|
503
|
+
const indexPath = path.join(uiPath, 'index.html');
|
|
504
|
+
if (existsSync(indexPath)) {
|
|
505
|
+
res.sendFile(indexPath);
|
|
506
|
+
} else {
|
|
507
|
+
res.status(404).send('UI not built. The package may need to be rebuilt.');
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Start server
|
|
512
|
+
app.listen(port, () => {
|
|
513
|
+
console.log(`\n 🚀 Git Drive is running at http://localhost:${port}\n`);
|
|
514
|
+
});
|