import Foundation // Parser for raw `git diff --numstat` stdout // (`GET /api/sessions/:id/git-diff-numstat?staged=`), the behavioral twin of // `parseNumStat`/`createDiffStatsMap` in `web/src/lib/gitParsers.ts` and a // line-for-line transcription of the tested Android port // (`android/core/protocol/.../git/NumstatParser.kt`): binary files report // `-\t-` and count as 0/0, rename paths come in either the brace form // (`src/{old => new}/x.ts`) or the plain form (`old.txt => new.txt`), and the // stats map indexes the raw spelling plus both normalized paths so the // status-list merge finds them under the post-image path. /// One numstat row. public struct DiffFileStat: Equatable, Sendable { /// Raw path column, rename arrows included. public var file: String public var changes: Int public var insertions: Int public var deletions: Int public var binary: Bool public init(file: String, changes: Int, insertions: Int, deletions: Int, binary: Bool) { self.file = file self.changes = changes self.insertions = insertions self.deletions = deletions self.binary = binary } } public struct DiffSummary: Equatable, Sendable { public var files: [DiffFileStat] public var insertions: Int public var deletions: Int public var changes: Int /// Number of files listed. public var changed: Int public init(files: [DiffFileStat], insertions: Int, deletions: Int, changes: Int, changed: Int) { self.files = files self.insertions = insertions self.deletions = deletions self.changes = changes self.changed = changed } } /// Per-path counts for the Changes-list merge (web `createDiffStatsMap` values). public struct DiffLineStats: Equatable, Sendable { public var added: Int public var removed: Int public var binary: Bool public init(added: Int, removed: Int, binary: Bool) { self.added = added self.removed = removed self.binary = binary } } public enum NumstatParser { // NSRegularExpression is immutable and thread-safe; nonisolated(unsafe) // sidesteps SDK-dependent Sendable annotation gaps under Swift 6 (same // pattern as HapiUI's UnifiedDiffParser). nonisolated(unsafe) private static let numstatLine = try! NSRegularExpression( pattern: #"^(\d+|-)\t(\d+|-)\t(.*)$"# ) nonisolated(unsafe) private static let braceRename = try! NSRegularExpression( pattern: #"\{([^{}]+?)\s*=>\s*([^{}]+?)\}"# ) /// Numstat stdout → per-file counts + totals (`parseNumStat`). public static func parse(_ numStatOutput: String) -> DiffSummary { var files: [DiffFileStat] = [] var insertionsTotal = 0 var deletionsTotal = 0 var changesTotal = 0 let lines = numStatOutput .trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: "\n") .filter { !$0.isEmpty } for line in lines { guard let groups = GitRegex.groups(numstatLine, in: line) else { continue } let insertionsText = groups[1] let deletionsText = groups[2] let file = groups[3] let isBinary = insertionsText == "-" || deletionsText == "-" let insertions = isBinary ? 0 : (Int(insertionsText) ?? 0) let deletions = isBinary ? 0 : (Int(deletionsText) ?? 0) let changes = insertions + deletions files.append(DiffFileStat( file: file, changes: changes, insertions: insertions, deletions: deletions, binary: isBinary )) insertionsTotal += insertions deletionsTotal += deletions changesTotal += changes } return DiffSummary( files: files, insertions: insertionsTotal, deletions: deletionsTotal, changes: changesTotal, changed: files.count ) } /// Counts keyed by every spelling of each path — the raw column plus the /// normalized new/old paths of a rename (`createDiffStatsMap`), so lookups /// by porcelain-status path always hit. public static func statsMap(_ summary: DiffSummary) -> [String: DiffLineStats] { var stats: [String: DiffLineStats] = [:] for file in summary.files { let stat = DiffLineStats(added: file.insertions, removed: file.deletions, binary: file.binary) let paths = normalizePath(file.file) stats[file.file] = stat if !paths.newPath.isEmpty && paths.newPath != file.file { stats[paths.newPath] = stat } if let oldPath = paths.oldPath, !oldPath.isEmpty, oldPath != file.file, oldPath != paths.newPath { stats[oldPath] = stat } } return stats } public struct NormalizedPaths: Equatable, Sendable { public var newPath: String public var oldPath: String? public init(newPath: String, oldPath: String? = nil) { self.newPath = newPath self.oldPath = oldPath } } /// `src/{old => new}/x.ts` / `old.txt => new.txt` → post/pre-image paths. public static func normalizePath(_ rawPath: String) -> NormalizedPaths { let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.contains("{") && trimmed.contains("=>") && trimmed.contains("}") { let newPath = replaceBraceRenames(in: trimmed, keepingGroup: 2) let oldPath = replaceBraceRenames(in: trimmed, keepingGroup: 1) return NormalizedPaths(newPath: newPath, oldPath: oldPath) } if trimmed.contains("=>") { // The Kotlin/web references split on `\s*=>\s*` and trim each // side; splitting on the bare arrow and trimming is equivalent // for the first/last components used here. let parts = trimmed.components(separatedBy: "=>") let oldPath = parts.first?.trimmingCharacters(in: .whitespaces) let newPath = parts.last?.trimmingCharacters(in: .whitespaces) if let newPath, !newPath.isEmpty { return NormalizedPaths(newPath: newPath, oldPath: oldPath) } } return NormalizedPaths(newPath: trimmed) } /// Replaces every `{old => new}` segment with the trimmed requested group /// (the Kotlin `Regex.replace` lambda equivalent). private static func replaceBraceRenames(in text: String, keepingGroup group: Int) -> String { let range = NSRange(text.startIndex.. [String]? { let range = NSRange(line.startIndex..