resulgit 1.0.21 → 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 +187 -69
package/package.json
CHANGED
package/resulgit.js
CHANGED
|
@@ -516,12 +516,28 @@ function shouldIgnore(p, patterns) {
|
|
|
516
516
|
return false
|
|
517
517
|
}
|
|
518
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.
|
|
519
533
|
async function collectLocal(dir) {
|
|
520
534
|
const out = {}
|
|
521
535
|
const base = path.resolve(dir)
|
|
522
536
|
const patterns = loadIgnorePatterns(base)
|
|
537
|
+
const STREAM_THRESHOLD = 50 * 1024 * 1024 // 50 MB
|
|
523
538
|
async function walk(cur, rel) {
|
|
524
|
-
|
|
539
|
+
let entries
|
|
540
|
+
try { entries = await fs.promises.readdir(cur, { withFileTypes: true }) } catch { return }
|
|
525
541
|
for (const e of entries) {
|
|
526
542
|
const rp = rel ? rel + '/' + e.name : e.name
|
|
527
543
|
if (shouldIgnore(rp, patterns)) continue
|
|
@@ -529,8 +545,17 @@ async function collectLocal(dir) {
|
|
|
529
545
|
if (e.isDirectory()) {
|
|
530
546
|
await walk(abs, rp)
|
|
531
547
|
} else if (e.isFile()) {
|
|
532
|
-
|
|
533
|
-
|
|
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 */ }
|
|
534
559
|
}
|
|
535
560
|
}
|
|
536
561
|
}
|
|
@@ -816,18 +841,13 @@ async function cmdCommit(opts) {
|
|
|
816
841
|
const message = opts.message || ''
|
|
817
842
|
if (!message) throw new Error('Missing --message')
|
|
818
843
|
|
|
819
|
-
// Check for unresolved conflicts
|
|
820
844
|
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
821
845
|
if (unresolvedConflicts.length > 0) {
|
|
822
846
|
if (opts.json === 'true') {
|
|
823
847
|
print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
824
848
|
} else {
|
|
825
849
|
process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
|
|
826
|
-
process.stderr.write(color(
|
|
827
|
-
for (const p of unresolvedConflicts) {
|
|
828
|
-
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
829
|
-
}
|
|
830
|
-
process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
|
|
850
|
+
for (const p of unresolvedConflicts) process.stderr.write(color(` ${p}\n`, 'red'))
|
|
831
851
|
}
|
|
832
852
|
return
|
|
833
853
|
}
|
|
@@ -835,18 +855,15 @@ async function cmdCommit(opts) {
|
|
|
835
855
|
const metaDir = path.join(dir, '.vcs-next')
|
|
836
856
|
const localPath = path.join(metaDir, 'local.json')
|
|
837
857
|
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
838
|
-
try {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
} 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
|
|
842
861
|
const local = await collectLocal(dir)
|
|
843
|
-
const
|
|
844
|
-
for (const [p, v] of Object.entries(local))
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if (localMeta.conflicts)
|
|
848
|
-
delete localMeta.conflicts
|
|
849
|
-
}
|
|
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
|
|
850
867
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
851
868
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
852
869
|
print({ pendingCommit: message }, opts.json === 'true')
|
|
@@ -1297,29 +1314,96 @@ async function checkForUnresolvedConflicts(dir) {
|
|
|
1297
1314
|
return conflicts
|
|
1298
1315
|
}
|
|
1299
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
|
+
|
|
1300
1392
|
async function cmdPush(opts) {
|
|
1301
1393
|
const dir = path.resolve(opts.dir || '.')
|
|
1302
1394
|
const remoteMeta = readRemoteMeta(dir)
|
|
1303
1395
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1304
1396
|
const localPath = path.join(metaDir, 'local.json')
|
|
1305
1397
|
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
1306
|
-
try {
|
|
1307
|
-
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1308
|
-
localMeta = JSON.parse(s)
|
|
1309
|
-
} catch { }
|
|
1398
|
+
try { const s = await fs.promises.readFile(localPath, 'utf8'); localMeta = JSON.parse(s) } catch { }
|
|
1310
1399
|
|
|
1311
|
-
// Check for unresolved conflicts in files
|
|
1312
1400
|
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
1313
1401
|
if (unresolvedConflicts.length > 0) {
|
|
1314
1402
|
if (opts.json === 'true') {
|
|
1315
1403
|
print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
1316
1404
|
} else {
|
|
1317
1405
|
process.stderr.write(color('Error: Cannot push with unresolved conflicts\n', 'red'))
|
|
1318
|
-
process.stderr.write(color(
|
|
1319
|
-
for (const p of unresolvedConflicts) {
|
|
1320
|
-
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
1321
|
-
}
|
|
1322
|
-
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'))
|
|
1323
1407
|
}
|
|
1324
1408
|
return
|
|
1325
1409
|
}
|
|
@@ -1327,48 +1411,82 @@ async function cmdPush(opts) {
|
|
|
1327
1411
|
const cfg = loadConfig()
|
|
1328
1412
|
const server = getServer(opts, cfg) || remoteMeta.server
|
|
1329
1413
|
const token = getToken(opts, cfg) || remoteMeta.token
|
|
1330
|
-
|
|
1331
|
-
|
|
1414
|
+
|
|
1415
|
+
// Collect local files — hash-only (no content in RAM)
|
|
1332
1416
|
const local = await collectLocal(dir)
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
|
1370
1487
|
localMeta.pendingCommit = null
|
|
1371
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'))
|
|
1372
1490
|
print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
|
|
1373
1491
|
}
|
|
1374
1492
|
|