git-drive 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +157 -0
- package/docker-compose.yml +48 -0
- package/package.json +20 -55
- package/packages/cli/Dockerfile +26 -0
- package/packages/cli/jest.config.js +26 -0
- package/packages/cli/package.json +65 -0
- package/packages/cli/src/__tests__/commands/companion.test.ts +152 -0
- package/packages/cli/src/__tests__/commands/init.test.ts +154 -0
- package/packages/cli/src/__tests__/commands/list.test.ts +122 -0
- package/packages/cli/src/__tests__/commands/push.test.ts +155 -0
- package/packages/cli/src/__tests__/commands/restore.test.ts +135 -0
- package/packages/cli/src/__tests__/commands/status.test.ts +199 -0
- package/packages/cli/src/__tests__/config.test.ts +198 -0
- package/packages/cli/src/__tests__/e2e.test.ts +125 -0
- package/packages/cli/src/__tests__/errors.test.ts +66 -0
- package/packages/cli/src/__tests__/git.test.ts +250 -0
- package/packages/cli/src/__tests__/server.test.ts +371 -0
- package/packages/cli/src/commands/archive.ts +39 -0
- package/packages/cli/src/commands/companion.ts +205 -0
- package/packages/cli/src/commands/init.ts +130 -0
- package/packages/cli/src/commands/link.ts +151 -0
- package/packages/cli/src/commands/list.ts +94 -0
- package/packages/cli/src/commands/push.ts +77 -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 +60 -0
- package/packages/cli/src/index.ts +129 -0
- package/packages/cli/src/server.ts +700 -0
- package/packages/cli/tsconfig.json +13 -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 +52 -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.test.tsx +248 -0
- package/packages/ui/src/App.tsx +803 -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/src/test/setup.ts +1 -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/packages/ui/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +4 -0
- package/rewrite_app.js +731 -0
- package/tsconfig.json +14 -0
- package/dist/__tests__/commands/init.test.js +0 -123
- package/dist/__tests__/commands/list.test.js +0 -91
- package/dist/__tests__/commands/push.test.js +0 -128
- package/dist/__tests__/commands/restore.test.js +0 -99
- package/dist/__tests__/commands/status.test.js +0 -151
- package/dist/__tests__/config.test.js +0 -150
- package/dist/__tests__/e2e.test.js +0 -107
- package/dist/__tests__/errors.test.js +0 -56
- package/dist/__tests__/git.test.js +0 -184
- package/dist/__tests__/server.test.js +0 -310
- package/dist/commands/archive.js +0 -32
- package/dist/commands/init.js +0 -55
- package/dist/commands/link.js +0 -175
- package/dist/commands/list.js +0 -83
- package/dist/commands/push.js +0 -112
- package/dist/commands/restore.js +0 -30
- package/dist/commands/status.js +0 -116
- package/dist/config.js +0 -62
- package/dist/errors.js +0 -30
- package/dist/git.js +0 -67
- package/dist/index.js +0 -108
- package/dist/server.js +0 -535
- /package/{ui → packages/cli/ui}/assets/index-Br8xQbJz.js +0 -0
- /package/{ui → packages/cli/ui}/assets/index-Cc2q1t5k.js +0 -0
- /package/{ui → packages/cli/ui}/assets/index-DrL7ojPA.css +0 -0
- /package/{ui → packages/cli/ui}/index.html +0 -0
- /package/{ui → packages/cli/ui}/vite.svg +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "git-drive-docker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dockerized Git Drive environment and CLI wrapper",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build:images": "docker compose build",
|
|
8
|
+
"up": "docker compose up -d",
|
|
9
|
+
"down": "docker compose down",
|
|
10
|
+
"cli": "docker compose run git-drive-cli"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [],
|
|
13
|
+
"author": "",
|
|
14
|
+
"license": "ISC"
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "git-drive-server",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Server component for git-drive",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"git",
|
|
7
|
+
"backup",
|
|
8
|
+
"server"
|
|
9
|
+
],
|
|
10
|
+
"author": "",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/josmanvis/git-drive.git",
|
|
15
|
+
"directory": "packages/server"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/josmanvis/git-drive/issues"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/josmanvis/git-drive#readme",
|
|
21
|
+
"main": "dist/index.js",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"types": "dist/index.d.ts",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"start": "node dist/index.js",
|
|
30
|
+
"prepublishOnly": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"express": "^4.19.2",
|
|
34
|
+
"node-disk-info": "^1.3.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/express": "^4.17.21",
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"typescript": "^5.7.0"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { readdirSync, existsSync, mkdirSync, statSync, readFileSync } from 'fs';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { getDiskInfo } from 'node-disk-info';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const app = express();
|
|
13
|
+
const port = 4483;
|
|
14
|
+
|
|
15
|
+
app.use(express.json());
|
|
16
|
+
app.use(express.static(path.join(__dirname, '../../../packages/ui/dist')));
|
|
17
|
+
|
|
18
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function git(args: string, cwd?: string): string {
|
|
21
|
+
return execSync(`git ${args}`, {
|
|
22
|
+
cwd,
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
25
|
+
}).trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getGitDrivePath(mountpoint: string): string {
|
|
29
|
+
return path.join(mountpoint, '.git-drive');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensureGitDriveDir(mountpoint: string): string {
|
|
33
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
34
|
+
if (!existsSync(gitDrivePath)) {
|
|
35
|
+
try {
|
|
36
|
+
execSync(`mkdir -p "${gitDrivePath}"`);
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
throw new Error(`Failed to write to drive. Please ensure Terminal/Node has "Removable Volumes" access in macOS Privacy settings. Details: ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return gitDrivePath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listRepos(gitDrivePath: string): Array<{ name: string; path: string; lastModified: string }> {
|
|
45
|
+
if (!existsSync(gitDrivePath)) return [];
|
|
46
|
+
|
|
47
|
+
return readdirSync(gitDrivePath)
|
|
48
|
+
.filter((entry) => {
|
|
49
|
+
const entryPath = path.join(gitDrivePath, entry);
|
|
50
|
+
// Accept both bare repos (name.git) and directories with HEAD file
|
|
51
|
+
return (
|
|
52
|
+
statSync(entryPath).isDirectory() &&
|
|
53
|
+
(entry.endsWith('.git') || existsSync(path.join(entryPath, 'HEAD')))
|
|
54
|
+
);
|
|
55
|
+
})
|
|
56
|
+
.map((entry) => {
|
|
57
|
+
const entryPath = path.join(gitDrivePath, entry);
|
|
58
|
+
const stat = statSync(entryPath);
|
|
59
|
+
return {
|
|
60
|
+
name: entry.replace(/\.git$/, ''),
|
|
61
|
+
path: entryPath,
|
|
62
|
+
lastModified: stat.mtime.toISOString(),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadLinks(): Record<string, { mountpoint: string; repoName: string; linkedAt: string }> {
|
|
68
|
+
const linksFile = path.join(homedir(), '.config', 'git-drive', 'links.json');
|
|
69
|
+
if (!existsSync(linksFile)) return {};
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(readFileSync(linksFile, 'utf-8'));
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── API Routes ───────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
// List all connected drives
|
|
80
|
+
app.get('/api/drives', async (_req, res) => {
|
|
81
|
+
try {
|
|
82
|
+
const drives = await getDiskInfo();
|
|
83
|
+
const result = drives
|
|
84
|
+
.filter((d: any) => {
|
|
85
|
+
const mp = d.mounted;
|
|
86
|
+
if (!mp) return false;
|
|
87
|
+
if (mp === "/" || mp === "100%") return false;
|
|
88
|
+
|
|
89
|
+
if (process.platform === "darwin") {
|
|
90
|
+
return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
|
|
94
|
+
if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
|
|
95
|
+
|
|
96
|
+
return true;
|
|
97
|
+
})
|
|
98
|
+
.map((d: any) => ({
|
|
99
|
+
device: d.filesystem,
|
|
100
|
+
description: d.mounted,
|
|
101
|
+
size: d.blocks ? parseInt(d.blocks) * 1024 : 0,
|
|
102
|
+
isRemovable: true, // simplified assumption
|
|
103
|
+
isSystem: d.mounted === '/',
|
|
104
|
+
mountpoints: [d.mounted],
|
|
105
|
+
hasGitDrive: existsSync(getGitDrivePath(d.mounted)),
|
|
106
|
+
}));
|
|
107
|
+
res.json(result);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
res.status(500).json({ error: 'Failed to list drives' });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// List repos on a specific drive
|
|
114
|
+
app.get('/api/drives/:mountpoint/repos', (req, res) => {
|
|
115
|
+
try {
|
|
116
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
117
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
118
|
+
|
|
119
|
+
if (!existsSync(mountpoint)) {
|
|
120
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const repos = listRepos(gitDrivePath);
|
|
125
|
+
res.json({
|
|
126
|
+
mountpoint,
|
|
127
|
+
gitDrivePath,
|
|
128
|
+
initialized: existsSync(gitDrivePath),
|
|
129
|
+
repos,
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
res.status(500).json({ error: 'Failed to list repos' });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Initialize git-drive on a drive (create .git-drive directory)
|
|
137
|
+
app.post('/api/drives/:mountpoint/init', (req, res) => {
|
|
138
|
+
try {
|
|
139
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
140
|
+
|
|
141
|
+
if (!existsSync(mountpoint)) {
|
|
142
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const gitDrivePath = ensureGitDriveDir(mountpoint);
|
|
147
|
+
res.json({
|
|
148
|
+
mountpoint,
|
|
149
|
+
gitDrivePath,
|
|
150
|
+
message: 'Git Drive initialized on this drive',
|
|
151
|
+
});
|
|
152
|
+
} catch (err: any) {
|
|
153
|
+
console.error("Init Error:", err);
|
|
154
|
+
res.status(500).json({ error: err.message || 'Failed to initialize drive' });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Create a new bare repo on a drive
|
|
159
|
+
app.post('/api/drives/:mountpoint/repos', (req, res) => {
|
|
160
|
+
try {
|
|
161
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
162
|
+
const { name } = req.body;
|
|
163
|
+
|
|
164
|
+
if (!name || typeof name !== 'string') {
|
|
165
|
+
res.status(400).json({ error: 'Repo name is required' });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Sanitize name
|
|
170
|
+
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
171
|
+
|
|
172
|
+
if (!existsSync(mountpoint)) {
|
|
173
|
+
res.status(404).json({ error: 'Drive not found or not mounted' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const gitDrivePath = ensureGitDriveDir(mountpoint);
|
|
178
|
+
const repoName = safeName.endsWith('.git') ? safeName : `${safeName}.git`;
|
|
179
|
+
const repoPath = path.join(gitDrivePath, repoName);
|
|
180
|
+
|
|
181
|
+
if (existsSync(repoPath)) {
|
|
182
|
+
res.status(409).json({ error: 'Repository already exists' });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
git(`init --bare "${repoPath}"`);
|
|
187
|
+
|
|
188
|
+
res.status(201).json({
|
|
189
|
+
name: safeName.replace(/\.git$/, ''),
|
|
190
|
+
path: repoPath,
|
|
191
|
+
message: `Bare repository created: ${repoName}`,
|
|
192
|
+
remoteUrl: repoPath,
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
res.status(500).json({ error: 'Failed to create repository' });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Delete a repo from a drive
|
|
200
|
+
app.delete('/api/drives/:mountpoint/repos/:repoName', (req, res) => {
|
|
201
|
+
try {
|
|
202
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
203
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
204
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
205
|
+
|
|
206
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
207
|
+
const repoPath = path.join(gitDrivePath, bareRepoName);
|
|
208
|
+
|
|
209
|
+
if (!existsSync(repoPath)) {
|
|
210
|
+
// Also check without .git suffix
|
|
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, res) => {
|
|
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
|
+
// Get branches
|
|
246
|
+
let branches: string[] = [];
|
|
247
|
+
try {
|
|
248
|
+
const branchOutput = git("branch --format='%(refname:short)'", repoPath);
|
|
249
|
+
branches = branchOutput
|
|
250
|
+
.split('\n')
|
|
251
|
+
.map((b) => b.trim().replace(/^'|'$/g, ''))
|
|
252
|
+
.filter(Boolean);
|
|
253
|
+
} catch {
|
|
254
|
+
// Empty repo has no branches
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Get tags
|
|
258
|
+
let tags: string[] = [];
|
|
259
|
+
try {
|
|
260
|
+
const tagOutput = git("tag", repoPath);
|
|
261
|
+
tags = tagOutput
|
|
262
|
+
.split('\n')
|
|
263
|
+
.map((t) => t.trim())
|
|
264
|
+
.filter(Boolean);
|
|
265
|
+
} catch {
|
|
266
|
+
// No tags
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Get last commit info
|
|
270
|
+
let lastCommit: { hash: string; message: string; date: string } | null = null;
|
|
271
|
+
try {
|
|
272
|
+
const log = git('log -1 --format="%H|%s|%ci" --all', repoPath);
|
|
273
|
+
if (log) {
|
|
274
|
+
const [hash, message, date] = log.replace(/^"|"$/g, '').split('|');
|
|
275
|
+
lastCommit = { hash, message, date };
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// Empty repo
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const stat = statSync(repoPath);
|
|
282
|
+
|
|
283
|
+
res.json({
|
|
284
|
+
name: repoName.replace(/\.git$/, ''),
|
|
285
|
+
path: repoPath,
|
|
286
|
+
branches,
|
|
287
|
+
tags,
|
|
288
|
+
lastCommit,
|
|
289
|
+
lastModified: stat.mtime.toISOString(),
|
|
290
|
+
remoteUrl: repoPath,
|
|
291
|
+
});
|
|
292
|
+
} catch (err) {
|
|
293
|
+
res.status(500).json({ error: 'Failed to get repo info' });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Check if there are local working directory unpushed changes
|
|
298
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/local-status', (req, res) => {
|
|
299
|
+
try {
|
|
300
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
301
|
+
let repoName = decodeURIComponent(req.params.repoName);
|
|
302
|
+
repoName = repoName.replace(/\.git$/, '');
|
|
303
|
+
|
|
304
|
+
const links = loadLinks();
|
|
305
|
+
let localPath: string | null = null;
|
|
306
|
+
|
|
307
|
+
// Find if this specific drive's repo is globally linked to any local folder on the user's machine
|
|
308
|
+
for (const [p, data] of Object.entries(links)) {
|
|
309
|
+
if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
|
|
310
|
+
if (existsSync(p)) {
|
|
311
|
+
localPath = p;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!localPath) {
|
|
318
|
+
res.json({ linked: false });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Check git status locally
|
|
323
|
+
let hasChanges = false;
|
|
324
|
+
let unpushed = false;
|
|
325
|
+
try {
|
|
326
|
+
const statusOutput = git('status --porcelain', localPath);
|
|
327
|
+
hasChanges = statusOutput.trim().length > 0;
|
|
328
|
+
|
|
329
|
+
const unpushedOutput = git('log gd/main..HEAD --oneline', localPath); // Assuming main for now
|
|
330
|
+
unpushed = unpushedOutput.trim().length > 0;
|
|
331
|
+
} catch {
|
|
332
|
+
// Ignore git errors if repo is in weird state
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
res.json({
|
|
336
|
+
linked: true,
|
|
337
|
+
localPath,
|
|
338
|
+
hasChanges,
|
|
339
|
+
unpushed
|
|
340
|
+
});
|
|
341
|
+
} catch (err) {
|
|
342
|
+
res.status(500).json({ error: 'Failed to check local status' });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Remotely push local working directory to git-drive
|
|
347
|
+
app.post('/api/drives/:mountpoint/repos/:repoName/push', (req, res) => {
|
|
348
|
+
try {
|
|
349
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
350
|
+
let repoName = decodeURIComponent(req.params.repoName);
|
|
351
|
+
repoName = repoName.replace(/\.git$/, '');
|
|
352
|
+
|
|
353
|
+
const links = loadLinks();
|
|
354
|
+
let localPath: string | null = null;
|
|
355
|
+
for (const [p, data] of Object.entries(links)) {
|
|
356
|
+
if (data.mountpoint === mountpoint && data.repoName.replace(/\.git$/, '') === repoName) {
|
|
357
|
+
if (existsSync(p)) {
|
|
358
|
+
localPath = p;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!localPath) {
|
|
365
|
+
res.status(404).json({ error: 'Local linked repository not found.' });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
git('push gd --all', localPath);
|
|
370
|
+
git('push gd --tags', localPath);
|
|
371
|
+
|
|
372
|
+
// Save push telemetry
|
|
373
|
+
try {
|
|
374
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
375
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
376
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
377
|
+
if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
|
|
378
|
+
|
|
379
|
+
const payload = {
|
|
380
|
+
date: new Date().toISOString(),
|
|
381
|
+
computer: homedir(), // Server relies on os module
|
|
382
|
+
user: process.env.USER || 'local-user',
|
|
383
|
+
localDir: localPath,
|
|
384
|
+
mode: 'web-ui',
|
|
385
|
+
};
|
|
386
|
+
const logFile = path.join(repoPath, "git-drive-pushlog.json");
|
|
387
|
+
const fs = require('fs');
|
|
388
|
+
fs.appendFileSync(logFile, JSON.stringify(payload) + "\n", "utf-8");
|
|
389
|
+
} catch { }
|
|
390
|
+
|
|
391
|
+
res.json({ success: true, message: 'Successfully backed up local code to Git Drive!' });
|
|
392
|
+
} catch (err: any) {
|
|
393
|
+
res.status(500).json({ error: err.message || 'Failed to push' });
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Browse repository files tree
|
|
398
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/tree', (req, res) => {
|
|
399
|
+
try {
|
|
400
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
401
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
402
|
+
const branch = req.query.branch || 'main'; // Provide a default if they use main
|
|
403
|
+
const treePath = (req.query.path as string) || '';
|
|
404
|
+
|
|
405
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
406
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
407
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
408
|
+
|
|
409
|
+
if (!existsSync(repoPath)) {
|
|
410
|
+
repoPath = path.join(gitDrivePath, repoName);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Resolves default branch if passed "HEAD" or empty logic. Safely ask git for HEAD branch:
|
|
414
|
+
let targetBranch = branch as string;
|
|
415
|
+
if (!targetBranch) {
|
|
416
|
+
try {
|
|
417
|
+
const branchOutput = git('branch --show-current', repoPath);
|
|
418
|
+
targetBranch = branchOutput || 'HEAD';
|
|
419
|
+
} catch {
|
|
420
|
+
targetBranch = 'main';
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// git ls-tree
|
|
425
|
+
const target = treePath ? `${targetBranch}:${treePath}` : targetBranch;
|
|
426
|
+
const output = git(`ls-tree ${target}`, repoPath);
|
|
427
|
+
|
|
428
|
+
const files = output.split('\n').filter(Boolean).map((line) => {
|
|
429
|
+
// 040000 tree <hash>\t<path>
|
|
430
|
+
// 100644 blob <hash>\t<path>
|
|
431
|
+
const parts = line.split('\t');
|
|
432
|
+
const meta = parts[0].split(' ');
|
|
433
|
+
return {
|
|
434
|
+
mode: meta[0],
|
|
435
|
+
type: meta[1],
|
|
436
|
+
hash: meta[2],
|
|
437
|
+
path: parts[1],
|
|
438
|
+
name: parts[1].split('/').pop(),
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
res.json({ files });
|
|
443
|
+
} catch (err) {
|
|
444
|
+
res.json({ files: [] }); // Typically happens if repo has zero commits
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Get combined git commit history and git-drive push logs
|
|
449
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/commits', (req, res) => {
|
|
450
|
+
try {
|
|
451
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
452
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
453
|
+
const branch = req.query.branch || 'main'; // Using branch filters
|
|
454
|
+
|
|
455
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
456
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
457
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
458
|
+
|
|
459
|
+
if (!existsSync(repoPath)) {
|
|
460
|
+
repoPath = path.join(gitDrivePath, repoName);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Git commits log
|
|
464
|
+
let commits: any[] = [];
|
|
465
|
+
try {
|
|
466
|
+
// hash|authorName|authorEmail|message|date
|
|
467
|
+
const logOutput = git(`log ${branch} -n 100 --format="%H|%an|%ae|%s|%ci"`, repoPath);
|
|
468
|
+
commits = logOutput
|
|
469
|
+
.split('\n')
|
|
470
|
+
.filter(Boolean)
|
|
471
|
+
.map((line) => {
|
|
472
|
+
const [hash, author, email, message, date] = line.split('|');
|
|
473
|
+
return { hash, author, email, message, date };
|
|
474
|
+
});
|
|
475
|
+
} catch (e) {
|
|
476
|
+
// Empty repo or invalid branch
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Git drive push logs overlay
|
|
480
|
+
let pushLogs: any[] = [];
|
|
481
|
+
try {
|
|
482
|
+
const logFile = path.join(repoPath, "git-drive-pushlog.json");
|
|
483
|
+
if (existsSync(logFile)) {
|
|
484
|
+
const rawLogs = require('fs').readFileSync(logFile, "utf-8").trim().split('\n');
|
|
485
|
+
pushLogs = rawLogs.map((l: string) => {
|
|
486
|
+
try {
|
|
487
|
+
return JSON.parse(l);
|
|
488
|
+
} catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}).filter(Boolean);
|
|
492
|
+
pushLogs.reverse(); // Newest first
|
|
493
|
+
}
|
|
494
|
+
} catch { }
|
|
495
|
+
|
|
496
|
+
res.json({ commits, pushLogs });
|
|
497
|
+
} catch (err) {
|
|
498
|
+
res.status(500).json({ error: 'Failed to retrieve history' });
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Get single commit details (patch/diff)
|
|
503
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/commits/:hash', (req, res) => {
|
|
504
|
+
try {
|
|
505
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
506
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
507
|
+
const hash = req.params.hash;
|
|
508
|
+
|
|
509
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
510
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
511
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
512
|
+
if (!existsSync(repoPath)) repoPath = path.join(gitDrivePath, repoName);
|
|
513
|
+
|
|
514
|
+
// hash|authorName|authorEmail|message|date
|
|
515
|
+
const logOutput = git(`log -1 --format="%H|%an|%ae|%s|%ci" ${hash}`, repoPath);
|
|
516
|
+
const [commitHash, author, email, message, date] = logOutput.split('|');
|
|
517
|
+
|
|
518
|
+
// Get the diff/patch
|
|
519
|
+
const patch = git(`show --format="" ${hash}`, repoPath);
|
|
520
|
+
|
|
521
|
+
res.json({
|
|
522
|
+
hash: commitHash,
|
|
523
|
+
author,
|
|
524
|
+
email,
|
|
525
|
+
message,
|
|
526
|
+
date,
|
|
527
|
+
patch
|
|
528
|
+
});
|
|
529
|
+
} catch (err) {
|
|
530
|
+
res.status(500).json({ error: 'Failed to retrieve commit details' });
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// Read raw file content
|
|
535
|
+
app.get('/api/drives/:mountpoint/repos/:repoName/blob', (req, res) => {
|
|
536
|
+
try {
|
|
537
|
+
const mountpoint = decodeURIComponent(req.params.mountpoint);
|
|
538
|
+
const repoName = decodeURIComponent(req.params.repoName);
|
|
539
|
+
const branch = req.query.branch || 'main';
|
|
540
|
+
const filePath = req.query.path as string;
|
|
541
|
+
|
|
542
|
+
const gitDrivePath = getGitDrivePath(mountpoint);
|
|
543
|
+
const bareRepoName = repoName.endsWith('.git') ? repoName : `${repoName}.git`;
|
|
544
|
+
let repoPath = path.join(gitDrivePath, bareRepoName);
|
|
545
|
+
|
|
546
|
+
if (!existsSync(repoPath)) {
|
|
547
|
+
repoPath = path.join(gitDrivePath, repoName);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const content = git(`show ${branch}:${filePath}`, repoPath);
|
|
551
|
+
res.send(content);
|
|
552
|
+
} catch (err) {
|
|
553
|
+
res.status(500).json({ error: 'Failed to read file' });
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// SPA fallback - serve index.html for all non-API routes
|
|
558
|
+
app.get('*', (_req, res) => {
|
|
559
|
+
const indexPath = path.join(__dirname, '../../../packages/ui/dist', 'index.html');
|
|
560
|
+
if (existsSync(indexPath)) {
|
|
561
|
+
res.sendFile(indexPath);
|
|
562
|
+
} else {
|
|
563
|
+
res.status(404).send('UI not built. Run: pnpm build:ui');
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
app.listen(port, () => {
|
|
568
|
+
console.log(`\n 🚀 Git Drive is running at http://localhost:${port}\n`);
|
|
569
|
+
});
|