Commit 0aff03c96c

0aff03c96cadf617e4b748eec710cfa84a45858b

parent: b2f3bce0c2

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 22:35 UTC

fix: show empty state for bare git repos instead of an error

git.sr.ht returns "internal system error" from the references query and
"reference not found" from the log/readme queries when a repo has no
commits. Broaden isEmptyRepositoryError to cover missingReference,
unknownRevision, noRows, notFound, and those two message strings, then
apply the same silent-empty treatment to loadReferences and loadArtifacts
(which previously surfaced any error directly to the user).

Fixes: https://todo.sr.ht/~ccleberg/hutch/60

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 65;
518 CURRENT_PROJECT_VERSION = 66;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 3.0.4;
535 MARKETING_VERSION = 3.0.5;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 65;
555 CURRENT_PROJECT_VERSION = 66;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 3.0.4;
572 MARKETING_VERSION = 3.0.5;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 65;
635 CURRENT_PROJECT_VERSION = 66;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 3.0.4;
645 MARKETING_VERSION = 3.0.5;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 65;
664 CURRENT_PROJECT_VERSION = 66;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 3.0.4;
674 MARKETING_VERSION = 3.0.5;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +19 −5
@@ -226,7 +226,7 @@ final class RepositoryDetailViewModel {
226226 responseType: LogResponse.self
227227 )
228228 } catch {
229 if isMissingGitReferenceError(error) {
229 if isEmptyRepositoryError(error) {
230230 return LogPage(results: [], cursor: nil)
231231 }
232232 throw error
@@ -273,7 +273,12 @@ final class RepositoryDetailViewModel {
273273 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }.map { $0.toDetail() }
274274 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }.map { $0.toDetail() }
275275 } catch {
276 self.error = error.userFacingMessage
276 if isEmptyRepositoryError(error) {
277 branches = []
278 tags = []
279 } else {
280 self.error = error.userFacingMessage
281 }
277282 }
278283
279284 isLoadingRefs = false
@@ -340,7 +345,7 @@ final class RepositoryDetailViewModel {
340345 responseType: PathResponse.self
341346 )
342347 } catch {
343 if isMissingGitReferenceError(error) {
348 if isEmptyRepositoryError(error) {
344349 readmeContent = nil
345350 readmePath = nil
346351 readmeLoaded = true
@@ -371,8 +376,13 @@ final class RepositoryDetailViewModel {
371376 }
372377 }
373378
374 private func isMissingGitReferenceError(_ error: Error) -> Bool {
379 private func isEmptyRepositoryError(_ error: Error) -> Bool {
375380 error.matchesGraphQLErrorClassification(.missingReference)
381 || error.matchesGraphQLErrorClassification(.unknownRevision)
382 || error.matchesGraphQLErrorClassification(.noRows)
383 || error.matchesGraphQLErrorClassification(.notFound)
384 || error.containsGraphQLErrorMessage("missing")
385 || error.containsGraphQLErrorMessage("internal system error")
376386 }
377387
378388 // MARK: - Artifacts
@@ -417,7 +427,11 @@ final class RepositoryDetailViewModel {
417427 .filter { !$0.artifacts.results.isEmpty }
418428 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
419429 } catch {
420 self.error = error.userFacingMessage
430 if isEmptyRepositoryError(error) {
431 referenceArtifacts = []
432 } else {
433 self.error = error.userFacingMessage
434 }
421435 }
422436
423437 isLoadingArtifacts = false
Hutch/Views/Tickets/TicketListViewModel.swift +27 −7
@@ -99,13 +99,13 @@ final class TicketListViewModel {
9999 var filter: TicketFilter = .open {
100100 didSet {
101101 persistFilterState()
102 updateFilteredTickets()
102 resetPaginationAndUpdateFilters()
103103 }
104104 }
105105 var selectedLabelIDs: Set<Int> = [] {
106106 didSet {
107107 persistFilterState()
108 updateFilteredTickets()
108 resetPaginationAndUpdateFilters()
109109 }
110110 }
111111 var searchText = "" {
@@ -283,6 +283,13 @@ final class TicketListViewModel {
283283 }
284284 }
285285
286 private func resetPaginationAndUpdateFilters() {
287 // Reset pagination when filters change since the cursor is tied to the unfiltered dataset
288 cursor = nil
289 hasMore = true
290 updateFilteredTickets()
291 }
292
286293 // MARK: - Public API
287294
288295 func loadTickets() async {
@@ -305,19 +312,32 @@ final class TicketListViewModel {
305312 }
306313
307314 func loadMoreIfNeeded(currentItem: TicketSummary) async {
308 guard let last = tickets.last,
309 last.id == currentItem.id,
310 hasMore,
311 !isLoadingMore else {
315 // Check if currentItem is in the filtered list and close to the end
316 guard hasMore, !isLoadingMore else { return }
317
318 guard let index = filteredTickets.firstIndex(where: { $0.id == currentItem.id }) else {
312319 return
313320 }
314321
322 let itemsFromEnd = filteredTickets.count - index - 1
323 guard itemsFromEnd < 5 else { return }
324
315325 isLoadingMore = true
316326
317327 do {
318328 let page = try await fetchPage(cursor: cursor)
319 tickets.append(contentsOf: page.results)
329
330 // Deduplicate: only add tickets that don't already exist
331 let existingIDs = Set(tickets.map(\.id))
332 let newTickets = page.results.filter { !existingIDs.contains($0.id) }
333
334 // If we got back the same tickets, the API cursor pagination isn't working
335 if newTickets.isEmpty && !page.results.isEmpty {
336 hasMore = false
337 }
338
320339 cursor = page.cursor
340 tickets.append(contentsOf: newTickets)
321341 hasMore = page.cursor != nil
322342 reconcileSelectionWithLoadedTickets()
323343 } catch {