From 334f274f7df28e8ac86fe0b8f9589ea1a2462be4 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Tue, 22 Sep 2026 14:10:49 +0700 Subject: [PATCH 1/2] feat: compare files and folders with a revision in a separate window --- .../App/PathComparisonWindowController.swift | 54 ++++++++++ .../App/ReferenceComparisonController.swift | 27 ++++- macgit/Models/ComparisonEndpoint.swift | 16 +++ macgit/Models/ComparisonPath.swift | 20 ++++ .../Models/ReferenceComparisonSnapshot.swift | 11 ++ .../GitStatusService+BranchComparison.swift | 16 +-- .../GitStatusService+PathComparison.swift | 36 +++++++ .../Services/ReferenceComparisonServing.swift | 3 + .../Common/PathComparisonHeaderView.swift | 62 +++++++++++ .../Common/ReferenceComparisonFilesView.swift | 5 +- macgit/Views/Common/ReferenceDiffView.swift | 5 +- macgit/Views/FileStatus/FileStatusView.swift | 18 ++++ macgit/Views/MainWindow/MainWindowView.swift | 2 + .../BranchComparisonServiceTests.swift | 100 ++++++++++++++++++ .../ReferenceComparisonControllerTests.swift | 47 +++++++- 15 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 macgit/App/PathComparisonWindowController.swift create mode 100644 macgit/Models/ComparisonEndpoint.swift create mode 100644 macgit/Models/ComparisonPath.swift create mode 100644 macgit/Services/GitStatusService+PathComparison.swift create mode 100644 macgit/Views/Common/PathComparisonHeaderView.swift diff --git a/macgit/App/PathComparisonWindowController.swift b/macgit/App/PathComparisonWindowController.swift new file mode 100644 index 0000000..8695819 --- /dev/null +++ b/macgit/App/PathComparisonWindowController.swift @@ -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 + } +} diff --git a/macgit/App/ReferenceComparisonController.swift b/macgit/App/ReferenceComparisonController.swift index 1c81392..c24ac35 100644 --- a/macgit/App/ReferenceComparisonController.swift +++ b/macgit/App/ReferenceComparisonController.swift @@ -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 @@ -35,14 +38,17 @@ final class ReferenceComparisonController { @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) { + 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) { @@ -83,6 +89,7 @@ final class ReferenceComparisonController { } func reload(reuseSnapshot: Bool = false) { + let previousSelection = selectedFile let previousSnapshot = reuseSnapshot ? snapshot : nil cancel() let id = requestID @@ -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 { + 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) @@ -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 diff --git a/macgit/Models/ComparisonEndpoint.swift b/macgit/Models/ComparisonEndpoint.swift new file mode 100644 index 0000000..9c81900 --- /dev/null +++ b/macgit/Models/ComparisonEndpoint.swift @@ -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)" + } + } +} diff --git a/macgit/Models/ComparisonPath.swift b/macgit/Models/ComparisonPath.swift new file mode 100644 index 0000000..1757a1a --- /dev/null +++ b/macgit/Models/ComparisonPath.swift @@ -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 + "/"))) + } +} diff --git a/macgit/Models/ReferenceComparisonSnapshot.swift b/macgit/Models/ReferenceComparisonSnapshot.swift index e2df819..8bd6f4f 100644 --- a/macgit/Models/ReferenceComparisonSnapshot.swift +++ b/macgit/Models/ReferenceComparisonSnapshot.swift @@ -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 { diff --git a/macgit/Services/GitStatusService+BranchComparison.swift b/macgit/Services/GitStatusService+BranchComparison.swift index 47cd0c5..251c3c2 100644 --- a/macgit/Services/GitStatusService+BranchComparison.swift +++ b/macgit/Services/GitStatusService+BranchComparison.swift @@ -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 { @@ -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] { @@ -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" }, diff --git a/macgit/Services/GitStatusService+PathComparison.swift b/macgit/Services/GitStatusService+PathComparison.swift new file mode 100644 index 0000000..c2e8b0f --- /dev/null +++ b/macgit/Services/GitStatusService+PathComparison.swift @@ -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) + } +} diff --git a/macgit/Services/ReferenceComparisonServing.swift b/macgit/Services/ReferenceComparisonServing.swift index 6fa4ffd..e90f9cd 100644 --- a/macgit/Services/ReferenceComparisonServing.swift +++ b/macgit/Services/ReferenceComparisonServing.swift @@ -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] diff --git a/macgit/Views/Common/PathComparisonHeaderView.swift b/macgit/Views/Common/PathComparisonHeaderView.swift new file mode 100644 index 0000000..cf2d7f8 --- /dev/null +++ b/macgit/Views/Common/PathComparisonHeaderView.swift @@ -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) } + } +} diff --git a/macgit/Views/Common/ReferenceComparisonFilesView.swift b/macgit/Views/Common/ReferenceComparisonFilesView.swift index bddd632..36686df 100644 --- a/macgit/Views/Common/ReferenceComparisonFilesView.swift +++ b/macgit/Views/Common/ReferenceComparisonFilesView.swift @@ -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) diff --git a/macgit/Views/Common/ReferenceDiffView.swift b/macgit/Views/Common/ReferenceDiffView.swift index 32d4edd..7739ac6 100644 --- a/macgit/Views/Common/ReferenceDiffView.swift +++ b/macgit/Views/Common/ReferenceDiffView.swift @@ -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 { @@ -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 { diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index 5b0a7c1..1be0d0b 100644 --- a/macgit/Views/FileStatus/FileStatusView.swift +++ b/macgit/Views/FileStatus/FileStatusView.swift @@ -40,6 +40,7 @@ struct FileStatusView: View { var canUpdateCurrentBranch = false var onRequestUpdateCurrentBranch: (CurrentBranchIntegrationStatus) -> Void = { _ in } var onRequestApplyStash: (String) -> Void = { _ in } + var onRequestComparePath: (ComparisonPath) -> Void = { _ in } var onAuthorizeCommit: () async -> Bool = { true } var onRequestPushAfterCommit: (String, String) async throws -> Void var onRunRepositoryOperation: RepositoryOperationRunner @@ -600,6 +601,7 @@ struct FileStatusView: View { let selection = actionSelection return Menu { + comparisonMenu(file: file) Button("Open") { openFile(file: file) } .disabled(selection.isSingleFileActionDisabled) Button("Show in Finder") { showInFinder(file: file) } @@ -694,10 +696,26 @@ struct FileStatusView: View { .frame(width: 24) } + @ViewBuilder + private func comparisonMenu(file: StatusFile) -> some View { + Button("Compare with Revision…") { + onRequestComparePath(ComparisonPath(path: file.path, isDirectory: false)) + } + .disabled(file.status == .untracked || actionSelection.isSingleFileActionDisabled) + Button("Compare Parent Folder with Revision…") { + let components = file.path.split(separator: "/", omittingEmptySubsequences: false).dropLast() + onRequestComparePath(ComparisonPath(path: components.isEmpty ? "." : components.joined(separator: "/"), + isDirectory: true)) + } + .disabled(actionSelection.isSingleFileActionDisabled) + Divider() + } + @ViewBuilder private func fileContextMenu(file: StatusFile, isStaged: Bool) -> some View { let selection = actionSelection + comparisonMenu(file: file) Button("Open") { openFile(file: file) } .disabled(selection.isSingleFileActionDisabled) Button("Show in Finder") { showInFinder(file: file) } diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index fdfd96c..79d36de 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -151,6 +151,7 @@ struct MainWindowView: View { @State private var repoIconName: String = "code-branch" @State private var remoteURLString: String = "" @State var selectedBranchName: String? = nil + @State private var pathComparisonWindow = PathComparisonWindowController() @State private var referenceDiffBase: String? @State private var referenceDiffTarget: String? @State private var referenceDiffTitle: String? @@ -1193,6 +1194,7 @@ struct MainWindowView: View { onRequestApplyStash: { ref in requestStashAction(ref: ref, action: .apply) }, + onRequestComparePath: { pathComparisonWindow.show(path: $0, in: repositoryURL) }, onAuthorizeCommit: authorizeProtectedBranchCommit, onRequestPushAfterCommit: pushAfterCommit, onRunRepositoryOperation: runRepositoryOperation diff --git a/macgitTests/BranchComparisonServiceTests.swift b/macgitTests/BranchComparisonServiceTests.swift index 57fce99..5bc3c28 100644 --- a/macgitTests/BranchComparisonServiceTests.swift +++ b/macgitTests/BranchComparisonServiceTests.swift @@ -115,6 +115,106 @@ final class BranchComparisonServiceTests: XCTestCase { XCTAssertThrowsError(try GitStatusService.parseComparisonFiles(Data("R100\0old\0".utf8))) } + func testPathComparisonWorkingTreeIncludesStagedAndUnstagedWithoutMutation() async throws { + let repo = try makeRepository() + try "staged\n".write(to: repo.appendingPathComponent("tracked.txt"), atomically: true, encoding: .utf8) + try git(["add", "tracked.txt"], in: repo) + try "working\n".write(to: repo.appendingPathComponent("tracked.txt"), atomically: true, encoding: .utf8) + try "new staged\n".write(to: repo.appendingPathComponent("added.txt"), atomically: true, encoding: .utf8) + try git(["add", "added.txt"], in: repo) + try "ignored untracked\n".write(to: repo.appendingPathComponent("untracked.txt"), atomically: true, encoding: .utf8) + let indexBefore = try Data(contentsOf: repo.appendingPathComponent(".git/index")) + let headBefore = try git(["rev-parse", "HEAD"], in: repo) + let path = ComparisonPath(path: ".", isDirectory: true) + let snapshot = try await service.pathComparisonSnapshot(base: "HEAD", target: .workingTree, path: path, in: repo) + let files = try await service.comparisonFiles(snapshot: snapshot, mode: .tips, in: repo) + XCTAssertEqual(Set(files.map(\.path)), ["tracked.txt", "added.txt"]) + let file = try XCTUnwrap(files.first { $0.path == "tracked.txt" }) + let patch = try await service.comparisonPatch(file: file, snapshot: snapshot, mode: .tips, in: repo) + XCTAssertTrue(patch.hunks.flatMap(\.lines).contains { $0.type == .added && $0.text == "working" }) + let staged = try await service.pathComparisonSnapshot(base: "HEAD", target: .index, path: path, in: repo) + let stagedPatch = try await service.comparisonPatch(file: file, snapshot: staged, mode: .tips, in: repo) + XCTAssertTrue(stagedPatch.hunks.flatMap(\.lines).contains { $0.type == .added && $0.text == "staged" }) + XCTAssertEqual(try Data(contentsOf: repo.appendingPathComponent(".git/index")), indexBefore) + XCTAssertEqual(try git(["rev-parse", "HEAD"], in: repo), headBefore) + XCTAssertEqual(try String(contentsOf: repo.appendingPathComponent("tracked.txt"), encoding: .utf8), "working\n") + } + + func testFolderComparisonIncludesCrossBoundaryRenamesAndExcludesSiblingPrefix() async throws { + let repo = try makeRepository() + for folder in ["src", "src-other"] { + try FileManager.default.createDirectory(at: repo.appendingPathComponent(folder), withIntermediateDirectories: true) + } + try "move out\n".write(to: repo.appendingPathComponent("src/out.txt"), atomically: true, encoding: .utf8) + try "sibling\n".write(to: repo.appendingPathComponent("src-other/file.txt"), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-m", "folders"], in: repo) + try git(["mv", "src/out.txt", "outside.txt"], in: repo) + try git(["mv", "tracked.txt", "src/in.txt"], in: repo) + try "changed\n".write(to: repo.appendingPathComponent("src-other/file.txt"), atomically: true, encoding: .utf8) + let snapshot = try await service.pathComparisonSnapshot(base: "HEAD", target: .workingTree, + path: ComparisonPath(path: "src", isDirectory: true), in: repo) + let files = try await service.comparisonFiles(snapshot: snapshot, mode: .tips, in: repo) + XCTAssertEqual(files.count, 2) + XCTAssertTrue(files.allSatisfy { $0.status == .renamed }) + XCTAssertTrue(files.contains { $0.oldPath == "src/out.txt" && $0.path == "outside.txt" }) + XCTAssertTrue(files.contains { $0.oldPath == "tracked.txt" && $0.path == "src/in.txt" }) + } + + func testPathComparisonSupportsTagsRemoteRefsAndBinaryRevisionChanges() async throws { + let repo = try makeRepository() + try git(["tag", "-a", "release", "-m", "release", "feature"], in: repo) + try git(["update-ref", "refs/remotes/origin/feature", "feature"], in: repo) + let revisions = try await service.comparisonRevisions(in: repo) + XCTAssertTrue(revisions.contains("refs/tags/release")) + XCTAssertTrue(revisions.contains("refs/remotes/origin/feature")) + let snapshot = try await service.pathComparisonSnapshot(base: "HEAD", target: .revision("refs/tags/release"), + path: ComparisonPath(path: "binary.dat", isDirectory: false), in: repo) + let files = try await service.comparisonFiles(snapshot: snapshot, mode: .tips, in: repo) + XCTAssertEqual(files.map(\.path), ["binary.dat"]) + XCTAssertEqual(files.first?.status, .added) + let patch = try await service.comparisonPatch(file: XCTUnwrap(files.first), snapshot: snapshot, mode: .tips, in: repo) + XCTAssertTrue(patch.isBinary) + } + + func testLiteralPathComparisonAndMissingSide() async throws { + let repo = try makeRepository() + let paths = ["-option.txt", ":(glob)*", "space 日本語.txt", "tab\tline\n.txt"] + for path in paths { + try "before\n".write(to: repo.appendingPathComponent(path), atomically: true, encoding: .utf8) + } + try git(["add", "."], in: repo) + try git(["commit", "-m", "unusual paths"], in: repo) + for path in paths { + try "after\n".write(to: repo.appendingPathComponent(path), atomically: true, encoding: .utf8) + let snapshot = try await service.pathComparisonSnapshot(base: "HEAD", target: .workingTree, + path: ComparisonPath(path: path, isDirectory: false), in: repo) + let files = try await service.comparisonFiles(snapshot: snapshot, mode: .tips, in: repo) + XCTAssertEqual(files.map(\.path), [path]) + let patch = try await service.comparisonPatch(file: XCTUnwrap(files.first), snapshot: snapshot, mode: .tips, in: repo) + XCTAssertEqual(patch.hunks.flatMap(\.lines).filter { $0.type == .added }.map(\.text), ["after"]) + } + try FileManager.default.removeItem(at: repo.appendingPathComponent("deleted.txt")) + let snapshot = try await service.pathComparisonSnapshot(base: "HEAD", target: .workingTree, + path: ComparisonPath(path: "deleted.txt", isDirectory: false), in: repo) + let files = try await service.comparisonFiles(snapshot: snapshot, mode: .tips, in: repo) + XCTAssertEqual(files.first?.status, .deleted) + let empty = try await service.pathComparisonSnapshot(base: "HEAD", target: .revision("HEAD"), + path: ComparisonPath(path: "missing", isDirectory: true), in: repo) + let emptyFiles = try await service.comparisonFiles(snapshot: empty, mode: .tips, in: repo) + XCTAssertTrue(emptyFiles.isEmpty) + for ref in ["--all", "missing-revision", "HEAD:tracked.txt"] { + do { + _ = try await service.pathComparisonSnapshot(base: ref, target: .workingTree, + path: ComparisonPath(path: ".", isDirectory: true), in: repo) + XCTFail("Accepted invalid revision") + } catch { XCTAssertFalse(error is CancellationError) } + } + for path in ["../escape", "/absolute", "a/../b", "a\0b"] { + XCTAssertThrowsError(try ComparisonPath(path: path, isDirectory: false).validate()) + } + } + 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) } diff --git a/macgitTests/ReferenceComparisonControllerTests.swift b/macgitTests/ReferenceComparisonControllerTests.swift index ff57bc6..3829432 100644 --- a/macgitTests/ReferenceComparisonControllerTests.swift +++ b/macgitTests/ReferenceComparisonControllerTests.swift @@ -122,6 +122,43 @@ final class ReferenceComparisonControllerTests: XCTestCase { XCTAssertEqual(second.files.first?.path, "refs/heads/feature.txt") } + func testPathComparisonRejectsLateRevisionAndAutomaticallyLoadsFile() async throws { + let gate = ComparisonTestGate() + let controller = ReferenceComparisonController(repositoryURL: URL(fileURLWithPath: "/tmp/comparison"), + baseRef: "refs/heads/main", targetRef: "", path: ComparisonPath(path: "tips.txt", isDirectory: false), + pathTarget: .workingTree, service: ComparisonTestService(filesGate: gate)) + controller.reload() + let oldTask = controller.loadTask + await gate.waitForArrival() + controller.setBase("refs/heads/feature") + await controller.loadTask?.value + await controller.patchTask?.value + XCTAssertEqual(controller.snapshot?.base, "refs/heads/feature") + XCTAssertEqual(controller.selectedFile?.path, "tips.txt") + XCTAssertNotNil(controller.patch) + await gate.open() + await oldTask?.value + XCTAssertEqual(controller.snapshot?.base, "refs/heads/feature") + XCTAssertFalse(controller.isBranchComparison) + XCTAssertTrue(controller.baseCommits.isEmpty) + } + + func testPathRefreshPreservesFolderSelectionAndReloadsPatch() async { + let controller = ReferenceComparisonController(repositoryURL: URL(fileURLWithPath: "/tmp/comparison"), + baseRef: "refs/heads/feature", targetRef: "", path: ComparisonPath(path: ".", isDirectory: true), + pathTarget: .workingTree, service: ComparisonTestService()) + controller.reload() + await controller.loadTask?.value + controller.selectFile(controller.files.last) + await controller.patchTask?.value + XCTAssertEqual(controller.selectedFile?.path, "binary.dat") + controller.reload() + await controller.loadTask?.value + await controller.patchTask?.value + XCTAssertEqual(controller.selectedFile?.path, "binary.dat") + XCTAssertEqual(controller.patch?.isBinary, true) + } + private func makeController(_ service: ComparisonTestService) -> ReferenceComparisonController { ReferenceComparisonController(repositoryURL: URL(fileURLWithPath: "/tmp/comparison"), baseRef: "refs/heads/main", targetRef: "refs/heads/feature", service: service) @@ -167,6 +204,14 @@ private actor ComparisonTestService: ReferenceComparisonServing { self.failFiles = failFiles } + func comparisonRevisions(in repositoryURL: URL) async throws -> [String] { ["refs/heads/main"] } + + func pathComparisonSnapshot(base: String, target: ComparisonEndpoint, path: ComparisonPath, + in repositoryURL: URL) async throws -> ReferenceComparisonSnapshot { + ReferenceComparisonSnapshot(base: base, target: "", mergeBases: [], baseOnlyCount: 0, + targetOnlyCount: 0, path: path, targetEndpoint: target) + } + func comparisonBranches(in repositoryURL: URL) async throws -> [ComparisonBranch] { [ComparisonBranch(ref: "refs/heads/main"), ComparisonBranch(ref: "refs/heads/feature")] } @@ -178,7 +223,7 @@ private actor ComparisonTestService: ReferenceComparisonServing { 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() } + if snapshot.base == "refs/heads/main" && (mode == .mergeBase || snapshot.path != nil) { await filesGate?.wait() } return [CommitFileChange(path: mode == .tips ? "tips.txt" : "\(snapshot.base).txt", status: .modified), CommitFileChange(path: "binary.dat", status: .added)] } From 4c8ff8f7535e85065769dafb755d01e81aa1ced6 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Tue, 22 Sep 2026 15:03:03 +0700 Subject: [PATCH 2/2] fix: code reivew --- macgit/App/ReferenceComparisonController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macgit/App/ReferenceComparisonController.swift b/macgit/App/ReferenceComparisonController.swift index c24ac35..f6456c2 100644 --- a/macgit/App/ReferenceComparisonController.swift +++ b/macgit/App/ReferenceComparisonController.swift @@ -118,7 +118,7 @@ final class ReferenceComparisonController { guard isCurrent(id) else { return } branches = available } - if path != nil && revisions.isEmpty { + if path != nil && (revisions.isEmpty || !reuseSnapshot) { let available = try await service.comparisonRevisions(in: repositoryURL) guard isCurrent(id) else { return } revisions = available