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
77 changes: 77 additions & 0 deletions src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubCommitLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,83 @@ struct PRCommitPick: Equatable {
struct PRNotice: Equatable {
let ref: HubPRRef
let text: String
/// False for a note that the link worked another way (its commits are local only).
var isError = true
}

/// A link's commits that the forge does not list yet: an agent links its own commits before it pushes them, so
/// they exist only in the PR's checkout (2026-10-23: a range ending in four local commits
/// opened the whole MR). Read from that checkout's git, off the main thread.
struct HubLocalCommits: Equatable {
/// Full shas, oldest first; `last` is nil for one commit.
let first: String
let last: String?
/// The commits the range holds, both ends included, and how many of them the PR's pushed head lacks.
let count: Int
let unpushed: Int
let title: String

enum Miss: Error, Equatable {
/// The checkout does not have this end either.
case unknown(String)
/// Both ends exist, but neither descends from the other.
case unrelated
/// The commits do not continue the PR: the last one does not descend from its pushed head, or the first one is
/// neither one of its commits nor after that head (a commit from before the PR would show the base's changes).
case offBranch
}

/// `ends` are the link's normalized shas, `head` the PR's pushed head and `pushed` the shas the forge lists for it.
/// Runs git, so never on the main thread.
nonisolated static func resolve(ends: [String], repo: String, head: String, pushed: Set<String> = []) -> Result<HubLocalCommits, Miss> {
var full: [String] = []
for end in ends {
guard let sha = git(repo, ["rev-parse", "--verify", "--quiet", end + "^{commit}"])?
.trimmingCharacters(in: .whitespacesAndNewlines), !sha.isEmpty else { return .failure(.unknown(end)) }
full.append(sha)
}
var first = full[0]
var last = full.count > 1 ? full[1] : full[0]
if first != last, !isAncestor(first, of: last, repo: repo) {
guard isAncestor(last, of: first, repo: repo) else { return .failure(.unrelated) }
swap(&first, &last)
}
guard isAncestor(head, of: last, repo: repo),
pushed.contains(first) || isAncestor(head, of: first, repo: repo) else {
return .failure(.offBranch)
}
let span = Int(git(repo, ["rev-list", "--count", "--ancestry-path", first + ".." + last])?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") ?? 0
// Count only descendants of the first endpoint within this link, excluding the pushed head's history.
// The exclusive range omits the first endpoint itself, so include it when it is also unpushed.
let unpushedAfterFirst = Int(git(repo, ["rev-list", "--count", "--ancestry-path=" + first, first + ".." + last, "--not", head])?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") ?? 0
let unpushed = unpushedAfterFirst + (isAncestor(first, of: head, repo: repo) ? 0 : 1)
let title = git(repo, ["log", "-1", "--format=%s", last])?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return .success(HubLocalCommits(first: first, last: full.count > 1 ? last : nil, count: span + 1,
unpushed: unpushed, title: title))
}

nonisolated private static func isAncestor(_ older: String, of newer: String, repo: String) -> Bool {
git(repo, ["merge-base", "--is-ancestor", older, newer]) != nil
}

nonisolated private static func git(_ repo: String, _ args: [String]) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = ["-C", repo, "--no-optional-locks"] + args
let out = Pipe()
process.standardOutput = out
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
return nil
}
let data = out.fileHandleForReading.readDataToEndOfFile()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚡ Performance | 🟡 Medium · confidence 92/100

⚠️ Potential issue

Bound the local git subprocess waits with a deadline

The new local lookup blocks on both readDataToEndOfFile() and waitUntilExit() without any deadline or cancellation. Moving this to a detached task protects the UI thread, but a stalled git process (for example on an unavailable filesystem) leaves the link unresolved indefinitely, and subsequent links can accumulate more blocked tasks and children. Invalidating rangeLookup only discards the eventual answer; it does not stop the process. This violates the project review-memory rule that every wait needs a deadline and an explicit expiry outcome. Bound the entire subprocess operation, terminate/reap it on expiry, and surface a lookup failure rather than leaving it pending.

🧩 Analysis

Grep evidence: readDataToEndOfFile\(|waitUntilExit\(|HubLocalCommits.resolve

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted. The follow-up will bound the local git subprocess waits with a deadline. The pull request was merged after its fix rounds, so this finding waits for a follow-up, and this thread stays open until then.

process.waitUntilExit()
return process.terminationStatus == 0 ? String(decoding: data, as: UTF8.self) : nil
}
}

/// The commit row a PR overview scrolls to once (`PRsModel.commitScroll`).
Expand Down
95 changes: 83 additions & 12 deletions src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ final class PRsModel: ObservableObject {
/// The range a link's `commit=<from>..<to>` set, so the commits list marks its commits even when it spans
/// the whole PR; any other scope (the whole PR, one commit) is not it.
@Published private(set) var linkedRange: PRLinkedRange?
/// The parent lookup of the last range link (`showLinkedRange`): an older link's answer sets no scope.
/// The parent or local-commit lookup of the last link: an older link's answer sets no scope or notice.
private var rangeLookup: UUID?
/// What a link asked of a PR and could not get (its commit is not in the PR), in that PR's header until
/// dismissed or another PR opens. Not in `errors`: every list load replaces those.
Expand Down Expand Up @@ -750,15 +750,15 @@ final class PRsModel: ObservableObject {
let settled = (detailFetched[id].map { $0 >= pending.asked } ?? false) && reviewHeadSha == shown.headSha
guard let commit = HubCommitSha.find(pending.sha, in: commits) else {
if settled {
showMissingCommit(pending, missing: pending.sha, in: shown, review: review)
pickLocalCommits(pending, in: shown, commits: commits, review: review)
}
return
}
var last: HubPRDetail.Commit?
if let to = pending.to {
guard let found = HubCommitSha.find(to, in: commits) else {
if settled {
showMissingCommit(pending, missing: to, in: shown, review: review)
pickLocalCommits(pending, in: shown, commits: commits, review: review)
}
return
}
Expand All @@ -775,6 +775,77 @@ final class PRsModel: ObservableObject {
}
}

/// A link's commit that the forge does not list may be one the PR's checkout has not pushed yet: look there before
/// saying the PR does not hold it. The diff then comes from the checkout, which the review reads anyway.
private func pickLocalCommits(_ pending: PRCommitPick, in shown: HubPR, commits: [HubPRDetail.Commit], review: ReviewModel) {
pendingCommit = nil
let repo = review.repo.path
let ends = [pending.sha] + (pending.to.map { [$0] } ?? [])
// The newest commit the forge lists stands in for an unknown pushed head; with neither, nothing can be checked.
guard let head = shown.headSha ?? commits.last?.sha else {
showMissingCommit(pending, missing: pending.to ?? pending.sha, in: shown, review: review)
return
}
let pushed = Set(commits.map(\.sha))
let lookup = beginRangeLookup()
Task.detached(priority: .userInitiated) { [weak self] in
let found = HubLocalCommits.resolve(ends: ends, repo: repo, head: head, pushed: pushed)
await MainActor.run {
guard let self, self.reviewPRID == shown.id, self.review === review else { return }
Comment thread
genesiscz marked this conversation as resolved.
self.finishRangeLookup(lookup) {
switch found {
case .success(let local):
self.showLocalCommits(local, pending: pending, in: commits, pr: shown, review: review)
case .failure(.unknown(let sha)):
self.showMissingCommit(pending, missing: sha, in: shown, review: review)
case .failure(.unrelated), .failure(.offBranch):
self.showMissingCommit(pending, missing: pending.to ?? pending.sha, in: shown, review: review)
}
}
}
}
}

/// The link's commits from the checkout: the range from the commit before its first one (the PR's own base
/// when that first commit is the PR's first), and a note that some of them are not pushed.
func showLocalCommits(_ local: HubLocalCommits, pending: PRCommitPick, in commits: [HubPRDetail.Commit], pr: HubPR, review: ReviewModel) {
HubDefaults.store.set(true, forKey: "hub.prs.showDiff")
HubDefaults.store.set(false, forKey: "hub.prs.fold.commits")
HubPerf.log("prs.commit \(pr.label) \(local.first.prefix(10))\(local.last.map { "..\($0.prefix(10))" } ?? "") local unpushed=\(local.unpushed)")
let scope: DiffScope
if let last = local.last {
let label = "\(pr.label) \(local.first.prefix(7))..\(last.prefix(7)) (\(Plural.count(local.count, "commit")))"
let index = commits.firstIndex(where: { $0.sha == local.first })
// The PR's first commit starts at the PR's own base; any other at its first parent from git (`rangeScope`).
if index == 0, case .range(let base, _, _, let fallback) = Self.scope(pr, detail: details[pr.id], fetch: fetchedHead(pr)) {
scope = .range(base: base, head: last, label: label, fallbackBase: fallback)
} else {
scope = .range(base: local.first + "^", head: last, label: label)
}
// The listed commits it covers: the pushed ones from its first on; none when it starts after the pushed head.
linkedRange = PRLinkedRange(scope: scope, shas: index.map { Set(commits[$0...].map(\.sha)) } ?? [])
} else {
scope = .commit(sha: local.first, title: local.title)
}
if review.scope != scope { review.setScope(scope) }
if local.unpushed > 0 {
notice = PRNotice(ref: pending.ref, text: "\(Plural.count(local.unpushed, "commit")) of this link \(local.unpushed == 1 ? "is" : "are") not pushed yet; the diff comes from \((review.repo.path as NSString).abbreviatingWithTildeInPath)", isError: false)
}
}

/// Both detached lookups share request invalidation, including repeated links to the same PR and review.
func beginRangeLookup() -> UUID {
let lookup = UUID()
rangeLookup = lookup
return lookup
}

func finishRangeLookup(_ lookup: UUID, apply: () -> Void) {
guard rangeLookup == lookup else { return }
rangeLookup = nil
apply()
}

/// A link's `commit=<from>..<to>`: those commits of the PR, both ends included, in either order.
func showLinkedRange(_ one: HubPRDetail.Commit, _ other: HubPRDetail.Commit, in commits: [HubPRDetail.Commit], pr: HubPR, ref: HubPRRef,
review: ReviewModel) {
Expand All @@ -794,17 +865,17 @@ final class PRsModel: ObservableObject {
// The first parent from git, not the commit listed before: a branched PR's list is no line of ancestry.
let sha = commits[first].sha
let root = review.repo.path
let lookup = UUID()
rangeLookup = lookup
let lookup = beginRangeLookup()
Task {
let parent = await Task.detached(priority: .userInitiated) { HubCommitLookup.parent(of: sha, in: root) }.value
guard rangeLookup == lookup, self.review === review else { return }
rangeLookup = nil
guard let parent else {
notice = PRNotice(ref: ref, text: HubCommitLookup.missingParent(sha, in: root))
return
guard self.review === review else { return }
finishRangeLookup(lookup) {
guard let parent else {
notice = PRNotice(ref: ref, text: HubCommitLookup.missingParent(sha, in: root))
return
}
show(from: parent)
}
show(from: parent)
}
}

Expand Down Expand Up @@ -2087,7 +2158,7 @@ struct PRDetailView: View {
.foregroundColor(ReviewPalette.dim)
// A row of its own: in the title row beside the title it had no width and wrapped a word per line.
if let notice = prs.notice, notice.ref.matches(pr) {
NoticePill(text: notice.text, isError: true) { prs.notice = nil }
NoticePill(text: notice.text, isError: notice.isError) { prs.notice = nil }
.padding(.top, 6)
}
}
Expand Down
89 changes: 89 additions & 0 deletions src/macos/GenesisTools/Tests/HubLogicTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,74 @@ final class HubLogicTests: XCTestCase {
}

/// `commit=<from>..<to>`: a run of a PR's commits, both ends included, in either order.
/// A link may name commits an agent made but did not push yet (2026-10-23): the forge does not list
/// them, the PR's checkout has them, so the range comes from the checkout instead of the whole MR.
func testALinkRangeOfUnpushedCommitsResolvesFromTheCheckout() throws {
let repo = FileManager.default.temporaryDirectory.appendingPathComponent("hub-local-range-\(UUID().uuidString)").path
try FileManager.default.createDirectory(atPath: repo, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: repo) }
func git(_ args: String...) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = ["-C", repo, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com",
"-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false"] + args
let out = Pipe()
process.standardOutput = out
try process.run()
let data = out.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
XCTAssertEqual(process.terminationStatus, 0, "git \(args.joined(separator: " "))")
return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
}
_ = try git("init", "-q", "-b", "main")
var shas: [String] = []
for index in 0 ..< 6 {
_ = try git("commit", "-q", "--allow-empty", "-m", "commit \(index)")
shas.append(try git("rev-parse", "HEAD"))
}
let pushedHead = shas[2]
let pushed = Set(shas[1 ... 2])
let range = try XCTUnwrap(try? HubLocalCommits.resolve(
ends: [String(shas[2].prefix(10)), String(shas[4].prefix(10))], repo: repo, head: pushedHead, pushed: pushed).get())
XCTAssertEqual(range, HubLocalCommits(first: shas[2], last: shas[4], count: 3, unpushed: 2, title: "commit 4"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Tests | 🔵 Low · confidence 93/100

⚠️ Potential issue

Test application of a resolved local range to the review

The new fixture verifies HubLocalCommits.resolve, and the other new test verifies request-token invalidation, but neither exercises showLocalCommits. Consequently the user-visible half of this fix—the PR-base versus first-parent scope choice, PRLinkedRange pushed-sha marking, and informational notice—is not asserted. Existing rangeScope tests exercise the separate forge-listed path, not the new implementation. Add model-level coverage applying local results for a range starting at the PR's first commit, a later pushed commit, and a local-only commit, asserting scope, linkedRange.shas, and notice.isError/count. These are explicit promises in the PR description and can regress while the resolver fixture continues passing.

🧩 Analysis

Grep evidence: showLocalCommits|testALinkRangeOfUnpushedCommitsResolvesFromTheCheckout|testANewPRLinkDiscardsBothOutcomesOfAnOlderLocalLookup

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted. The follow-up will test application of a resolved local range to the review. The pull request was merged after its fix rounds, so this finding waits for a follow-up, and this thread stays open until then.

let reversed = try? HubLocalCommits.resolve(ends: [shas[4], shas[2]], repo: repo, head: pushedHead, pushed: pushed).get()
XCTAssertEqual(reversed?.first, shas[2], "either order")
XCTAssertEqual(try? HubLocalCommits.resolve(ends: [shas[3]], repo: repo, head: pushedHead).get().unpushed, 1)
XCTAssertEqual(try? HubLocalCommits.resolve(ends: [shas[4]], repo: repo, head: pushedHead).get().unpushed, 1,
"a later single commit does not include earlier unpushed commits")
let localOnly = try HubLocalCommits.resolve(ends: [shas[3], shas[4]], repo: repo, head: pushedHead).get()
XCTAssertEqual(localOnly.count, 2)
XCTAssertEqual(localOnly.unpushed, 2, "both unpushed endpoints count")
let laterRange = try HubLocalCommits.resolve(ends: [shas[4], shas[4]], repo: repo, head: pushedHead).get()
XCTAssertEqual(laterRange.unpushed, 1, "equal endpoints include that commit once")
let pushedOnly = try HubLocalCommits.resolve(ends: [shas[2]], repo: repo, head: pushedHead, pushed: pushed).get()
XCTAssertEqual(pushedOnly.unpushed, 0)
let laterPair = try HubLocalCommits.resolve(ends: [shas[4], shas[5]], repo: repo, head: pushedHead).get()
XCTAssertEqual(laterPair.unpushed, 2, "earlier unpushed commits before this range do not count")
XCTAssertEqual(try HubLocalCommits.resolve(ends: [shas[5], shas[4]], repo: repo, head: pushedHead).get().unpushed, 2)
let tree = try git("rev-parse", pushedHead + "^{tree}")
let parallel = try git("commit-tree", tree, "-p", pushedHead, "-m", "parallel")
let merged = try git("commit-tree", tree, "-p", shas[5], "-p", parallel, "-m", "merged")
let mergedRange = try HubLocalCommits.resolve(ends: [shas[4], merged], repo: repo, head: pushedHead).get()
XCTAssertEqual(mergedRange.count, 3)
XCTAssertEqual(mergedRange.unpushed, 3, "exclude a merged side commit outside the first endpoint's ancestry path")
guard case .failure(.offBranch) = HubLocalCommits.resolve(ends: [shas[0], shas[4]], repo: repo, head: pushedHead, pushed: pushed) else {
return XCTFail("a first commit from before the PR would show the base's changes")
}
guard case .failure(.unknown("0123456789")) = HubLocalCommits.resolve(ends: ["0123456789"], repo: repo, head: pushedHead) else {
return XCTFail("an end the checkout lacks is named")
}
_ = try git("checkout", "-q", "-b", "side", shas[0])
_ = try git("commit", "-q", "--allow-empty", "-m", "side")
let side = try git("rev-parse", "HEAD")
guard case .failure(.unrelated) = HubLocalCommits.resolve(ends: [side, shas[4]], repo: repo, head: pushedHead) else {
return XCTFail("ends on two lines are refused")
}
guard case .failure(.offBranch) = HubLocalCommits.resolve(ends: [side], repo: repo, head: pushedHead) else {
return XCTFail("a commit off the PR's line is refused")
}
}

func testACommitRangeLinkShowsItsCommitsFromTheParentOfTheFirst() throws {
XCTAssertEqual(HubCommitSpan("abcdef0"), HubCommitSpan(from: "abcdef0"))
XCTAssertEqual(HubCommitSpan("67AD51671E..3cac936e2f"), HubCommitSpan(from: "67ad51671e", to: "3cac936e2f"))
Expand Down Expand Up @@ -482,6 +550,27 @@ final class HubLogicTests: XCTestCase {
XCTAssertNil(model.notice, "a whole-PR request clears the previous validation error too")
}

@MainActor
func testANewPRLinkDiscardsBothOutcomesOfAnOlderLocalLookup() throws {
let model = PRsModel()
let ref = try XCTUnwrap(HubPRRef("7"))
// Bare refs and no rows keep these requests off the CLI.
for commit in ["abcdef0", "abcdef0..1234567", "invalid", ""] {
let old = model.beginRangeLookup()
model.request(ref, commit: commit.isEmpty ? nil : commit)
var applied: [String] = []
model.finishRangeLookup(old) { applied.append("stale success") }
model.finishRangeLookup(old) { applied.append("stale failure") }
XCTAssertTrue(applied.isEmpty, "old results cannot change scope or reset it to the whole PR")
let current = model.beginRangeLookup()
model.finishRangeLookup(old) { applied.append("stale while newer lookup runs") }
model.finishRangeLookup(current) { applied.append("current") }
XCTAssertEqual(applied, ["current"], "discarding old results must preserve the newer lookup")
model.finishRangeLookup(current) { applied.append("duplicate") }
XCTAssertEqual(applied, ["current"])
}
}

@MainActor
func testAMissingPRCommitReplacesThePreviousCommitScopeWithTheWholePR() throws {
let json = """
Expand Down
Loading