Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
<br>

## 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)
Expand Down
4 changes: 2 additions & 2 deletions tag.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
40 changes: 38 additions & 2 deletions tag/Tagger/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions tag/Tagger/ConfigStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion tag/Tagger/ScanHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct PersistedFolderStatus: Codable, Hashable, Sendable {
case localChanges
case localOnly
case noGit
case multipleGitRepos
case file
case error
}
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -121,4 +126,3 @@ enum ScanHistoryStore {
return decoder
}
}

83 changes: 77 additions & 6 deletions tag/Tagger/Tagger.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Foundation

enum EntryKind: Sendable {
enum EntryKind: Sendable, Equatable {
case dir
case file
case gitRepoGroup
}

struct EntryTask: Sendable {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion tag/Views/Components/StatusSummaryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -52,7 +55,8 @@ private struct StatusCount: View {
localChanges: 2,
localOnly: 1,
noGit: 0,
total: 8
multipleGitRepos: 1,
total: 9
))

StatusSummaryView(summary: RootStatusSummary(
Expand Down
4 changes: 4 additions & 0 deletions tag/Views/Components/StatusTagView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ enum FolderStatus: String, CaseIterable {
case localChanges
case localOnly
case noGit
case multipleGitRepos
case pending
case scanning

Expand All @@ -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"
}
Expand All @@ -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
}
Expand All @@ -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"
}
Expand Down
1 change: 1 addition & 0 deletions tag/Views/FoldersTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
6 changes: 6 additions & 0 deletions tag/Views/Settings/TagsSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Loading
Loading