From afe0337d63fc01bb377c62247e924f24969c1d20 Mon Sep 17 00:00:00 2001 From: Gustavo Lima <23362688+athousanddetails@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:46:13 +0100 Subject: [PATCH 1/2] feat: skip gitignored files when compressing folders --- Config/products/macpacker.json | 24 +++ .../UrlHandling/AppUrlCompressHandler.swift | 7 +- .../ArchiveSavePanel.swift | 6 + .../Settings/GeneralSettingsView.swift | 16 ++ MacPacker/Localizable.xcstrings | 8 + Modules/Sources/Core/AppStorageKeys.swift | 2 + Modules/Sources/Core/ArchiveState.swift | 24 ++- .../Core/Settings/ArchiveSaveOptions.swift | 23 ++- .../Swift7zip/CompressionOptions.swift | 8 +- .../Sources/Swift7zip/GitIgnoreFilter.swift | 170 ++++++++++++++++++ .../Sources/Swift7zip/SevenZipWriter.swift | 3 +- .../CoreTests/GitIgnoreCompressionTests.swift | 108 +++++++++++ 12 files changed, 389 insertions(+), 10 deletions(-) create mode 100644 Modules/Sources/Swift7zip/GitIgnoreFilter.swift create mode 100644 Modules/Tests/CoreTests/GitIgnoreCompressionTests.swift diff --git a/Config/products/macpacker.json b/Config/products/macpacker.json index a81e610..c229104 100644 --- a/Config/products/macpacker.json +++ b/Config/products/macpacker.json @@ -38,6 +38,30 @@ { "version": "1.0.1", "items": [ + { + "type": "feat", + "title": { + "en": "Skip gitignored files when compressing folders", + "zh-Hans": "压缩文件夹时跳过 .gitignore 忽略的文件", + "fr": "Ignorer les fichiers exclus par .gitignore lors de la compression", + "de": "Beim Komprimieren von .gitignore ignorierte Dateien auslassen", + "it": "Salta i file ignorati da .gitignore comprimendo cartelle", + "ja": "フォルダ圧縮時に .gitignore 対象ファイルを除外", + "fa": "نادیده گرفتن فایل‌های مستثناشده با .gitignore هنگام فشرده‌سازی پوشه‌ها", + "pl": "Pomijaj pliki ignorowane przez .gitignore przy kompresji", + "pt-BR": "Ignorar arquivos indicados pelo .gitignore ao compactar pastas", + "ru": "Пропускать файлы, игнорируемые .gitignore, при сжатии папок", + "uk": "Пропускати файли, ігноровані .gitignore, під час стиснення тек", + "es-MX": "Omitir archivos ignorados por .gitignore al comprimir carpetas", + "ko": "폴더 압축 시 .gitignore에서 무시한 파일 제외", + "nl": "Door .gitignore genegeerde bestanden overslaan bij comprimeren", + "tr": "Klasör sıkıştırırken .gitignore'ın yok saydığı dosyaları atla", + "vi": "Bỏ qua tệp bị .gitignore loại trừ khi nén thư mục" + }, + "issues": [ + "237" + ] + }, { "type": "fix", "highlight": true, diff --git a/MacPacker/Core/UrlHandling/AppUrlCompressHandler.swift b/MacPacker/Core/UrlHandling/AppUrlCompressHandler.swift index 8421427..2743f17 100644 --- a/MacPacker/Core/UrlHandling/AppUrlCompressHandler.swift +++ b/MacPacker/Core/UrlHandling/AppUrlCompressHandler.swift @@ -9,6 +9,7 @@ import AppKit import Core import FinderMenu import Foundation +import Swift7zip import tb private let log = tb.Logger(subsystem: "app.MacPacker", category: "url") @@ -25,7 +26,11 @@ private func writeArchive( ) async -> Bool { log.notice("Compressing \(items.count) item(s) to \(destination.lastPathComponent)") let state = ArchiveState(catalog: catalog, engineSelector: engineSelector) - await state.compress(items, to: destination) + let options = CompressionOptions( + format: destination.pathExtension.lowercased() == "7z" ? .sevenZ : .zip, + respectGitIgnore: UserDefaults.standard.bool(forKey: Keys.respectGitIgnore) + ) + await state.compress(items, to: destination, options: options) if let error = state.error { log.error("Compress failed", context: ["error": error]) diff --git a/MacPacker/Features/ArchiveContentViewer/ArchiveSavePanel.swift b/MacPacker/Features/ArchiveContentViewer/ArchiveSavePanel.swift index 9d56328..49ce37c 100644 --- a/MacPacker/Features/ArchiveContentViewer/ArchiveSavePanel.swift +++ b/MacPacker/Features/ArchiveContentViewer/ArchiveSavePanel.swift @@ -179,6 +179,12 @@ struct SaveOptionsRows: View { .labelsHidden() .accessibilityIdentifier("saveOptions.excludeDSStore") } + row(Text("Skip Git-ignored project files", comment: "Setting to omit files matched by a folder's .gitignore when compressing")) { + Toggle(isOn: $options.respectGitIgnore) { EmptyView() } + .labelsHidden() + .accessibilityIdentifier("saveOptions.respectGitIgnore") + .help(Text("If a folder has a .gitignore file, skip the files and folders it says to ignore.", comment: "Explains the Git-ignored project files setting")) + } } } diff --git a/MacPacker/Features/Settings/GeneralSettingsView.swift b/MacPacker/Features/Settings/GeneralSettingsView.swift index f5c96a9..484c4d0 100644 --- a/MacPacker/Features/Settings/GeneralSettingsView.swift +++ b/MacPacker/Features/Settings/GeneralSettingsView.swift @@ -20,6 +20,7 @@ struct GeneralSettingsView: View { @AppStorage(Keys.showMenuBarItem) var showMenuBarItem: Bool = false @AppStorage(Keys.rememberRecentArchives) var rememberRecentArchives: Bool = true @AppStorage(Keys.smartExtraction, store: .macPackerShared) var smartExtraction: Bool = true + @AppStorage(Keys.respectGitIgnore) var respectGitIgnore: Bool = false var body: some View { VStack(spacing: 8) { @@ -119,6 +120,21 @@ struct GeneralSettingsView: View { .padding(.leading, 8) .frame(width: 240, alignment: .leading) } + HStack(alignment: .top) { + Text("Skip Git-ignored project files", comment: "Setting to omit files matched by a folder's .gitignore when compressing") + .frame(width: 200, alignment: .trailing) + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: $respectGitIgnore) { EmptyView() } + .labelsHidden() + .accessibilityIdentifier("settings.respectGitIgnore") + Text("If a folder has a .gitignore file, skip the files and folders it says to ignore.", comment: "Explains the Git-ignored project files setting") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.leading, 8) + .frame(width: 240, alignment: .leading) + } } .padding() // Turning it off is a privacy switch, so what was collected goes with it — diff --git a/MacPacker/Localizable.xcstrings b/MacPacker/Localizable.xcstrings index 453bc36..5bc0cf4 100644 --- a/MacPacker/Localizable.xcstrings +++ b/MacPacker/Localizable.xcstrings @@ -1,6 +1,14 @@ { "sourceLanguage": "en", "strings": { + "If a folder has a .gitignore file, skip the files and folders it says to ignore.": { + "comment": "Explains the Git-ignored project files setting", + "extractionState": "manual" + }, + "Skip Git-ignored project files": { + "comment": "Setting to omit files matched by a folder's .gitignore when compressing", + "extractionState": "manual" + }, "appExtractionInProgress": { "comment": "Title of the alert shown when the user quits while an extraction is running.", "extractionState": "manual", diff --git a/Modules/Sources/Core/AppStorageKeys.swift b/Modules/Sources/Core/AppStorageKeys.swift index de564b5..7c2f6b6 100644 --- a/Modules/Sources/Core/AppStorageKeys.swift +++ b/Modules/Sources/Core/AppStorageKeys.swift @@ -70,6 +70,8 @@ public enum Keys { /// Leave `.DS_Store` files out of archives. Off by default, as in Finder's /// own Compress. public static let saveOptionsExcludeDSStore = "saveOptionsExcludeDSStore" + /// Skip additions matched by `.gitignore` when compressing folders. + public static let respectGitIgnore = "respectGitIgnore" // register defaults upon app start so that the archive table has a default it // can use when showing the table for the first time diff --git a/Modules/Sources/Core/ArchiveState.swift b/Modules/Sources/Core/ArchiveState.swift index 3716b2b..16921f9 100644 --- a/Modules/Sources/Core/ArchiveState.swift +++ b/Modules/Sources/Core/ArchiveState.swift @@ -548,6 +548,11 @@ extension ArchiveState { /// is left out, with the reason in `error`, and so is one inside it. @discardableResult public func add(url: URL) -> Bool { + add(url: url, ignoring: nil) + } + + @discardableResult + private func add(url: URL, ignoring gitIgnore: GitIgnoreFilter?) -> Bool { guard !isSaving else { log.notice("Ignoring add — a save is in progress", context: ["file": url.lastPathComponent]) return false @@ -569,7 +574,8 @@ extension ArchiveState { // drop or a panel, a grant reused from storage gives no access // outside it (#278). added = Sandbox.accessSync(url: url) { - addFolder(url: url, archivePath: base + url.lastPathComponent, under: selectedItem) + addFolder(url: url, archivePath: base + url.lastPathComponent, + under: selectedItem, ignoring: gitIgnore) } } else { addFile(url: url, archivePath: base + url.lastPathComponent, under: selectedItem) @@ -590,8 +596,9 @@ extension ArchiveState { /// for a single folder, an empty one (#278). public func compress(_ items: [URL], to destination: URL, options: CompressionOptions? = nil) async { create() + let gitIgnore = options?.respectGitIgnore == true ? GitIgnoreFilter(paths: items) : nil for item in items { - guard add(url: item) else { return } + guard add(url: item, ignoring: gitIgnore) else { return } } await save(to: destination, options: options)?.value } @@ -617,7 +624,8 @@ extension ArchiveState { } /// - Returns: whether `url` and every folder below it could be read. - private func addFolder(url: URL, archivePath: String, under parent: ArchiveItem) -> Bool { + private func addFolder(url: URL, archivePath: String, under parent: ArchiveItem, + ignoring gitIgnore: GitIgnoreFilter? = nil) -> Bool { // Read the contents first: if the folder can't be enumerated we must not // add it as an empty directory (that would silently drop its real // contents on save). Skip it and surface the error instead. @@ -654,8 +662,15 @@ extension ArchiveState { var complete = true for child in children.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + do { + if try gitIgnore?.isIgnored(child, directory: child.isDirectory) == true { continue } + } catch { + self.error = error.localizedDescription + return false + } if child.isDirectory { - complete = addFolder(url: child, archivePath: archivePath + "/" + child.lastPathComponent, under: item) + complete = addFolder(url: child, archivePath: archivePath + "/" + child.lastPathComponent, + under: item, ignoring: gitIgnore) && complete } else { addFile(url: child, archivePath: archivePath + "/" + child.lastPathComponent, under: item) @@ -1710,4 +1725,3 @@ extension ArchiveState { return adjustedSelection } } - diff --git a/Modules/Sources/Core/Settings/ArchiveSaveOptions.swift b/Modules/Sources/Core/Settings/ArchiveSaveOptions.swift index 05097d6..6895181 100644 --- a/Modules/Sources/Core/Settings/ArchiveSaveOptions.swift +++ b/Modules/Sources/Core/Settings/ArchiveSaveOptions.swift @@ -87,9 +87,25 @@ public final class ArchiveSaveOptions: ObservableObject { /// Leave `.DS_Store` files out. Not per format: it is about the files, not /// the archive. @Published public var excludeDSStore: Bool { didSet { autosave() } } + /// Shared with Finder compression and General Settings, rather than per + /// format: the same folder should package the same way as zip or 7z. + public var respectGitIgnore: Bool { + get { pendingRespectGitIgnore ?? defaults.bool(forKey: Keys.respectGitIgnore) } + set { + objectWillChange.send() + if storage == .quickCompress { + defaults.set(newValue, forKey: Keys.respectGitIgnore) + } else { + // A save panel's choices take effect only when Save goes ahead. + // Cancel must not alter the global Finder preference. + pendingRespectGitIgnore = newValue + } + } + } private let defaults: UserDefaults private let storage: Storage + private var pendingRespectGitIgnore: Bool? private var remembered: [Format: Remembered] = [:] /// Set while a format's settings are taken over, which changes one property /// after the other: storing each step would store half of one format. @@ -161,13 +177,14 @@ public final class ArchiveSaveOptions: ObservableObject { wordSize: compresses ? wordSize : nil, solidBlockSize: compresses && hasSolidBlocks && (solidBlockSize ?? 0) > 0 ? solidBlockSize : nil, volumeSize: volumeSize, - excludeDSStore: excludeDSStore) + excludeDSStore: excludeDSStore, + respectGitIgnore: respectGitIgnore) } /// What the start page's drop area writes: the format picked there, defaults /// for the rest. It shows no other option, so no password or volume size set /// for Quick Compress may reach it. - public var startPageOptions: CompressionOptions { .init(format: format) } + public var startPageOptions: CompressionOptions { .init(format: format, respectGitIgnore: respectGitIgnore) } // MARK: Memory @@ -183,6 +200,8 @@ public final class ArchiveSaveOptions: ObservableObject { } defaults.set(format.rawValue, forKey: storage.formatKey) defaults.set(excludeDSStore, forKey: storage.excludeDSStoreKey) + defaults.set(respectGitIgnore, forKey: Keys.respectGitIgnore) + pendingRespectGitIgnore = nil } private func autosave() { diff --git a/Modules/Sources/Swift7zip/CompressionOptions.swift b/Modules/Sources/Swift7zip/CompressionOptions.swift index 896b08f..825e146 100644 --- a/Modules/Sources/Swift7zip/CompressionOptions.swift +++ b/Modules/Sources/Swift7zip/CompressionOptions.swift @@ -154,6 +154,10 @@ public struct CompressionOptions: Sendable { /// anyone means to archive. Entries the archive holds already stay. public var excludeDSStore: Bool + /// Leave out files matched by a folder's `.gitignore` when adding files + /// from disk. Entries already in an archive are not changed. + public var respectGitIgnore: Bool + /// Whether this writes an encrypted archive. public var encrypts: Bool { !(password ?? "").isEmpty } @@ -170,7 +174,8 @@ public struct CompressionOptions: Sendable { wordSize: UInt32? = nil, solidBlockSize: UInt64? = nil, volumeSize: UInt64? = nil, - excludeDSStore: Bool = false + excludeDSStore: Bool = false, + respectGitIgnore: Bool = false ) { self.format = format self.level = level @@ -184,5 +189,6 @@ public struct CompressionOptions: Sendable { self.solidBlockSize = solidBlockSize self.volumeSize = volumeSize self.excludeDSStore = excludeDSStore + self.respectGitIgnore = respectGitIgnore } } diff --git a/Modules/Sources/Swift7zip/GitIgnoreFilter.swift b/Modules/Sources/Swift7zip/GitIgnoreFilter.swift new file mode 100644 index 0000000..c4bb675 --- /dev/null +++ b/Modules/Sources/Swift7zip/GitIgnoreFilter.swift @@ -0,0 +1,170 @@ +import Foundation + +extension Array where Element == ArchiveUpdateItem { + /// Only additions from disk are filtered. A Save or Save As never silently + /// removes entries that the archive already contains. + func excludingGitIgnoredFiles() throws -> [ArchiveUpdateItem] { + let paths = compactMap { item -> (archivePath: String, url: URL)? in + switch item { + case .addFile(let path, let url, _, _): (path, url) + case .addDirectory(let path, let url?, _, _): (path, url) + default: nil + } + } + guard !paths.isEmpty else { return self } + // The first archive path component identifies what was selected. A + // single selected folder is its own ignore root; its parent's rules + // must not unexpectedly remove files from inside it. Multiple selected + // items (including Compress Contents) share their containing folder. + let selected = paths.filter { !$0.archivePath.contains("/") }.map(\.url) + let filter = GitIgnoreFilter(paths: selected.isEmpty ? paths.map(\.url) : selected) + return try filterItems(filter) + } + + private func filterItems(_ filter: GitIgnoreFilter) throws -> [ArchiveUpdateItem] { + try self.filter { item in + switch item { + case .addFile(_, let url, _, _): + return try !filter.isIgnored(url, directory: false) + case .addDirectory(_, let url?, _, _): + return try !filter.isIgnored(url, directory: true) + default: + return true + } + } + } +} + +/// Reads only `.gitignore` files on the path from the selection's common parent +/// to each added item. Rules closer to the item take precedence. An ignored +/// directory cannot be re-included by a rule about a file inside it, as in Git. +public final class GitIgnoreFilter { + private let root: [String] + private var cachedRules: [String: [Rule]] = [:] + + public init(paths: [URL]) { + let first = paths[0].standardizedFileURL + var isDirectory: ObjCBool = false + let singleDirectory = paths.count == 1 + && FileManager.default.fileExists(atPath: first.path, isDirectory: &isDirectory) + && isDirectory.boolValue + var common = (singleDirectory + ? first : first.deletingLastPathComponent()).pathComponents + for path in paths.dropFirst() { + let parts = path.standardizedFileURL.deletingLastPathComponent().pathComponents + common = Array(zip(common, parts).prefix { $0.0 == $0.1 }.map(\.0)) + } + root = common + } + + public func isIgnored(_ url: URL, directory: Bool) throws -> Bool { + let parts = url.standardizedFileURL.pathComponents + guard parts.starts(with: root) else { return false } + var active: [(depth: Int, rule: Rule)] = [] + for depth in root.count.. [Rule] { + let path = folder.path + if let cached = cachedRules[path] { return cached } + let file = folder.appendingPathComponent(".gitignore") + guard FileManager.default.fileExists(atPath: file.path) else { + cachedRules[path] = [] + return [] + } + // Failure is surfaced before opening the destination. Silently ignoring + // an unreadable rule file could publish files the user meant to exclude. + let contents = try String(contentsOf: file, encoding: .utf8) + let rules = contents.components(separatedBy: .newlines).compactMap(Rule.init) + cachedRules[path] = rules + return rules + } + + private struct Rule { + let negated: Bool + let directoryOnly: Bool + let pathPattern: Bool + let regex: NSRegularExpression + + init?(_ line: String) { + var pattern = line + while pattern.last == " " { + let slashes = pattern.dropLast().reversed().prefix(while: { $0 == "\\" }).count + if slashes % 2 == 1 { break } + pattern.removeLast() + } + guard !pattern.isEmpty, !pattern.hasPrefix("#") else { return nil } + let negated = pattern.hasPrefix("!") + if negated { pattern.removeFirst() } + let directoryOnly = pattern.hasSuffix("/") && !pattern.hasSuffix("\\/") + if directoryOnly { pattern.removeLast() } + let pathPattern = pattern.hasPrefix("/") || pattern.contains("/") + if pattern.hasPrefix("/") { pattern.removeFirst() } + guard !pattern.isEmpty, let expression = Self.expression(for: pattern) else { return nil } + self.negated = negated + self.directoryOnly = directoryOnly + self.pathPattern = pathPattern + regex = expression + } + + func matches(_ components: [String], directory: Bool) -> Bool { + guard !components.isEmpty, !directoryOnly || directory else { return false } + let value = pathPattern ? components.joined(separator: "/") : components.last! + return regex.firstMatch(in: value, range: NSRange(value.startIndex.. NSRegularExpression? { + let chars = Array(pattern) + var result = "^" + var index = 0 + while index < chars.count { + let char = chars[index] + if char == "\\" { + index += 1 + guard index < chars.count else { return nil } + result += NSRegularExpression.escapedPattern(for: String(chars[index])) + } else if char == "*" { + if index + 1 < chars.count, chars[index + 1] == "*", + (index == 0 || chars[index - 1] == "/") { + if index + 2 < chars.count, chars[index + 2] == "/" { + result += "(?:.*/)?" + index += 2 + } else if index > 0, index + 2 == chars.count { + result += ".*" + index += 1 + } else { + result += "[^/]*" + index += 1 + } + } else { + result += "[^/]*" + } + } else if char == "?" { + result += "[^/]" + } else if char == "[", let end = chars[(index + 1)...].firstIndex(of: "]") { + var contents = String(chars[(index + 1).. 0 { diff --git a/Modules/Tests/CoreTests/GitIgnoreCompressionTests.swift b/Modules/Tests/CoreTests/GitIgnoreCompressionTests.swift new file mode 100644 index 0000000..f4beb02 --- /dev/null +++ b/Modules/Tests/CoreTests/GitIgnoreCompressionTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Swift7zip +import Testing +@testable import Core + +extension AllCoreTests { + @MainActor struct GitIgnoreCompressionTests { + private func fixture() throws -> (folder: URL, project: URL) { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let project = folder.appendingPathComponent("Project") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try "keep.txt\n".write(to: folder.appendingPathComponent(".gitignore"), atomically: true, encoding: .utf8) + for path in ["node_modules", "build", "docs/deep", "nested", "other"] { + try FileManager.default.createDirectory( + at: project.appendingPathComponent(path), withIntermediateDirectories: true) + } + let files: [String: String] = [ + ".gitignore": "node_modules/\n!node_modules/reinclude.js\nbuild/*\n!build/keep.txt\n/only-root.log\n*.tmp\n!important.tmp\ndocs/**/cache?.bin\n\\#literal\n", + "keep.txt": "keep", "node_modules/pkg.js": "skip", "build/generated.o": "skip", + "node_modules/reinclude.js": "still skip", + "build/keep.txt": "keep", "only-root.log": "skip", "nested/only-root.log": "keep", + "other/only-root.log": "keep", + "scratch.tmp": "skip", "important.tmp": "keep", "nested/scratch.tmp": "skip", + "docs/cache1.bin": "skip", "docs/deep/cache2.bin": "skip", "#literal": "skip", + "nested/.gitignore": "*.log\n!important.log\n!only-root.log\n", + "nested/debug.log": "skip", "nested/important.log": "keep", + ] + for (path, contents) in files { + try contents.write(to: project.appendingPathComponent(path), atomically: true, encoding: .utf8) + } + return (folder, project) + } + + @Test(arguments: CompressionOptions.Format.allCases) + func ignoresNestedRulesAndPreservesExceptions(_ format: CompressionOptions.Format) async throws { + let (folder, project) = try fixture() + defer { try? FileManager.default.removeItem(at: folder) } + let output = folder.appendingPathComponent("out.\(format.rawValue)") + let state = ArchiveState(catalog: ArchiveTypeCatalog(), engineSelector: ArchiveEngineSelector7zip()) + await state.compress([project], to: output, + options: .init(format: format, respectGitIgnore: true)) + #expect(state.error == nil, "\(state.error ?? "")") + let paths = Set(try SevenZipArchive(url: output).entries.map(\.path)) + for path in ["Project/keep.txt", "Project/build/keep.txt", "Project/important.tmp", + "Project/nested/only-root.log", "Project/other/only-root.log", + "Project/nested/important.log", + "Project/.gitignore", "Project/nested/.gitignore"] { + #expect(paths.contains(path), "\(path) should be kept") + } + for path in ["Project/node_modules/pkg.js", "Project/node_modules/reinclude.js", + "Project/build/generated.o", + "Project/only-root.log", "Project/scratch.tmp", "Project/nested/scratch.tmp", + "Project/docs/cache1.bin", "Project/docs/deep/cache2.bin", + "Project/#literal", "Project/nested/debug.log"] { + #expect(!paths.contains(path), "\(path) should be ignored") + } + } + + @Test func compressContentsUsesTheContainingFoldersRules() async throws { + let (folder, project) = try fixture() + defer { try? FileManager.default.removeItem(at: folder) } + let contents = try FileManager.default.contentsOfDirectory(at: project, includingPropertiesForKeys: nil) + let output = folder.appendingPathComponent("contents.zip") + let state = ArchiveState(catalog: ArchiveTypeCatalog(), engineSelector: ArchiveEngineSelector7zip()) + await state.compress(contents, to: output, options: .init(format: .zip, respectGitIgnore: true)) + #expect(state.error == nil, "\(state.error ?? "")") + let paths = Set(try SevenZipArchive(url: output).entries.map(\.path)) + #expect(paths.contains("keep.txt")) + #expect(paths.contains("build/keep.txt")) + #expect(!paths.contains("node_modules/pkg.js")) + #expect(!paths.contains("build/generated.o")) + #expect(!paths.contains("Project/keep.txt"), "Compress Contents omits the enclosing folder") + } + + @Test func offByDefaultAndDoesNotRemoveExistingEntries() async throws { + let (folder, project) = try fixture() + defer { try? FileManager.default.removeItem(at: folder) } + let output = folder.appendingPathComponent("out.zip") + let state = ArchiveState(catalog: ArchiveTypeCatalog(), engineSelector: ArchiveEngineSelector7zip()) + await state.compress([project], to: output, options: .init(format: .zip)) + #expect(state.error == nil) + #expect(try SevenZipArchive(url: output).entries.contains { $0.path == "Project/node_modules/pkg.js" }) + + let second = folder.appendingPathComponent("updated.zip") + try SevenZipArchive.writeArchive( + source: output, destination: second, items: [], + options: .init(format: .zip, respectGitIgnore: true)) + #expect(try SevenZipArchive(url: second).entries.contains { $0.path == "Project/node_modules/pkg.js" }) + } + + @Test func preferenceAppliesAcrossSaveAndQuickCompress() throws { + let suite = "gitignore-compression-\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let panel = ArchiveSaveOptions(defaults: defaults) + #expect(!panel.respectGitIgnore) + panel.respectGitIgnore = true + #expect(!defaults.bool(forKey: Keys.respectGitIgnore), "Cancel must not change the global setting") + panel.remember() + let quick = ArchiveSaveOptions(defaults: defaults, storage: .quickCompress) + #expect(quick.respectGitIgnore) + #expect(try #require(quick.compressionOptions).respectGitIgnore) + #expect(quick.startPageOptions.respectGitIgnore) + quick.respectGitIgnore = false + #expect(!defaults.bool(forKey: Keys.respectGitIgnore), "Quick Compress changes take effect immediately") + } + } +} From 2a2faa064d3fac8423050913cd4239e193932e23 Mon Sep 17 00:00:00 2001 From: Gustavo Lima <23362688+athousanddetails@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:49:10 +0100 Subject: [PATCH 2/2] fix: handle empty compression selections with gitignore --- Modules/Sources/Swift7zip/GitIgnoreFilter.swift | 9 ++++++--- Modules/Tests/CoreTests/GitIgnoreCompressionTests.swift | 5 +++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/Swift7zip/GitIgnoreFilter.swift b/Modules/Sources/Swift7zip/GitIgnoreFilter.swift index c4bb675..2ceec3c 100644 --- a/Modules/Sources/Swift7zip/GitIgnoreFilter.swift +++ b/Modules/Sources/Swift7zip/GitIgnoreFilter.swift @@ -39,11 +39,14 @@ extension Array where Element == ArchiveUpdateItem { /// to each added item. Rules closer to the item take precedence. An ignored /// directory cannot be re-included by a rule about a file inside it, as in Git. public final class GitIgnoreFilter { - private let root: [String] + private let root: [String]? private var cachedRules: [String: [Rule]] = [:] public init(paths: [URL]) { - let first = paths[0].standardizedFileURL + guard let first = paths.first?.standardizedFileURL else { + root = nil + return + } var isDirectory: ObjCBool = false let singleDirectory = paths.count == 1 && FileManager.default.fileExists(atPath: first.path, isDirectory: &isDirectory) @@ -59,7 +62,7 @@ public final class GitIgnoreFilter { public func isIgnored(_ url: URL, directory: Bool) throws -> Bool { let parts = url.standardizedFileURL.pathComponents - guard parts.starts(with: root) else { return false } + guard let root, parts.starts(with: root) else { return false } var active: [(depth: Int, rule: Rule)] = [] for depth in root.count.. (folder: URL, project: URL) { let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let project = folder.appendingPathComponent("Project")