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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/resulgit.js +253 -76
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resulgit",
3
- "version": "1.0.20",
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
@@ -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
- const email = opts.email
106
- const password = opts.password
107
- if (!email || !password) throw new Error('Missing --email and --password')
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
- print({ server, email, password, url }, opts.json === 'true')
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
- const username = opts.username
119
- const email = opts.email
120
- const password = opts.password
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
- const entries = await fs.promises.readdir(cur, { withFileTypes: true })
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
- const buf = await fs.promises.readFile(abs)
474
- 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 */ }
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('Conflicts in files:\n', 'yellow'))
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
- const s = await fs.promises.readFile(localPath, 'utf8')
781
- localMeta = JSON.parse(s)
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 files = {}
785
- for (const [p, v] of Object.entries(local)) files[p] = v.content
786
- localMeta.pendingCommit = { message, files, createdAt: Date.now() }
787
- // Clear conflicts if they were resolved
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('Conflicts in files:\n', 'yellow'))
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
- const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
1272
- const base = localMeta.baseFiles || {}
1414
+
1415
+ // Collect local files hash-only (no content in RAM)
1273
1416
  const local = await collectLocal(dir)
1274
- const conflicts = []
1275
- const merged = {}
1276
- const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
1277
- for (const p of paths) {
1278
- const b = p in base ? base[p] : null
1279
- const r = p in remote.files ? remote.files[p] : null
1280
- const l = p in local ? local[p].content : null
1281
- const changedLocal = String(l) !== String(b)
1282
- const changedRemote = String(r) !== String(b)
1283
- if (changedLocal && changedRemote && String(l) !== String(r)) {
1284
- const line = firstDiffLine(l || '', r || '')
1285
- conflicts.push({ path: p, line })
1286
- } else if (changedLocal && !changedRemote) {
1287
- if (l !== null) merged[p] = l
1288
- } else if (!changedLocal && changedRemote) {
1289
- if (r !== null) merged[p] = r
1290
- } else {
1291
- if (b !== null) merged[p] = b
1292
- }
1293
- }
1294
- if (conflicts.length > 0) {
1295
- if (opts.json === 'true') {
1296
- print({ conflicts }, true)
1297
- } else {
1298
- process.stderr.write(color('Error: Cannot push with conflicts\n', 'red'))
1299
- process.stderr.write(color('Conflicts detected:\n', 'yellow'))
1300
- for (const c of conflicts) {
1301
- process.stderr.write(color(` ${c.path}:${c.line}\n`, 'red'))
1302
- }
1303
- }
1304
- return
1305
- }
1306
- const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
1307
- const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
1308
- const data = await request('POST', url, body, token)
1309
- localMeta.baseCommitId = data.id || remote.commitId || ''
1310
- 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
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