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
213 changes: 213 additions & 0 deletions macgit/App/ReferenceComparisonController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Observation

@MainActor @Observable
final class ReferenceComparisonController {
let repositoryURL: URL
let isBranchComparison: Bool
let title: String
private(set) var baseRef: String
private(set) var targetRef: String
private(set) var mode: ReferenceComparisonMode
private(set) var branches: [ComparisonBranch] = []
private(set) var snapshot: ReferenceComparisonSnapshot?
private(set) var files: [CommitFileChange] = []
private(set) var baseCommits: [Commit] = []
private(set) var targetCommits: [Commit] = []
private(set) var selectedFile: CommitFileChange?
private(set) var patch: ReferenceComparisonPatch?
private(set) var error: String?
private(set) var fileError: String?
private(set) var baseCommitsError: String?
private(set) var targetCommitsError: String?
private(set) var isLoading = false
private(set) var isLoadingPatch = false
private(set) var isLoadingBaseCommits = false
private(set) var isLoadingTargetCommits = false
private(set) var isCancelled = false
@ObservationIgnored private let service: any ReferenceComparisonServing
@ObservationIgnored private var requestID = UUID()
@ObservationIgnored private var patchID = UUID()
@ObservationIgnored private(set) var loadTask: Task<Void, Never>?
@ObservationIgnored private(set) var patchTask: Task<Void, Never>?
@ObservationIgnored private(set) var baseCommitsTask: Task<Void, Never>?
@ObservationIgnored private(set) var targetCommitsTask: Task<Void, Never>?

init(repositoryURL: URL, baseRef: String, targetRef: String, isBranchComparison: Bool = true,
title: String = "Compare Branches", service: any ReferenceComparisonServing = GitStatusService.shared) {
self.repositoryURL = repositoryURL
self.baseRef = baseRef
self.targetRef = targetRef
self.isBranchComparison = isBranchComparison
self.title = title
self.service = service
self.mode = isBranchComparison ? .mergeBase : .tips
}

func setBase(_ ref: String) {
guard baseRef != ref else { return }
baseRef = ref
reload()
}

func setTarget(_ ref: String) {
guard targetRef != ref else { return }
targetRef = ref
reload()
}

func swap() {
(baseRef, targetRef) = (targetRef, baseRef)
reload()
}

func setMode(_ mode: ReferenceComparisonMode) {
guard self.mode != mode else { return }
self.mode = mode
reload(reuseSnapshot: true)
}

func cancel() {
requestID = UUID()
patchID = UUID()
loadTask?.cancel()
patchTask?.cancel()
baseCommitsTask?.cancel()
targetCommitsTask?.cancel()
isLoading = false
isLoadingPatch = false
isLoadingBaseCommits = false
isLoadingTargetCommits = false
isCancelled = true
}

func reload(reuseSnapshot: Bool = false) {
let previousSnapshot = reuseSnapshot ? snapshot : nil
cancel()
let id = requestID
let base = baseRef
let target = targetRef
let requestedMode = mode
isCancelled = false
isLoading = true
error = nil
fileError = nil
files = []
selectedFile = nil
patch = nil
snapshot = previousSnapshot
if previousSnapshot == nil {
baseCommits = []
targetCommits = []
}
baseCommitsError = nil
targetCommitsError = nil
loadTask = Task { [weak self] in
guard let self else { return }
do {
if isBranchComparison && (branches.isEmpty || !reuseSnapshot) {
let available = try await service.comparisonBranches(in: repositoryURL)
guard isCurrent(id) else { return }
branches = available
}
guard !base.isEmpty, !target.isEmpty else {
isLoading = false
return
}
let resolved: ReferenceComparisonSnapshot
if let previousSnapshot {
resolved = previousSnapshot
} else {
resolved = try await service.comparisonSnapshot(base: base, target: target,
branchesOnly: isBranchComparison, in: repositoryURL)
}
guard isCurrent(id) else { return }
snapshot = resolved
if isBranchComparison {
if baseCommits.isEmpty { loadMoreCommits(targetSide: false) }
if targetCommits.isEmpty { loadMoreCommits(targetSide: true) }
}
let changes = try await service.comparisonFiles(snapshot: resolved, mode: requestedMode, in: repositoryURL)
guard isCurrent(id) else { return }
files = changes
isLoading = false
} catch {
guard isCurrent(id) else { return }
self.error = error.localizedDescription
isLoading = false
}
}
}

func selectFile(_ file: CommitFileChange?) {
patchTask?.cancel()
patchID = UUID()
selectedFile = file
patch = nil
fileError = nil
isLoadingPatch = false
guard let file, let snapshot, files.contains(file), !isCancelled else { return }
let id = requestID
let fileID = patchID
let requestedMode = mode
isLoadingPatch = true
patchTask = Task { [weak self] in
guard let self else { return }
do {
let result = try await service.comparisonPatch(file: file, snapshot: snapshot, mode: requestedMode, in: repositoryURL)
guard isCurrent(id), patchID == fileID else { return }
patch = result
isLoadingPatch = false
} catch {
guard isCurrent(id), patchID == fileID else { return }
fileError = error.localizedDescription
isLoadingPatch = false
}
}
}

func loadMoreCommits(targetSide: Bool) {
guard let snapshot, !isCancelled else { return }
let count = targetSide ? targetCommits.count : baseCommits.count
let total = targetSide ? snapshot.targetOnlyCount : snapshot.baseOnlyCount
guard count < total, !(targetSide ? isLoadingTargetCommits : isLoadingBaseCommits) else { return }
let id = requestID
if targetSide {
isLoadingTargetCommits = true
targetCommitsError = nil
} else {
isLoadingBaseCommits = true
baseCommitsError = nil
}
let task = Task { [weak self] in
guard let self else { return }
do {
let commits = try await service.comparisonCommits(snapshot: snapshot, targetSide: targetSide,
skip: count, limit: 100, in: repositoryURL)
guard isCurrent(id) else { return }
if targetSide {
targetCommits += commits
isLoadingTargetCommits = false
} else {
baseCommits += commits
isLoadingBaseCommits = false
}
} catch {
guard isCurrent(id) else { return }
if targetSide {
targetCommitsError = error.localizedDescription
isLoadingTargetCommits = false
} else {
baseCommitsError = error.localizedDescription
isLoadingBaseCommits = false
}
}
}
if targetSide { targetCommitsTask = task } else { baseCommitsTask = task }
}

private func isCurrent(_ id: UUID) -> Bool {
!Task.isCancelled && requestID == id
}
}
12 changes: 12 additions & 0 deletions macgit/Models/ComparisonBranch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

nonisolated struct ComparisonBranch: Identifiable, Hashable, Sendable {
let ref: String
var id: String { ref }
var isRemote: Bool { ref.hasPrefix("refs/remotes/") }
var name: String {
String(ref.dropFirst(isRemote ? "refs/remotes/".count : "refs/heads/".count))
}
var label: String { "\(name) (\(isRemote ? "remote" : "local"))" }
}
15 changes: 15 additions & 0 deletions macgit/Models/ReferenceComparisonMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

nonisolated enum ReferenceComparisonMode: String, CaseIterable, Identifiable, Sendable {
case mergeBase
case tips

var id: Self { self }
var title: String {
switch self {
case .mergeBase: "Changes since merge base"
case .tips: "Tip-to-tip"
}
}
}
8 changes: 8 additions & 0 deletions macgit/Models/ReferenceComparisonPatch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

nonisolated struct ReferenceComparisonPatch: Sendable {
let hunks: [DiffHunk]
let isBinary: Bool
let isTruncated: Bool
}
20 changes: 20 additions & 0 deletions macgit/Models/ReferenceComparisonSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

nonisolated struct ReferenceComparisonSnapshot: Sendable {
let base: String
let target: String
let mergeBases: [String]
let baseOnlyCount: Int
let targetOnlyCount: Int

func diffBase(for mode: ReferenceComparisonMode) throws -> String {
if mode == .tips { return base }
guard mergeBases.count == 1, let mergeBase = mergeBases.first else {
throw GitError.commandFailed(mergeBases.isEmpty
? "These branches have no common ancestor. Select Tip-to-tip to compare their trees."
: "These branches have multiple merge bases. Select Tip-to-tip to compare their trees.")
}
return mergeBase
}
}
5 changes: 3 additions & 2 deletions macgit/Services/Commit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,14 @@ nonisolated struct Commit: Identifiable, Equatable, Sendable {

// MARK: - Commit File Change

struct CommitFileChange: Identifiable, Hashable {
nonisolated struct CommitFileChange: Identifiable, Hashable, Sendable {
let id = UUID()
let path: String
let status: CommitFileStatus
var oldPath: String? = nil
}

enum CommitFileStatus: String {
nonisolated enum CommitFileStatus: String, Sendable {
case added = "A"
case modified = "M"
case deleted = "D"
Expand Down
8 changes: 4 additions & 4 deletions macgit/Services/GitDiffModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,29 @@
//
import Foundation

enum DiffLineType: Sendable {
nonisolated enum DiffLineType: Sendable {
case context
case added
case removed
case header
case conflictMarker
}

struct DiffLine: Identifiable, Sendable {
nonisolated struct DiffLine: Identifiable, Sendable {
let id = UUID()
let oldLineNumber: Int?
let newLineNumber: Int?
let text: String
let type: DiffLineType
}

struct DiffHunk: Identifiable, Sendable {
nonisolated struct DiffHunk: Identifiable, Sendable {
let id = UUID()
let header: String
let lines: [DiffLine]
}

enum DiffParser {
nonisolated enum DiffParser {
static func parse(_ raw: String) -> [DiffHunk] {
var hunks: [DiffHunk] = []
var currentLines: [DiffLine] = []
Expand Down
Loading
Loading