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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/resulgit.js +187 -69
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resulgit",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "description": "A powerful CLI tool for version control system operations - clone, commit, push, pull, merge, branch management, and more",
5
5
  "main": "resulgit.js",
6
6
  "bin": {
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
- const entries = await fs.promises.readdir(cur, { withFileTypes: true })
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
- const buf = await fs.promises.readFile(abs)
533
- out[rp] = { content: buf.toString('utf8'), id: hashContent(buf) }
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('Conflicts in files:\n', 'yellow'))
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
- const s = await fs.promises.readFile(localPath, 'utf8')
840
- localMeta = JSON.parse(s)
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 files = {}
844
- for (const [p, v] of Object.entries(local)) files[p] = v.content
845
- localMeta.pendingCommit = { message, files, createdAt: Date.now() }
846
- // Clear conflicts if they were resolved
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('Conflicts in files:\n', 'yellow'))
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
- const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
1331
- const base = localMeta.baseFiles || {}
1414
+
1415
+ // Collect local files hash-only (no content in RAM)
1332
1416
  const local = await collectLocal(dir)
1333
- const conflicts = []
1334
- const merged = {}
1335
- const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
1336
- for (const p of paths) {
1337
- const b = p in base ? base[p] : null
1338
- const r = p in remote.files ? remote.files[p] : null
1339
- const l = p in local ? local[p].content : null
1340
- const changedLocal = String(l) !== String(b)
1341
- const changedRemote = String(r) !== String(b)
1342
- if (changedLocal && changedRemote && String(l) !== String(r)) {
1343
- const line = firstDiffLine(l || '', r || '')
1344
- conflicts.push({ path: p, line })
1345
- } else if (changedLocal && !changedRemote) {
1346
- if (l !== null) merged[p] = l
1347
- } else if (!changedLocal && changedRemote) {
1348
- if (r !== null) merged[p] = r
1349
- } else {
1350
- if (b !== null) merged[p] = b
1351
- }
1352
- }
1353
- if (conflicts.length > 0) {
1354
- if (opts.json === 'true') {
1355
- print({ conflicts }, true)
1356
- } else {
1357
- process.stderr.write(color('Error: Cannot push with conflicts\n', 'red'))
1358
- process.stderr.write(color('Conflicts detected:\n', 'yellow'))
1359
- for (const c of conflicts) {
1360
- process.stderr.write(color(` ${c.path}:${c.line}\n`, 'red'))
1361
- }
1362
- }
1363
- return
1364
- }
1365
- const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
1366
- const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
1367
- const data = await request('POST', url, body, token)
1368
- localMeta.baseCommitId = data.id || remote.commitId || ''
1369
- localMeta.baseFiles = merged
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