resulgit 1.0.20 → 1.0.22
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/package.json +1 -1
- package/resulgit.js +253 -76
package/package.json
CHANGED
package/resulgit.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs')
|
|
|
3
3
|
const path = require('path')
|
|
4
4
|
const os = require('os')
|
|
5
5
|
const crypto = require('crypto')
|
|
6
|
+
const readline = require('readline')
|
|
6
7
|
const COLORS = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }
|
|
7
8
|
function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
|
|
8
9
|
|
|
@@ -85,6 +86,44 @@ function print(obj, json) {
|
|
|
85
86
|
process.stdout.write(Object.entries(obj).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join('\n') + '\n')
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
async function prompt(msg, mask = false) {
|
|
90
|
+
if (mask) {
|
|
91
|
+
process.stdout.write(msg)
|
|
92
|
+
process.stdin.setRawMode(true)
|
|
93
|
+
process.stdin.resume()
|
|
94
|
+
return new Promise(resolve => {
|
|
95
|
+
let pw = ''
|
|
96
|
+
const onData = buf => {
|
|
97
|
+
const s = buf.toString('utf8')
|
|
98
|
+
if (s === '\r' || s === '\n') {
|
|
99
|
+
process.stdin.setRawMode(false)
|
|
100
|
+
process.stdin.pause()
|
|
101
|
+
process.stdin.removeListener('data', onData)
|
|
102
|
+
process.stdout.write('\n')
|
|
103
|
+
resolve(pw)
|
|
104
|
+
} else if (s === '\u0003') { // Ctrl-C
|
|
105
|
+
process.stdin.setRawMode(false)
|
|
106
|
+
process.stdin.pause()
|
|
107
|
+
process.stdout.write('\n')
|
|
108
|
+
process.exit(0)
|
|
109
|
+
} else if (s === '\u007f' || s === '\b' || s === '\x08') { // Backspace
|
|
110
|
+
if (pw.length > 0) {
|
|
111
|
+
pw = pw.slice(0, -1)
|
|
112
|
+
process.stdout.write('\b \b')
|
|
113
|
+
}
|
|
114
|
+
} else if (s.length === 1 && s.charCodeAt(0) >= 32) {
|
|
115
|
+
pw += s
|
|
116
|
+
process.stdout.write('*')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
process.stdin.on('data', onData)
|
|
120
|
+
})
|
|
121
|
+
} else {
|
|
122
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
123
|
+
return new Promise(resolve => rl.question(msg, line => { rl.close(); resolve(line) }))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
88
127
|
async function cmdAuth(sub, opts) {
|
|
89
128
|
if (sub === 'set-token') {
|
|
90
129
|
const token = opts.token || ''
|
|
@@ -102,11 +141,22 @@ async function cmdAuth(sub, opts) {
|
|
|
102
141
|
}
|
|
103
142
|
if (sub === 'login') {
|
|
104
143
|
const server = opts.server || loadConfig().server
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
144
|
+
let email = opts.email
|
|
145
|
+
let password = opts.password
|
|
146
|
+
|
|
147
|
+
if (!email) {
|
|
148
|
+
email = await prompt('Email: ')
|
|
149
|
+
}
|
|
150
|
+
if (!password) {
|
|
151
|
+
password = await prompt('Password: ', true)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!email || !password) throw new Error('Email and password required')
|
|
155
|
+
|
|
108
156
|
const url = new URL('/api/auth/login', server).toString()
|
|
109
|
-
|
|
157
|
+
const maskedPassword = password.replace(/./g, '*')
|
|
158
|
+
print({ server, email, password: maskedPassword, url }, opts.json === 'true')
|
|
159
|
+
|
|
110
160
|
const res = await request('POST', url, { email, password }, '')
|
|
111
161
|
const token = res.token || ''
|
|
112
162
|
if (token) saveConfig({ token })
|
|
@@ -115,12 +165,21 @@ async function cmdAuth(sub, opts) {
|
|
|
115
165
|
}
|
|
116
166
|
if (sub === 'register') {
|
|
117
167
|
const server = opts.server || loadConfig().server
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
168
|
+
let username = opts.username
|
|
169
|
+
let email = opts.email
|
|
170
|
+
let password = opts.password
|
|
121
171
|
const displayName = opts.displayName || username
|
|
172
|
+
|
|
173
|
+
if (!username) username = await prompt('Username: ')
|
|
174
|
+
if (!email) email = await prompt('Email: ')
|
|
175
|
+
if (!password) password = await prompt('Password: ', true)
|
|
176
|
+
|
|
122
177
|
if (!username || !email || !password) throw new Error('Missing --username --email --password')
|
|
178
|
+
|
|
123
179
|
const url = new URL('/api/auth/register', server).toString()
|
|
180
|
+
const maskedPassword = password.replace(/./g, '*')
|
|
181
|
+
print({ server, username, email, password: maskedPassword, url }, opts.json === 'true')
|
|
182
|
+
|
|
124
183
|
const res = await request('POST', url, { username, email, password, displayName }, '')
|
|
125
184
|
const token = res.token || ''
|
|
126
185
|
if (token) saveConfig({ token })
|
|
@@ -457,12 +516,28 @@ function shouldIgnore(p, patterns) {
|
|
|
457
516
|
return false
|
|
458
517
|
}
|
|
459
518
|
|
|
519
|
+
// Stream-hash a file of any size without loading it into RAM.
|
|
520
|
+
// Uses SHA-1 (same as VS Code extension and server).
|
|
521
|
+
function streamHashFile(absPath) {
|
|
522
|
+
return new Promise((resolve, reject) => {
|
|
523
|
+
const h = crypto.createHash('sha1')
|
|
524
|
+
const s = require('fs').createReadStream(absPath, { highWaterMark: 4 * 1024 * 1024 })
|
|
525
|
+
s.on('data', chunk => h.update(chunk))
|
|
526
|
+
s.on('end', () => resolve(h.digest('hex')))
|
|
527
|
+
s.on('error', reject)
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// collectLocal — stores ONLY the SHA-1 hash per file.
|
|
532
|
+
// Content is never kept in memory so large repos (5GB+) don't cause OOM.
|
|
460
533
|
async function collectLocal(dir) {
|
|
461
534
|
const out = {}
|
|
462
535
|
const base = path.resolve(dir)
|
|
463
536
|
const patterns = loadIgnorePatterns(base)
|
|
537
|
+
const STREAM_THRESHOLD = 50 * 1024 * 1024 // 50 MB
|
|
464
538
|
async function walk(cur, rel) {
|
|
465
|
-
|
|
539
|
+
let entries
|
|
540
|
+
try { entries = await fs.promises.readdir(cur, { withFileTypes: true }) } catch { return }
|
|
466
541
|
for (const e of entries) {
|
|
467
542
|
const rp = rel ? rel + '/' + e.name : e.name
|
|
468
543
|
if (shouldIgnore(rp, patterns)) continue
|
|
@@ -470,8 +545,17 @@ async function collectLocal(dir) {
|
|
|
470
545
|
if (e.isDirectory()) {
|
|
471
546
|
await walk(abs, rp)
|
|
472
547
|
} else if (e.isFile()) {
|
|
473
|
-
|
|
474
|
-
|
|
548
|
+
try {
|
|
549
|
+
const stat = await fs.promises.stat(abs)
|
|
550
|
+
let id
|
|
551
|
+
if (stat.size > STREAM_THRESHOLD) {
|
|
552
|
+
id = await streamHashFile(abs) // stream-hash large files
|
|
553
|
+
} else {
|
|
554
|
+
const buf = await fs.promises.readFile(abs)
|
|
555
|
+
id = hashContent(buf)
|
|
556
|
+
}
|
|
557
|
+
out[rp] = { id } // hash only — no content stored
|
|
558
|
+
} catch { /* skip unreadable */ }
|
|
475
559
|
}
|
|
476
560
|
}
|
|
477
561
|
}
|
|
@@ -757,18 +841,13 @@ async function cmdCommit(opts) {
|
|
|
757
841
|
const message = opts.message || ''
|
|
758
842
|
if (!message) throw new Error('Missing --message')
|
|
759
843
|
|
|
760
|
-
// Check for unresolved conflicts
|
|
761
844
|
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
762
845
|
if (unresolvedConflicts.length > 0) {
|
|
763
846
|
if (opts.json === 'true') {
|
|
764
847
|
print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
765
848
|
} else {
|
|
766
849
|
process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
|
|
767
|
-
process.stderr.write(color(
|
|
768
|
-
for (const p of unresolvedConflicts) {
|
|
769
|
-
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
770
|
-
}
|
|
771
|
-
process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
|
|
850
|
+
for (const p of unresolvedConflicts) process.stderr.write(color(` ${p}\n`, 'red'))
|
|
772
851
|
}
|
|
773
852
|
return
|
|
774
853
|
}
|
|
@@ -776,18 +855,15 @@ async function cmdCommit(opts) {
|
|
|
776
855
|
const metaDir = path.join(dir, '.vcs-next')
|
|
777
856
|
const localPath = path.join(metaDir, 'local.json')
|
|
778
857
|
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
779
|
-
try {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
} catch { }
|
|
858
|
+
try { const s = await fs.promises.readFile(localPath, 'utf8'); localMeta = JSON.parse(s) } catch { }
|
|
859
|
+
|
|
860
|
+
// Collect only hashes — never full content — so local.json stays tiny
|
|
783
861
|
const local = await collectLocal(dir)
|
|
784
|
-
const
|
|
785
|
-
for (const [p, v] of Object.entries(local))
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
if (localMeta.conflicts)
|
|
789
|
-
delete localMeta.conflicts
|
|
790
|
-
}
|
|
862
|
+
const fileHashes = {}
|
|
863
|
+
for (const [p, v] of Object.entries(local)) fileHashes[p] = v.id
|
|
864
|
+
|
|
865
|
+
localMeta.pendingCommit = { message, files: fileHashes, createdAt: Date.now() }
|
|
866
|
+
if (localMeta.conflicts) delete localMeta.conflicts
|
|
791
867
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
792
868
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
793
869
|
print({ pendingCommit: message }, opts.json === 'true')
|
|
@@ -1238,29 +1314,96 @@ async function checkForUnresolvedConflicts(dir) {
|
|
|
1238
1314
|
return conflicts
|
|
1239
1315
|
}
|
|
1240
1316
|
|
|
1317
|
+
// Upload a single large file (>50MB) via streaming multipart — no RAM usage.
|
|
1318
|
+
async function uploadLargeFile(server, repoId, relPath, absPath, token) {
|
|
1319
|
+
const http = require('http')
|
|
1320
|
+
const https = require('https')
|
|
1321
|
+
const stat = await fs.promises.stat(absPath)
|
|
1322
|
+
const fileSize = stat.size
|
|
1323
|
+
const boundary = `----resulgit-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
1324
|
+
const eol = '\r\n'
|
|
1325
|
+
const safeFilename = relPath.replace(/"/g, '\\"')
|
|
1326
|
+
const headerBuf = Buffer.from(
|
|
1327
|
+
`--${boundary}${eol}` +
|
|
1328
|
+
`Content-Disposition: form-data; name="files"; filename="${safeFilename}"${eol}` +
|
|
1329
|
+
`Content-Type: application/octet-stream${eol}${eol}`, 'utf8'
|
|
1330
|
+
)
|
|
1331
|
+
const footerBuf = Buffer.from(`${eol}--${boundary}--${eol}`, 'utf8')
|
|
1332
|
+
const contentLength = headerBuf.length + fileSize + footerBuf.length
|
|
1333
|
+
const timeoutMs = Math.max(300000, Math.min(contentLength * 2, 12 * 3600000))
|
|
1334
|
+
return new Promise((resolve, reject) => {
|
|
1335
|
+
let parsed; try { parsed = new URL(`${server}/api/repositories/${repoId}/blobs`) } catch (e) { reject(e); return }
|
|
1336
|
+
const isHttps = parsed.protocol === 'https:'
|
|
1337
|
+
const lib = isHttps ? https : http
|
|
1338
|
+
const req = lib.request({
|
|
1339
|
+
hostname: parsed.hostname,
|
|
1340
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
1341
|
+
path: parsed.pathname,
|
|
1342
|
+
method: 'POST',
|
|
1343
|
+
timeout: timeoutMs,
|
|
1344
|
+
headers: {
|
|
1345
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
1346
|
+
'Content-Length': String(contentLength),
|
|
1347
|
+
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
|
1348
|
+
}
|
|
1349
|
+
}, (res) => {
|
|
1350
|
+
let data = ''
|
|
1351
|
+
res.on('data', c => { data += c })
|
|
1352
|
+
res.on('end', () => {
|
|
1353
|
+
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
1354
|
+
reject(new Error(`Large-file upload HTTP ${res.statusCode} for ${relPath}: ${data.slice(0, 200)}`)); return
|
|
1355
|
+
}
|
|
1356
|
+
try {
|
|
1357
|
+
const parsed = JSON.parse(data)
|
|
1358
|
+
const blobId = (parsed.blobs || {})[relPath]
|
|
1359
|
+
if (!blobId) { reject(new Error(`No blob ID returned for ${relPath}`)); return }
|
|
1360
|
+
resolve(blobId)
|
|
1361
|
+
} catch { reject(new Error('Invalid JSON from blob server')) }
|
|
1362
|
+
})
|
|
1363
|
+
})
|
|
1364
|
+
req.on('error', e => reject(e))
|
|
1365
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`Upload timed out for ${relPath}`)) })
|
|
1366
|
+
req.write(headerBuf)
|
|
1367
|
+
const stream = require('fs').createReadStream(absPath, { highWaterMark: 1024 * 1024 })
|
|
1368
|
+
stream.on('data', chunk => req.write(chunk))
|
|
1369
|
+
stream.on('end', () => { req.write(footerBuf); req.end() })
|
|
1370
|
+
stream.on('error', e => { req.destroy(); reject(e) })
|
|
1371
|
+
})
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// Upload a batch of small files (<= 50MB each) as JSON.
|
|
1375
|
+
async function uploadSmallBatch(server, repoId, entries, token) {
|
|
1376
|
+
// entries: Array<[relPath, absPath]>
|
|
1377
|
+
const payload = {}
|
|
1378
|
+
for (const [relPath, absPath] of entries) {
|
|
1379
|
+
const buf = await fs.promises.readFile(absPath)
|
|
1380
|
+
const text = buf.toString('utf8')
|
|
1381
|
+
const isBinary = text.includes('\uFFFD') || buf.includes(0)
|
|
1382
|
+
payload[relPath] = isBinary ? `__base64__${buf.toString('base64')}` : text
|
|
1383
|
+
}
|
|
1384
|
+
const url = new URL(`/api/repositories/${repoId}/blobs`, server).toString()
|
|
1385
|
+
const headers = { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }
|
|
1386
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) })
|
|
1387
|
+
if (!res.ok) { const t = await res.text(); throw new Error(`Blob batch upload failed: ${res.status} ${t.slice(0, 200)}`) }
|
|
1388
|
+
const data = await res.json()
|
|
1389
|
+
return data.blobs || {}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1241
1392
|
async function cmdPush(opts) {
|
|
1242
1393
|
const dir = path.resolve(opts.dir || '.')
|
|
1243
1394
|
const remoteMeta = readRemoteMeta(dir)
|
|
1244
1395
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1245
1396
|
const localPath = path.join(metaDir, 'local.json')
|
|
1246
1397
|
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
1247
|
-
try {
|
|
1248
|
-
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1249
|
-
localMeta = JSON.parse(s)
|
|
1250
|
-
} catch { }
|
|
1398
|
+
try { const s = await fs.promises.readFile(localPath, 'utf8'); localMeta = JSON.parse(s) } catch { }
|
|
1251
1399
|
|
|
1252
|
-
// Check for unresolved conflicts in files
|
|
1253
1400
|
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
1254
1401
|
if (unresolvedConflicts.length > 0) {
|
|
1255
1402
|
if (opts.json === 'true') {
|
|
1256
1403
|
print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
1257
1404
|
} else {
|
|
1258
1405
|
process.stderr.write(color('Error: Cannot push with unresolved conflicts\n', 'red'))
|
|
1259
|
-
process.stderr.write(color(
|
|
1260
|
-
for (const p of unresolvedConflicts) {
|
|
1261
|
-
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
1262
|
-
}
|
|
1263
|
-
process.stderr.write(color('\nResolve conflicts manually, then try pushing again.\n', 'yellow'))
|
|
1406
|
+
for (const p of unresolvedConflicts) process.stderr.write(color(` ${p}\n`, 'red'))
|
|
1264
1407
|
}
|
|
1265
1408
|
return
|
|
1266
1409
|
}
|
|
@@ -1268,48 +1411,82 @@ async function cmdPush(opts) {
|
|
|
1268
1411
|
const cfg = loadConfig()
|
|
1269
1412
|
const server = getServer(opts, cfg) || remoteMeta.server
|
|
1270
1413
|
const token = getToken(opts, cfg) || remoteMeta.token
|
|
1271
|
-
|
|
1272
|
-
|
|
1414
|
+
|
|
1415
|
+
// Collect local files — hash-only (no content in RAM)
|
|
1273
1416
|
const local = await collectLocal(dir)
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1417
|
+
const base = localMeta.baseFiles || {}
|
|
1418
|
+
|
|
1419
|
+
// Determine which files changed vs base (use hash comparison)
|
|
1420
|
+
const upserts = [] // [relPath] files to upload
|
|
1421
|
+
const deletions = [] // [relPath] files deleted locally
|
|
1422
|
+
const LARGE = 50 * 1024 * 1024
|
|
1423
|
+
|
|
1424
|
+
const allPaths = new Set([...Object.keys(base), ...Object.keys(local)])
|
|
1425
|
+
for (const p of allPaths) {
|
|
1426
|
+
const inLocal = p in local
|
|
1427
|
+
const inBase = p in base
|
|
1428
|
+
if (!inLocal) {
|
|
1429
|
+
deletions.push(p)
|
|
1430
|
+
} else if (!inBase || local[p].id !== base[p]) {
|
|
1431
|
+
upserts.push(p)
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Upload blobs (stream large, batch small)
|
|
1436
|
+
const finalFiles = {} // relPath -> blobId
|
|
1437
|
+
const BATCH = 200
|
|
1438
|
+
const largeFiles = []
|
|
1439
|
+
const smallFiles = []
|
|
1440
|
+
for (const relPath of upserts) {
|
|
1441
|
+
const absPath = path.join(dir, relPath)
|
|
1442
|
+
let size = 0
|
|
1443
|
+
try { size = (await fs.promises.stat(absPath)).size } catch { continue }
|
|
1444
|
+
if (size > LARGE) largeFiles.push(relPath)
|
|
1445
|
+
else smallFiles.push(relPath)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Stream large files one at a time
|
|
1449
|
+
for (let i = 0; i < largeFiles.length; i++) {
|
|
1450
|
+
const relPath = largeFiles[i]
|
|
1451
|
+
const absPath = path.join(dir, relPath)
|
|
1452
|
+
const sizeMB = Math.round((await fs.promises.stat(absPath)).size / 1024 / 1024)
|
|
1453
|
+
process.stderr.write(color(` Streaming [${i + 1}/${largeFiles.length}] ${relPath} (${sizeMB} MB)...\n`, 'cyan'))
|
|
1454
|
+
const blobId = await uploadLargeFile(server, remoteMeta.repoId, relPath, absPath, token)
|
|
1455
|
+
finalFiles[relPath] = blobId
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// Batch upload small files
|
|
1459
|
+
const totalBatches = Math.ceil(smallFiles.length / BATCH)
|
|
1460
|
+
for (let i = 0; i < smallFiles.length; i += BATCH) {
|
|
1461
|
+
const slice = smallFiles.slice(i, i + BATCH)
|
|
1462
|
+
const batchNum = Math.floor(i / BATCH) + 1
|
|
1463
|
+
process.stderr.write(color(` Uploading files ${Math.min(i + BATCH, smallFiles.length)}/${smallFiles.length} (batch ${batchNum}/${totalBatches})...\n`, 'cyan'))
|
|
1464
|
+
const entries = slice.map(p => [p, path.join(dir, p)])
|
|
1465
|
+
const blobIds = await uploadSmallBatch(server, remoteMeta.repoId, entries, token)
|
|
1466
|
+
for (const [p, id] of Object.entries(blobIds)) finalFiles[p] = id
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Add deletions
|
|
1470
|
+
for (const p of deletions) finalFiles[p] = null
|
|
1471
|
+
|
|
1472
|
+
// Push commit
|
|
1473
|
+
const message = localMeta.pendingCommit?.message || opts.message || 'Push'
|
|
1474
|
+
const commitUrl = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
|
|
1475
|
+
const commitHeaders = { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }
|
|
1476
|
+
const body = { message, files: finalFiles, branchName: remoteMeta.branch, parentCommitId: localMeta.baseCommitId || undefined }
|
|
1477
|
+
const res = await fetch(commitUrl, { method: 'POST', headers: commitHeaders, body: JSON.stringify(body) })
|
|
1478
|
+
if (!res.ok) { const t = await res.text(); throw new Error(`Push failed: ${res.status} ${t.slice(0, 300)}`) }
|
|
1479
|
+
const data = await res.json()
|
|
1480
|
+
|
|
1481
|
+
// Update local metadata — store hashes, not content
|
|
1482
|
+
const newBase = { ...base }
|
|
1483
|
+
for (const p of upserts) { if (local[p]) newBase[p] = local[p].id }
|
|
1484
|
+
for (const p of deletions) { delete newBase[p] }
|
|
1485
|
+
localMeta.baseCommitId = data.id || data.commitId || ''
|
|
1486
|
+
localMeta.baseFiles = newBase
|
|
1311
1487
|
localMeta.pendingCommit = null
|
|
1312
1488
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1489
|
+
process.stderr.write(color(`Push complete: ${upserts.length} file(s) uploaded, ${deletions.length} deleted\n`, 'green'))
|
|
1313
1490
|
print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
|
|
1314
1491
|
}
|
|
1315
1492
|
|