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.
- package/android/src/main/java/expo/modules/externalstorage/ExternalStorageModule.kt +29 -0
- package/android/src/main/java/expo/modules/externalstorage/GitHelper.kt +1 -11
- package/build/index.js +2 -2
- package/build/index.js.map +1 -1
- package/expo-module.config.json +4 -1
- package/ios/ExternalStorageModule.swift +359 -0
- package/ios/GitHelper.swift +697 -0
- package/ios/TarExtractor.swift +158 -0
- package/ios/TiddlerParser.swift +254 -0
- package/package.json +3 -2
- package/src/index.ts +2 -2
|
@@ -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
|
+
}
|