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

@MainActor
final class PathComparisonWindowController: NSWindowController, NSWindowDelegate {
private var comparison: ReferenceComparisonController?

init() {
super.init(window: nil)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func show(path: ComparisonPath, in repositoryURL: URL) {
close()
let comparison = ReferenceComparisonController(
repositoryURL: repositoryURL, baseRef: "HEAD", targetRef: "",
isBranchComparison: false, title: "Compare with Revision", path: path,
pathTarget: .workingTree)
self.comparison = comparison
let screen = NSApp.keyWindow?.screen ?? NSScreen.main
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800)
let size = NSSize(width: min(1100, visibleFrame.width - 40),
height: min(760, visibleFrame.height - 80))
let window = NSWindow(contentRect: NSRect(origin: .zero, size: size),
styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
window.title = "Compare — \(path.path) — \(repositoryURL.lastPathComponent)"
window.isReleasedWhenClosed = false
window.tabbingMode = .disallowed
window.contentMinSize = NSSize(width: min(760, size.width), height: min(420, size.height))
window.delegate = self
let hostingView = NSHostingView(rootView: GeometryReader { geometry in
ReferenceDiffView(controller: comparison, onClose: { [weak self] in self?.close() })
.frame(width: geometry.size.width, height: geometry.size.height)
})
// Window geometry owns the viewport; diff content must never expand the window.
hostingView.sizingOptions = []
window.contentView = hostingView
window.setContentSize(size)
window.setFrameOrigin(NSPoint(x: visibleFrame.midX - window.frame.width / 2,
y: visibleFrame.midY - window.frame.height / 2))
self.window = window
showWindow(nil)
window.makeKeyAndOrderFront(nil)
}

func windowWillClose(_ notification: Notification) {
comparison?.cancel()
comparison = nil
}
}
27 changes: 23 additions & 4 deletions macgit/App/ReferenceComparisonController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ final class ReferenceComparisonController {
let repositoryURL: URL
let isBranchComparison: Bool
let title: String
let path: ComparisonPath?
let pathTarget: ComparisonEndpoint?
private(set) var revisions: [String] = []
private(set) var baseRef: String
private(set) var targetRef: String
private(set) var mode: ReferenceComparisonMode
Expand Down Expand Up @@ -35,14 +38,17 @@ final class ReferenceComparisonController {
@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) {
title: String = "Compare Branches", path: ComparisonPath? = nil,
pathTarget: ComparisonEndpoint? = nil, service: any ReferenceComparisonServing = GitStatusService.shared) {
self.repositoryURL = repositoryURL
self.baseRef = baseRef
self.targetRef = targetRef
self.isBranchComparison = isBranchComparison
self.path = path
self.pathTarget = path.map { _ in pathTarget ?? .workingTree }
self.isBranchComparison = path == nil && isBranchComparison
self.title = title
self.service = service
self.mode = isBranchComparison ? .mergeBase : .tips
self.mode = self.isBranchComparison ? .mergeBase : .tips
}

func setBase(_ ref: String) {
Expand Down Expand Up @@ -83,6 +89,7 @@ final class ReferenceComparisonController {
}

func reload(reuseSnapshot: Bool = false) {
let previousSelection = selectedFile
let previousSnapshot = reuseSnapshot ? snapshot : nil
cancel()
let id = requestID
Expand Down Expand Up @@ -111,13 +118,21 @@ final class ReferenceComparisonController {
guard isCurrent(id) else { return }
branches = available
}
guard !base.isEmpty, !target.isEmpty else {
if path != nil && (revisions.isEmpty || !reuseSnapshot) {
let available = try await service.comparisonRevisions(in: repositoryURL)
guard isCurrent(id) else { return }
revisions = available
}
guard !base.isEmpty, path != nil || !target.isEmpty else {
isLoading = false
return
}
let resolved: ReferenceComparisonSnapshot
if let previousSnapshot {
resolved = previousSnapshot
} else if let path, let pathTarget {
resolved = try await service.pathComparisonSnapshot(base: base, target: pathTarget,
path: path, in: repositoryURL)
} else {
resolved = try await service.comparisonSnapshot(base: base, target: target,
branchesOnly: isBranchComparison, in: repositoryURL)
Expand All @@ -132,6 +147,10 @@ final class ReferenceComparisonController {
guard isCurrent(id) else { return }
files = changes
isLoading = false
if let path {
let selected = changes.first { $0.path == previousSelection?.path }
selectFile(selected ?? (path.isDirectory ? nil : changes.first))
}
} catch {
guard isCurrent(id) else { return }
self.error = error.localizedDescription
Expand Down
16 changes: 16 additions & 0 deletions macgit/Models/ComparisonEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

nonisolated enum ComparisonEndpoint: Equatable, Sendable {
case revision(String)
case workingTree
case index

var label: String {
switch self {
case .revision(let ref): ref
case .workingTree: "Working Tree"
case .index: "Index (staged content)"
}
}
}
20 changes: 20 additions & 0 deletions macgit/Models/ComparisonPath.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 ComparisonPath: Equatable, Sendable, Identifiable {
let path: String
let isDirectory: Bool
var id: String { "\(isDirectory):\(path)" }

func validate() throws {
guard !path.isEmpty, !path.hasPrefix("/"), !path.contains("\0"),
!path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." || $0.isEmpty }),
path == "." || !path.split(separator: "/").contains(".") else {
throw GitError.commandFailed("Select a repository-relative file or folder.")
}
}

func contains(_ candidate: String) -> Bool {
candidate == path || (isDirectory && (path == "." || candidate.hasPrefix(path + "/")))
}
}
11 changes: 11 additions & 0 deletions macgit/Models/ReferenceComparisonSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ nonisolated struct ReferenceComparisonSnapshot: Sendable {
let baseOnlyCount: Int
let targetOnlyCount: Int

var path: ComparisonPath? = nil
var targetEndpoint: ComparisonEndpoint? = nil

var diffArguments: [String] {
switch targetEndpoint ?? .revision(target) {
case .revision(let sha): [base, sha]
case .workingTree: [base]
case .index: ["--cached", base]
}
}

func diffBase(for mode: ReferenceComparisonMode) throws -> String {
if mode == .tips { return base }
guard mergeBases.count == 1, let mergeBase = mergeBases.first else {
Expand Down
16 changes: 9 additions & 7 deletions macgit/Services/GitStatusService+BranchComparison.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ extension GitStatusService: ReferenceComparisonServing {
baseOnlyCount: numbers[0], targetOnlyCount: numbers[1])
}

private func resolveComparisonRef(_ ref: String, branchesOnly: Bool, in repositoryURL: URL) async throws -> String {
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 {
Expand All @@ -56,9 +56,12 @@ extension GitStatusService: ReferenceComparisonServing {
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)
"--no-optional-locks", "diff", "--name-status", "-z", "--find-renames", "--no-ext-diff", "--no-textconv"
] + (snapshot.path == nil ? [base, snapshot.target] : snapshot.diffArguments) + ["--"], in: repositoryURL)
let files = try Self.parseComparisonFiles(output)
guard let path = snapshot.path else { return files }
// Detect renames before filtering so moves across the folder boundary retain both paths.
return files.filter { path.contains($0.path) || ($0.oldPath.map(path.contains) ?? false) }
}

nonisolated static func parseComparisonFiles(_ data: Data) throws -> [CommitFileChange] {
Expand Down Expand Up @@ -100,9 +103,8 @@ extension GitStatusService: ReferenceComparisonServing {
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)
"--no-optional-locks", "--literal-pathspecs", "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--find-renames", "-U3",
] + (snapshot.path == nil ? [base, snapshot.target] : snapshot.diffArguments) + ["--"] + 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" },
Expand Down
36 changes: 36 additions & 0 deletions macgit/Services/GitStatusService+PathComparison.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

extension GitStatusService {
func comparisonRevisions(in repositoryURL: URL) async throws -> [String] {
let output = try await runGit(arguments: [
"for-each-ref", "--sort=refname", "--format=%(refname)%00%(symref)",
"refs/heads/", "refs/remotes/", "refs/tags/"
], in: repositoryURL)
try Task.checkCancellation()
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 String(fields[0])
}
}

func pathComparisonSnapshot(base: String, target: ComparisonEndpoint, path: ComparisonPath,
in repositoryURL: URL) async throws -> ReferenceComparisonSnapshot {
try path.validate()
let baseSHA = try await resolveComparisonRef(base, branchesOnly: false, in: repositoryURL)
let resolvedTarget: ComparisonEndpoint
let targetSHA: String
switch target {
case .revision(let ref):
targetSHA = try await resolveComparisonRef(ref, branchesOnly: false, in: repositoryURL)
resolvedTarget = .revision(targetSHA)
case .workingTree, .index:
targetSHA = ""
resolvedTarget = target
}
try Task.checkCancellation()
return ReferenceComparisonSnapshot(base: baseSHA, target: targetSHA, mergeBases: [],
baseOnlyCount: 0, targetOnlyCount: 0, path: path, targetEndpoint: resolvedTarget)
}
}
3 changes: 3 additions & 0 deletions macgit/Services/ReferenceComparisonServing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import Foundation

protocol ReferenceComparisonServing: Sendable {
func comparisonRevisions(in repositoryURL: URL) async throws -> [String]
func pathComparisonSnapshot(base: String, target: ComparisonEndpoint, path: ComparisonPath,
in repositoryURL: URL) async throws -> ReferenceComparisonSnapshot
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]
Expand Down
62 changes: 62 additions & 0 deletions macgit/Views/Common/PathComparisonHeaderView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI

struct PathComparisonHeaderView: View {
let controller: ReferenceComparisonController
@State private var revision = "HEAD"

private var matchingRevisions: [String] {
controller.revisions.filter { revision.isEmpty || revision == "HEAD" || $0.localizedStandardContains(revision) }
}

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(controller.path?.path ?? "")
.font(.callout.monospaced())
.textSelection(.enabled)
HStack {
TextField("Commit, branch, or tag", text: $revision)
.textFieldStyle(.roundedBorder)
.onSubmit(compare)
.accessibilityLabel("Base revision")
Menu("Choose Revision") {
Button("HEAD") { choose("HEAD") }
ForEach(matchingRevisions, id: \.self) { ref in
Button(ref) { choose(ref) }
}
}
Button("Compare", action: compare)
.disabled(revision.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text("Left: \(controller.baseRef)\(baseHash) → Right: \(controller.pathTarget?.label ?? controller.targetRef)\(targetHash)")
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
if controller.pathTarget == .workingTree {
Text("Current tracked-file content, including staged and unstaged edits. Untracked files are excluded.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onAppear { revision = controller.baseRef }
}

private var baseHash: String {
controller.snapshot.map { " (\($0.base.prefix(8)))" } ?? ""
}

private var targetHash: String {
guard case .revision = controller.pathTarget, let snapshot = controller.snapshot else { return "" }
return " (\(snapshot.target.prefix(8)))"
}

private func choose(_ ref: String) {
revision = ref
compare()
}

private func compare() {
let ref = revision.trimmingCharacters(in: .whitespacesAndNewlines)
if ref == controller.baseRef { controller.reload() } else { controller.setBase(ref) }
}
}
5 changes: 4 additions & 1 deletion macgit/Views/Common/ReferenceComparisonFilesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ struct ReferenceComparisonFilesView: View {
var body: some View {
if controller.files.isEmpty {
EmptyStateView(icon: "checkmark.circle", message: "No file changes",
detail: controller.mode == .mergeBase
detail: controller.path != nil ? "No changes for this path between the selected sides." : 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 if controller.path?.isDirectory == false {
fileDetail
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
GeometryReader { geometry in
let availableWidth = max(0, geometry.size.width - 6)
Expand Down
5 changes: 4 additions & 1 deletion macgit/Views/Common/ReferenceDiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct ReferenceDiffView: View {
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 {
} else if controller.baseRef.isEmpty || (controller.path == nil && 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 {
Expand Down Expand Up @@ -73,6 +73,9 @@ struct ReferenceDiffView: View {
.help("Reload from local references without fetching")
Button("Close", systemImage: "xmark", action: onClose)
}
if controller.path != nil {
PathComparisonHeaderView(controller: controller)
}
if controller.isBranchComparison {
ViewThatFits(in: .horizontal) {
HStack {
Expand Down
Loading
Loading