diff --git a/Sources/Timber/TimberCommand.swift b/Sources/Timber/TimberCommand.swift index c784f6e..0d79d4d 100644 --- a/Sources/Timber/TimberCommand.swift +++ b/Sources/Timber/TimberCommand.swift @@ -105,6 +105,7 @@ enum TimberCommand { let root = ProcessInfo.processInfo.environment["TIMBER_WORKTREE_ROOT"] ?? fm.homeDirectoryForCurrentUser.appendingPathComponent("worktrees").path + let gitExe = findExecutable("git") var worktrees: [TimberWorktree] = [] for repo in repoNames { let base = URL(fileURLWithPath: root).appendingPathComponent(repo) @@ -114,12 +115,34 @@ enum TimberCommand { ? String(parent.path.dropFirst(base.path.count + 1)) : "" guard !rel.isEmpty else { continue } - worktrees.append(TimberWorktree(name: rel, repo: repo, path: dir.path)) + worktrees.append(TimberWorktree( + name: rel, + repo: repo, + path: dir.path, + lastCommitAt: commitDate(atPath: dir.path, gitExe: gitExe) + )) } } return (repoNames.map { TimberRepo(name: $0) }, worktrees.sorted { $0.value < $1.value }) } + /// Newest commit date for recency sorting, falling back to the + /// worktree directory mtime when git is missing or fails. + private static func commitDate(atPath path: String, gitExe: URL?) -> Date? { + if let stamp = gitTimestamp(atPath: path, gitExe: gitExe) { + return Date(timeIntervalSince1970: stamp) + } + guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else { return nil } + return attrs[.modificationDate] as? Date + } + + private static func gitTimestamp(atPath path: String, gitExe: URL?) -> Double? { + guard let gitExe else { return nil } + guard let result = try? run(gitExe, args: ["-C", path, "log", "-1", "--format=%ct"]) else { return nil } + guard result.code == 0 else { return nil } + return Double(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) + } + /// Directories named `name` containing a `.git` entry, up to maxDepth /// below (and excluding) the base itself. private static func walkDirectories(at base: URL, matching name: String, maxDepth: Int) -> [URL] { diff --git a/Sources/Timber/TimberState.swift b/Sources/Timber/TimberState.swift index b72a681..cb64a48 100644 --- a/Sources/Timber/TimberState.swift +++ b/Sources/Timber/TimberState.swift @@ -21,6 +21,7 @@ final class TimberState: ObservableObject { @Published var repoName = "" @Published var busy = false @Published var listing = true + @Published var sort: TimberModel.SortMode = .recency func onMain(_ work: @escaping () -> Void) { if Thread.isMainThread { @@ -96,7 +97,7 @@ final class TimberState: ObservableObject { func rebuild() { // Any list change disarms a pending delete. armedRemoveValue = "" - items = TimberModel.itemsForTerm(repos: repos, worktrees: worktrees, term: filter) + items = TimberModel.itemsForTerm(repos: repos, worktrees: worktrees, term: filter, sort: sort) if let id = selectedID, items.contains(where: { $0.id == id }) { return } diff --git a/Sources/Timber/TimberViews.swift b/Sources/Timber/TimberViews.swift index 4cad610..0fb8735 100644 --- a/Sources/Timber/TimberViews.swift +++ b/Sources/Timber/TimberViews.swift @@ -39,6 +39,16 @@ struct TimberPopover: View { .disabled(state.busy) } + Picker("Sort", selection: $state.sort) { + ForEach(TimberModel.SortMode.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .help("Sort worktrees (timber list --sort)") + .onChange(of: state.sort) { _, _ in state.rebuild() } + if state.repoFormOpen { TextField("Remote URL or path", text: $state.repoURL) .textFieldStyle(.roundedBorder) diff --git a/Sources/TimberModel/TimberModel.swift b/Sources/TimberModel/TimberModel.swift index 2647626..88a094d 100644 --- a/Sources/TimberModel/TimberModel.swift +++ b/Sources/TimberModel/TimberModel.swift @@ -15,10 +15,13 @@ public struct TimberWorktree: Equatable { public let name: String public let repo: String public let path: String - public init(name: String, repo: String, path: String) { + /// Newest commit date for recency sorting; nil when unknown. + public let lastCommitAt: Date? + public init(name: String, repo: String, path: String, lastCommitAt: Date? = nil) { self.name = name self.repo = repo self.path = path + self.lastCommitAt = lastCommitAt } } @@ -51,6 +54,65 @@ public struct TimberItem: Equatable, Identifiable { } public enum TimberModel { + // MARK: - Sort + + /// Sort modes mirroring `timber list --sort`: recency (newest + /// commit first), repo, or worktree name. + public enum SortMode: String, CaseIterable, Identifiable { + case recency + case repo + case worktree + + public var id: String { + rawValue + } + + public var label: String { + switch self { + case .recency: "Recency" + case .repo: "Repo" + case .worktree: "Worktree" + } + } + } + + /// Sort worktrees for display. Recency falls back to `name@repo` + /// when commit dates are missing or tied. + public static func sortWorktrees(_ worktrees: [TimberWorktree], mode: SortMode) -> [TimberWorktree] { + switch mode { + case .recency: + worktrees.sorted { + switch ($0.lastCommitAt, $1.lastCommitAt) { + case let (lhs?, rhs?): + if lhs != rhs { + return lhs > rhs + } + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + break + } + return worktreeValue(name: $0.name, repo: $0.repo) < worktreeValue(name: $1.name, repo: $1.repo) + } + case .repo: + worktrees.sorted { + if $0.repo != $1.repo { + return $0.repo < $1.repo + } + return $0.name < $1.name + } + case .worktree: + worktrees.sorted { + if $0.name != $1.name { + return $0.name < $1.name + } + return $0.repo < $1.repo + } + } + } + // MARK: - Values /// Split "name@repo" on the LAST "@". Returns nil unless qualified @@ -140,11 +202,12 @@ public enum TimberModel { public static func itemsForTerm( repos: [TimberRepo], worktrees: [TimberWorktree], - term: String + term: String, + sort: SortMode = .recency ) -> [TimberItem] { var items: [TimberItem] = [] - for rank in filterWorktrees(term, worktrees: worktrees) { - let worktree = worktrees[rank] + let ranked = filterWorktrees(term, worktrees: worktrees).map { worktrees[$0] } + for worktree in sortWorktrees(ranked, mode: sort) { let value = worktreeValue(name: worktree.name, repo: worktree.repo) let item = TimberItem( kind: .open, name: worktree.name, repo: worktree.repo, value: value, path: worktree.path diff --git a/Tests/TimberModelTests/TimberModelSortTests.swift b/Tests/TimberModelTests/TimberModelSortTests.swift new file mode 100644 index 0000000..2061fe2 --- /dev/null +++ b/Tests/TimberModelTests/TimberModelSortTests.swift @@ -0,0 +1,53 @@ +@testable import TimberModel +import XCTest + +final class TimberModelSortTests: XCTestCase { + private let repos = [ + TimberRepo(name: "timber"), + TimberRepo(name: "scribble"), + ] + private let worktrees = [ + TimberWorktree(name: "feature/login", repo: "timber", path: "/wt/timber/feature/login/timber"), + TimberWorktree(name: "fix-crash", repo: "scribble", path: "/wt/scribble/fix-crash/scribble"), + ] + + func testSortWorktreesByRepo() { + let sorted = TimberModel.sortWorktrees(worktrees, mode: .repo) + XCTAssertEqual(sorted.map(\.repo), ["scribble", "timber"]) + } + + func testSortWorktreesByWorktree() { + let sorted = TimberModel.sortWorktrees(worktrees, mode: .worktree) + XCTAssertEqual(sorted.map(\.name), ["feature/login", "fix-crash"]) + } + + func testSortWorktreesByRecencyNewestFirst() { + let old = TimberWorktree( + name: "aaa", repo: "zzz", path: "/wt/old", lastCommitAt: Date(timeIntervalSince1970: 100) + ) + let new = TimberWorktree( + name: "zzz", repo: "aaa", path: "/wt/new", lastCommitAt: Date(timeIntervalSince1970: 200) + ) + // Recency wins over name/repo ordering. + XCTAssertEqual(TimberModel.sortWorktrees([old, new], mode: .recency).map(\.name), ["zzz", "aaa"]) + } + + func testSortWorktreesByRecencyMissingDatesLast() { + let dated = TimberWorktree( + name: "b", repo: "b", path: "/wt/b", lastCommitAt: Date(timeIntervalSince1970: 100) + ) + let undated = TimberWorktree(name: "a", repo: "a", path: "/wt/a") + XCTAssertEqual(TimberModel.sortWorktrees([undated, dated], mode: .recency).map(\.name), ["b", "a"]) + } + + func testItemsForTermRespectsSortMode() { + let ordered = [ + TimberWorktree(name: "b", repo: "a", path: "/wt/b"), + TimberWorktree(name: "a", repo: "b", path: "/wt/a"), + ] + let byRepo = TimberModel.itemsForTerm(repos: repos, worktrees: ordered, term: "", sort: .repo) + XCTAssertEqual(byRepo.map(\.value), ["b@a", "a@b"]) + let byWorktree = TimberModel.itemsForTerm(repos: repos, worktrees: ordered, term: "", sort: .worktree) + XCTAssertEqual(byWorktree.map(\.value), ["a@b", "b@a"]) + } +}