From 1171439f02377ebe1f695a2c5ea011690cdf50be Mon Sep 17 00:00:00 2001 From: Manuel Kraus Date: Tue, 20 Oct 2026 09:32:52 +0200 Subject: [PATCH] Add Git Diff tool to clean up diffs for AI review Drops lockfiles, minified/source-map and binary files, filters by path globs, lets single files be unticked, and can collapse deleted files. --- README.md | 2 +- Sources/Decant/DecantApp.swift | 6 +- Sources/Decant/TextToolPages.swift | 106 +++++++++++++++++++++++ Sources/Decant/Views.swift | 1 + Sources/DecantCore/TextTools.swift | 119 ++++++++++++++++++++++++++ Tests/DecantCoreTests/ToolTests.swift | 47 ++++++++++ 6 files changed, 278 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9059a0c..cdc6712 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ swift test # engine tests | Time | Unix time (live clock, results table, date picker, time zones, differences), Cron (plain words, next runs) | | Tokens & Security | JWT decoder with HMAC check, UUID v4/v7 + ULID generator and inspector, password generator | | Data | CSV ↔ JSON with table preview | -| Text | Diff (side by side / unified, word highlights, folding), Regex tester, character/word/line counter | +| Text | Diff (side by side / unified, word highlights, folding), Git Diff cleanup for AI review (drops lockfiles, generated and binary files; path filters), Regex tester, character/word/line counter | | Miscellaneous | Color converter (color panel, eyedropper, contrast), QR code generator and reader | Shortcuts: ⌘K command palette, ⌘1–⌘9 tools (order from Settings → Sidebar), ⌘N new document, ⌘O open file, ⌘S save output, ⇧⌘C copy output, ⌘F find in output, ⌘+ / ⌘− font size, ⌥⌘Z wrap lines. diff --git a/Sources/Decant/DecantApp.swift b/Sources/Decant/DecantApp.swift index daee10c..e34006e 100644 --- a/Sources/Decant/DecantApp.swift +++ b/Sources/Decant/DecantApp.swift @@ -115,7 +115,7 @@ enum Category: String, CaseIterable { } enum Tool: String, CaseIterable, Identifiable, Codable, Hashable { - case detect, formatter, base64, url, unixTime, cron, jwt, uuid, password, csv, diff, regex, counter, markdown, color, qr + case detect, formatter, base64, url, unixTime, cron, jwt, uuid, password, csv, diff, gitDiff, regex, counter, markdown, color, qr var id: String { rawValue } @@ -132,6 +132,7 @@ enum Tool: String, CaseIterable, Identifiable, Codable, Hashable { case .password: "Passwords" case .csv: "CSV ↔ JSON" case .diff: "Diff" + case .gitDiff: "Git Diff" case .regex: "Regex" case .counter: "Counter" case .markdown: "Markdown" @@ -153,6 +154,7 @@ enum Tool: String, CaseIterable, Identifiable, Codable, Hashable { case .password: "lock" case .csv: "tablecells" case .diff: "plusminus" + case .gitDiff: "arrow.triangle.branch" case .regex: "asterisk" case .counter: "textformat.123" case .markdown: "doc.richtext" @@ -168,7 +170,7 @@ enum Tool: String, CaseIterable, Identifiable, Codable, Hashable { case .unixTime, .cron: .time case .jwt, .uuid, .password: .security case .csv: .data - case .diff, .regex, .counter: .text + case .diff, .gitDiff, .regex, .counter: .text case .color, .qr: .misc } } diff --git a/Sources/Decant/TextToolPages.swift b/Sources/Decant/TextToolPages.swift index 23b8531..59c47d7 100644 --- a/Sources/Decant/TextToolPages.swift +++ b/Sources/Decant/TextToolPages.swift @@ -207,6 +207,112 @@ struct DiffView: View { } } +// MARK: - Git diff + +struct GitDiffPage: View { + @Environment(Store.self) private var store + @AppStorage("gitDiffSkipNoise") private var skipNoise = true + @AppStorage("gitDiffCollapseDeleted") private var collapseDeleted = false + @State private var result = GitDiff.Result() + + private struct Key: Equatable { var diff, patterns: String; var noise, deleted: Bool } + + var body: some View { + @Bindable var store = store + let doc = store[doc: .gitDiff] + let kept = result.files.filter { $0.skipped == nil }.count + ToolPage(tool: .gitDiff, + status: doc.input.isEmpty ? nil : result.files.isEmpty ? .warning("No git diff found") : Status(text: "\(kept) of \(result.files.count) files"), + meta: doc.input.isEmpty ? ["Trim a diff down to what a reviewer needs"] : ["~\(tokens(result.output)) tokens", "was ~\(tokens(doc.input))"]) { + HStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease").foregroundStyle(Palette.text4) + TextField("Paths, e.g. src/api !*.test.ts", text: $store[doc: .gitDiff].aux) + .textFieldStyle(.plain) + .font(.system(size: 13.5, design: .monospaced)) + .frame(width: 320) + } + .padding(.horizontal, 14) + .frame(height: 32) + .glassCapsule() + OptionToggle(title: "Skip lockfiles & generated", isOn: $skipNoise) + OptionToggle(title: "Collapse deleted files", isOn: $collapseDeleted) + } content: { + GeometryReader { g in + HStack(spacing: 14) { + VStack(spacing: 14) { + EditorPane(title: "Git Diff", text: $store[doc: .gitDiff].input, + placeholder: "Paste the output of git diff, git show or a .patch file, e.g. git diff main | pbcopy") + Pane("Files") { fileList } + .frame(height: max(140, g.size.height * 0.4)) + } + EditorPane(title: "Cleaned", text: .constant(result.output), editable: false, output: true, primary: true, fileExtension: "diff") + } + } + } + .task(id: Key(diff: doc.input, patterns: doc.aux, noise: skipNoise, deleted: collapseDeleted)) { + let (d, p, n, c) = (doc.input, doc.aux, skipNoise, collapseDeleted) + result = await Task.detached { GitDiff.clean(d, patterns: p, skipNoise: n, collapseDeleted: c) }.value + } + } + + @ViewBuilder private var fileList: some View { + if result.files.isEmpty { + Text("Files in the diff show up here. Untick one to leave it out.") + .foregroundStyle(Palette.text4).frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(result.files, id: \.self) { f in + fileRow(f) + Rectangle().fill(Palette.cardSeparator).frame(height: 1) + } + } + } + } + } + + private func fileRow(_ f: GitDiff.File) -> some View { + let pattern = GitDiff.excludePattern(f.path) + // Only files kept, or dropped by their own `!path`, can be ticked here; the rest follow the filter and toggles. + let ownExclude = patternTokens.contains(pattern) + return HStack(spacing: 10) { + Toggle(f.path, isOn: Binding(get: { f.skipped == nil }, set: { on in + var t = patternTokens.filter { $0 != pattern } + if !on { t.append(pattern) } + store[doc: .gitDiff].aux = t.joined(separator: " ") + })) + .labelsHidden() + .toggleStyle(.checkbox) + .disabled(f.skipped != nil && !ownExclude) + Text(f.path) + .font(.system(size: 12.5, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(f.skipped == nil ? AnyShapeStyle(.primary) : AnyShapeStyle(Palette.text4)) + .help(f.path) + Spacer(minLength: 8) + if let reason = f.skipped, !ownExclude { + Text(reason).font(.system(size: 11.5)).foregroundStyle(Palette.text4) + } + HStack(spacing: 6) { + Text("+\(f.added)").foregroundStyle(Palette.added) + Text("−\(f.removed)").foregroundStyle(Palette.removed) + } + .font(.system(size: 11.5, weight: .semibold, design: .monospaced)) + .opacity(f.skipped == nil ? 1 : 0.5) + } + .padding(.horizontal, 16) + .frame(height: 30) + } + + private var patternTokens: [String] { + store[doc: .gitDiff].aux.split(whereSeparator: { $0 == "," || $0.isWhitespace }).map(String.init) + } + + /// Rough count at four characters per token. + private func tokens(_ s: String) -> String { (s.utf8.count / 4).formatted(.number.notation(.compactName)) } +} + // MARK: - Regex struct RegexPage: View { diff --git a/Sources/Decant/Views.swift b/Sources/Decant/Views.swift index 4c5caa8..0aa9477 100644 --- a/Sources/Decant/Views.swift +++ b/Sources/Decant/Views.swift @@ -440,6 +440,7 @@ struct DetailView: View { case .password: PasswordPage() case .csv: CSVPage() case .diff: DiffPage() + case .gitDiff: GitDiffPage() case .regex: RegexPage() case .counter: CounterPage() case .markdown: MarkdownPage() diff --git a/Sources/DecantCore/TextTools.swift b/Sources/DecantCore/TextTools.swift index 04b54ce..42a2b7f 100644 --- a/Sources/DecantCore/TextTools.swift +++ b/Sources/DecantCore/TextTools.swift @@ -101,6 +101,125 @@ public enum Diff { } } +// MARK: - Git diff cleanup + +/// Splits `git diff` output into files and drops the ones a reviewer doesn't need (lockfiles, generated, filtered). +public enum GitDiff { + public struct File: Sendable, Hashable { + public let path: String + public let added: Int, removed: Int + public let binary: Bool, deleted: Bool + /// Why the file was left out, or nil when it's kept. + public let skipped: String? + } + + public struct Result: Sendable, Equatable { + public var files: [File] = [] + public var output = "" + public init() {} + } + + static let lockfiles: Set = [ + "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb", "deno.lock", + "Cargo.lock", "Gemfile.lock", "composer.lock", "poetry.lock", "uv.lock", "Pipfile.lock", "go.sum", "Package.resolved", + "Podfile.lock", "pubspec.lock", "mix.lock", "flake.lock", "packages.lock.json", "gradle.lockfile", + ] + static let generated = [".min.js", ".min.css", ".map"] + + /// `patterns`: globs separated by spaces or commas. Plain ones keep only matching files, `!`-prefixed ones drop files. + /// A pattern with a slash is anchored at the repo root and also matches everything below a directory; one without + /// matches any file or directory name (`*.test.ts`, `docs`). `*` stays within a name, `**` crosses directories. + public static func clean(_ diff: String, patterns: String = "", skipNoise: Bool = true, collapseDeleted: Bool = false) -> Result { + var preamble: [Substring] = [], sections: [[Substring]] = [] + for line in diff.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("diff --git ") { sections.append([line]) } + else if sections.isEmpty { preamble.append(line) } + else { sections[sections.count - 1].append(line) } + } + let rules = patterns.split(whereSeparator: { $0 == "," || $0.isWhitespace }).compactMap(Rule.init) + let includes = rules.filter { !$0.exclude }, excludes = rules.filter(\.exclude) + var result = Result(), out = preamble + for lines in sections { + let header = lines.prefix { !$0.hasPrefix("@@") } + var added = 0, removed = 0 + for l in lines[header.endIndex...] { + if l.hasPrefix("+") { added += 1 } else if l.hasPrefix("-") { removed += 1 } + } + let path = path(lines[0], header) + let name = path.split(separator: "/").last.map(String.init) ?? path + let binary = header.contains { $0.hasPrefix("Binary files ") || $0 == "GIT binary patch" } + let deleted = header.contains { $0.hasPrefix("deleted file mode") } + var skipped: String? + if skipNoise, lockfiles.contains(name) { skipped = "Lockfile" } + else if skipNoise, generated.contains(where: name.hasSuffix) { skipped = "Generated" } + else if skipNoise, binary { skipped = "Binary" } + else if !includes.isEmpty, !includes.contains(where: { $0.matches(path) }) { skipped = "Not in filter" } + else if excludes.contains(where: { $0.matches(path) }) { skipped = "Excluded" } + result.files.append(File(path: path, added: added, removed: removed, binary: binary, deleted: deleted, skipped: skipped)) + if skipped == nil { out += deleted && collapseDeleted ? Array(header) : lines } + } + result.output = out.joined(separator: "\n") + return result + } + + /// The new path, or the old one for deletions; `diff --git a/P b/P` as the fallback (binary files, mode changes). + static func path(_ first: Substring, _ header: ArraySlice) -> String { + func value(_ prefix: String) -> String? { + guard let l = header.first(where: { $0.hasPrefix(prefix) }) else { return nil } + var v = String(l.dropFirst(prefix.count)) + if v.hasSuffix("\t") { v.removeLast() } + if v.hasPrefix("\""), v.hasSuffix("\""), v.count > 1 { v = String(v.dropFirst().dropLast()) } + if v == "/dev/null" { return nil } + return v.hasPrefix("a/") || v.hasPrefix("b/") ? String(v.dropFirst(2)) : v + } + if let p = value("+++ ") ?? value("rename to ") ?? value("--- ") { return p } + let rest = first.dropFirst("diff --git ".count) // "a/P b/P" + return String(rest.dropFirst(2).prefix(max(0, (rest.count - 5) / 2))) + } + + /// A pattern that drops exactly this file, for unticking it in a file list. + public static func excludePattern(_ path: String) -> String { + let p = path.replacingOccurrences(of: " ", with: "?").replacingOccurrences(of: ",", with: "?") + return "!" + (p.contains("/") ? p : "/" + p) + } + + struct Rule { + let exclude: Bool + let anchored: Bool + let regex: Regex + + init?(_ raw: Substring) { + exclude = raw.hasPrefix("!") + var p = exclude ? raw.dropFirst() : raw + if p.hasPrefix("./") { p = p.dropFirst(2) } + anchored = p.hasPrefix("/") || p.dropLast().contains("/") + p = p.drop { $0 == "/" } + while p.hasSuffix("/") { p = p.dropLast() } + guard !p.isEmpty else { return nil } + var r = "", c = Array(p), i = 0 + while i < c.count { + if c[i] == "*", i + 1 < c.count, c[i + 1] == "*" { + let slash = i + 2 < c.count && c[i + 2] == "/" + r += slash ? "(?:.*/)?" : ".*" + i += slash ? 3 : 2 + continue + } + r += c[i] == "*" ? "[^/]*" : c[i] == "?" ? "[^/]" : NSRegularExpression.escapedPattern(for: String(c[i])) + i += 1 + } + guard let regex = try? Regex(r) else { return nil } + self.regex = regex + } + + /// Anchored patterns match the path or a parent directory; the others any single name in the path. + func matches(_ path: String) -> Bool { + let parts = path.split(separator: "/") + let candidates = anchored ? parts.indices.map { parts[...$0].joined(separator: "/") } : parts.map(String.init) + return candidates.contains { (try? regex.wholeMatch(in: $0)) != nil } + } + } +} + // MARK: - Regex public enum RegexGroups { diff --git a/Tests/DecantCoreTests/ToolTests.swift b/Tests/DecantCoreTests/ToolTests.swift index 35e2985..b616e04 100644 --- a/Tests/DecantCoreTests/ToolTests.swift +++ b/Tests/DecantCoreTests/ToolTests.swift @@ -169,3 +169,50 @@ import Testing #expect(bad.errorLine == 3 && bad.errorColumn == 5) #expect(f.format("x", language: .xml, indent: .two) == nil) // Prettier doesn't do XML without a plugin } + +@Test func gitDiffDropsNoiseAndFilters() { + let diff = """ + commit abc + diff --git a/src/api/user.ts b/src/api/user.ts + index 1..2 100644 + --- a/src/api/user.ts + +++ b/src/api/user.ts + @@ -1 +1 @@ + -old + +new + diff --git a/yarn.lock b/yarn.lock + --- a/yarn.lock + +++ b/yarn.lock + @@ -1 +1 @@ + -a + +b + diff --git a/src/api/user.test.ts b/src/api/user.test.ts + deleted file mode 100644 + --- a/src/api/user.test.ts + +++ /dev/null + @@ -1,2 +0,0 @@ + -x + -y + diff --git a/logo.png b/logo.png + Binary files a/logo.png and b/logo.png differ + diff --git a/README.md b/README.md + --- a/README.md + +++ b/README.md + @@ -1 +1 @@ + -r + +s + """ + let all = GitDiff.clean(diff) + #expect(all.files.map(\.path) == ["src/api/user.ts", "yarn.lock", "src/api/user.test.ts", "logo.png", "README.md"]) + #expect(all.files.map(\.skipped) == [nil, "Lockfile", nil, "Binary", nil]) + #expect(all.files[2].removed == 2 && all.files[2].deleted) + #expect(all.output.hasPrefix("commit abc\ndiff --git a/src/api/user.ts")) + #expect(!all.output.contains("yarn.lock")) + + let api = GitDiff.clean(diff, patterns: "src/api, !*.test.ts") + #expect(api.files.filter { $0.skipped == nil }.map(\.path) == ["src/api/user.ts"]) + #expect(GitDiff.clean(diff, patterns: "src/**/*.ts").files.filter { $0.skipped == nil }.count == 2) + #expect(GitDiff.clean(diff, patterns: GitDiff.excludePattern("README.md")).files.last?.skipped == "Excluded") + #expect(GitDiff.clean(diff, skipNoise: false).files.allSatisfy { $0.skipped == nil }) + #expect(!GitDiff.clean(diff, collapseDeleted: true).output.contains("-x")) +}