expo-tiddlywiki-filesystem-android-external-storage 2.2.10 → 2.2.13

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.
@@ -0,0 +1,697 @@
1
+ import CommonCrypto
2
+ import Foundation
3
+
4
+ /// Git operations for iOS — direct port of GitHelper.kt.
5
+ /// Parses .git/index, reads git objects from loose and pack files,
6
+ /// applies pack deltas, and builds git index files.
7
+ enum GitHelper {
8
+
9
+ // ─── Public API ──────────────────────────────────────────────────
10
+
11
+ /// Compare working directory against git index, returning JSON array of changes.
12
+ static func gitStatus(rootDir: String) throws -> String {
13
+ let root = URL(fileURLWithPath: rootDir)
14
+ let gitDir = root.appendingPathComponent(".git")
15
+
16
+ guard FileManager.default.fileExists(atPath: gitDir.path) else {
17
+ throw NSError(domain: "Git", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not a git repository: \(rootDir)"])
18
+ }
19
+
20
+ let indexFile = gitDir.appendingPathComponent("index")
21
+ guard FileManager.default.fileExists(atPath: indexFile.path) else {
22
+ return "[]"
23
+ }
24
+
25
+ let indexEntries = try parseGitIndex(path: indexFile.path)
26
+
27
+ let skipDirs: Set<String> = [".git", "node_modules", "output"]
28
+ var workdirFiles = Set<String>()
29
+ walkWorkDir(dir: root, prefix: "", skipDirs: skipDirs, files: &workdirFiles)
30
+
31
+ var changes = [[String: String]]()
32
+ var indexPaths = Set<String>()
33
+
34
+ for entry in indexEntries {
35
+ indexPaths.insert(entry.path)
36
+ let workFile = root.appendingPathComponent(entry.path)
37
+ let fm = FileManager.default
38
+
39
+ if !fm.fileExists(atPath: workFile.path) {
40
+ changes.append(["path": entry.path, "type": "delete"])
41
+ } else {
42
+ let attrs = try? fm.attributesOfItem(atPath: workFile.path)
43
+ let diskSize = (attrs?[.size] as? UInt64) ?? 0
44
+ let diskMtimeMs = ((attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0)
45
+ let diskMtimeS = Int(diskMtimeMs)
46
+
47
+ if diskSize != entry.size || diskMtimeS != entry.mtimeSeconds {
48
+ changes.append(["path": entry.path, "type": "modify"])
49
+ }
50
+ }
51
+ }
52
+
53
+ for path in workdirFiles {
54
+ if !indexPaths.contains(path) {
55
+ changes.append(["path": path, "type": "add"])
56
+ }
57
+ }
58
+
59
+ let jsonData = try JSONSerialization.data(withJSONObject: changes, options: [])
60
+ return String(data: jsonData, encoding: .utf8) ?? "[]"
61
+ }
62
+
63
+ /// Debug information about git repository state.
64
+ static func gitStatusDebug(rootDir: String) throws -> String {
65
+ let root = URL(fileURLWithPath: rootDir)
66
+ let gitDir = root.appendingPathComponent(".git")
67
+ let indexFile = gitDir.appendingPathComponent("index")
68
+ let fm = FileManager.default
69
+
70
+ var result = [String: Any]()
71
+ result["rootExists"] = fm.fileExists(atPath: root.path)
72
+ var isDir: ObjCBool = false
73
+ fm.fileExists(atPath: root.path, isDirectory: &isDir)
74
+ result["rootIsDir"] = isDir.boolValue
75
+ result["gitDirExists"] = fm.fileExists(atPath: gitDir.path)
76
+ result["indexFileExists"] = fm.fileExists(atPath: indexFile.path)
77
+ result["rootPath"] = root.path
78
+ result["gitDirPath"] = gitDir.path
79
+ result["indexPath"] = indexFile.path
80
+
81
+ let rootChildren = (try? fm.contentsOfDirectory(atPath: root.path))?.sorted().prefix(10).map { $0 } ?? []
82
+ result["rootChildren"] = rootChildren
83
+
84
+ if fm.fileExists(atPath: gitDir.path) {
85
+ let gitChildren = (try? fm.contentsOfDirectory(atPath: gitDir.path))?.sorted() ?? []
86
+ result["gitDirChildren"] = gitChildren
87
+ }
88
+
89
+ if fm.fileExists(atPath: indexFile.path) {
90
+ let entries = try parseGitIndex(path: indexFile.path)
91
+ result["indexEntryCount"] = entries.count
92
+
93
+ let skipDirs: Set<String> = [".git", "node_modules", "output"]
94
+ var workdirFiles = Set<String>()
95
+ walkWorkDir(dir: root, prefix: "", skipDirs: skipDirs, files: &workdirFiles)
96
+ result["workdirFileCount"] = workdirFiles.count
97
+ result["tiddlerFileCount"] = workdirFiles.filter { $0.hasPrefix("tiddlers/") }.count
98
+
99
+ let indexPaths = Set(entries.map { $0.path })
100
+ let addCount = workdirFiles.filter { !indexPaths.contains($0) }.count
101
+ result["potentialAddCount"] = addCount
102
+ }
103
+
104
+ let jsonData = try JSONSerialization.data(withJSONObject: result, options: [])
105
+ return String(data: jsonData, encoding: .utf8) ?? "{}"
106
+ }
107
+
108
+ /// Build .git/index from HEAD tree, stat'ing all files on disk.
109
+ static func buildGitIndex(rootDir: String) throws -> String {
110
+ let root = URL(fileURLWithPath: rootDir)
111
+ let gitDir = root.appendingPathComponent(".git")
112
+
113
+ guard FileManager.default.fileExists(atPath: gitDir.path) else {
114
+ throw NSError(domain: "Git", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not a git repository: \(rootDir)"])
115
+ }
116
+
117
+ do {
118
+ let headFile = gitDir.appendingPathComponent("HEAD")
119
+ let headContent = try String(contentsOfFile: headFile.path, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
120
+
121
+ let commitSha: String
122
+ if headContent.hasPrefix("ref: ") {
123
+ let refPath = String(headContent.dropFirst("ref: ".count))
124
+ let refFile = gitDir.appendingPathComponent(refPath)
125
+ if FileManager.default.fileExists(atPath: refFile.path) {
126
+ commitSha = try String(contentsOfFile: refFile.path, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
127
+ } else {
128
+ guard let resolved = resolvePackedRef(gitDir: gitDir, refPath: refPath) else {
129
+ throw NSError(domain: "Git", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot resolve HEAD ref: \(refPath)"])
130
+ }
131
+ commitSha = resolved
132
+ }
133
+ } else {
134
+ commitSha = headContent
135
+ }
136
+
137
+ guard let commitBytes = readGitObject(gitDir: gitDir, sha: commitSha) else {
138
+ throw NSError(domain: "Git", code: 3, userInfo: [NSLocalizedDescriptionKey: "Cannot read commit: \(commitSha)"])
139
+ }
140
+ guard let treeSha = parseCommitTreeSha(commitData: commitBytes) else {
141
+ throw NSError(domain: "Git", code: 4, userInfo: [NSLocalizedDescriptionKey: "Cannot find tree in commit: \(commitSha)"])
142
+ }
143
+
144
+ var entries = [GitTreeEntry]()
145
+ try walkTreeRecursive(gitDir: gitDir, treeSha: treeSha, prefix: "", entries: &entries)
146
+ entries.sort { $0.path < $1.path }
147
+
148
+ let indexData = buildIndexBinary(root: root, entries: entries)
149
+ let indexFile = gitDir.appendingPathComponent("index")
150
+ try indexData.write(to: indexFile)
151
+
152
+ let result: [String: Any] = [
153
+ "ok": true,
154
+ "entries": entries.count,
155
+ "indexSize": indexData.count,
156
+ ]
157
+ let jsonData = try JSONSerialization.data(withJSONObject: result, options: [])
158
+ return String(data: jsonData, encoding: .utf8) ?? "{}"
159
+ } catch {
160
+ let result: [String: Any] = ["ok": false, "error": error.localizedDescription]
161
+ let jsonData = try JSONSerialization.data(withJSONObject: result, options: [])
162
+ return String(data: jsonData, encoding: .utf8) ?? "{}"
163
+ }
164
+ }
165
+
166
+ // ─── Git index parsing ──────────────────────────────────────────
167
+
168
+ struct IndexEntry {
169
+ let path: String
170
+ let size: UInt64
171
+ let mtimeSeconds: Int
172
+ }
173
+
174
+ private static func parseGitIndex(path: String) throws -> [IndexEntry] {
175
+ let data = try Data(contentsOf: URL(fileURLWithPath: path))
176
+ guard data.count >= 12 else { return [] }
177
+
178
+ // Verify "DIRC" signature
179
+ let sig = String(data: data[0..<4], encoding: .ascii)
180
+ guard sig == "DIRC" else { return [] }
181
+
182
+ let version = readInt32(data: data, offset: 4)
183
+ let entryCount = readInt32(data: data, offset: 8)
184
+
185
+ var entries = [IndexEntry]()
186
+ var offset = 12
187
+
188
+ for _ in 0..<entryCount {
189
+ guard offset + 62 <= data.count else { break }
190
+
191
+ let mtimeS = readInt32(data: data, offset: offset)
192
+ // Skip ctime (8 bytes), dev (4 bytes), ino (4 bytes) → offset+8 is ctime_s, +16 is dev
193
+ let fileSize = UInt64(readInt32(data: data, offset: offset + 36))
194
+ // SHA is at offset+40, 20 bytes
195
+ let flags = readInt16(data: data, offset: offset + 60)
196
+
197
+ let nameLen = flags & 0xFFF
198
+
199
+ // For v4, path names use prefix compression — handle v2/v3 simple case
200
+ let pathStart = offset + 62
201
+ let path: String
202
+ let entryEnd: Int
203
+
204
+ if version < 4 {
205
+ // v2/v3: null-terminated path, padded to 8-byte boundary
206
+ if nameLen > 0 && pathStart + nameLen <= data.count {
207
+ path = String(data: data[pathStart..<(pathStart + nameLen)], encoding: .utf8) ?? ""
208
+ } else {
209
+ // Find null terminator
210
+ var end = pathStart
211
+ while end < data.count && data[end] != 0 { end += 1 }
212
+ path = String(data: data[pathStart..<end], encoding: .utf8) ?? ""
213
+ }
214
+ let entrySize = 62 + path.utf8.count + 1
215
+ let padding = (8 - (entrySize % 8)) % 8
216
+ entryEnd = pathStart + path.utf8.count + 1 + padding
217
+ } else {
218
+ // v4: prefix-compressed paths — for now, find null terminator
219
+ var end = pathStart
220
+ while end < data.count && data[end] != 0 { end += 1 }
221
+ path = String(data: data[pathStart..<end], encoding: .utf8) ?? ""
222
+ entryEnd = end + 1
223
+ }
224
+
225
+ entries.append(IndexEntry(path: path, size: fileSize, mtimeSeconds: mtimeS))
226
+ offset = entryEnd
227
+ }
228
+
229
+ return entries
230
+ }
231
+
232
+ // ─── Working directory walk ─────────────────────────────────────
233
+
234
+ private static func walkWorkDir(dir: URL, prefix: String, skipDirs: Set<String>, files: inout Set<String>) {
235
+ let fm = FileManager.default
236
+ guard let children = try? fm.contentsOfDirectory(atPath: dir.path) else { return }
237
+ for child in children {
238
+ if skipDirs.contains(child) { continue }
239
+ let fullPath = dir.appendingPathComponent(child)
240
+ let relPath = prefix.isEmpty ? child : "\(prefix)/\(child)"
241
+ var isDir: ObjCBool = false
242
+ if fm.fileExists(atPath: fullPath.path, isDirectory: &isDir), isDir.boolValue {
243
+ walkWorkDir(dir: fullPath, prefix: relPath, skipDirs: skipDirs, files: &files)
244
+ } else {
245
+ files.insert(relPath)
246
+ }
247
+ }
248
+ }
249
+
250
+ // ─── Git object reading ─────────────────────────────────────────
251
+
252
+ private static func readGitObject(gitDir: URL, sha: String) -> Data? {
253
+ // Try loose object first
254
+ let prefix = String(sha.prefix(2))
255
+ let suffix = String(sha.dropFirst(2))
256
+ let loosePath = gitDir.appendingPathComponent("objects/\(prefix)/\(suffix)")
257
+ if FileManager.default.fileExists(atPath: loosePath.path) {
258
+ return readLooseObject(path: loosePath.path)
259
+ }
260
+
261
+ // Try pack files
262
+ let packDir = gitDir.appendingPathComponent("objects/pack")
263
+ guard let idxFiles = try? FileManager.default.contentsOfDirectory(atPath: packDir.path).filter({ $0.hasSuffix(".idx") }) else {
264
+ return nil
265
+ }
266
+ for idxFilename in idxFiles {
267
+ let idxPath = packDir.appendingPathComponent(idxFilename).path
268
+ let packPath = idxPath.replacingOccurrences(of: ".idx", with: ".pack")
269
+ guard FileManager.default.fileExists(atPath: packPath) else { continue }
270
+ if let offset = findObjectInPackIndex(idxPath: idxPath, sha: sha) {
271
+ return readObjectFromPack(packPath: packPath, offset: offset, gitDir: gitDir)
272
+ }
273
+ }
274
+ return nil
275
+ }
276
+
277
+ private static func readLooseObject(path: String) -> Data? {
278
+ guard let compressed = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
279
+ guard let inflated = inflate(data: compressed) else { return nil }
280
+ // Skip "type size\0" header
281
+ if let nullIndex = inflated.firstIndex(of: 0) {
282
+ return inflated.subdata(in: inflated.index(after: nullIndex)..<inflated.endIndex)
283
+ }
284
+ return inflated
285
+ }
286
+
287
+ private static func findObjectInPackIndex(idxPath: String, sha: String) -> UInt64? {
288
+ guard let handle = FileHandle(forReadingAtPath: idxPath) else { return nil }
289
+ defer { handle.closeFile() }
290
+
291
+ let shaBytes = hexToBytes(sha)
292
+
293
+ // Read magic + version
294
+ let magic = handle.readData(ofLength: 4)
295
+ guard magic.count == 4,
296
+ magic[0] == 0xFF, magic[1] == 0x74,
297
+ magic[2] == 0x4F, magic[3] == 0x63
298
+ else { return nil }
299
+
300
+ _ = handle.readData(ofLength: 4) // version
301
+
302
+ // Read fanout table
303
+ var fanout = [Int](repeating: 0, count: 256)
304
+ for i in 0..<256 {
305
+ let bytes = handle.readData(ofLength: 4)
306
+ guard bytes.count == 4 else { return nil }
307
+ fanout[i] = readInt32FromData(bytes, offset: 0)
308
+ }
309
+ let totalObjects = fanout[255]
310
+
311
+ let firstByte = Int(shaBytes[0])
312
+ let lo = firstByte == 0 ? 0 : fanout[firstByte - 1]
313
+ let hi = fanout[firstByte]
314
+
315
+ let shaTableStart: UInt64 = 8 + 256 * 4
316
+ var low = lo
317
+ var high = hi - 1
318
+
319
+ while low <= high {
320
+ let mid = (low + high) / 2
321
+ handle.seek(toFileOffset: shaTableStart + UInt64(mid) * 20)
322
+ let entry = handle.readData(ofLength: 20)
323
+ guard entry.count == 20 else { return nil }
324
+
325
+ let cmp = compareSha(Array(entry), shaBytes)
326
+ if cmp < 0 {
327
+ low = mid + 1
328
+ } else if cmp > 0 {
329
+ high = mid - 1
330
+ } else {
331
+ // Found — read offset
332
+ let offsetTableStart = shaTableStart + UInt64(totalObjects) * 20 + UInt64(totalObjects) * 4
333
+ handle.seek(toFileOffset: offsetTableStart + UInt64(mid) * 4)
334
+ let offsetBytes = handle.readData(ofLength: 4)
335
+ guard offsetBytes.count == 4 else { return nil }
336
+ let rawOffset = UInt64(readInt32FromData(offsetBytes, offset: 0)) & 0xFFFF_FFFF
337
+
338
+ if rawOffset & 0x8000_0000 != 0 {
339
+ // Large offset
340
+ let largeOffsetTableStart = offsetTableStart + UInt64(totalObjects) * 4
341
+ let idx = Int(rawOffset & 0x7FFF_FFFF)
342
+ handle.seek(toFileOffset: largeOffsetTableStart + UInt64(idx) * 8)
343
+ let bigBytes = handle.readData(ofLength: 8)
344
+ guard bigBytes.count == 8 else { return nil }
345
+ return readInt64FromData(bigBytes, offset: 0)
346
+ }
347
+ return rawOffset
348
+ }
349
+ }
350
+ return nil
351
+ }
352
+
353
+ private static func readObjectFromPack(packPath: String, offset: UInt64, gitDir: URL) -> Data? {
354
+ guard let handle = FileHandle(forReadingAtPath: packPath) else { return nil }
355
+ defer { handle.closeFile() }
356
+
357
+ handle.seek(toFileOffset: offset)
358
+ guard let firstByte = handle.readData(ofLength: 1).first else { return nil }
359
+
360
+ var byte = Int(firstByte)
361
+ let type = (byte >> 4) & 0x07
362
+ var size = Int64(byte & 0x0F)
363
+ var shift = 4
364
+
365
+ while byte & 0x80 != 0 {
366
+ guard let nextByte = handle.readData(ofLength: 1).first else { return nil }
367
+ byte = Int(nextByte)
368
+ size = size | (Int64(byte & 0x7F) << shift)
369
+ shift += 7
370
+ }
371
+
372
+ switch type {
373
+ case 1, 2, 3, 4:
374
+ return decompressFromHandle(handle, expectedSize: size)
375
+ case 6:
376
+ // OFS_DELTA
377
+ guard var b = handle.readData(ofLength: 1).first.map({ Int($0) }) else { return nil }
378
+ var deltaOffset = Int64(b & 0x7F)
379
+ while b & 0x80 != 0 {
380
+ guard let nextB = handle.readData(ofLength: 1).first.map({ Int($0) }) else { return nil }
381
+ b = nextB
382
+ deltaOffset = ((deltaOffset + 1) << 7) | Int64(b & 0x7F)
383
+ }
384
+ let baseOffset = Int64(offset) - deltaOffset
385
+ guard baseOffset >= 0 else { return nil }
386
+ guard let base = readObjectFromPack(packPath: packPath, offset: UInt64(baseOffset), gitDir: gitDir) else { return nil }
387
+ guard let delta = decompressFromHandle(handle, expectedSize: size) else { return nil }
388
+ return applyDelta(base: base, delta: delta)
389
+ case 7:
390
+ // REF_DELTA
391
+ let baseShaData = handle.readData(ofLength: 20)
392
+ guard baseShaData.count == 20 else { return nil }
393
+ let baseSha = bytesToHex(Array(baseShaData))
394
+ guard let base = readGitObject(gitDir: gitDir, sha: baseSha) else { return nil }
395
+ guard let delta = decompressFromHandle(handle, expectedSize: size) else { return nil }
396
+ return applyDelta(base: base, delta: delta)
397
+ default:
398
+ return nil
399
+ }
400
+ }
401
+
402
+ // ─── Zlib decompression ─────────────────────────────────────────
403
+
404
+ private static func inflate(data: Data) -> Data? {
405
+ let bufferSize = max(data.count * 4, 65536)
406
+ var buffer = [UInt8](repeating: 0, count: bufferSize)
407
+ var stream = z_stream()
408
+
409
+ stream.next_in = UnsafeMutablePointer<UInt8>(mutating: (data as NSData).bytes.assumingMemoryBound(to: UInt8.self))
410
+ stream.avail_in = uInt(data.count)
411
+
412
+ guard inflateInit2_(&stream, MAX_WBITS + 32, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) == Z_OK else {
413
+ return nil
414
+ }
415
+
416
+ var result = Data()
417
+ repeat {
418
+ stream.next_out = &buffer
419
+ stream.avail_out = uInt(buffer.count)
420
+ let status = Foundation.inflate(&stream, Z_NO_FLUSH)
421
+ guard status == Z_OK || status == Z_STREAM_END else {
422
+ inflateEnd(&stream)
423
+ return nil
424
+ }
425
+ let written = buffer.count - Int(stream.avail_out)
426
+ result.append(buffer, count: written)
427
+ if status == Z_STREAM_END { break }
428
+ } while stream.avail_in > 0 || stream.avail_out == 0
429
+
430
+ inflateEnd(&stream)
431
+ return result
432
+ }
433
+
434
+ private static func decompressFromHandle(_ handle: FileHandle, expectedSize: Int64) -> Data? {
435
+ let pos = handle.offsetInFile
436
+ let fileSize = handle.seekToEndOfFile()
437
+ handle.seek(toFileOffset: pos)
438
+ let remaining = min(Int(fileSize - pos), Int(expectedSize * 4 + 4096))
439
+ let compressed = handle.readData(ofLength: remaining)
440
+ return inflate(data: compressed)
441
+ }
442
+
443
+ // ─── Delta application ──────────────────────────────────────────
444
+
445
+ private static func applyDelta(base: Data, delta: Data) -> Data? {
446
+ let deltaBytes = Array(delta)
447
+ var pos = 0
448
+
449
+ // Read base size (variable-length int)
450
+ var shift = 0
451
+ var baseSize: Int64 = 0
452
+ repeat {
453
+ guard pos < deltaBytes.count else { return nil }
454
+ let b = Int(deltaBytes[pos]); pos += 1
455
+ baseSize |= Int64(b & 0x7F) << shift
456
+ shift += 7
457
+ if b & 0x80 == 0 { break }
458
+ } while true
459
+
460
+ // Read result size
461
+ var resultSize: Int64 = 0
462
+ shift = 0
463
+ repeat {
464
+ guard pos < deltaBytes.count else { return nil }
465
+ let b = Int(deltaBytes[pos]); pos += 1
466
+ resultSize |= Int64(b & 0x7F) << shift
467
+ shift += 7
468
+ if b & 0x80 == 0 { break }
469
+ } while true
470
+
471
+ let baseBytes = Array(base)
472
+ var result = [UInt8](repeating: 0, count: Int(resultSize))
473
+ var resultPos = 0
474
+
475
+ while pos < deltaBytes.count {
476
+ let cmd = Int(deltaBytes[pos]); pos += 1
477
+
478
+ if cmd & 0x80 != 0 {
479
+ // Copy from base
480
+ var copyOffset = 0
481
+ var copySize = 0
482
+ if cmd & 0x01 != 0 { copyOffset = Int(deltaBytes[pos]); pos += 1 }
483
+ if cmd & 0x02 != 0 { copyOffset |= Int(deltaBytes[pos]) << 8; pos += 1 }
484
+ if cmd & 0x04 != 0 { copyOffset |= Int(deltaBytes[pos]) << 16; pos += 1 }
485
+ if cmd & 0x08 != 0 { copyOffset |= Int(deltaBytes[pos]) << 24; pos += 1 }
486
+ if cmd & 0x10 != 0 { copySize = Int(deltaBytes[pos]); pos += 1 }
487
+ if cmd & 0x20 != 0 { copySize |= Int(deltaBytes[pos]) << 8; pos += 1 }
488
+ if cmd & 0x40 != 0 { copySize |= Int(deltaBytes[pos]) << 16; pos += 1 }
489
+ if copySize == 0 { copySize = 0x10000 }
490
+
491
+ guard copyOffset + copySize <= baseBytes.count, resultPos + copySize <= result.count else { return nil }
492
+ result.replaceSubrange(resultPos..<(resultPos + copySize), with: baseBytes[copyOffset..<(copyOffset + copySize)])
493
+ resultPos += copySize
494
+ } else if cmd != 0 {
495
+ // Insert from delta
496
+ guard pos + cmd <= deltaBytes.count, resultPos + cmd <= result.count else { return nil }
497
+ result.replaceSubrange(resultPos..<(resultPos + cmd), with: deltaBytes[pos..<(pos + cmd)])
498
+ pos += cmd
499
+ resultPos += cmd
500
+ }
501
+ }
502
+
503
+ return Data(result)
504
+ }
505
+
506
+ // ─── Tree/commit parsing ────────────────────────────────────────
507
+
508
+ private static func parseCommitTreeSha(commitData: Data) -> String? {
509
+ guard let str = String(data: commitData, encoding: .utf8) else { return nil }
510
+ for line in str.components(separatedBy: .newlines) {
511
+ if line.hasPrefix("tree ") {
512
+ return String(line.dropFirst("tree ".count)).trimmingCharacters(in: .whitespaces)
513
+ }
514
+ if line.isEmpty { break }
515
+ }
516
+ return nil
517
+ }
518
+
519
+ private struct GitTreeEntry {
520
+ let path: String
521
+ let mode: Int
522
+ let sha: [UInt8]
523
+ }
524
+
525
+ private static func parseTreeEntries(treeData: Data) -> [(name: String, mode: Int, sha: [UInt8])] {
526
+ let bytes = Array(treeData)
527
+ var entries = [(String, Int, [UInt8])]()
528
+ var pos = 0
529
+
530
+ while pos < bytes.count {
531
+ // Find space (separates mode from name)
532
+ var spaceIdx = pos
533
+ while spaceIdx < bytes.count && bytes[spaceIdx] != 0x20 { spaceIdx += 1 }
534
+ guard spaceIdx < bytes.count else { break }
535
+ let modeStr = String(bytes: Array(bytes[pos..<spaceIdx]), encoding: .ascii) ?? ""
536
+ guard let mode = Int(modeStr, radix: 8) else { break }
537
+ pos = spaceIdx + 1
538
+
539
+ // Find null (end of name)
540
+ var nullIdx = pos
541
+ while nullIdx < bytes.count && bytes[nullIdx] != 0 { nullIdx += 1 }
542
+ guard nullIdx < bytes.count else { break }
543
+ let name = String(bytes: Array(bytes[pos..<nullIdx]), encoding: .utf8) ?? ""
544
+ pos = nullIdx + 1
545
+
546
+ // Read 20-byte SHA
547
+ guard pos + 20 <= bytes.count else { break }
548
+ let sha = Array(bytes[pos..<(pos + 20)])
549
+ pos += 20
550
+
551
+ entries.append((name, mode, sha))
552
+ }
553
+ return entries
554
+ }
555
+
556
+ private static func walkTreeRecursive(
557
+ gitDir: URL, treeSha: String, prefix: String, entries: inout [GitTreeEntry]
558
+ ) throws {
559
+ guard let treeData = readGitObject(gitDir: gitDir, sha: treeSha) else {
560
+ throw NSError(domain: "Git", code: 5, userInfo: [NSLocalizedDescriptionKey: "Cannot read tree: \(treeSha)"])
561
+ }
562
+ for (name, mode, sha) in parseTreeEntries(treeData: treeData) {
563
+ let fullPath = prefix.isEmpty ? name : "\(prefix)/\(name)"
564
+ if mode == 0o40000 {
565
+ // Directory — recurse
566
+ try walkTreeRecursive(gitDir: gitDir, treeSha: bytesToHex(sha), prefix: fullPath, entries: &entries)
567
+ } else {
568
+ entries.append(GitTreeEntry(path: fullPath, mode: mode, sha: sha))
569
+ }
570
+ }
571
+ }
572
+
573
+ // ─── Build git index binary ─────────────────────────────────────
574
+
575
+ private static func buildIndexBinary(root: URL, entries: [GitTreeEntry]) -> Data {
576
+ var data = Data()
577
+
578
+ // Header: "DIRC", version 2, entry count
579
+ data.append("DIRC".data(using: .ascii)!)
580
+ appendInt32(&data, 2)
581
+ appendInt32(&data, Int(entries.count))
582
+
583
+ let fm = FileManager.default
584
+
585
+ for entry in entries {
586
+ let workFile = root.appendingPathComponent(entry.path)
587
+ let attrs = try? fm.attributesOfItem(atPath: workFile.path)
588
+ let mtimeMs = ((attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0) * 1000
589
+ let mtimeS = Int(mtimeMs / 1000)
590
+ let mtimeNs = Int(mtimeMs.truncatingRemainder(dividingBy: 1000)) * 1_000_000
591
+ let fileSize = Int((attrs?[.size] as? UInt64) ?? 0)
592
+
593
+ appendInt32(&data, mtimeS)
594
+ appendInt32(&data, mtimeNs)
595
+ appendInt32(&data, mtimeS) // ctime = mtime
596
+ appendInt32(&data, mtimeNs)
597
+ appendInt32(&data, 0) // dev
598
+ appendInt32(&data, 0) // ino
599
+ appendInt32(&data, entry.mode)
600
+ appendInt32(&data, 0) // uid
601
+ appendInt32(&data, 0) // gid
602
+ appendInt32(&data, fileSize)
603
+ data.append(contentsOf: entry.sha)
604
+
605
+ let pathBytes = Array(entry.path.utf8)
606
+ let flags = min(pathBytes.count, 0xFFF)
607
+ data.append(UInt8((flags >> 8) & 0xFF))
608
+ data.append(UInt8(flags & 0xFF))
609
+ data.append(contentsOf: pathBytes)
610
+ data.append(0) // null terminator
611
+
612
+ let entrySize = 62 + pathBytes.count + 1
613
+ let padding = (8 - (entrySize % 8)) % 8
614
+ for _ in 0..<padding { data.append(0) }
615
+ }
616
+
617
+ // SHA-1 checksum
618
+ var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
619
+ data.withUnsafeBytes { ptr in
620
+ _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &hash)
621
+ }
622
+ data.append(contentsOf: hash)
623
+
624
+ return data
625
+ }
626
+
627
+ // ─── Packed ref resolution ──────────────────────────────────────
628
+
629
+ private static func resolvePackedRef(gitDir: URL, refPath: String) -> String? {
630
+ let packedRefsPath = gitDir.appendingPathComponent("packed-refs").path
631
+ guard let content = try? String(contentsOfFile: packedRefsPath, encoding: .utf8) else { return nil }
632
+ for line in content.components(separatedBy: .newlines) {
633
+ if line.hasPrefix("#") || line.isEmpty { continue }
634
+ let parts = line.split(separator: " ", maxSplits: 1)
635
+ if parts.count == 2, String(parts[1]).trimmingCharacters(in: .whitespaces) == refPath {
636
+ return String(parts[0]).trimmingCharacters(in: .whitespaces)
637
+ }
638
+ }
639
+ return nil
640
+ }
641
+
642
+ // ─── Utility functions ──────────────────────────────────────────
643
+
644
+ private static func hexToBytes(_ hex: String) -> [UInt8] {
645
+ let chars = Array(hex)
646
+ var bytes = [UInt8]()
647
+ for i in stride(from: 0, to: chars.count - 1, by: 2) {
648
+ let str = String(chars[i]) + String(chars[i + 1])
649
+ if let byte = UInt8(str, radix: 16) {
650
+ bytes.append(byte)
651
+ }
652
+ }
653
+ return bytes
654
+ }
655
+
656
+ private static func bytesToHex(_ bytes: [UInt8]) -> String {
657
+ bytes.map { String(format: "%02x", $0) }.joined()
658
+ }
659
+
660
+ private static func compareSha(_ a: [UInt8], _ b: [UInt8]) -> Int {
661
+ for i in 0..<min(a.count, b.count) {
662
+ if a[i] != b[i] { return Int(a[i]) - Int(b[i]) }
663
+ }
664
+ return 0
665
+ }
666
+
667
+ private static func readInt32(data: Data, offset: Int) -> Int {
668
+ guard offset + 4 <= data.count else { return 0 }
669
+ return (Int(data[offset]) << 24) | (Int(data[offset + 1]) << 16) |
670
+ (Int(data[offset + 2]) << 8) | Int(data[offset + 3])
671
+ }
672
+
673
+ private static func readInt16(data: Data, offset: Int) -> Int {
674
+ guard offset + 2 <= data.count else { return 0 }
675
+ return (Int(data[offset]) << 8) | Int(data[offset + 1])
676
+ }
677
+
678
+ private static func readInt32FromData(_ data: Data, offset: Int) -> Int {
679
+ readInt32(data: data, offset: offset)
680
+ }
681
+
682
+ private static func readInt64FromData(_ data: Data, offset: Int) -> UInt64 {
683
+ guard offset + 8 <= data.count else { return 0 }
684
+ var result: UInt64 = 0
685
+ for i in 0..<8 {
686
+ result = (result << 8) | UInt64(data[offset + i])
687
+ }
688
+ return result
689
+ }
690
+
691
+ private static func appendInt32(_ data: inout Data, _ value: Int) {
692
+ data.append(UInt8((value >> 24) & 0xFF))
693
+ data.append(UInt8((value >> 16) & 0xFF))
694
+ data.append(UInt8((value >> 8) & 0xFF))
695
+ data.append(UInt8(value & 0xFF))
696
+ }
697
+ }