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
25 changes: 24 additions & 1 deletion Sources/Timber/TimberCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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] {
Expand Down
3 changes: 2 additions & 1 deletion Sources/Timber/TimberState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
10 changes: 10 additions & 0 deletions Sources/Timber/TimberViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
71 changes: 67 additions & 4 deletions Sources/TimberModel/TimberModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions Tests/TimberModelTests/TimberModelSortTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
}
Loading