From 04d25c250f3abc12761c63c0bd79e3541708a815 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Mon, 21 Sep 2026 21:08:15 +0700 Subject: [PATCH 1/2] fix: improve right click on commit row --- .../HistoryTableScrollCoordinator.swift | 37 +++++++++++++++++++ macgit/Views/History/HistoryView.swift | 26 +++++++++++++ 2 files changed, 63 insertions(+) diff --git a/macgit/Views/History/HistoryTableScrollCoordinator.swift b/macgit/Views/History/HistoryTableScrollCoordinator.swift index ac214aa..cf64f35 100644 --- a/macgit/Views/History/HistoryTableScrollCoordinator.swift +++ b/macgit/Views/History/HistoryTableScrollCoordinator.swift @@ -34,6 +34,7 @@ final class HistoryTableScrollCoordinator { private var columnResizeObserver: NSObjectProtocol? private var restoreWidthsTask: Task? private var isRestoringWidths = false + private var contextClickMonitor: Any? init(defaults: UserDefaults = .standard) { self.defaults = defaults @@ -56,6 +57,9 @@ final class HistoryTableScrollCoordinator { } deinit { + if let contextClickMonitor { + NSEvent.removeMonitor(contextClickMonitor) + } restoreWidthsTask?.cancel() if let columnResizeObserver { NotificationCenter.default.removeObserver(columnResizeObserver) @@ -254,6 +258,39 @@ final class HistoryTableScrollCoordinator { return ["graph", "message", "author", "date", "commit"].contains(key) ? key : nil } + func startContextClickMonitoring(onRow: @escaping (Int) -> Void) { + stopContextClickMonitoring() + contextClickMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.rightMouseDown, .leftMouseDown] + ) { [weak self] event in + MainActor.assumeIsolated { + guard event.type == .rightMouseDown || event.modifierFlags.contains(.control), + let tableView = self?.tableView, + let window = tableView.window, + event.window === window, + let contentView = window.contentView, + let hitView = contentView.hitTest( + contentView.convert(event.locationInWindow, from: nil) + ), + hitView === tableView || hitView.isDescendant(of: tableView) else { + return event + } + let row = tableView.row(at: tableView.convert(event.locationInWindow, from: nil)) + guard row >= 0 else { return event } + // Update selection before AppKit dispatches the context menu. + onRow(row) + return event + } + } + } + + func stopContextClickMonitoring() { + if let contextClickMonitor { + NSEvent.removeMonitor(contextClickMonitor) + self.contextClickMonitor = nil + } + } + func isContextClick(onRows selectedRows: IndexSet) -> Bool { guard let tableView, let event = NSApp.currentEvent, diff --git a/macgit/Views/History/HistoryView.swift b/macgit/Views/History/HistoryView.swift index 19455a1..0f0a39d 100644 --- a/macgit/Views/History/HistoryView.swift +++ b/macgit/Views/History/HistoryView.swift @@ -201,7 +201,22 @@ struct HistoryView: View { paging = HistoryPagingState(pageSize: pageSize) historyCache.removeAll() } + .onAppear { + tableScrollCoordinator.startContextClickMonitoring { row in + guard commits.indices.contains(row) else { return } + let commit = commits[row] + guard !tableSelection.contains(commit.hash) else { return } + commitSelection = HistoryCommitSelection( + selectedHashes: [commit.hash], + primaryHash: commit.hash, + anchorHash: commit.hash + ) + selectedCommit = commit + tableSelection = [commit.hash] + } + } .onDisappear { + tableScrollCoordinator.stopContextClickMonitoring() historySearchDebounceTask?.cancel() dragClickSuppressionTask?.cancel() dragCompletionMonitorTask?.cancel() @@ -2064,6 +2079,17 @@ struct HistoryView: View { NSCursor.arrow.set() } } + .contextMenu { + // Cell gestures own pointer selection. Resolve the menu from + // that selection and the clicked row, rather than the native + // Table's contextual selection, which can include another row. + let contextCommits = Self.contextMenuCommits( + startingAt: commit.hash, + commits: commits, + selection: commitSelection + ) + commitContextMenu(for: Set(contextCommits.map(\.hash))) + } .onDrag { makeCommitItemProvider(startingAt: commit) } preview: { From 384101b335cc14059bd8e9e0322fdef4e3995a06 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Mon, 21 Sep 2026 21:39:47 +0700 Subject: [PATCH 2/2] Add visual branch comparison workflow --- .../App/ReferenceComparisonController.swift | 213 ++++++++++++++++++ macgit/Models/ComparisonBranch.swift | 12 + macgit/Models/ReferenceComparisonMode.swift | 15 ++ macgit/Models/ReferenceComparisonPatch.swift | 8 + .../Models/ReferenceComparisonSnapshot.swift | 20 ++ macgit/Services/Commit.swift | 5 +- macgit/Services/GitDiffModels.swift | 8 +- .../GitStatusService+BranchComparison.swift | 111 +++++++++ macgit/Services/GitStatusService+Commit.swift | 2 +- .../Services/ReferenceComparisonServing.swift | 10 + macgit/Views/Common/DiffView.swift | 124 ---------- .../ReferenceComparisonCommitsView.swift | 52 +++++ .../Common/ReferenceComparisonFilesView.swift | 66 ++++++ macgit/Views/Common/ReferenceDiffView.swift | 144 ++++++++++++ macgit/Views/History/CommitFileListView.swift | 7 + macgit/Views/MainWindow/MainWindowView.swift | 25 +- .../Sidebar/SidebarBranchContextMenu.swift | 3 +- .../Sidebar/SidebarBranchSectionActions.swift | 1 + .../Sidebar/SidebarRemoteContextMenu.swift | 4 +- .../Sidebar/SidebarRemoteSectionActions.swift | 1 + macgit/Views/MainWindow/SidebarView.swift | 9 +- .../BranchComparisonServiceTests.swift | 165 ++++++++++++++ .../ReferenceComparisonControllerTests.swift | 194 ++++++++++++++++ 23 files changed, 1060 insertions(+), 139 deletions(-) create mode 100644 macgit/App/ReferenceComparisonController.swift create mode 100644 macgit/Models/ComparisonBranch.swift create mode 100644 macgit/Models/ReferenceComparisonMode.swift create mode 100644 macgit/Models/ReferenceComparisonPatch.swift create mode 100644 macgit/Models/ReferenceComparisonSnapshot.swift create mode 100644 macgit/Services/GitStatusService+BranchComparison.swift create mode 100644 macgit/Services/ReferenceComparisonServing.swift create mode 100644 macgit/Views/Common/ReferenceComparisonCommitsView.swift create mode 100644 macgit/Views/Common/ReferenceComparisonFilesView.swift create mode 100644 macgit/Views/Common/ReferenceDiffView.swift create mode 100644 macgitTests/BranchComparisonServiceTests.swift create mode 100644 macgitTests/ReferenceComparisonControllerTests.swift diff --git a/macgit/App/ReferenceComparisonController.swift b/macgit/App/ReferenceComparisonController.swift new file mode 100644 index 0000000..1c81392 --- /dev/null +++ b/macgit/App/ReferenceComparisonController.swift @@ -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? + @ObservationIgnored private(set) var patchTask: Task? + @ObservationIgnored private(set) var baseCommitsTask: Task? + @ObservationIgnored private(set) var targetCommitsTask: Task? + + 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 + } +} diff --git a/macgit/Models/ComparisonBranch.swift b/macgit/Models/ComparisonBranch.swift new file mode 100644 index 0000000..bfaffa6 --- /dev/null +++ b/macgit/Models/ComparisonBranch.swift @@ -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"))" } +} diff --git a/macgit/Models/ReferenceComparisonMode.swift b/macgit/Models/ReferenceComparisonMode.swift new file mode 100644 index 0000000..98a18e7 --- /dev/null +++ b/macgit/Models/ReferenceComparisonMode.swift @@ -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" + } + } +} diff --git a/macgit/Models/ReferenceComparisonPatch.swift b/macgit/Models/ReferenceComparisonPatch.swift new file mode 100644 index 0000000..d02d336 --- /dev/null +++ b/macgit/Models/ReferenceComparisonPatch.swift @@ -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 +} diff --git a/macgit/Models/ReferenceComparisonSnapshot.swift b/macgit/Models/ReferenceComparisonSnapshot.swift new file mode 100644 index 0000000..e2df819 --- /dev/null +++ b/macgit/Models/ReferenceComparisonSnapshot.swift @@ -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 + } +} diff --git a/macgit/Services/Commit.swift b/macgit/Services/Commit.swift index 9441230..6444051 100644 --- a/macgit/Services/Commit.swift +++ b/macgit/Services/Commit.swift @@ -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" diff --git a/macgit/Services/GitDiffModels.swift b/macgit/Services/GitDiffModels.swift index 055058f..f5c8243 100644 --- a/macgit/Services/GitDiffModels.swift +++ b/macgit/Services/GitDiffModels.swift @@ -22,7 +22,7 @@ // import Foundation -enum DiffLineType: Sendable { +nonisolated enum DiffLineType: Sendable { case context case added case removed @@ -30,7 +30,7 @@ enum DiffLineType: Sendable { case conflictMarker } -struct DiffLine: Identifiable, Sendable { +nonisolated struct DiffLine: Identifiable, Sendable { let id = UUID() let oldLineNumber: Int? let newLineNumber: Int? @@ -38,13 +38,13 @@ struct DiffLine: Identifiable, Sendable { 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] = [] diff --git a/macgit/Services/GitStatusService+BranchComparison.swift b/macgit/Services/GitStatusService+BranchComparison.swift new file mode 100644 index 0000000..47cd0c5 --- /dev/null +++ b/macgit/Services/GitStatusService+BranchComparison.swift @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +extension GitStatusService: ReferenceComparisonServing { + func comparisonBranches(in repositoryURL: URL) async throws -> [ComparisonBranch] { + try Task.checkCancellation() + let output = try await runGit(arguments: [ + "for-each-ref", "--sort=refname", "--format=%(refname)%00%(symref)", "refs/heads/", "refs/remotes/" + ], in: repositoryURL) + return output.split(separator: "\n").compactMap { line in + let fields = line.split(separator: "\0", omittingEmptySubsequences: false) + guard fields.count == 2, fields[1].isEmpty else { return nil } + return ComparisonBranch(ref: String(fields[0])) + } + } + + func comparisonSnapshot(base: String, target: String, branchesOnly: Bool, in repositoryURL: URL) async throws -> ReferenceComparisonSnapshot { + let baseSHA = try await resolveComparisonRef(base, branchesOnly: branchesOnly, in: repositoryURL) + let targetSHA = try await resolveComparisonRef(target, branchesOnly: branchesOnly, in: repositoryURL) + try Task.checkCancellation() + let counts = try await runGit(arguments: ["rev-list", "--left-right", "--count", "\(baseSHA)...\(targetSHA)", "--"], in: repositoryURL) + let numbers = counts.split(whereSeparator: \.isWhitespace).compactMap { Int($0) } + guard numbers.count == 2 else { throw GitError.commandFailed("Could not read comparison commit counts.") } + let mergeBases: [String] + do { + let output = try await runGit(arguments: ["merge-base", "--all", baseSHA, targetSHA], in: repositoryURL) + mergeBases = output.split(whereSeparator: \.isWhitespace).map(String.init) + } catch GitError.commandFailed(let message) where message.isEmpty { + // git merge-base exits 1 with no output for unrelated histories. + mergeBases = [] + } + try Task.checkCancellation() + return ReferenceComparisonSnapshot(base: baseSHA, target: targetSHA, mergeBases: mergeBases, + baseOnlyCount: numbers[0], targetOnlyCount: numbers[1]) + } + + private func resolveComparisonRef(_ ref: String, branchesOnly: Bool, in repositoryURL: URL) async throws -> String { + try Task.checkCancellation() + if branchesOnly { + guard ref.hasPrefix("refs/heads/") || ref.hasPrefix("refs/remotes/") else { + throw GitError.commandFailed("Select a local or remote-tracking branch.") + } + _ = try await runGit(arguments: ["check-ref-format", ref], in: repositoryURL) + // Exact ref validation avoids interpreting suffixes such as ^ or : as revisions. + _ = try await runGit(arguments: ["show-ref", "--verify", ref], in: repositoryURL) + } + let output = try await runGit(arguments: ["rev-parse", "--verify", "--end-of-options", "\(ref)^{commit}"], in: repositoryURL) + let sha = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard [40, 64].contains(sha.count), sha.allSatisfy(\.isHexDigit) else { + throw GitError.commandFailed("Could not resolve the selected reference to a commit.") + } + return sha + } + + func comparisonFiles(snapshot: ReferenceComparisonSnapshot, mode: ReferenceComparisonMode, in repositoryURL: URL) async throws -> [CommitFileChange] { + try Task.checkCancellation() + let base = try snapshot.diffBase(for: mode) + let output = try await runGitRaw(arguments: [ + "diff", "--name-status", "-z", "--find-renames", "--no-ext-diff", "--no-textconv", base, snapshot.target, "--" + ], in: repositoryURL) + return try Self.parseComparisonFiles(output) + } + + nonisolated static func parseComparisonFiles(_ data: Data) throws -> [CommitFileChange] { + let fields = data.split(separator: 0, omittingEmptySubsequences: false) + var index = 0 + var result: [CommitFileChange] = [] + while index < fields.count, !fields[index].isEmpty { + try Task.checkCancellation() + let code = String(decoding: fields[index], as: UTF8.self) + let hasOldPath = code.hasPrefix("R") || code.hasPrefix("C") + let pathIndex = index + (hasOldPath ? 2 : 1) + guard pathIndex < fields.count, !fields[pathIndex].isEmpty else { + throw GitError.commandFailed("Could not read comparison file paths.") + } + let status = CommitFileStatus(rawValue: String(code.prefix(1))) ?? .modified + result.append(CommitFileChange(path: String(decoding: fields[pathIndex], as: UTF8.self), status: status, + oldPath: hasOldPath ? String(decoding: fields[index + 1], as: UTF8.self) : nil)) + index = pathIndex + 1 + } + return result + } + + func comparisonCommits(snapshot: ReferenceComparisonSnapshot, targetSide: Bool, skip: Int, limit: Int, in repositoryURL: URL) async throws -> [Commit] { + try Task.checkCancellation() + let range = targetSide ? "\(snapshot.base)..\(snapshot.target)" : "\(snapshot.target)..\(snapshot.base)" + let output = try await runGit(arguments: [ + "log", "--topo-order", "--no-decorate", "--no-notes", + "--format=%H%x00%P%x00%s%x00%an%x00%ae%x00%ad", "--date=iso-strict", + "--max-count=\(max(1, min(limit, 200)))", "--skip=\(max(0, skip))", range, "--" + ], in: repositoryURL) + try Task.checkCancellation() + return parseCommitLog(output) + } + + func comparisonPatch(file: CommitFileChange, snapshot: ReferenceComparisonSnapshot, mode: ReferenceComparisonMode, in repositoryURL: URL) async throws -> ReferenceComparisonPatch { + try Task.checkCancellation() + let base = try snapshot.diffBase(for: mode) + let paths = [file.oldPath, file.path].compactMap { $0 } + var environment = ProcessInfo.processInfo.environment + environment["LC_ALL"] = "C" + let output = try await runGitBounded(arguments: [ + "--literal-pathspecs", "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--find-renames", "-U3", + base, snapshot.target, "--" + ] + paths, in: repositoryURL, environment: environment, outputByteLimit: 2_000_000) + try Task.checkCancellation() + return ReferenceComparisonPatch(hunks: DiffParser.parse(output.text), + isBinary: output.text.split(separator: "\n").contains { $0.hasPrefix("Binary files ") || $0 == "GIT binary patch" }, + isTruncated: output.isTruncated) + } +} diff --git a/macgit/Services/GitStatusService+Commit.swift b/macgit/Services/GitStatusService+Commit.swift index fb5b5e1..413a6ab 100644 --- a/macgit/Services/GitStatusService+Commit.swift +++ b/macgit/Services/GitStatusService+Commit.swift @@ -292,7 +292,7 @@ extension GitStatusService { return (ahead: ahead, behind: behind) } - private func parseCommitLog(_ raw: String) -> [Commit] { + func parseCommitLog(_ raw: String) -> [Commit] { let dateFormatter = ISO8601DateFormatter() var commits: [Commit] = [] for line in raw.split(separator: "\n") { diff --git a/macgit/Services/ReferenceComparisonServing.swift b/macgit/Services/ReferenceComparisonServing.swift new file mode 100644 index 0000000..6fa4ffd --- /dev/null +++ b/macgit/Services/ReferenceComparisonServing.swift @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +protocol ReferenceComparisonServing: Sendable { + func comparisonBranches(in repositoryURL: URL) async throws -> [ComparisonBranch] + func comparisonSnapshot(base: String, target: String, branchesOnly: Bool, in repositoryURL: URL) async throws -> ReferenceComparisonSnapshot + func comparisonFiles(snapshot: ReferenceComparisonSnapshot, mode: ReferenceComparisonMode, in repositoryURL: URL) async throws -> [CommitFileChange] + func comparisonCommits(snapshot: ReferenceComparisonSnapshot, targetSide: Bool, skip: Int, limit: Int, in repositoryURL: URL) async throws -> [Commit] + func comparisonPatch(file: CommitFileChange, snapshot: ReferenceComparisonSnapshot, mode: ReferenceComparisonMode, in repositoryURL: URL) async throws -> ReferenceComparisonPatch +} diff --git a/macgit/Views/Common/DiffView.swift b/macgit/Views/Common/DiffView.swift index 42bb126..95116f4 100644 --- a/macgit/Views/Common/DiffView.swift +++ b/macgit/Views/Common/DiffView.swift @@ -190,130 +190,6 @@ struct DiffView: View { } } -struct ReferenceDiffView: View { - let repositoryURL: URL - let baseRef: String - let targetRef: String - let title: String - let onClose: () -> Void - - @State private var changes: [CommitFileChange] = [] - @State private var selectedFile: CommitFileChange? - @State private var diffHunks: [DiffHunk] = [] - @State private var isLoading = true - @State private var isLoadingDiff = false - @State private var diffLoadID = UUID() - @AppStorage("referenceDiff.fileListWidth") private var fileListWidth: Double = 240 - - var body: some View { - VStack(spacing: 0) { - HStack(spacing: 8) { - Image(systemName: "arrow.left.arrow.right") - Text(title).font(.headline) - Spacer() - Text("\(changes.count) file\(changes.count == 1 ? "" : "s") changed") - .font(.caption) - .foregroundStyle(.secondary) - Button("Close", systemImage: "xmark", action: onClose) - .buttonStyle(.bordered) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(.ultraThinMaterial) - .overlay(alignment: .bottom) { - Rectangle().fill(.separator).frame(height: 0.5) - } - - if isLoading { - ProgressView("Loading comparison…") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if changes.isEmpty { - EmptyStateView( - icon: "checkmark.circle", - message: "No differences", - detail: "These references point to the same tree" - ) - } else { - GeometryReader { geometry in - let availableWidth = max(0, geometry.size.width - 6) - HStack(spacing: 0) { - CommitFileListView(changes: changes, selectedFile: $selectedFile) - .frame(width: CGFloat(fileListWidth)) - - ColumnResizer( - leftWidth: Binding( - get: { CGFloat(fileListWidth) }, - set: { fileListWidth = Double($0) } - ), - rightWidth: Binding( - get: { max(40, availableWidth - CGFloat(fileListWidth)) }, - set: { fileListWidth = Double(availableWidth - $0) } - ), - minimumLeftWidth: 240 - ) - - Group { - if let selectedFile { - if isLoadingDiff { - ProgressView("Loading file diff…") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - DiffView( - hunks: diffHunks, - repositoryURL: repositoryURL, - filePath: selectedFile.path - ) - } - } else { - EmptyStateView( - icon: "doc.text", - message: "Select a file", - detail: "Click a file to see its changes" - ) - } - } - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) - } - } - } - } - .task(id: "\(baseRef)→\(targetRef)") { - isLoading = true - changes = await GitStatusService.shared.changedFiles( - from: baseRef, - to: targetRef, - in: repositoryURL - ) - selectedFile = nil - diffHunks = [] - isLoadingDiff = false - isLoading = false - } - .onChange(of: selectedFile) { _, file in - guard let file else { - diffHunks = [] - isLoadingDiff = false - return - } - - let loadID = UUID() - diffLoadID = loadID - diffHunks = [] - isLoadingDiff = true - Task { - let hunks = await GitStatusService.shared.diff( - for: file.path, - from: baseRef, - to: targetRef, - in: repositoryURL - ) - guard !Task.isCancelled, diffLoadID == loadID else { return } - diffHunks = hunks - isLoadingDiff = false - } - } - } -} struct HunkView: View { let hunk: DiffHunk diff --git a/macgit/Views/Common/ReferenceComparisonCommitsView.swift b/macgit/Views/Common/ReferenceComparisonCommitsView.swift new file mode 100644 index 0000000..238abad --- /dev/null +++ b/macgit/Views/Common/ReferenceComparisonCommitsView.swift @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct ReferenceComparisonCommitsView: View { + let controller: ReferenceComparisonController + let targetSide: Bool + + private var commits: [Commit] { targetSide ? controller.targetCommits : controller.baseCommits } + private var total: Int { (targetSide ? controller.snapshot?.targetOnlyCount : controller.snapshot?.baseOnlyCount) ?? 0 } + private var isLoading: Bool { targetSide ? controller.isLoadingTargetCommits : controller.isLoadingBaseCommits } + private var error: String? { targetSide ? controller.targetCommitsError : controller.baseCommitsError } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Only in \(targetSide ? "Target" : "Base") (\(total))") + .font(.headline).padding(12) + Divider() + if total == 0 { + EmptyStateView(icon: "checkmark.circle", message: "No unique commits", detail: "All commits on this side are reachable from the other branch.") + } else { + List { + ForEach(commits) { commit in + VStack(alignment: .leading, spacing: 4) { + Text(commit.message).lineLimit(2) + HStack { + Text(commit.shortHash).monospaced() + Text(commit.author).lineLimit(1) + Spacer() + Text(commit.date, style: .date) + } + .font(.caption).foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .textSelection(.enabled) + } + if let error { + Text(error).foregroundStyle(.secondary) + } + if isLoading { + ProgressView("Loading commits…") + } else if commits.count < total { + Button(error == nil ? "Load more commits" : "Retry") { + controller.loadMoreCommits(targetSide: targetSide) + } + } + } + .listStyle(.inset) + } + } + .frame(minWidth: 240, maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/macgit/Views/Common/ReferenceComparisonFilesView.swift b/macgit/Views/Common/ReferenceComparisonFilesView.swift new file mode 100644 index 0000000..bddd632 --- /dev/null +++ b/macgit/Views/Common/ReferenceComparisonFilesView.swift @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct ReferenceComparisonFilesView: View { + let controller: ReferenceComparisonController + @AppStorage("referenceDiff.fileListWidth") private var fileListWidth: Double = 240 + + var body: some View { + if controller.files.isEmpty { + EmptyStateView(icon: "checkmark.circle", message: "No file changes", + detail: controller.mode == .mergeBase + ? "Target has no file changes since the merge base. Commit histories may still differ." + : "These references point to the same tree. Commit histories may still differ.") + } else { + GeometryReader { geometry in + let availableWidth = max(0, geometry.size.width - 6) + let width = min(CGFloat(fileListWidth), max(180, availableWidth - 300)) + HStack(spacing: 0) { + CommitFileListView(changes: controller.files, selectedFile: Binding( + get: { controller.selectedFile }, set: controller.selectFile)) + .frame(width: width) + ColumnResizer( + leftWidth: Binding(get: { width }, set: { fileListWidth = Double($0) }), + rightWidth: Binding(get: { max(40, availableWidth - width) }, + set: { fileListWidth = Double(availableWidth - $0) }), minimumLeftWidth: 180) + fileDetail + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) + } + } + } + } + + @ViewBuilder private var fileDetail: some View { + if let file = controller.selectedFile { + VStack(spacing: 0) { + Text(file.oldPath.map { "\($0) → \(file.path)" } ?? file.path) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + Divider() + if controller.isLoadingPatch { + ProgressView("Loading file diff…").frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = controller.fileError { + EmptyStateView(icon: "exclamationmark.triangle", message: "Could not load file diff", detail: error) + Button("Retry") { controller.selectFile(file) }.padding() + } else if let patch = controller.patch { + if patch.isTruncated { + Label("Large diff: showing the first 2 MB of output.", systemImage: "exclamationmark.triangle") + .font(.caption).padding(8) + } + if patch.isBinary { + EmptyStateView(icon: "doc", message: "Binary file changed", detail: "A text diff is not available for this file.") + } else if patch.hunks.isEmpty { + EmptyStateView(icon: "doc", message: "No text changes", + detail: file.status == .renamed ? "The file was renamed without text changes." : "The change affects file metadata or an empty file.") + } else { + DiffView(hunks: patch.hunks, repositoryURL: controller.repositoryURL, filePath: file.path) + } + } + } + } else { + EmptyStateView(icon: "doc.text", message: "Select a file", detail: "Click a file to see its changes.") + } + } +} diff --git a/macgit/Views/Common/ReferenceDiffView.swift b/macgit/Views/Common/ReferenceDiffView.swift new file mode 100644 index 0000000..32d4edd --- /dev/null +++ b/macgit/Views/Common/ReferenceDiffView.swift @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct ReferenceDiffView: View { + @State private var controller: ReferenceComparisonController + let onClose: () -> Void + @State private var showsCommits = false + + init(repositoryURL: URL, baseRef: String, targetRef: String, title: String, onClose: @escaping () -> Void) { + _controller = State(initialValue: ReferenceComparisonController(repositoryURL: repositoryURL, + baseRef: baseRef, targetRef: targetRef, isBranchComparison: false, title: title)) + self.onClose = onClose + } + + init(controller: ReferenceComparisonController, onClose: @escaping () -> Void) { + _controller = State(initialValue: controller) + self.onClose = onClose + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + if controller.isBranchComparison { + Picker("Comparison content", selection: $showsCommits) { + Text("Files (\(controller.files.count))").tag(false) + Text("Commits").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 320) + .padding(8) + } + if controller.isCancelled { + EmptyStateView(icon: "pause.circle", message: "Comparison stopped", detail: "Select Refresh to load this comparison again.") + } else if let error = controller.error, controller.snapshot == nil { + EmptyStateView(icon: "exclamationmark.triangle", message: "Could not compare references", detail: error) + } else if controller.baseRef.isEmpty || controller.targetRef.isEmpty { + EmptyStateView(icon: "arrow.triangle.branch", message: "Select two branches", detail: "Choose a base and a target to compare their commits and files.") + } else if showsCommits && controller.snapshot != nil { + HSplitView { + ReferenceComparisonCommitsView(controller: controller, targetSide: false) + ReferenceComparisonCommitsView(controller: controller, targetSide: true) + } + } else if controller.isLoading { + ProgressView("Loading comparison…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = controller.error { + EmptyStateView(icon: "exclamationmark.triangle", message: "Could not compare references", detail: error) + } else { + ReferenceComparisonFilesView(controller: controller) + } + } + .onAppear { controller.reload() } + .onDisappear { controller.cancel() } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label(controller.title, systemImage: "arrow.left.arrow.right") + .font(.headline) + Spacer() + if !controller.isBranchComparison { + Text("\(controller.files.count) files changed") + .font(.caption) + .foregroundStyle(.secondary) + } + if controller.isLoading || controller.isLoadingPatch || controller.isLoadingBaseCommits || controller.isLoadingTargetCommits { + Button("Stop", systemImage: "stop.circle") { controller.cancel() } + } + Button("Refresh", systemImage: "arrow.clockwise") { controller.reload() } + .help("Reload from local references without fetching") + Button("Close", systemImage: "xmark", action: onClose) + } + if controller.isBranchComparison { + ViewThatFits(in: .horizontal) { + HStack { + branchPicker(targetSide: false) + swapButton + branchPicker(targetSide: true) + } + VStack(alignment: .leading) { + branchPicker(targetSide: false) + HStack { swapButton; branchPicker(targetSide: true) } + } + } + Picker("Diff mode", selection: Binding(get: { controller.mode }, set: controller.setMode)) { + ForEach(ReferenceComparisonMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .fixedSize(horizontal: false, vertical: true) + Text(controller.mode == .mergeBase + ? "Files show changes introduced by Target since the common ancestor." + : "Files show changes from the Base tree to the Target tree.") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 16) { + Text("Merge base: \(mergeBaseLabel)") + .help(controller.snapshot?.mergeBases.joined(separator: "\n") ?? "Not yet resolved") + if let snapshot = controller.snapshot { + Text("Target: \(snapshot.targetOnlyCount) ahead · \(snapshot.baseOnlyCount) behind Base") + } else { + Text("Target ahead / behind Base: —") + } + } + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + .padding(12) + .background(.ultraThinMaterial) + } + + private var swapButton: some View { + Button("Swap", systemImage: "arrow.left.arrow.right") { controller.swap() } + .disabled(controller.baseRef.isEmpty || controller.targetRef.isEmpty) + .help("Swap Base and Target") + } + + private func branchPicker(targetSide: Bool) -> some View { + let ref = targetSide ? controller.targetRef : controller.baseRef + return Picker(targetSide ? "Target" : "Base", selection: Binding( + get: { targetSide ? controller.targetRef : controller.baseRef }, + set: { if targetSide { controller.setTarget($0) } else { controller.setBase($0) } } + )) { + Text("Select branch…").tag("") + if !ref.isEmpty && !controller.branches.contains(where: { $0.ref == ref }) { + Text(ComparisonBranch(ref: ref).label).tag(ref) + } + ForEach(controller.branches) { branch in + Text(branch.label).tag(branch.ref) + } + } + } + + private var mergeBaseLabel: String { + guard let snapshot = controller.snapshot else { return "—" } + if snapshot.mergeBases.isEmpty { return "None" } + return snapshot.mergeBases.map { String($0.prefix(8)) }.joined(separator: ", ") + } +} diff --git a/macgit/Views/History/CommitFileListView.swift b/macgit/Views/History/CommitFileListView.swift index 794f213..0b4b168 100644 --- a/macgit/Views/History/CommitFileListView.swift +++ b/macgit/Views/History/CommitFileListView.swift @@ -48,6 +48,13 @@ struct CommitFileListView: View { .font(.system(size: 10)) .foregroundStyle(.tertiary) .lineLimit(1) + if let oldPath = change.oldPath { + Text("From: \(oldPath)") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .help(oldPath) + } } Spacer() diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index 88b6b2c..fdfd96c 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -155,6 +155,7 @@ struct MainWindowView: View { @State private var referenceDiffTarget: String? @State private var referenceDiffTitle: String? @State private var isOpeningReferenceDiff = false + @State private var branchComparison: ReferenceComparisonController? @State private var pullPreselectedBranch: String? = nil @State var showingSearchModal = false @State var showingRepositorySettings = false @@ -735,6 +736,8 @@ struct MainWindowView: View { } private func clearReferenceDiff() { + branchComparison?.cancel() + branchComparison = nil referenceDiffBase = nil referenceDiffTarget = nil referenceDiffTitle = nil @@ -889,10 +892,21 @@ struct MainWindowView: View { } }, onRequestDiffTagAgainstCurrent: { tag in + clearReferenceDiff() referenceDiffBase = tag referenceDiffTarget = "HEAD" referenceDiffTitle = "Diff: \(tag) against Current (HEAD)" - isOpeningReferenceDiff = true + isOpeningReferenceDiff = selectedItem != .item(.history) + selectedItem = .item(.history) + }, + onRequestCompareBranches: { target, current in + clearReferenceDiff() + branchComparison = ReferenceComparisonController( + repositoryURL: repositoryURL, + baseRef: current.isEmpty || current == "HEAD" ? "" : "refs/heads/\(current)", + targetRef: target + ) + isOpeningReferenceDiff = selectedItem != .item(.history) selectedItem = .item(.history) }, onRequestPushTagToRemote: { tag, remote in @@ -1184,7 +1198,13 @@ struct MainWindowView: View { onRunRepositoryOperation: runRepositoryOperation ) case .item(.history): - if let referenceDiffBase, let referenceDiffTarget, let referenceDiffTitle { + if let branchComparison { + ReferenceDiffView(controller: branchComparison, onClose: { + isOpeningReferenceDiff = false + clearReferenceDiff() + }) + .id(ObjectIdentifier(branchComparison)) + } else if let referenceDiffBase, let referenceDiffTarget, let referenceDiffTitle { ReferenceDiffView( repositoryURL: repositoryURL, baseRef: referenceDiffBase, @@ -1195,6 +1215,7 @@ struct MainWindowView: View { clearReferenceDiff() } ) + .id("\(referenceDiffBase)→\(referenceDiffTarget)") } else { HistoryView( repositoryURL: repositoryURL, diff --git a/macgit/Views/MainWindow/Sidebar/SidebarBranchContextMenu.swift b/macgit/Views/MainWindow/Sidebar/SidebarBranchContextMenu.swift index 77eceb7..5719abd 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarBranchContextMenu.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarBranchContextMenu.swift @@ -136,8 +136,7 @@ struct SidebarBranchContextMenu: View { Divider() - Button("Diff Against Current") {} - .disabled(true) + Button("Compare with…") { actions.compare(branch) } Divider() diff --git a/macgit/Views/MainWindow/Sidebar/SidebarBranchSectionActions.swift b/macgit/Views/MainWindow/Sidebar/SidebarBranchSectionActions.swift index 4c6e7f1..5ff2fe5 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarBranchSectionActions.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarBranchSectionActions.swift @@ -42,4 +42,5 @@ struct SidebarBranchSectionActions { let drop: SidebarDropActions var forcePushTracked: (String) -> Void = { _ in } var forcePushToRemote: (String, String) -> Void = { _, _ in } + var compare: (String) -> Void = { _ in } } diff --git a/macgit/Views/MainWindow/Sidebar/SidebarRemoteContextMenu.swift b/macgit/Views/MainWindow/Sidebar/SidebarRemoteContextMenu.swift index 858268e..6a5be40 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarRemoteContextMenu.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarRemoteContextMenu.swift @@ -41,8 +41,8 @@ struct SidebarRemoteContextMenu: View { copyBranchNameButton - Button("Diff Against Current") {} - .disabled(true) + Button("Compare with…") { actions.compare(fullPath) } + .disabled(remoteBranch.branch == "HEAD") Divider() diff --git a/macgit/Views/MainWindow/Sidebar/SidebarRemoteSectionActions.swift b/macgit/Views/MainWindow/Sidebar/SidebarRemoteSectionActions.swift index 0405bb9..04b4c7e 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarRemoteSectionActions.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarRemoteSectionActions.swift @@ -30,4 +30,5 @@ struct SidebarRemoteSectionActions { let finishDrag: (String) -> Void let setHeaderDropTargeted: (Bool) -> Void let drop: SidebarDropActions + var compare: (String) -> Void = { _ in } } diff --git a/macgit/Views/MainWindow/SidebarView.swift b/macgit/Views/MainWindow/SidebarView.swift index 156d794..596aec9 100644 --- a/macgit/Views/MainWindow/SidebarView.swift +++ b/macgit/Views/MainWindow/SidebarView.swift @@ -55,6 +55,7 @@ struct SidebarView: View { let onRequestCreateTagFromBranch: (String) -> Void let onRequestTagDetails: (String) -> Void let onRequestDiffTagAgainstCurrent: (String) -> Void + let onRequestCompareBranches: (String, String) -> Void let onRequestPushTagToRemote: (String, String) -> Void let onRequestForcePushTagToRemote: (String, String) -> Void let onRequestDeleteTag: (String) -> Void @@ -214,6 +215,7 @@ struct SidebarView: View { onRequestCreateTagFromBranch: @escaping (String) -> Void = { _ in }, onRequestTagDetails: @escaping (String) -> Void = { _ in }, onRequestDiffTagAgainstCurrent: @escaping (String) -> Void = { _ in }, + onRequestCompareBranches: @escaping (String, String) -> Void = { _, _ in }, onRequestPushTagToRemote: @escaping (String, String) -> Void = { _, _ in }, onRequestForcePushTagToRemote: @escaping (String, String) -> Void = { _, _ in }, onRequestDeleteTag: @escaping (String) -> Void = { _ in }, @@ -284,6 +286,7 @@ struct SidebarView: View { self.onRequestCreateTagFromBranch = onRequestCreateTagFromBranch self.onRequestTagDetails = onRequestTagDetails self.onRequestDiffTagAgainstCurrent = onRequestDiffTagAgainstCurrent + self.onRequestCompareBranches = onRequestCompareBranches self.onRequestPushTagToRemote = onRequestPushTagToRemote self.onRequestForcePushTagToRemote = onRequestForcePushTagToRemote self.onRequestDeleteTag = onRequestDeleteTag @@ -383,7 +386,8 @@ struct SidebarView: View { currentDropLabel: currentBranchDropLabel, drop: dropActions, forcePushTracked: onRequestForcePushToTracked, - forcePushToRemote: onRequestForcePushBranchToRemote + forcePushToRemote: onRequestForcePushBranchToRemote, + compare: { onRequestCompareBranches("refs/heads/\($0)", currentBranch) } ) } @@ -453,7 +457,8 @@ struct SidebarView: View { makePayload: makeRemoteBranchPayload, finishDrag: finishRemoteBranchDrag, setHeaderDropTargeted: updateRemotesHeaderDropTarget, - drop: dropActions + drop: dropActions, + compare: { onRequestCompareBranches("refs/remotes/\($0)", currentBranch) } ) } diff --git a/macgitTests/BranchComparisonServiceTests.swift b/macgitTests/BranchComparisonServiceTests.swift new file mode 100644 index 0000000..57fce99 --- /dev/null +++ b/macgitTests/BranchComparisonServiceTests.swift @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import XCTest +@testable import macgit + +@MainActor +final class BranchComparisonServiceTests: XCTestCase { + private let service = GitStatusService.shared + + func testDirectionCountsUniqueCommitsAndReadOnlyWorktree() async throws { + let repo = try makeRepository() + try "uncommitted\n".write(to: repo.appendingPathComponent("dirty.txt"), atomically: true, encoding: .utf8) + let statusBefore = try git(["status", "--porcelain=v1"], in: repo) + let headBefore = try git(["rev-parse", "HEAD"], in: repo) + let snapshot = try await compare(in: repo) + XCTAssertEqual(snapshot.baseOnlyCount, 1) + XCTAssertEqual(snapshot.targetOnlyCount, 1) + let review = try await service.comparisonFiles(snapshot: snapshot, mode: .mergeBase, in: repo) + let tips = try await service.comparisonFiles(snapshot: snapshot, mode: .tips, in: repo) + XCTAssertFalse(review.contains { $0.path == "main.txt" }) + XCTAssertTrue(tips.contains { $0.path == "main.txt" && $0.status == .deleted }) + let target = try await service.comparisonCommits(snapshot: snapshot, targetSide: true, skip: 0, limit: 100, in: repo) + let base = try await service.comparisonCommits(snapshot: snapshot, targetSide: false, skip: 0, limit: 100, in: repo) + XCTAssertEqual(target.map(\.message), ["feature work"]) + XCTAssertEqual(base.map(\.message), ["main work"]) + let swapped = try await service.comparisonSnapshot(base: "refs/heads/feature", target: "refs/heads/main", branchesOnly: true, in: repo) + let swappedFiles = try await service.comparisonFiles(snapshot: swapped, mode: .mergeBase, in: repo) + XCTAssertEqual(swappedFiles.map(\.path), ["main.txt"]) + XCTAssertEqual(try git(["status", "--porcelain=v1"], in: repo), statusBefore) + XCTAssertEqual(try git(["rev-parse", "HEAD"], in: repo), headBefore) + } + + func testRenameUnusualPathsBinaryAndEmptyFiles() async throws { + let repo = try makeRepository() + let snapshot = try await compare(in: repo) + let files = try await service.comparisonFiles(snapshot: snapshot, mode: .mergeBase, in: repo) + let rename = try XCTUnwrap(files.first { $0.status == .renamed }) + XCTAssertEqual(rename.oldPath, "old\tname.txt") + XCTAssertEqual(rename.path, "new\nname.txt") + let renamePatch = try await service.comparisonPatch(file: rename, snapshot: snapshot, mode: .mergeBase, in: repo) + XCTAssertTrue(renamePatch.hunks.isEmpty) + let binary = try XCTUnwrap(files.first { $0.path == "binary.dat" }) + let binaryPatch = try await service.comparisonPatch(file: binary, snapshot: snapshot, mode: .mergeBase, in: repo) + XCTAssertTrue(binaryPatch.isBinary) + let empty = try XCTUnwrap(files.first { $0.path == "empty.txt" }) + XCTAssertEqual(empty.status, .added) + let emptyPatch = try await service.comparisonPatch(file: empty, snapshot: snapshot, mode: .mergeBase, in: repo) + XCTAssertFalse(emptyPatch.isBinary) + XCTAssertTrue(emptyPatch.hunks.isEmpty) + XCTAssertTrue(files.contains { $0.path == "deleted.txt" && $0.status == .deleted }) + let modified = try XCTUnwrap(files.first { $0.path == "tracked.txt" }) + XCTAssertEqual(modified.status, .modified) + let modifiedPatch = try await service.comparisonPatch(file: modified, snapshot: snapshot, mode: .mergeBase, in: repo) + XCTAssertTrue(modifiedPatch.hunks.flatMap(\.lines).contains { $0.type == .added && $0.text == "feature" }) + } + + func testAvailableRemoteBranchesAndRefValidation() async throws { + let repo = try makeRepository() + try git(["update-ref", "refs/remotes/origin/feature", "feature"], in: repo) + try git(["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/feature"], in: repo) + let branches = try await service.comparisonBranches(in: repo) + XCTAssertTrue(branches.contains { $0.ref == "refs/remotes/origin/feature" }) + XCTAssertFalse(branches.contains { $0.ref == "refs/remotes/origin/HEAD" }) + let remote = try await service.comparisonSnapshot(base: "refs/heads/main", target: "refs/remotes/origin/feature", branchesOnly: true, in: repo) + XCTAssertEqual(remote.targetOnlyCount, 1) + for invalid in ["main", "--all", "refs/heads/missing", "refs/heads/main^", "refs/tags/release"] { + do { + _ = try await service.comparisonSnapshot(base: invalid, target: "refs/heads/main", branchesOnly: true, in: repo) + XCTFail("Accepted invalid branch: \(invalid)") + } catch { XCTAssertFalse(error is CancellationError) } + } + } + + func testIdenticalRefsAndUnrelatedHistories() async throws { + let repo = try makeRepository() + let identical = try await service.comparisonSnapshot(base: "refs/heads/main", target: "refs/heads/main", branchesOnly: true, in: repo) + let files = try await service.comparisonFiles(snapshot: identical, mode: .mergeBase, in: repo) + XCTAssertTrue(files.isEmpty) + XCTAssertEqual(identical.baseOnlyCount + identical.targetOnlyCount, 0) + try git(["checkout", "--orphan", "unrelated"], in: repo) + try git(["commit", "-m", "other root"], in: repo) + let unrelated = try await service.comparisonSnapshot(base: "refs/heads/main", target: "refs/heads/unrelated", branchesOnly: true, in: repo) + XCTAssertTrue(unrelated.mergeBases.isEmpty) + do { + _ = try await service.comparisonFiles(snapshot: unrelated, mode: .mergeBase, in: repo) + XCTFail("Expected missing merge-base error") + } catch { XCTAssertTrue(error.localizedDescription.contains("no common ancestor")) } + _ = try await service.comparisonFiles(snapshot: unrelated, mode: .tips, in: repo) + } + + func testSnapshotSurvivesBranchMovementAndCommitPagination() async throws { + let repo = try makeRepository() + let snapshot = try await compare(in: repo) + try git(["checkout", "feature"], in: repo) + try git(["commit", "--allow-empty", "-m", "second"], in: repo) + try git(["commit", "--allow-empty", "-m", "third"], in: repo) + let original = try await service.comparisonCommits(snapshot: snapshot, targetSide: true, skip: 0, limit: 100, in: repo) + XCTAssertEqual(original.map(\.message), ["feature work"]) + let updated = try await compare(in: repo) + let first = try await service.comparisonCommits(snapshot: updated, targetSide: true, skip: 0, limit: 2, in: repo) + let next = try await service.comparisonCommits(snapshot: updated, targetSide: true, skip: 2, limit: 2, in: repo) + XCTAssertEqual(first.map(\.message), ["third", "second"]) + XCTAssertEqual(next.map(\.message), ["feature work"]) + } + + func testTagComparisonRemainsTipToTip() async throws { + let repo = try makeRepository() + try git(["tag", "release", "feature"], in: repo) + let snapshot = try await service.comparisonSnapshot(base: "release", target: "HEAD", branchesOnly: false, in: repo) + let files = try await service.comparisonFiles(snapshot: snapshot, mode: .tips, in: repo) + XCTAssertTrue(files.contains { $0.path == "main.txt" && $0.status == .added }) + XCTAssertTrue(files.contains { $0.path == "empty.txt" && $0.status == .deleted }) + } + + func testParserRejectsIncompleteRenameRecord() throws { + XCTAssertThrowsError(try GitStatusService.parseComparisonFiles(Data("R100\0old\0".utf8))) + } + + private func compare(in repo: URL) async throws -> ReferenceComparisonSnapshot { + try await service.comparisonSnapshot(base: "refs/heads/main", target: "refs/heads/feature", branchesOnly: true, in: repo) + } + + private func makeRepository() throws -> URL { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("compare-tests-\(UUID())", isDirectory: true) + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: repo) } + try git(["init", "-b", "main"], in: repo) + try git(["config", "user.name", "Comparison Tests"], in: repo) + try git(["config", "user.email", "tests@example.com"], in: repo) + try git(["config", "commit.gpgsign", "false"], in: repo) + for path in ["tracked.txt", "deleted.txt", "old\tname.txt"] { + try "base \(path)\n".write(to: repo.appendingPathComponent(path), atomically: true, encoding: .utf8) + } + try git(["add", "."], in: repo) + try git(["commit", "-m", "initial"], in: repo) + try git(["checkout", "-b", "feature"], in: repo) + try "feature\n".write(to: repo.appendingPathComponent("tracked.txt"), atomically: true, encoding: .utf8) + try FileManager.default.removeItem(at: repo.appendingPathComponent("deleted.txt")) + try FileManager.default.moveItem(at: repo.appendingPathComponent("old\tname.txt"), to: repo.appendingPathComponent("new\nname.txt")) + try Data([0, 1, 2, 3]).write(to: repo.appendingPathComponent("binary.dat")) + try Data().write(to: repo.appendingPathComponent("empty.txt")) + try git(["add", "-A"], in: repo) + try git(["commit", "-m", "feature work"], in: repo) + try git(["checkout", "main"], in: repo) + try "main\n".write(to: repo.appendingPathComponent("main.txt"), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-m", "main work"], in: repo) + return repo + } + + @discardableResult private func git(_ arguments: [String], in repo: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = repo + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(decoding: data, as: UTF8.self) + guard process.terminationStatus == 0 else { throw GitError.commandFailed(output) } + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/macgitTests/ReferenceComparisonControllerTests.swift b/macgitTests/ReferenceComparisonControllerTests.swift new file mode 100644 index 0000000..ff57bc6 --- /dev/null +++ b/macgitTests/ReferenceComparisonControllerTests.swift @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import XCTest +@testable import macgit + +@MainActor +final class ReferenceComparisonControllerTests: XCTestCase { + func testSwapDiscardsLateFileListAndReloadsCommits() async { + let gate = ComparisonTestGate() + let service = ComparisonTestService(filesGate: gate) + let controller = makeController(service) + controller.reload() + let oldTask = controller.loadTask + await gate.waitForArrival() + controller.swap() + await controller.loadTask?.value + await controller.baseCommitsTask?.value + await controller.targetCommitsTask?.value + XCTAssertEqual(controller.baseRef, "refs/heads/feature") + XCTAssertEqual(controller.snapshot?.base, "refs/heads/feature") + XCTAssertEqual(controller.files.first?.path, "refs/heads/feature.txt") + XCTAssertEqual(controller.targetCommits.first?.hash, "refs/heads/main") + await gate.open() + await oldTask?.value + XCTAssertEqual(controller.files.first?.path, "refs/heads/feature.txt") + XCTAssertFalse(controller.isLoading) + } + + func testChangingModeUsesSameSnapshotAndRejectsPreviousFiles() async { + let gate = ComparisonTestGate() + let service = ComparisonTestService(filesGate: gate) + let controller = makeController(service) + controller.reload() + let oldTask = controller.loadTask + await gate.waitForArrival() + controller.setMode(.tips) + await controller.loadTask?.value + XCTAssertEqual(controller.files.first?.path, "tips.txt") + await gate.open() + await oldTask?.value + XCTAssertEqual(controller.files.first?.path, "tips.txt") + let calls = await service.snapshotCalls + XCTAssertEqual(calls, 1) + } + + func testChangingFileRejectsLatePatch() async throws { + let gate = ComparisonTestGate() + let service = ComparisonTestService(patchGate: gate) + let controller = makeController(service) + controller.reload() + await controller.loadTask?.value + let first = try XCTUnwrap(controller.files.first) + let second = try XCTUnwrap(controller.files.last) + controller.selectFile(first) + let oldTask = controller.patchTask + await gate.waitForArrival() + controller.selectFile(second) + await controller.patchTask?.value + XCTAssertEqual(controller.patch?.isBinary, true) + await gate.open() + await oldTask?.value + XCTAssertEqual(controller.selectedFile, second) + XCTAssertEqual(controller.patch?.isBinary, true) + } + + func testCancelRejectsLateResults() async { + let gate = ComparisonTestGate() + let controller = makeController(ComparisonTestService(filesGate: gate)) + controller.reload() + let task = controller.loadTask + await gate.waitForArrival() + controller.cancel() + await gate.open() + await task?.value + XCTAssertTrue(controller.isCancelled) + XCTAssertFalse(controller.isLoading) + XCTAssertTrue(controller.files.isEmpty) + XCTAssertNil(controller.error) + } + + func testFileErrorsDoNotBecomeEmptySuccess() async { + let controller = makeController(ComparisonTestService(failFiles: true)) + controller.reload() + await controller.loadTask?.value + XCTAssertEqual(controller.error, "Unable to read files") + XCTAssertFalse(controller.isLoading) + XCTAssertNotNil(controller.snapshot) + } + + func testDetachedHeadRequiresBaseSelectionAndTagModeDefaultsToTips() async { + let service = ComparisonTestService() + let detached = ReferenceComparisonController(repositoryURL: URL(fileURLWithPath: "/tmp/comparison"), + baseRef: "", targetRef: "refs/heads/feature", service: service) + detached.reload() + await detached.loadTask?.value + XCTAssertNil(detached.snapshot) + XCTAssertFalse(detached.isLoading) + let calls = await service.snapshotCalls + XCTAssertEqual(calls, 0) + detached.setBase("refs/heads/main") + await detached.loadTask?.value + XCTAssertNotNil(detached.snapshot) + let tag = ReferenceComparisonController(repositoryURL: detached.repositoryURL, + baseRef: "v1", targetRef: "HEAD", isBranchComparison: false, service: service) + XCTAssertEqual(tag.mode, .tips) + } + + func testSeparateRepositoriesDoNotShareComparisonState() async { + let gate = ComparisonTestGate() + let service = ComparisonTestService(filesGate: gate) + let first = makeController(service) + first.reload() + let oldTask = first.loadTask + await gate.waitForArrival() + first.cancel() + let second = ReferenceComparisonController(repositoryURL: URL(fileURLWithPath: "/tmp/other-repository"), + baseRef: "refs/heads/feature", targetRef: "refs/heads/main", service: service) + second.reload() + await second.loadTask?.value + await gate.open() + await oldTask?.value + XCTAssertTrue(first.files.isEmpty) + XCTAssertEqual(second.files.first?.path, "refs/heads/feature.txt") + } + + private func makeController(_ service: ComparisonTestService) -> ReferenceComparisonController { + ReferenceComparisonController(repositoryURL: URL(fileURLWithPath: "/tmp/comparison"), + baseRef: "refs/heads/main", targetRef: "refs/heads/feature", service: service) + } +} + +private actor ComparisonTestGate { + private var arrived = false + private var isOpen = false + private var arrivalWaiter: CheckedContinuation? + private var releaseWaiter: CheckedContinuation? + + func wait() async { + arrived = true + arrivalWaiter?.resume() + arrivalWaiter = nil + if !isOpen { + // Deliberately ignore cancellation to simulate a late service response. + await withCheckedContinuation { releaseWaiter = $0 } + } + } + + func waitForArrival() async { + if !arrived { await withCheckedContinuation { arrivalWaiter = $0 } } + } + + func open() { + isOpen = true + releaseWaiter?.resume() + releaseWaiter = nil + } +} + +private actor ComparisonTestService: ReferenceComparisonServing { + let filesGate: ComparisonTestGate? + let patchGate: ComparisonTestGate? + let failFiles: Bool + private(set) var snapshotCalls = 0 + + init(filesGate: ComparisonTestGate? = nil, patchGate: ComparisonTestGate? = nil, failFiles: Bool = false) { + self.filesGate = filesGate + self.patchGate = patchGate + self.failFiles = failFiles + } + + func comparisonBranches(in repositoryURL: URL) async throws -> [ComparisonBranch] { + [ComparisonBranch(ref: "refs/heads/main"), ComparisonBranch(ref: "refs/heads/feature")] + } + + func comparisonSnapshot(base: String, target: String, branchesOnly: Bool, in repositoryURL: URL) async throws -> ReferenceComparisonSnapshot { + snapshotCalls += 1 + return ReferenceComparisonSnapshot(base: base, target: target, mergeBases: ["ancestor"], baseOnlyCount: 1, targetOnlyCount: 1) + } + + func comparisonFiles(snapshot: ReferenceComparisonSnapshot, mode: ReferenceComparisonMode, in repositoryURL: URL) async throws -> [CommitFileChange] { + if failFiles { throw GitError.commandFailed("Unable to read files") } + if snapshot.base == "refs/heads/main" && mode == .mergeBase { await filesGate?.wait() } + return [CommitFileChange(path: mode == .tips ? "tips.txt" : "\(snapshot.base).txt", status: .modified), + CommitFileChange(path: "binary.dat", status: .added)] + } + + func comparisonCommits(snapshot: ReferenceComparisonSnapshot, targetSide: Bool, skip: Int, limit: Int, in repositoryURL: URL) async throws -> [Commit] { + [Commit(hash: targetSide ? snapshot.target : snapshot.base, parents: [], message: "Commit", author: "Test", email: "test@example.com", date: .distantPast, refs: [])] + } + + func comparisonPatch(file: CommitFileChange, snapshot: ReferenceComparisonSnapshot, mode: ReferenceComparisonMode, in repositoryURL: URL) async throws -> ReferenceComparisonPatch { + if file.path != "binary.dat" { await patchGate?.wait() } + return ReferenceComparisonPatch(hunks: [], isBinary: file.path == "binary.dat", isTruncated: false) + } +}