expo-tiddlywiki-filesystem-android-external-storage 2.5.0 → 2.7.0

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.
@@ -4,25 +4,20 @@ import org.eclipse.jgit.api.Git
4
4
  import org.eclipse.jgit.api.ResetCommand
5
5
  import org.eclipse.jgit.diff.DiffEntry
6
6
  import org.eclipse.jgit.diff.DiffFormatter
7
- import org.eclipse.jgit.internal.storage.pack.PackWriter
8
7
  import org.eclipse.jgit.lib.Constants
9
- import org.eclipse.jgit.lib.NullProgressMonitor
10
8
  import org.eclipse.jgit.lib.ObjectId
11
9
  import org.eclipse.jgit.lib.Repository
12
10
  import org.eclipse.jgit.revwalk.RevWalk
13
11
  import org.eclipse.jgit.storage.file.FileRepositoryBuilder
14
- import org.eclipse.jgit.transport.PacketLineIn
12
+ import org.eclipse.jgit.transport.RefSpec
15
13
  import org.eclipse.jgit.transport.TransportHttp
16
14
  import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider
17
15
  import org.eclipse.jgit.treewalk.TreeWalk
16
+ import org.eclipse.jgit.treewalk.filter.PathFilterGroup
18
17
  import org.json.JSONArray
19
18
  import org.json.JSONObject
20
- import java.io.ByteArrayInputStream
21
19
  import java.io.ByteArrayOutputStream
22
20
  import java.io.File
23
- import java.io.InputStream
24
- import java.net.HttpURLConnection
25
- import java.net.URL
26
21
  import android.util.Base64
27
22
 
28
23
  /**
@@ -49,567 +44,90 @@ internal object GitHelper {
49
44
  .build()
50
45
  }
51
46
 
52
- // ─── Raw HTTP helpers for Git Smart HTTP protocol ─────────────────
53
-
54
- /**
55
- * Parse headers JSON into a map.
56
- */
57
- private fun parseHeaders(headers: String?): Map<String, String> {
58
- if (headers == null) return emptyMap()
59
- return try {
60
- val obj = JSONObject(headers)
61
- val map = mutableMapOf<String, String>()
62
- for (key in obj.keys()) map[key] = obj.getString(key)
63
- map
64
- } catch (e: Exception) {
65
- emptyMap()
66
- }
67
- }
68
-
69
- /**
70
- * Open an HTTP connection with custom headers applied.
71
- */
72
- private fun openHttpConnection(url: String, method: String, headers: Map<String, String>, contentType: String? = null): HttpURLConnection {
73
- val conn = URL(url).openConnection() as HttpURLConnection
74
- conn.requestMethod = method
75
- conn.connectTimeout = 30_000
76
- conn.readTimeout = 120_000
77
- conn.instanceFollowRedirects = true
78
- for ((k, v) in headers) conn.setRequestProperty(k, v)
79
- if (contentType != null) conn.setRequestProperty("Content-Type", contentType)
80
- return conn
81
- }
82
-
83
- /**
84
- * Parse pkt-line formatted /info/refs response into a map of ref→oid.
85
- * Also returns the set of capabilities advertised by the server.
86
- */
87
- private data class ServerRefs(
88
- val refs: Map<String, String>,
89
- val capabilities: Set<String>
90
- )
91
-
92
- private fun parseInfoRefs(input: InputStream, service: String): ServerRefs {
93
- val pktIn = PacketLineIn(input)
94
- val refs = mutableMapOf<String, String>()
95
- val capabilities = mutableSetOf<String>()
96
-
97
- // First: skip service announcement line(s) and flush
98
- // Format: "# service=git-upload-pack\n" + "0000"
99
- // The server may also send the line as a pkt-line.
100
- var firstRef = true
101
- while (true) {
102
- val line = pktIn.readString()
103
- if (PacketLineIn.isEnd(line)) break // flush packet after service announcement
104
- // Some servers include "# service=..." in pkt-line format
105
- if (line.startsWith("# ")) continue
106
- // If we got here without a flush, it's the first ref line
107
- // (shouldn't happen with standard servers)
108
- }
109
-
110
- // Now read refs
111
- while (true) {
112
- val line = pktIn.readString()
113
- if (PacketLineIn.isEnd(line)) break
114
- if (line.isEmpty()) continue
115
-
116
- val parts = line.split(" ", limit = 2)
117
- if (parts.size < 2) continue
118
- val oid = parts[0]
119
- val refWithCaps = parts[1]
120
-
121
- if (firstRef && refWithCaps.contains('\u0000')) {
122
- // First ref line contains capabilities after NUL
123
- val refParts = refWithCaps.split('\u0000', limit = 2)
124
- refs[refParts[0]] = oid
125
- if (refParts.size > 1) {
126
- capabilities.addAll(refParts[1].trim().split(" "))
127
- }
128
- firstRef = false
129
- } else {
130
- refs[refWithCaps.trim()] = oid
131
- firstRef = false
132
- }
133
- }
134
-
135
- return ServerRefs(refs, capabilities)
136
- }
137
-
138
- // ─── Git push via raw HTTP ──────────────────────────────────────
139
-
140
- /**
141
- * Push local branch to remote using raw Git Smart HTTP protocol.
142
- *
143
- * This bypasses JGit's TransportHttp (which has issues with some servers)
144
- * and directly implements the stateless-rpc push protocol:
145
- *
146
- * 1. GET /info/refs?service=git-receive-pack → parse server refs + capabilities
147
- * 2. Build pack data containing objects the server is missing
148
- * 3. POST /git-receive-pack with ref-update command + pack data
149
- * 4. Parse server response for success/failure
150
- */
151
- fun gitPush(
152
- gitRootDir: String,
153
- remoteName: String,
154
- localBranch: String,
155
- remoteBranch: String,
156
- force: Boolean,
47
+ /** Apply custom HTTP headers and credentials to a JGit transport command. */
48
+ private fun applyHeaders(
49
+ command: org.eclipse.jgit.api.TransportCommand<*, *>,
157
50
  headers: String?
158
- ): String {
159
- val result = JSONObject()
160
- try {
161
- val repo = openRepo(gitRootDir)
51
+ ) {
52
+ // Parse custom headers JSON
53
+ val headerMap = mutableMapOf<String, String>()
54
+ if (headers != null) {
162
55
  try {
163
- val headerMap = parseHeaders(headers)
164
- val remoteUrl = repo.config.getString("remote", remoteName, "url")
165
- ?: throw Exception("Remote '$remoteName' not configured")
166
-
167
- // Resolve local branch to its SHA
168
- val localRef = repo.resolve("refs/heads/$localBranch")
169
- ?: throw Exception("Cannot resolve local branch: $localBranch")
170
-
171
- // Step 1: GET /info/refs?service=git-receive-pack
172
- val infoUrl = "$remoteUrl/info/refs?service=git-receive-pack"
173
- val infoConn = openHttpConnection(infoUrl, "GET", headerMap)
174
- infoConn.setRequestProperty("Accept", "application/x-git-receive-pack-advertisement, */*")
175
- val serverRefs: ServerRefs
176
- try {
177
- if (infoConn.responseCode != 200) {
178
- throw Exception("info/refs failed: ${infoConn.responseCode} ${infoConn.responseMessage}")
179
- }
180
- serverRefs = parseInfoRefs(infoConn.inputStream, "git-receive-pack")
181
- } finally {
182
- infoConn.disconnect()
56
+ val headerObj = JSONObject(headers)
57
+ for (key in headerObj.keys()) {
58
+ headerMap[key] = headerObj.getString(key)
183
59
  }
184
-
185
- // Check if push is needed
186
- val remoteOid = serverRefs.refs[remoteBranch] ?: ObjectId.zeroId().name
187
- if (remoteOid == localRef.name) {
188
- result.put("ok", true)
189
- result.put("updates", JSONArray().put(JSONObject().apply {
190
- put("remoteName", remoteBranch)
191
- put("status", "UP_TO_DATE")
192
- put("message", "")
193
- }))
194
- return result.toString()
195
- }
196
-
197
- // Step 2: Build the pack data
198
- val packBuf = ByteArrayOutputStream()
199
-
200
- // Write the ref-update command line in pkt-line format
201
- // Format: <old-oid> <new-oid> <ref-name>\0<capabilities>\n
202
- val caps = mutableListOf("report-status", "side-band-64k")
203
- if (serverRefs.capabilities.contains("ofs-delta")) caps.add("ofs-delta")
204
- val commandLine = "$remoteOid ${localRef.name} $remoteBranch\u0000${caps.joinToString(" ")}\n"
205
- writePktLine(packBuf, commandLine)
206
- // Flush packet (0000)
207
- packBuf.write("0000".toByteArray())
208
-
209
- // Build pack with objects missing on server
210
- val remoteObjectId = if (remoteOid == ObjectId.zeroId().name) null else ObjectId.fromString(remoteOid)
211
- val localObjectId = localRef
212
-
213
- val packData = ByteArrayOutputStream()
214
- val writer = PackWriter(repo)
215
- try {
216
- writer.setUseBitmaps(true)
217
- writer.setThin(true)
218
- writer.setDeltaBaseAsOffset(serverRefs.capabilities.contains("ofs-delta"))
219
-
220
- // Determine which objects to send
221
- val want = setOf(localObjectId)
222
- val have = if (remoteObjectId != null) setOf(remoteObjectId) else emptySet<ObjectId>()
223
- writer.preparePack(NullProgressMonitor.INSTANCE, want, have)
224
- writer.writePack(NullProgressMonitor.INSTANCE, NullProgressMonitor.INSTANCE, packData)
225
- } finally {
226
- writer.close()
227
- }
228
-
229
- packBuf.write(packData.toByteArray())
230
-
231
- // Step 3: POST /git-receive-pack
232
- val postUrl = "$remoteUrl/git-receive-pack"
233
- val postConn = openHttpConnection(postUrl, "POST", headerMap, "application/x-git-receive-pack-request")
234
- postConn.setRequestProperty("Accept", "application/x-git-receive-pack-result")
235
- postConn.doOutput = true
236
- val requestBody = packBuf.toByteArray()
237
- postConn.setFixedLengthStreamingMode(requestBody.size)
238
-
239
- try {
240
- postConn.outputStream.use { it.write(requestBody) }
241
-
242
- if (postConn.responseCode != 200) {
243
- throw Exception("git-receive-pack failed: ${postConn.responseCode} ${postConn.responseMessage}")
244
- }
245
-
246
- // Step 4: Parse response
247
- val responseBytes = postConn.inputStream.readBytes()
248
- val responseStr = tryParseReceivePackResponse(responseBytes)
249
-
250
- val updatesArray = JSONArray()
251
- val updateObj = JSONObject()
252
- updateObj.put("remoteName", remoteBranch)
253
- updateObj.put("status", if (responseStr.contains("unpack ok")) "OK" else "REJECTED")
254
- updateObj.put("message", responseStr)
255
- updatesArray.put(updateObj)
256
-
257
- if (!responseStr.contains("unpack ok")) {
258
- throw Exception("Push rejected by server: $responseStr")
259
- }
260
-
261
- result.put("ok", true)
262
- result.put("updates", updatesArray)
263
- android.util.Log.i("GitPush", "Raw HTTP push completed: ${localRef.name.take(8)} → $remoteBranch")
264
- } finally {
265
- postConn.disconnect()
266
- }
267
- } finally {
268
- repo.close()
60
+ } catch (e: Exception) {
61
+ android.util.Log.w("GitHelper", "Failed to parse headers: ${e.message}")
269
62
  }
270
- } catch (e: Exception) {
271
- result.put("ok", false)
272
- result.put("error", e.message ?: "Unknown push error")
273
- android.util.Log.e("GitPush", "Push failed: ${e.message}", e)
274
63
  }
275
- return result.toString()
276
- }
277
64
 
278
- private fun writePktLine(out: ByteArrayOutputStream, data: String) {
279
- val bytes = data.toByteArray()
280
- val length = bytes.size + 4
281
- val hex = String.format("%04x", length)
282
- out.write(hex.toByteArray())
283
- out.write(bytes)
284
- }
285
-
286
- /**
287
- * Try to parse receive-pack response, handling side-band encoding.
288
- */
289
- private fun tryParseReceivePackResponse(responseBytes: ByteArray): String {
290
- // The response may be plain pkt-line or side-band encoded.
291
- // Try to extract meaningful text.
292
- val text = StringBuilder()
293
- try {
294
- val pktIn = PacketLineIn(ByteArrayInputStream(responseBytes))
295
- while (true) {
296
- val line = pktIn.readString()
297
- if (PacketLineIn.isEnd(line)) break
298
- text.append(line).append("\n")
299
- }
300
- } catch (e: Exception) {
301
- // Fallback: just decode as UTF-8
302
- text.append(String(responseBytes, Charsets.UTF_8))
303
- }
304
- return text.toString().trim()
305
- }
306
-
307
- // ─── Git fetch via raw HTTP ─────────────────────────────────────
308
-
309
- /**
310
- * Fetch from remote using raw Git Smart HTTP protocol.
311
- *
312
- * This bypasses JGit's TransportHttp and directly implements:
313
- *
314
- * 1. GET /info/refs?service=git-upload-pack → parse server refs
315
- * 2. Build wants/haves negotiation message
316
- * 3. POST /git-upload-pack with wants/haves + done → receive pack data
317
- * 4. Parse and apply pack to local repo, update tracking refs
318
- */
319
- fun gitFetch(
320
- gitRootDir: String,
321
- remoteName: String,
322
- branch: String,
323
- headers: String?
324
- ): String {
325
- val result = JSONObject()
326
- try {
327
- val repo = openRepo(gitRootDir)
65
+ // Extract Basic Auth from Authorization header and set as CredentialsProvider.
66
+ val authHeader = headerMap["Authorization"] ?: headerMap["authorization"]
67
+ if (authHeader != null && authHeader.startsWith("Basic ", ignoreCase = true)) {
328
68
  try {
329
- val headerMap = parseHeaders(headers)
330
- val remoteUrl = repo.config.getString("remote", remoteName, "url")
331
- ?: throw Exception("Remote '$remoteName' not configured")
332
-
333
- // Step 1: GET /info/refs?service=git-upload-pack
334
- val infoUrl = "$remoteUrl/info/refs?service=git-upload-pack"
335
- val infoConn = openHttpConnection(infoUrl, "GET", headerMap)
336
- infoConn.setRequestProperty("Accept", "application/x-git-upload-pack-advertisement, */*")
337
- val serverRefs: ServerRefs
338
- try {
339
- if (infoConn.responseCode != 200) {
340
- throw Exception("info/refs failed: ${infoConn.responseCode} ${infoConn.responseMessage}")
341
- }
342
- serverRefs = parseInfoRefs(infoConn.inputStream, "git-upload-pack")
343
- } finally {
344
- infoConn.disconnect()
345
- }
346
-
347
- // Find the ref we want
348
- val remoteRef = "refs/heads/$branch"
349
- val wantOid = serverRefs.refs[remoteRef]
350
- if (wantOid == null) {
351
- // Branch doesn't exist on remote — nothing to fetch
352
- result.put("ok", true)
353
- result.put("updates", JSONArray())
354
- return result.toString()
355
- }
356
-
357
- // Check if we already have this object
358
- val wantObjectId = ObjectId.fromString(wantOid)
359
- val trackingRef = "refs/remotes/$remoteName/$branch"
360
- val localTrackingOid = repo.resolve(trackingRef)
361
-
362
- if (localTrackingOid != null && localTrackingOid == wantObjectId) {
363
- // Already up to date
364
- result.put("ok", true)
365
- result.put("updates", JSONArray())
366
- return result.toString()
367
- }
368
-
369
- // Also check if we already have the object in our object database
370
- if (repo.objectDatabase.has(wantObjectId)) {
371
- // We have the object, just update the tracking ref
372
- updateRef(repo, trackingRef, wantObjectId)
373
- val updatesArray = JSONArray()
374
- updatesArray.put(JSONObject().apply {
375
- put("ref", trackingRef)
376
- put("oldObjectId", localTrackingOid?.name ?: ObjectId.zeroId().name)
377
- put("newObjectId", wantOid)
378
- })
379
- result.put("ok", true)
380
- result.put("updates", updatesArray)
381
- return result.toString()
382
- }
383
-
384
- // Step 2: Build the upload-pack request
385
- val requestBuf = ByteArrayOutputStream()
386
-
387
- // Write "want" lines
388
- // First want line includes capabilities
389
- val caps = mutableListOf("no-progress", "report-status", "side-band-64k")
390
- if (serverRefs.capabilities.contains("ofs-delta")) caps.add("ofs-delta")
391
- if (serverRefs.capabilities.contains("thin-pack")) caps.add("thin-pack")
392
- writePktLine(requestBuf, "want $wantOid ${caps.joinToString(" ")}\n")
393
-
394
- // Additional wants: also fetch any other refs we might need
395
- // (for now, just the one branch)
396
- // Flush after wants
397
- requestBuf.write("0000".toByteArray())
398
-
399
- // Write "have" lines — tell server what we already have
400
- // Send recent commits so server can compute a thin pack
401
- val haveOids = collectHaveOids(repo, 256)
402
- for (have in haveOids) {
403
- writePktLine(requestBuf, "have ${have.name}\n")
404
- }
405
-
406
- // "done" — single-round stateless negotiation
407
- writePktLine(requestBuf, "done\n")
408
- // Flush
409
- requestBuf.write("0000".toByteArray())
410
-
411
- // Step 3: POST /git-upload-pack
412
- val postUrl = "$remoteUrl/git-upload-pack"
413
- val postConn = openHttpConnection(postUrl, "POST", headerMap, "application/x-git-upload-pack-request")
414
- postConn.setRequestProperty("Accept", "application/x-git-upload-pack-result")
415
- postConn.doOutput = true
416
- val requestBody = requestBuf.toByteArray()
417
- postConn.setFixedLengthStreamingMode(requestBody.size)
418
-
419
- try {
420
- postConn.outputStream.use { it.write(requestBody) }
421
-
422
- if (postConn.responseCode != 200) {
423
- throw Exception("git-upload-pack failed: ${postConn.responseCode} ${postConn.responseMessage}")
424
- }
425
-
426
- // Step 4: Parse response and apply pack
427
- val responseStream = postConn.inputStream
428
- val responseBytes = responseStream.readBytes()
429
-
430
- // The response is pkt-line encoded. It starts with NAK or ACK lines,
431
- // then contains pack data (possibly side-band encoded).
432
- val packData = extractPackData(responseBytes)
433
-
434
- if (packData != null && packData.isNotEmpty()) {
435
- // Parse and index the pack
436
- val inserter = repo.newObjectInserter()
437
- try {
438
- val parser = inserter.newPackParser(ByteArrayInputStream(packData))
439
- parser.setAllowThin(true)
440
- parser.parse(NullProgressMonitor.INSTANCE)
441
- inserter.flush()
442
- } finally {
443
- inserter.close()
444
- }
445
- }
446
-
447
- // Update tracking ref
448
- updateRef(repo, trackingRef, wantObjectId)
449
-
450
- val updatesArray = JSONArray()
451
- updatesArray.put(JSONObject().apply {
452
- put("ref", trackingRef)
453
- put("oldObjectId", localTrackingOid?.name ?: ObjectId.zeroId().name)
454
- put("newObjectId", wantOid)
455
- })
456
-
457
- result.put("ok", true)
458
- result.put("updates", updatesArray)
459
- android.util.Log.i("GitFetch", "Raw HTTP fetch completed: $wantOid → $trackingRef")
460
- } finally {
461
- postConn.disconnect()
462
- }
463
- } finally {
464
- repo.close()
69
+ val decoded = String(Base64.decode(authHeader.substring(6), Base64.DEFAULT))
70
+ val colonIndex = decoded.indexOf(':')
71
+ val username = if (colonIndex >= 0) decoded.substring(0, colonIndex) else ""
72
+ val password = if (colonIndex >= 0) decoded.substring(colonIndex + 1) else decoded
73
+ command.setCredentialsProvider(UsernamePasswordCredentialsProvider(username, password))
74
+ } catch (e: Exception) {
75
+ android.util.Log.w("GitHelper", "Failed to decode Basic auth: ${e.message}")
465
76
  }
466
- } catch (e: Exception) {
467
- result.put("ok", false)
468
- result.put("error", e.message ?: "Unknown fetch error")
469
- android.util.Log.e("GitFetch", "Fetch failed: ${e.message}", e)
470
77
  }
471
- return result.toString()
472
- }
473
78
 
474
- /**
475
- * Collect recent commit OIDs that we can tell the server we "have".
476
- * This helps the server send a minimal pack.
477
- */
478
- private fun collectHaveOids(repo: Repository, maxCount: Int): List<ObjectId> {
479
- val oids = mutableListOf<ObjectId>()
480
- try {
481
- val walk = RevWalk(repo)
482
- // Add all refs as starting points
483
- for (ref in repo.refDatabase.refs) {
484
- try {
485
- val peeled = repo.refDatabase.peel(ref)
486
- val target = peeled.peeledObjectId ?: peeled.objectId ?: continue
487
- walk.markStart(walk.parseCommit(target))
488
- } catch (e: Exception) {
489
- // Skip non-commit refs
490
- }
79
+ command.setTransportConfigCallback { transport ->
80
+ if (transport is TransportHttp) {
81
+ transport.setAdditionalHeaders(headerMap)
491
82
  }
492
- var count = 0
493
- for (commit in walk) {
494
- oids.add(commit.id)
495
- if (++count >= maxCount) break
496
- }
497
- walk.close()
498
- } catch (e: Exception) {
499
- android.util.Log.w("GitFetch", "collectHaveOids error: ${e.message}")
500
83
  }
501
- return oids
502
84
  }
503
85
 
504
86
  /**
505
- * Update a ref to point to a new object ID.
506
- */
507
- private fun updateRef(repo: Repository, refName: String, newId: ObjectId) {
508
- val refUpdate = repo.updateRef(refName)
509
- refUpdate.setNewObjectId(newId)
510
- refUpdate.isForceUpdate = true
511
- val updateResult = refUpdate.update()
512
- android.util.Log.i("GitFetch", "Updated ref $refName → ${newId.name.take(8)}: $updateResult")
513
- }
514
-
515
- /**
516
- * Extract pack data from a git-upload-pack response.
87
+ * Ensure repository git config forces protocol.version=0 and
88
+ * constrains pack memory for Android's limited heap.
517
89
  *
518
- * The response format is:
519
- * - pkt-line with "NAK\n" or "ACK <oid>\n" lines
520
- * - Then pack data, possibly side-band encoded
90
+ * TidGi Desktop's git server uses `git --stateless-rpc` which only speaks V0/V1.
91
+ * JGit defaults to V2 negotiation, which causes the error:
92
+ * "Starting read stage without written request data pending is not supported"
93
+ * because the server doesn't handle V2 capability advertisements.
521
94
  *
522
- * Side-band encoding: each pkt-line starts with a channel byte:
523
- * - 1 = pack data
524
- * - 2 = progress messages
525
- * - 3 = error messages
526
- *
527
- * We need to handle both side-band and non-side-band responses.
95
+ * Pack memory limits prevent OOM on large repos (Android heap is ~268MB).
96
+ * Default JGit settings: deltaCacheSize=50MB, windowMemory=unlimited,
97
+ * bigFileThreshold=50MB far too much for a mobile device.
528
98
  */
529
- private fun extractPackData(responseBytes: ByteArray): ByteArray? {
530
- if (responseBytes.isEmpty()) return null
531
-
532
- val packBytes = ByteArrayOutputStream()
533
- val input = ByteArrayInputStream(responseBytes)
534
-
535
- try {
536
- // Read pkt-lines
537
- while (input.available() > 0) {
538
- // Read 4-byte hex length
539
- val hexBytes = ByteArray(4)
540
- val read = input.read(hexBytes)
541
- if (read < 4) break
542
-
543
- val hexStr = String(hexBytes)
544
- if (hexStr == "0000") continue // flush packet
545
- if (hexStr == "0001") continue // delimiter packet
546
- if (hexStr == "0002") continue // response-end packet
547
-
548
- val length = try { hexStr.toInt(16) } catch (e: Exception) {
549
- // Not a valid pkt-line, might be raw pack data
550
- // Check if the first 4 bytes are "PACK" signature
551
- if (hexStr == "PACK") {
552
- packBytes.write(hexBytes)
553
- val remaining = ByteArray(input.available())
554
- input.read(remaining)
555
- packBytes.write(remaining)
556
- break
557
- }
558
- break
559
- }
560
-
561
- if (length <= 4) continue // empty line
562
-
563
- val dataLen = length - 4
564
- val data = ByteArray(dataLen)
565
- var totalRead = 0
566
- while (totalRead < dataLen) {
567
- val n = input.read(data, totalRead, dataLen - totalRead)
568
- if (n < 0) break
569
- totalRead += n
570
- }
571
-
572
- // Check the content
573
- val text = String(data, Charsets.UTF_8).trim()
574
- if (text == "NAK" || text.startsWith("ACK ")) continue
575
-
576
- // Check for side-band encoding
577
- if (data.isNotEmpty()) {
578
- val channel = data[0].toInt()
579
- when (channel) {
580
- 1 -> {
581
- // Pack data
582
- packBytes.write(data, 1, data.size - 1)
583
- }
584
- 2 -> {
585
- // Progress - log and skip
586
- android.util.Log.d("GitFetch", "progress: ${String(data, 1, data.size - 1).trim()}")
587
- }
588
- 3 -> {
589
- // Error
590
- val errorMsg = String(data, 1, data.size - 1).trim()
591
- android.util.Log.e("GitFetch", "server error: $errorMsg")
592
- throw Exception("Server error during fetch: $errorMsg")
593
- }
594
- else -> {
595
- // No side-band: check if this is raw pack data
596
- if (data.size >= 4 && data[0] == 'P'.code.toByte() && data[1] == 'A'.code.toByte()
597
- && data[2] == 'C'.code.toByte() && data[3] == 'K'.code.toByte()) {
598
- packBytes.write(data)
599
- } else {
600
- // Unknown data — might be text response, skip
601
- android.util.Log.d("GitFetch", "Unknown pkt-line: ${text.take(100)}")
602
- }
603
- }
604
- }
605
- }
606
- }
607
- } catch (e: Exception) {
608
- android.util.Log.w("GitFetch", "extractPackData: ${e.message}")
99
+ private fun ensureProtocolV0(repo: Repository) {
100
+ val config = repo.config
101
+ var dirty = false
102
+ val current = config.getString("protocol", null, "version")
103
+ if (current != "0") {
104
+ config.setString("protocol", null, "version", "0")
105
+ dirty = true
106
+ }
107
+ // Limit pack memory to avoid OOM on push
108
+ // pack.windowMemory: max bytes for delta search window (per thread), default unlimited
109
+ if (config.getLong("pack", "windowmemory", 0) == 0L) {
110
+ config.setLong("pack", null, "windowmemory", 10L * 1024 * 1024) // 10MB
111
+ dirty = true
112
+ }
113
+ // pack.deltaCacheSize: total delta cache, default 50MB
114
+ if (config.getLong("pack", "deltacachesize", 50L * 1024 * 1024) >= 50L * 1024 * 1024) {
115
+ config.setLong("pack", null, "deltacachesize", 5L * 1024 * 1024) // 5MB
116
+ dirty = true
117
+ }
118
+ // pack.threads: limit to 1 to reduce memory pressure
119
+ if (config.getInt("pack", "threads", 0) == 0) {
120
+ config.setInt("pack", null, "threads", 1)
121
+ dirty = true
122
+ }
123
+ // pack.window: reduce from 10 to 5
124
+ if (config.getInt("pack", "window", 10) > 5) {
125
+ config.setInt("pack", null, "window", 5)
126
+ dirty = true
127
+ }
128
+ if (dirty) {
129
+ config.save()
609
130
  }
610
-
611
- val result = packBytes.toByteArray()
612
- return if (result.isEmpty()) null else result
613
131
  }
614
132
 
615
133
  // ─── Git status (JGit) ──────────────────────────────────────────
@@ -796,11 +314,108 @@ internal object GitHelper {
796
314
  return result.toString()
797
315
  }
798
316
 
799
- // ─── Git push (raw HTTP - see above) ──────────────────────────────
800
- // gitPush() is defined earlier in this file using raw HTTP transport.
317
+ // ─── Git push (JGit) ────────────────────────────────────────────
801
318
 
802
- // ─── Git fetch (raw HTTP - see above) ───────────────────────────
803
- // gitFetch() is defined earlier in this file using raw HTTP transport.
319
+ /**
320
+ * Push local branch to remote using JGit (efficient native pack building).
321
+ * JGit handles pack construction in Java with bounded memory usage,
322
+ * avoiding the OOM that isomorphic-git causes on large repos.
323
+ */
324
+ fun gitPush(
325
+ gitRootDir: String,
326
+ remoteName: String,
327
+ localBranch: String,
328
+ remoteBranch: String,
329
+ force: Boolean,
330
+ headers: String?
331
+ ): String {
332
+ val result = JSONObject()
333
+ try {
334
+ val repo = openRepo(gitRootDir)
335
+ try {
336
+ ensureProtocolV0(repo)
337
+ val git = Git(repo)
338
+ val pushCommand = git.push()
339
+ .setRemote(remoteName)
340
+ .setRefSpecs(RefSpec("refs/heads/$localBranch:$remoteBranch"))
341
+ .setForce(force)
342
+
343
+ applyHeaders(pushCommand, headers)
344
+
345
+ val pushResults = pushCommand.call()
346
+
347
+ val resultsArray = JSONArray()
348
+ for (pushResult in pushResults) {
349
+ for (update in pushResult.remoteUpdates) {
350
+ val updateObj = JSONObject()
351
+ updateObj.put("remoteName", update.remoteName)
352
+ updateObj.put("status", update.status.name)
353
+ updateObj.put("message", update.message ?: "")
354
+ resultsArray.put(updateObj)
355
+ }
356
+ }
357
+
358
+ result.put("ok", true)
359
+ result.put("updates", resultsArray)
360
+ android.util.Log.i("GitPush", "Push completed: ${resultsArray.length()} updates")
361
+ } finally {
362
+ repo.close()
363
+ }
364
+ } catch (e: Exception) {
365
+ result.put("ok", false)
366
+ result.put("error", e.message ?: "Unknown push error")
367
+ android.util.Log.e("GitPush", "Push failed: ${e.message}", e)
368
+ }
369
+ return result.toString()
370
+ }
371
+
372
+ // ─── Git fetch (JGit) ───────────────────────────────────────────
373
+
374
+ /**
375
+ * Fetch from remote using JGit (efficient native pack handling).
376
+ */
377
+ fun gitFetch(
378
+ gitRootDir: String,
379
+ remoteName: String,
380
+ branch: String,
381
+ headers: String?
382
+ ): String {
383
+ val result = JSONObject()
384
+ try {
385
+ val repo = openRepo(gitRootDir)
386
+ try {
387
+ ensureProtocolV0(repo)
388
+ val git = Git(repo)
389
+ val fetchCommand = git.fetch()
390
+ .setRemote(remoteName)
391
+ .setRefSpecs(RefSpec("+refs/heads/$branch:refs/remotes/$remoteName/$branch"))
392
+
393
+ applyHeaders(fetchCommand, headers)
394
+
395
+ val fetchResult = fetchCommand.call()
396
+
397
+ val updatesArray = JSONArray()
398
+ for (update in fetchResult.trackingRefUpdates) {
399
+ val updateObj = JSONObject()
400
+ updateObj.put("ref", update.localName)
401
+ updateObj.put("oldObjectId", update.oldObjectId?.name ?: "")
402
+ updateObj.put("newObjectId", update.newObjectId?.name ?: "")
403
+ updatesArray.put(updateObj)
404
+ }
405
+
406
+ result.put("ok", true)
407
+ result.put("updates", updatesArray)
408
+ android.util.Log.i("GitFetch", "Fetch completed: ${updatesArray.length()} updates")
409
+ } finally {
410
+ repo.close()
411
+ }
412
+ } catch (e: Exception) {
413
+ result.put("ok", false)
414
+ result.put("error", e.message ?: "Unknown fetch error")
415
+ android.util.Log.e("GitFetch", "Fetch failed: ${e.message}", e)
416
+ }
417
+ return result.toString()
418
+ }
804
419
 
805
420
  // ─── Git checkout changed files (JGit) ──────────────────────────
806
421
 
@@ -1038,36 +653,7 @@ internal object GitHelper {
1038
653
  cloneCommand.setNoTags()
1039
654
  }
1040
655
 
1041
- // Apply headers for clone (JGit transport is used here since clone
1042
- // is typically done via archive download; this is just a fallback)
1043
- if (headers != null) {
1044
- try {
1045
- val headerObj = JSONObject(headers)
1046
- val headerMap = mutableMapOf<String, String>()
1047
- for (key in headerObj.keys()) headerMap[key] = headerObj.getString(key)
1048
-
1049
- val authHeader = headerMap["Authorization"] ?: headerMap["authorization"]
1050
- if (authHeader != null && authHeader.startsWith("Basic ", ignoreCase = true)) {
1051
- try {
1052
- val decoded = String(Base64.decode(authHeader.substring(6), Base64.DEFAULT))
1053
- val colonIndex = decoded.indexOf(':')
1054
- val username = if (colonIndex >= 0) decoded.substring(0, colonIndex) else ""
1055
- val password = if (colonIndex >= 0) decoded.substring(colonIndex + 1) else decoded
1056
- cloneCommand.setCredentialsProvider(UsernamePasswordCredentialsProvider(username, password))
1057
- } catch (e: Exception) {
1058
- android.util.Log.w("GitHelper", "Failed to decode Basic auth: ${e.message}")
1059
- }
1060
- }
1061
-
1062
- cloneCommand.setTransportConfigCallback { transport ->
1063
- if (transport is TransportHttp) {
1064
- transport.setAdditionalHeaders(headerMap)
1065
- }
1066
- }
1067
- } catch (e: Exception) {
1068
- android.util.Log.w("GitHelper", "Failed to parse clone headers: ${e.message}")
1069
- }
1070
- }
656
+ applyHeaders(cloneCommand, headers)
1071
657
 
1072
658
  val git = cloneCommand.call()
1073
659
  val headId = git.repository.resolve(Constants.HEAD)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-tiddlywiki-filesystem-android-external-storage",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "Expo native module for TidGi-Mobile: filesystem I/O + TiddlyWiki .tid/.meta/.json batch parsing + git status in Kotlin (Android) and Swift (iOS)",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",