diff --git a/README.md b/README.md index d7bd921..f0cb905 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,11 @@
## How it works -You select one or more “root” folders (for example `~/projects`). Tag scans each *direct child* folder and: +You select one or more “root” folders (for example `~/projects`). Tag scans each direct child folder. When a non-repo child contains immediate child Git repositories, Tag marks the parent as “Multiple Git Repos” and scans those immediate children too. -- Detects whether it’s a Git repo +For scanned folders, it: + +- Detects whether the folder is a Git repo - Checks for uncommitted changes and unpushed commits - Applies a configurable Finder tag (name + color) - Optionally writes `owner/repo` as the Finder comment (requires macOS Automation permission for Finder) diff --git a/tag.xcodeproj/project.pbxproj b/tag.xcodeproj/project.pbxproj index 88eacff..d43f858 100644 --- a/tag.xcodeproj/project.pbxproj +++ b/tag.xcodeproj/project.pbxproj @@ -340,7 +340,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.0.2; + MARKETING_VERSION = 0.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.monotonic.tag; PRODUCT_MODULE_NAME = tag; PRODUCT_NAME = Tag; @@ -375,7 +375,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.0.2; + MARKETING_VERSION = 0.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.monotonic.tag; PRODUCT_MODULE_NAME = tag; PRODUCT_NAME = Tag; diff --git a/tag/Tagger/Config.swift b/tag/Tagger/Config.swift index 420d32a..683e68e 100644 --- a/tag/Tagger/Config.swift +++ b/tag/Tagger/Config.swift @@ -37,24 +37,59 @@ struct TagDefinition: Codable, Hashable, Sendable { } struct StatusTags: Codable, Hashable, Sendable { + static let defaultMultipleGitRepos = TagDefinition(name: "Multiple Git Repos", colorIndex: 3) + var localGitOnly: TagDefinition var gitSynced: TagDefinition var unexpectedFile: TagDefinition var noGitRepo: TagDefinition var gitLocalChanges: TagDefinition + var multipleGitRepos: TagDefinition init( localGitOnly: TagDefinition, gitSynced: TagDefinition, unexpectedFile: TagDefinition, noGitRepo: TagDefinition, - gitLocalChanges: TagDefinition + gitLocalChanges: TagDefinition, + multipleGitRepos: TagDefinition = StatusTags.defaultMultipleGitRepos ) { self.localGitOnly = localGitOnly self.gitSynced = gitSynced self.unexpectedFile = unexpectedFile self.noGitRepo = noGitRepo self.gitLocalChanges = gitLocalChanges + self.multipleGitRepos = multipleGitRepos + } + + enum CodingKeys: String, CodingKey { + case localGitOnly + case gitSynced + case unexpectedFile + case noGitRepo + case gitLocalChanges + case multipleGitRepos + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.localGitOnly = try container.decode(TagDefinition.self, forKey: .localGitOnly) + self.gitSynced = try container.decode(TagDefinition.self, forKey: .gitSynced) + self.unexpectedFile = try container.decode(TagDefinition.self, forKey: .unexpectedFile) + self.noGitRepo = try container.decode(TagDefinition.self, forKey: .noGitRepo) + self.gitLocalChanges = try container.decode(TagDefinition.self, forKey: .gitLocalChanges) + self.multipleGitRepos = + try container.decodeIfPresent(TagDefinition.self, forKey: .multipleGitRepos) ?? Self.defaultMultipleGitRepos + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(localGitOnly, forKey: .localGitOnly) + try container.encode(gitSynced, forKey: .gitSynced) + try container.encode(unexpectedFile, forKey: .unexpectedFile) + try container.encode(noGitRepo, forKey: .noGitRepo) + try container.encode(gitLocalChanges, forKey: .gitLocalChanges) + try container.encode(multipleGitRepos, forKey: .multipleGitRepos) } } @@ -88,7 +123,8 @@ extension TaggerConfig { gitSynced: TagDefinition(name: "Git Synced", colorIndex: 2), unexpectedFile: TagDefinition(name: "Unexpected File", colorIndex: 5, enabled: false), noGitRepo: TagDefinition(name: "No Git Repo", colorIndex: 6), - gitLocalChanges: TagDefinition(name: "Git Local Changes", colorIndex: 7) + gitLocalChanges: TagDefinition(name: "Git Local Changes", colorIndex: 7), + multipleGitRepos: StatusTags.defaultMultipleGitRepos ), concurrency: nil, scheduleSeconds: TaggerDefaults.scheduleSeconds, diff --git a/tag/Tagger/ConfigStore.swift b/tag/Tagger/ConfigStore.swift index 09539eb..44186d6 100644 --- a/tag/Tagger/ConfigStore.swift +++ b/tag/Tagger/ConfigStore.swift @@ -7,6 +7,7 @@ struct RootStatusSummary { var localChanges: Int = 0 var localOnly: Int = 0 var noGit: Int = 0 + var multipleGitRepos: Int = 0 var files: Int = 0 var errors: Int = 0 var total: Int = 0 @@ -127,6 +128,7 @@ final class TaggerStore: ObservableObject { for root in normalizedRoots { newResultsByRoot[root] = [:] } + var newFolderOrigins: [String: String] = [:] let concurrency = config.concurrency ?? Tagger.defaultConcurrency() let tagger = Tagger(config: config) @@ -142,6 +144,9 @@ final class TaggerStore: ObservableObject { self.folderResults[result.path] = result.status if let origin = result.origin { self.folderOrigins[result.path] = origin + newFolderOrigins[result.path] = origin + } else { + self.folderOrigins.removeValue(forKey: result.path) } // Find which root this result belongs to @@ -157,6 +162,14 @@ final class TaggerStore: ObservableObject { // Final update resultsByRoot = newResultsByRoot + var flattenedResults: [String: FolderScanResult.Status] = [:] + for (_, results) in newResultsByRoot { + for (path, status) in results { + flattenedResults[path] = status + } + } + folderResults = flattenedResults + folderOrigins = newFolderOrigins lastRunLines = summary.lines lastRunErrors = summary.errors @@ -371,6 +384,7 @@ final class TaggerStore: ObservableObject { case .localChanges: summary.localChanges += 1 case .localOnly: summary.localOnly += 1 case .noGit: summary.noGit += 1 + case .multipleGitRepos: summary.multipleGitRepos += 1 case .file: summary.files += 1 case .error: summary.errors += 1 } diff --git a/tag/Tagger/ScanHistory.swift b/tag/Tagger/ScanHistory.swift index a868f12..cb8bb57 100644 --- a/tag/Tagger/ScanHistory.swift +++ b/tag/Tagger/ScanHistory.swift @@ -11,6 +11,7 @@ struct PersistedFolderStatus: Codable, Hashable, Sendable { case localChanges case localOnly case noGit + case multipleGitRepos case file case error } @@ -33,6 +34,8 @@ struct PersistedFolderStatus: Codable, Hashable, Sendable { self.init(kind: .localOnly) case .noGit: self.init(kind: .noGit) + case .multipleGitRepos: + self.init(kind: .multipleGitRepos) case .file: self.init(kind: .file) case let .error(message): @@ -50,6 +53,8 @@ struct PersistedFolderStatus: Codable, Hashable, Sendable { return .localOnly case .noGit: return .noGit + case .multipleGitRepos: + return .multipleGitRepos case .file: return .file case .error: @@ -121,4 +126,3 @@ enum ScanHistoryStore { return decoder } } - diff --git a/tag/Tagger/Tagger.swift b/tag/Tagger/Tagger.swift index 48feda9..322bdd9 100644 --- a/tag/Tagger/Tagger.swift +++ b/tag/Tagger/Tagger.swift @@ -1,8 +1,9 @@ import Foundation -enum EntryKind: Sendable { +enum EntryKind: Sendable, Equatable { case dir case file + case gitRepoGroup } struct EntryTask: Sendable { @@ -29,11 +30,12 @@ struct ScanProgress: Sendable { } struct FolderScanResult: Sendable { - enum Status: Sendable { + enum Status: Sendable, Equatable { case synced case localChanges case localOnly case noGit + case multipleGitRepos case file case error(String) } @@ -74,7 +76,7 @@ struct Tagger: Sendable { let entries: [String] do { - entries = try FileManager.default.contentsOfDirectory(atPath: root) + entries = try FileManager.default.contentsOfDirectory(atPath: root).sorted() } catch { errors.append("Failed to list \(root): \(error.localizedDescription)") continue @@ -85,8 +87,33 @@ struct Tagger: Sendable { var entryIsDir = ObjCBool(false) guard FileManager.default.fileExists(atPath: fullPath, isDirectory: &entryIsDir) else { continue } let kind: EntryKind = entryIsDir.boolValue ? .dir : .file - let displayPath = multiRoot ? fullPath : name - tasks.append(EntryTask(name: name, fullPath: fullPath, displayPath: displayPath, kind: kind)) + let task = makeTask(name: name, fullPath: fullPath, root: root, multiRoot: multiRoot, kind: kind) + + guard case .dir = kind, !Git.isRepo(atPath: fullPath) else { + tasks.append(task) + continue + } + + do { + let childTasks = try childDirectoryTasks(in: fullPath, root: root, multiRoot: multiRoot) + if childTasks.isEmpty { + tasks.append(task) + continue + } + + let containsGitRepo = childTasks.contains { Git.isRepo(atPath: $0.fullPath) } + if containsGitRepo { + tasks.append( + makeTask(name: name, fullPath: fullPath, root: root, multiRoot: multiRoot, kind: .gitRepoGroup) + ) + tasks.append(contentsOf: childTasks) + } else { + tasks.append(task) + } + } catch { + errors.append("Failed to list \(fullPath): \(error.localizedDescription)") + tasks.append(task) + } } } @@ -109,7 +136,7 @@ struct Tagger: Sendable { let tasks = collected.tasks if tasks.isEmpty { - return TagRunSummary(lines: [], errors: errors) + return TagRunSummary(lines: lines.sorted(), errors: errors.sorted()) } let total = tasks.count @@ -169,6 +196,43 @@ struct Tagger: Sendable { return TagRunSummary(lines: lines.sorted(), errors: errors.sorted()) } + private func makeTask(name: String, fullPath: String, root: String, multiRoot: Bool, kind: EntryKind) -> EntryTask { + EntryTask( + name: name, + fullPath: fullPath, + displayPath: displayPath(for: fullPath, root: root, multiRoot: multiRoot), + kind: kind + ) + } + + private func displayPath(for fullPath: String, root: String, multiRoot: Bool) -> String { + if multiRoot { + return fullPath + } + + let normalizedRoot = URL(fileURLWithPath: root).standardizedFileURL.path + let normalizedPath = URL(fileURLWithPath: fullPath).standardizedFileURL.path + let rootPrefix = normalizedRoot.hasSuffix("/") ? normalizedRoot : "\(normalizedRoot)/" + + if normalizedPath.hasPrefix(rootPrefix) { + return String(normalizedPath.dropFirst(rootPrefix.count)) + } + + return (fullPath as NSString).lastPathComponent + } + + private func childDirectoryTasks(in parentPath: String, root: String, multiRoot: Bool) throws -> [EntryTask] { + let childNames = try FileManager.default.contentsOfDirectory(atPath: parentPath).sorted() + return childNames.compactMap { childName in + let childPath = (parentPath as NSString).appendingPathComponent(childName) + var childIsDir = ObjCBool(false) + guard FileManager.default.fileExists(atPath: childPath, isDirectory: &childIsDir), childIsDir.boolValue else { + return nil + } + return makeTask(name: childName, fullPath: childPath, root: root, multiRoot: multiRoot, kind: .dir) + } + } + private func processEntryWithResult(_ task: EntryTask) async throws -> (String, FolderScanResult) { let (line, status, origin) = try await processEntryInternal(task) let result = FolderScanResult(path: task.fullPath, status: status, message: line, origin: origin) @@ -189,6 +253,13 @@ struct Tagger: Sendable { } _ = try await FinderTagging.setOrderedUserTags(atPath: task.fullPath, tags: [tag]) return ("Tagged \(task.displayPath): \(tag.name)", .file, nil) + case .gitRepoGroup: + let tag = config.tags.multipleGitRepos + if !tag.enabled { + return ("Skipped tagging \(task.displayPath): \(tag.name) disabled", .multipleGitRepos, nil) + } + _ = try await FinderTagging.setOrderedUserTags(atPath: task.fullPath, tags: [tag]) + return ("Tagged \(task.displayPath): \(tag.name)", .multipleGitRepos, nil) case .dir: break } diff --git a/tag/Views/Components/StatusSummaryView.swift b/tag/Views/Components/StatusSummaryView.swift index bc44fec..fca7262 100644 --- a/tag/Views/Components/StatusSummaryView.swift +++ b/tag/Views/Components/StatusSummaryView.swift @@ -19,6 +19,9 @@ struct StatusSummaryView: View { if summary.noGit > 0 { StatusCount(count: summary.noGit, icon: "folder.fill", color: .red) } + if summary.multipleGitRepos > 0 { + StatusCount(count: summary.multipleGitRepos, icon: "folder.fill", color: .purple) + } } } else { Text("Not scanned") @@ -52,7 +55,8 @@ private struct StatusCount: View { localChanges: 2, localOnly: 1, noGit: 0, - total: 8 + multipleGitRepos: 1, + total: 9 )) StatusSummaryView(summary: RootStatusSummary( diff --git a/tag/Views/Components/StatusTagView.swift b/tag/Views/Components/StatusTagView.swift index 5e9d3f1..400d42e 100644 --- a/tag/Views/Components/StatusTagView.swift +++ b/tag/Views/Components/StatusTagView.swift @@ -5,6 +5,7 @@ enum FolderStatus: String, CaseIterable { case localChanges case localOnly case noGit + case multipleGitRepos case pending case scanning @@ -14,6 +15,7 @@ enum FolderStatus: String, CaseIterable { case .localChanges: return "Changes" case .localOnly: return "Local Git" case .noGit: return "No Git" + case .multipleGitRepos: return "Multi Repo" case .pending: return "Pending" case .scanning: return "Scanning" } @@ -25,6 +27,7 @@ enum FolderStatus: String, CaseIterable { case .localChanges: return .orange case .localOnly: return .gray case .noGit: return .red + case .multipleGitRepos: return .purple case .pending: return .secondary case .scanning: return .blue } @@ -36,6 +39,7 @@ enum FolderStatus: String, CaseIterable { case .localChanges: return "exclamationmark.circle.fill" case .localOnly: return "arrow.triangle.branch" case .noGit: return "folder.fill" + case .multipleGitRepos: return "folder.fill" case .pending: return "clock" case .scanning: return "arrow.clockwise" } diff --git a/tag/Views/FoldersTableView.swift b/tag/Views/FoldersTableView.swift index 82ce495..19f0973 100644 --- a/tag/Views/FoldersTableView.swift +++ b/tag/Views/FoldersTableView.swift @@ -387,6 +387,7 @@ struct ChildFolderRow: Identifiable { case .localChanges: self.folderStatus = .localChanges case .localOnly: self.folderStatus = .localOnly case .noGit: self.folderStatus = .noGit + case .multipleGitRepos: self.folderStatus = .multipleGitRepos case .file, .error: self.folderStatus = .pending } } diff --git a/tag/Views/Settings/TagsSettingsView.swift b/tag/Views/Settings/TagsSettingsView.swift index c21d875..ea8dadf 100644 --- a/tag/Views/Settings/TagsSettingsView.swift +++ b/tag/Views/Settings/TagsSettingsView.swift @@ -30,6 +30,12 @@ struct TagsSettingsView: View { tag: $store.config.tags.noGitRepo ) + TagSettingRow( + title: "Multiple Git Repos", + description: "Folder groups multiple child repositories", + tag: $store.config.tags.multipleGitRepos + ) + TagSettingRow( title: "Unexpected File", description: "Item is a file instead of a folder (usually disabled)", diff --git a/tests/Integration/GitIntegrationTests.swift b/tests/Integration/GitIntegrationTests.swift index 3d487cc..cca60d7 100644 --- a/tests/Integration/GitIntegrationTests.swift +++ b/tests/Integration/GitIntegrationTests.swift @@ -41,8 +41,69 @@ final class GitIntegrationTests: XCTestCase { XCTAssertEqual(Git.originUrl(atPath: repoURL.path, runner: runner), originURL.path) } + func testRunExpandsNonRepoChildWithGitRepoSubfolderAndTagsParentAsMultipleRepos() async throws { + try TestGates.requireIntegrationTestsEnabled() + + let runner = ProcessRunner() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("tag-tests-\(UUID().uuidString)", isDirectory: true) + let group = root.appendingPathComponent("m5", isDirectory: true) + let plain = group.appendingPathComponent("plain", isDirectory: true) + let repo = group.appendingPathComponent("repo", isDirectory: true) + + try FileManager.default.createDirectory(at: plain, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + + try runGit(runner, ["init"], cwd: repo) + + _ = try await FinderTagging.setOrderedUserTags( + atPath: group.path, + tags: [TaggerConfig.default.tags.noGitRepo] + ) + XCTAssertNotNil(try Xattr.get(name: FinderTagging.userTagsAttr, atPath: group.path)) + + let config = TaggerConfig( + roots: [], + tags: TaggerConfig.default.tags, + writeFinderComment: false + ) + let recorder = FolderResultRecorder() + + let summary = await Tagger(config: config, runner: runner).run( + roots: [root.path], + concurrency: 1, + onProgress: nil, + onResult: { result in + await recorder.record(result) + } + ) + let results = await recorder.results() + + XCTAssertEqual(summary.errors, []) + XCTAssertTrue(summary.lines.contains("Tagged m5: Multiple Git Repos")) + XCTAssertEqual(Set(results.map(\.path)), Set([group.path, plain.path, repo.path])) + XCTAssertTrue(results.contains { $0.path == group.path && $0.status == .multipleGitRepos }) + + let expectedTags = try FinderTagging.buildUserTagsPlistData(tags: [TaggerConfig.default.tags.multipleGitRepos]) + XCTAssertEqual(try Xattr.get(name: FinderTagging.userTagsAttr, atPath: group.path), expectedTags) + XCTAssertEqual(try FinderTagging.getFinderLabelIndex(atPath: group.path), 3) + } + // MARK: - Helpers + private actor FolderResultRecorder { + private var values: [FolderScanResult] = [] + + func record(_ result: FolderScanResult) { + values.append(result) + } + + func results() -> [FolderScanResult] { + values + } + } + private func makeRepoWithUpstream() throws -> (repoURL: URL, originURL: URL, fileURL: URL, runner: ProcessRunner) { let runner = ProcessRunner() diff --git a/tests/Unit/TagDefinitionCodableTests.swift b/tests/Unit/TagDefinitionCodableTests.swift index ce8ac67..2af5157 100644 --- a/tests/Unit/TagDefinitionCodableTests.swift +++ b/tests/Unit/TagDefinitionCodableTests.swift @@ -14,5 +14,19 @@ final class TagDefinitionCodableTests: XCTestCase { let decoded = try JSONDecoder().decode(TagDefinition.self, from: data) XCTAssertFalse(decoded.enabled) } -} + func testStatusTagsDefaultMultipleGitReposWhenOmitted() throws { + let data = """ + { + "localGitOnly": {"name":"Local Git Only","colorIndex":1,"enabled":true}, + "gitSynced": {"name":"Git Synced","colorIndex":2,"enabled":true}, + "unexpectedFile": {"name":"Unexpected File","colorIndex":5,"enabled":false}, + "noGitRepo": {"name":"No Git Repo","colorIndex":6,"enabled":true}, + "gitLocalChanges": {"name":"Git Local Changes","colorIndex":7,"enabled":true} + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(StatusTags.self, from: data) + XCTAssertEqual(decoded.multipleGitRepos, StatusTags.defaultMultipleGitRepos) + } +} diff --git a/tests/Unit/TaggerCollectTasksTests.swift b/tests/Unit/TaggerCollectTasksTests.swift new file mode 100644 index 0000000..23b3667 --- /dev/null +++ b/tests/Unit/TaggerCollectTasksTests.swift @@ -0,0 +1,87 @@ +import Foundation +import XCTest + +@testable import tag + +final class TaggerCollectTasksTests: XCTestCase { + func testEmptyNonRepoChildIsScannedAsNoGitCandidate() async throws { + let root = try makeTempRoot() + let empty = try createDirectory("empty", under: root) + + let collected = await Tagger(config: .default).collectTasks(roots: [root.path]) + + XCTAssertEqual(collected.errors, []) + XCTAssertEqual(collected.tasks.map(\.fullPath), [empty.path]) + XCTAssertEqual(collected.tasks.map(\.displayPath), ["empty"]) + XCTAssertEqual(collected.tasks.map(\.kind), [.dir]) + } + + func testNonRepoChildWithOnlyNonRepoSubfoldersIsScannedAsNoGitCandidate() async throws { + let root = try makeTempRoot() + let group = try createDirectory("group", under: root) + _ = try createDirectory("group", "plain", under: root) + + let collected = await Tagger(config: .default).collectTasks(roots: [root.path]) + + XCTAssertEqual(collected.errors, []) + XCTAssertEqual(collected.tasks.map(\.fullPath), [group.path]) + XCTAssertEqual(collected.tasks.map(\.displayPath), ["group"]) + XCTAssertEqual(collected.tasks.map(\.kind), [.dir]) + } + + func testNonRepoChildWithGitRepoSubfolderScansGroupAndImmediateSubfolders() async throws { + let root = try makeTempRoot() + let group = try createDirectory("m5", under: root) + let plain = try createDirectory("m5", "plain", under: root) + let repo = try createDirectory("m5", "repo", under: root) + try markGitRepo(repo) + + let collected = await Tagger(config: .default).collectTasks(roots: [root.path]) + + XCTAssertEqual(collected.errors, []) + XCTAssertEqual(collected.tasks.map(\.fullPath), [group.path, plain.path, repo.path]) + XCTAssertEqual(collected.tasks.map(\.displayPath), ["m5", "m5/plain", "m5/repo"]) + XCTAssertEqual(collected.tasks.map(\.kind), [.gitRepoGroup, .dir, .dir]) + } + + func testDirectGitRepoChildIsScannedWithoutExpandingChildren() async throws { + let root = try makeTempRoot() + let repo = try createDirectory("repo", under: root) + try markGitRepo(repo) + _ = try createDirectory("repo", "nested", under: root) + + let collected = await Tagger(config: .default).collectTasks(roots: [root.path]) + + XCTAssertEqual(collected.errors, []) + XCTAssertEqual(collected.tasks.map(\.fullPath), [repo.path]) + XCTAssertEqual(collected.tasks.map(\.displayPath), ["repo"]) + XCTAssertEqual(collected.tasks.map(\.kind), [.dir]) + } + + private func makeTempRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("tag-collect-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: root) + } + return root + } + + @discardableResult + private func createDirectory(_ components: String..., under root: URL) throws -> URL { + var url = root + for component in components { + url.appendPathComponent(component, isDirectory: true) + } + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func markGitRepo(_ repo: URL) throws { + try FileManager.default.createDirectory( + at: repo.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + } +}