a native ios client for gitbay

client ios swift

https://gitbay.org

Commit aeca25dc10

aeca25dc104577bcd71b9ef34e70068c1f1364d8

parent: e2cd0c66a0

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T04:41:51Z

client: speak both API surfaces with ETag revalidation

Fresh Xcode project rather than a stripped copy of hutch: the service
layer is the part that has nothing in common (argv over HTTP vs GraphQL)
and it is load-bearing there. Structural patterns — caching shape, view
model tests, stubbed URLProtocol — are lifted deliberately as they are
needed.

GitbayClient sends read-only commands to GET /api/v1/read and writes to
POST /api/v1/cmd, decodes the shared envelope, and maps exit codes to
typed errors: exit 3 is an empty state, exit 4 surfaces the server's
message verbatim, exit 2 is logged as an app bug and argv never reaches
the screen. 5xx retries once; 429 honours Retry-After up to 3s and then
surfaces the wait. ETags are cached per account and replayed as
If-None-Match, so a repeat read costs a 304 with no body.

The token travels only to the instance host: requests are checked before
the Authorization header goes on, and redirects off the instance are
refused.

Tests run against recorded envelopes through a stubbed URLProtocol; a
live test, gated on GITBAY_TEST_TOKEN, verifies a real 200-then-304
against gitbay.org.

Ref #11
gitbay.xcodeproj/project.pbxproj added +461
@@ -0,0 +1,461 @@
1// !$*UTF8*$!
2{
3 archiveVersion = 1;
4 classes = {
5 };
6 objectVersion = 77;
7 objects = {
8
9/* Begin PBXContainerItemProxy section */
10 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */ = {
11 isa = PBXContainerItemProxy;
12 containerPortal = 8B28980A303FF3E5001BF44A /* Project object */;
13 proxyType = 1;
14 remoteGlobalIDString = 8B289811303FF3E5001BF44A;
15 remoteInfo = gitbay;
16 };
17/* End PBXContainerItemProxy section */
18
19/* Begin PBXFileReference section */
20 8B289812303FF3E5001BF44A /* gitbay.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gitbay.app; sourceTree = BUILT_PRODUCTS_DIR; };
21 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = gitbayTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
22/* End PBXFileReference section */
23
24/* Begin PBXFileSystemSynchronizedRootGroup section */
25 8B289814303FF3E5001BF44A /* gitbay */ = {
26 isa = PBXFileSystemSynchronizedRootGroup;
27 path = gitbay;
28 sourceTree = "<group>";
29 };
30 8B2898A0303FF3E5001BF44A /* gitbayTests */ = {
31 isa = PBXFileSystemSynchronizedRootGroup;
32 path = gitbayTests;
33 sourceTree = "<group>";
34 };
35/* End PBXFileSystemSynchronizedRootGroup section */
36
37/* Begin PBXFrameworksBuildPhase section */
38 8B28980F303FF3E5001BF44A /* Frameworks */ = {
39 isa = PBXFrameworksBuildPhase;
40 buildActionMask = 2147483647;
41 files = (
42 );
43 runOnlyForDeploymentPostprocessing = 0;
44 };
45 8B2898A4303FF3E5001BF44A /* Frameworks */ = {
46 isa = PBXFrameworksBuildPhase;
47 buildActionMask = 2147483647;
48 files = (
49 );
50 runOnlyForDeploymentPostprocessing = 0;
51 };
52/* End PBXFrameworksBuildPhase section */
53
54/* Begin PBXGroup section */
55 8B289809303FF3E5001BF44A = {
56 isa = PBXGroup;
57 children = (
58 8B289814303FF3E5001BF44A /* gitbay */,
59 8B2898A0303FF3E5001BF44A /* gitbayTests */,
60 8B289813303FF3E5001BF44A /* Products */,
61 );
62 sourceTree = "<group>";
63 };
64 8B289813303FF3E5001BF44A /* Products */ = {
65 isa = PBXGroup;
66 children = (
67 8B289812303FF3E5001BF44A /* gitbay.app */,
68 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */,
69 );
70 name = Products;
71 sourceTree = "<group>";
72 };
73/* End PBXGroup section */
74
75/* Begin PBXNativeTarget section */
76 8B289811303FF3E5001BF44A /* gitbay */ = {
77 isa = PBXNativeTarget;
78 buildConfigurationList = 8B28981D303FF3E6001BF44A /* Build configuration list for PBXNativeTarget "gitbay" */;
79 buildPhases = (
80 8B28980E303FF3E5001BF44A /* Sources */,
81 8B28980F303FF3E5001BF44A /* Frameworks */,
82 8B289810303FF3E5001BF44A /* Resources */,
83 );
84 buildRules = (
85 );
86 dependencies = (
87 );
88 fileSystemSynchronizedGroups = (
89 8B289814303FF3E5001BF44A /* gitbay */,
90 );
91 name = gitbay;
92 packageProductDependencies = (
93 );
94 productName = gitbay;
95 productReference = 8B289812303FF3E5001BF44A /* gitbay.app */;
96 productType = "com.apple.product-type.application";
97 };
98 8B2898A1303FF3E5001BF44A /* gitbayTests */ = {
99 isa = PBXNativeTarget;
100 buildConfigurationList = 8B2898AA303FF3E5001BF44A /* Build configuration list for PBXNativeTarget "gitbayTests" */;
101 buildPhases = (
102 8B2898A3303FF3E5001BF44A /* Sources */,
103 8B2898A4303FF3E5001BF44A /* Frameworks */,
104 8B2898A5303FF3E5001BF44A /* Resources */,
105 );
106 buildRules = (
107 );
108 dependencies = (
109 8B2898A6303FF3E5001BF44A /* PBXTargetDependency */,
110 );
111 fileSystemSynchronizedGroups = (
112 8B2898A0303FF3E5001BF44A /* gitbayTests */,
113 );
114 name = gitbayTests;
115 packageProductDependencies = (
116 );
117 productName = gitbayTests;
118 productReference = 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */;
119 productType = "com.apple.product-type.bundle.unit-test";
120 };
121/* End PBXNativeTarget section */
122
123/* Begin PBXProject section */
124 8B28980A303FF3E5001BF44A /* Project object */ = {
125 isa = PBXProject;
126 attributes = {
127 BuildIndependentTargetsInParallel = 1;
128 LastSwiftUpdateCheck = 2660;
129 LastUpgradeCheck = 2660;
130 TargetAttributes = {
131 8B289811303FF3E5001BF44A = {
132 CreatedOnToolsVersion = 26.6;
133 };
134 8B2898A1303FF3E5001BF44A = {
135 CreatedOnToolsVersion = 26.6;
136 TestTargetID = 8B289811303FF3E5001BF44A;
137 };
138 };
139 };
140 buildConfigurationList = 8B28980D303FF3E5001BF44A /* Build configuration list for PBXProject "gitbay" */;
141 developmentRegion = en;
142 hasScannedForEncodings = 0;
143 knownRegions = (
144 en,
145 Base,
146 );
147 mainGroup = 8B289809303FF3E5001BF44A;
148 minimizedProjectReferenceProxies = 1;
149 preferredProjectObjectVersion = 77;
150 productRefGroup = 8B289813303FF3E5001BF44A /* Products */;
151 projectDirPath = "";
152 projectRoot = "";
153 targets = (
154 8B289811303FF3E5001BF44A /* gitbay */,
155 8B2898A1303FF3E5001BF44A /* gitbayTests */,
156 );
157 };
158/* End PBXProject section */
159
160/* Begin PBXResourcesBuildPhase section */
161 8B289810303FF3E5001BF44A /* Resources */ = {
162 isa = PBXResourcesBuildPhase;
163 buildActionMask = 2147483647;
164 files = (
165 );
166 runOnlyForDeploymentPostprocessing = 0;
167 };
168 8B2898A5303FF3E5001BF44A /* Resources */ = {
169 isa = PBXResourcesBuildPhase;
170 buildActionMask = 2147483647;
171 files = (
172 );
173 runOnlyForDeploymentPostprocessing = 0;
174 };
175/* End PBXResourcesBuildPhase section */
176
177/* Begin PBXSourcesBuildPhase section */
178 8B28980E303FF3E5001BF44A /* Sources */ = {
179 isa = PBXSourcesBuildPhase;
180 buildActionMask = 2147483647;
181 files = (
182 );
183 runOnlyForDeploymentPostprocessing = 0;
184 };
185 8B2898A3303FF3E5001BF44A /* Sources */ = {
186 isa = PBXSourcesBuildPhase;
187 buildActionMask = 2147483647;
188 files = (
189 );
190 runOnlyForDeploymentPostprocessing = 0;
191 };
192/* End PBXSourcesBuildPhase section */
193
194/* Begin PBXTargetDependency section */
195 8B2898A6303FF3E5001BF44A /* PBXTargetDependency */ = {
196 isa = PBXTargetDependency;
197 target = 8B289811303FF3E5001BF44A /* gitbay */;
198 targetProxy = 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */;
199 };
200/* End PBXTargetDependency section */
201
202/* Begin XCBuildConfiguration section */
203 8B28981B303FF3E6001BF44A /* Debug */ = {
204 isa = XCBuildConfiguration;
205 buildSettings = {
206 ALWAYS_SEARCH_USER_PATHS = NO;
207 ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
208 CLANG_ANALYZER_NONNULL = YES;
209 CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
210 CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
211 CLANG_ENABLE_MODULES = YES;
212 CLANG_ENABLE_OBJC_ARC = YES;
213 CLANG_ENABLE_OBJC_WEAK = YES;
214 CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
215 CLANG_WARN_BOOL_CONVERSION = YES;
216 CLANG_WARN_COMMA = YES;
217 CLANG_WARN_CONSTANT_CONVERSION = YES;
218 CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
219 CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
220 CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
221 CLANG_WARN_EMPTY_BODY = YES;
222 CLANG_WARN_ENUM_CONVERSION = YES;
223 CLANG_WARN_INFINITE_RECURSION = YES;
224 CLANG_WARN_INT_CONVERSION = YES;
225 CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
226 CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
227 CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
228 CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
229 CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
230 CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
231 CLANG_WARN_STRICT_PROTOTYPES = YES;
232 CLANG_WARN_SUSPICIOUS_MOVE = YES;
233 CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
234 CLANG_WARN_UNREACHABLE_CODE = YES;
235 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
236 COPY_PHASE_STRIP = NO;
237 DEBUG_INFORMATION_FORMAT = dwarf;
238 DEVELOPMENT_TEAM = ZCNAX3VL9D;
239 ENABLE_STRICT_OBJC_MSGSEND = YES;
240 ENABLE_TESTABILITY = YES;
241 ENABLE_USER_SCRIPT_SANDBOXING = YES;
242 GCC_C_LANGUAGE_STANDARD = gnu17;
243 GCC_DYNAMIC_NO_PIC = NO;
244 GCC_NO_COMMON_BLOCKS = YES;
245 GCC_OPTIMIZATION_LEVEL = 0;
246 GCC_PREPROCESSOR_DEFINITIONS = (
247 "DEBUG=1",
248 "$(inherited)",
249 );
250 GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
251 GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
252 GCC_WARN_UNDECLARED_SELECTOR = YES;
253 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
254 GCC_WARN_UNUSED_FUNCTION = YES;
255 GCC_WARN_UNUSED_VARIABLE = YES;
256 IPHONEOS_DEPLOYMENT_TARGET = 26.5;
257 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
258 MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
259 MTL_FAST_MATH = YES;
260 ONLY_ACTIVE_ARCH = YES;
261 SDKROOT = iphoneos;
262 SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
263 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
264 };
265 name = Debug;
266 };
267 8B28981C303FF3E6001BF44A /* Release */ = {
268 isa = XCBuildConfiguration;
269 buildSettings = {
270 ALWAYS_SEARCH_USER_PATHS = NO;
271 ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
272 CLANG_ANALYZER_NONNULL = YES;
273 CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
274 CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
275 CLANG_ENABLE_MODULES = YES;
276 CLANG_ENABLE_OBJC_ARC = YES;
277 CLANG_ENABLE_OBJC_WEAK = YES;
278 CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
279 CLANG_WARN_BOOL_CONVERSION = YES;
280 CLANG_WARN_COMMA = YES;
281 CLANG_WARN_CONSTANT_CONVERSION = YES;
282 CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
283 CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
284 CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
285 CLANG_WARN_EMPTY_BODY = YES;
286 CLANG_WARN_ENUM_CONVERSION = YES;
287 CLANG_WARN_INFINITE_RECURSION = YES;
288 CLANG_WARN_INT_CONVERSION = YES;
289 CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
290 CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
291 CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
292 CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
293 CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
294 CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
295 CLANG_WARN_STRICT_PROTOTYPES = YES;
296 CLANG_WARN_SUSPICIOUS_MOVE = YES;
297 CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
298 CLANG_WARN_UNREACHABLE_CODE = YES;
299 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
300 COPY_PHASE_STRIP = NO;
301 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
302 DEVELOPMENT_TEAM = ZCNAX3VL9D;
303 ENABLE_NS_ASSERTIONS = NO;
304 ENABLE_STRICT_OBJC_MSGSEND = YES;
305 ENABLE_USER_SCRIPT_SANDBOXING = YES;
306 GCC_C_LANGUAGE_STANDARD = gnu17;
307 GCC_NO_COMMON_BLOCKS = YES;
308 GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
309 GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
310 GCC_WARN_UNDECLARED_SELECTOR = YES;
311 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
312 GCC_WARN_UNUSED_FUNCTION = YES;
313 GCC_WARN_UNUSED_VARIABLE = YES;
314 IPHONEOS_DEPLOYMENT_TARGET = 26.5;
315 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
316 MTL_ENABLE_DEBUG_INFO = NO;
317 MTL_FAST_MATH = YES;
318 SDKROOT = iphoneos;
319 SWIFT_COMPILATION_MODE = wholemodule;
320 VALIDATE_PRODUCT = YES;
321 };
322 name = Release;
323 };
324 8B28981E303FF3E6001BF44A /* Debug */ = {
325 isa = XCBuildConfiguration;
326 buildSettings = {
327 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
328 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
329 CODE_SIGN_STYLE = Automatic;
330 CURRENT_PROJECT_VERSION = 1;
331 DEVELOPMENT_TEAM = ZCNAX3VL9D;
332 ENABLE_PREVIEWS = YES;
333 GENERATE_INFOPLIST_FILE = YES;
334 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
335 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
336 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
337 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
338 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
339 LD_RUNPATH_SEARCH_PATHS = (
340 "$(inherited)",
341 "@executable_path/Frameworks",
342 );
343 MARKETING_VERSION = 1.0;
344 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;
345 PRODUCT_NAME = "$(TARGET_NAME)";
346 STRING_CATALOG_GENERATE_SYMBOLS = YES;
347 SWIFT_APPROACHABLE_CONCURRENCY = YES;
348 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
349 SWIFT_EMIT_LOC_STRINGS = YES;
350 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
351 SWIFT_VERSION = 6.0;
352 TARGETED_DEVICE_FAMILY = "1,2";
353 };
354 name = Debug;
355 };
356 8B28981F303FF3E6001BF44A /* Release */ = {
357 isa = XCBuildConfiguration;
358 buildSettings = {
359 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
360 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
361 CODE_SIGN_STYLE = Automatic;
362 CURRENT_PROJECT_VERSION = 1;
363 DEVELOPMENT_TEAM = ZCNAX3VL9D;
364 ENABLE_PREVIEWS = YES;
365 GENERATE_INFOPLIST_FILE = YES;
366 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
367 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
368 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
369 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
370 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
371 LD_RUNPATH_SEARCH_PATHS = (
372 "$(inherited)",
373 "@executable_path/Frameworks",
374 );
375 MARKETING_VERSION = 1.0;
376 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;
377 PRODUCT_NAME = "$(TARGET_NAME)";
378 STRING_CATALOG_GENERATE_SYMBOLS = YES;
379 SWIFT_APPROACHABLE_CONCURRENCY = YES;
380 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
381 SWIFT_EMIT_LOC_STRINGS = YES;
382 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
383 SWIFT_VERSION = 6.0;
384 TARGETED_DEVICE_FAMILY = "1,2";
385 };
386 name = Release;
387 };
388 8B2898A8303FF3E5001BF44A /* Debug */ = {
389 isa = XCBuildConfiguration;
390 buildSettings = {
391 BUNDLE_LOADER = "$(TEST_HOST)";
392 CURRENT_PROJECT_VERSION = 1;
393 DEVELOPMENT_TEAM = ZCNAX3VL9D;
394 GENERATE_INFOPLIST_FILE = YES;
395 MARKETING_VERSION = 1.0;
396 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbayTests;
397 PRODUCT_NAME = "$(TARGET_NAME)";
398 SWIFT_APPROACHABLE_CONCURRENCY = YES;
399 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
400 SWIFT_EMIT_LOC_STRINGS = NO;
401 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
402 SWIFT_VERSION = 6.0;
403 TARGETED_DEVICE_FAMILY = "1,2";
404 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gitbay.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gitbay";
405 };
406 name = Debug;
407 };
408 8B2898A9303FF3E5001BF44A /* Release */ = {
409 isa = XCBuildConfiguration;
410 buildSettings = {
411 BUNDLE_LOADER = "$(TEST_HOST)";
412 CURRENT_PROJECT_VERSION = 1;
413 DEVELOPMENT_TEAM = ZCNAX3VL9D;
414 GENERATE_INFOPLIST_FILE = YES;
415 MARKETING_VERSION = 1.0;
416 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbayTests;
417 PRODUCT_NAME = "$(TARGET_NAME)";
418 SWIFT_APPROACHABLE_CONCURRENCY = YES;
419 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
420 SWIFT_EMIT_LOC_STRINGS = NO;
421 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
422 SWIFT_VERSION = 6.0;
423 TARGETED_DEVICE_FAMILY = "1,2";
424 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gitbay.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gitbay";
425 };
426 name = Release;
427 };
428/* End XCBuildConfiguration section */
429
430/* Begin XCConfigurationList section */
431 8B28980D303FF3E5001BF44A /* Build configuration list for PBXProject "gitbay" */ = {
432 isa = XCConfigurationList;
433 buildConfigurations = (
434 8B28981B303FF3E6001BF44A /* Debug */,
435 8B28981C303FF3E6001BF44A /* Release */,
436 );
437 defaultConfigurationIsVisible = 0;
438 defaultConfigurationName = Release;
439 };
440 8B28981D303FF3E6001BF44A /* Build configuration list for PBXNativeTarget "gitbay" */ = {
441 isa = XCConfigurationList;
442 buildConfigurations = (
443 8B28981E303FF3E6001BF44A /* Debug */,
444 8B28981F303FF3E6001BF44A /* Release */,
445 );
446 defaultConfigurationIsVisible = 0;
447 defaultConfigurationName = Release;
448 };
449 8B2898AA303FF3E5001BF44A /* Build configuration list for PBXNativeTarget "gitbayTests" */ = {
450 isa = XCConfigurationList;
451 buildConfigurations = (
452 8B2898A8303FF3E5001BF44A /* Debug */,
453 8B2898A9303FF3E5001BF44A /* Release */,
454 );
455 defaultConfigurationIsVisible = 0;
456 defaultConfigurationName = Release;
457 };
458/* End XCConfigurationList section */
459 };
460 rootObject = 8B28980A303FF3E5001BF44A /* Project object */;
461}
gitbay.xcodeproj/project.xcworkspace/contents.xcworkspacedata added +7
@@ -0,0 +1,7 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<Workspace
3 version = "1.0">
4 <FileRef
5 location = "self:">
6 </FileRef>
7</Workspace>
gitbay/Assets.xcassets/AccentColor.colorset/Contents.json added +11
@@ -0,0 +1,11 @@
1{
2 "colors" : [
3 {
4 "idiom" : "universal"
5 }
6 ],
7 "info" : {
8 "author" : "xcode",
9 "version" : 1
10 }
11}
gitbay/Assets.xcassets/AppIcon.appiconset/Contents.json added +35
@@ -0,0 +1,35 @@
1{
2 "images" : [
3 {
4 "idiom" : "universal",
5 "platform" : "ios",
6 "size" : "1024x1024"
7 },
8 {
9 "appearances" : [
10 {
11 "appearance" : "luminosity",
12 "value" : "dark"
13 }
14 ],
15 "idiom" : "universal",
16 "platform" : "ios",
17 "size" : "1024x1024"
18 },
19 {
20 "appearances" : [
21 {
22 "appearance" : "luminosity",
23 "value" : "tinted"
24 }
25 ],
26 "idiom" : "universal",
27 "platform" : "ios",
28 "size" : "1024x1024"
29 }
30 ],
31 "info" : {
32 "author" : "xcode",
33 "version" : 1
34 }
35}
gitbay/Assets.xcassets/Contents.json added +6
@@ -0,0 +1,6 @@
1{
2 "info" : {
3 "author" : "xcode",
4 "version" : 1
5 }
6}
gitbay/ContentView.swift added +24
@@ -0,0 +1,24 @@
1//
2// ContentView.swift
3// gitbay
4//
5// Created by cmc on 2026-08-26.
6//
7
8import SwiftUI
9
10struct ContentView: View {
11 var body: some View {
12 VStack {
13 Image(systemName: "globe")
14 .imageScale(.large)
15 .foregroundStyle(.tint)
16 Text("Hello, world!")
17 }
18 .padding()
19 }
20}
21
22#Preview {
23 ContentView()
24}
gitbay/Networking/ETagStore.swift added +63
@@ -0,0 +1,63 @@
1import Foundation
2import os
3
4/// The validator and body of one cached read.
5nonisolated struct ETagEntry: Sendable, Equatable {
6 let etag: String
7 let payload: Data
8}
9
10/// In-memory ETag cache for `GET /api/v1/read`.
11///
12/// The server salts each ETag with the account, so one caller's validator
13/// never matches another's; entries are keyed by account here too, so a
14/// 304 can never be answered out of someone else's body.
15nonisolated final class ETagStore: Sendable {
16
17 private struct Slot {
18 let entry: ETagEntry
19 var usedAt: UInt64
20 }
21
22 private let state: OSAllocatedUnfairLock<(slots: [String: Slot], clock: UInt64)>
23 private let capacity: Int
24
25 init(capacity: Int = 200) {
26 self.capacity = capacity
27 self.state = OSAllocatedUnfairLock(initialState: (slots: [:], clock: 0))
28 }
29
30 static func key(account: String, argv: [String]) -> String {
31 // NUL cannot appear in argv, so joining on it cannot collide.
32 ([account] + argv).joined(separator: "\u{0}")
33 }
34
35 func entry(for key: String) -> ETagEntry? {
36 state.withLock { state in
37 guard var slot = state.slots[key] else { return nil }
38 state.clock += 1
39 slot.usedAt = state.clock
40 state.slots[key] = slot
41 return slot.entry
42 }
43 }
44
45 func store(_ entry: ETagEntry, for key: String) {
46 state.withLock { state in
47 state.clock += 1
48 state.slots[key] = Slot(entry: entry, usedAt: state.clock)
49 guard state.slots.count > capacity else { return }
50 let oldest = state.slots.min { $0.value.usedAt < $1.value.usedAt }
51 if let oldest { state.slots.removeValue(forKey: oldest.key) }
52 }
53 }
54
55 /// Drop everything. Called on sign-out: the entries are account data.
56 func clear() {
57 state.withLock { $0.slots.removeAll() }
58 }
59
60 var count: Int {
61 state.withLock { $0.slots.count }
62 }
63}
gitbay/Networking/Envelope.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2
3/// Exit codes shared by the CLI, SSH and both HTTP surfaces.
4/// `internal/protocol/protocol.go` is the source of truth.
5nonisolated enum ExitCode: Int, Sendable {
6 case ok = 0
7 case failure = 1
8 case usage = 2
9 case notFound = 3
10 case denied = 4
11 case protocolError = 5
12}
13
14/// Every response from either API surface is the command's own JSON
15/// envelope with `exit_code` added.
16///
17/// `data` is absent when a command emits an empty result Go omits a nil
18/// slice so an empty list arrives as no key at all rather than `[]`.
19/// `output` carries commands that write raw text instead of JSON (`mr
20/// diff`, `help`); the server wraps those.
21nonisolated struct Envelope<Payload: Decodable & Sendable>: Decodable, Sendable {
22 let protocolVersion: Int
23 let data: Payload?
24 let error: String?
25 let stderr: String?
26 let output: String?
27 let exitCode: Int?
28
29 enum CodingKeys: String, CodingKey {
30 case protocolVersion = "protocol_version"
31 case data, error, stderr, output
32 case exitCode = "exit_code"
33 }
34
35 /// The message to put in front of a person. `error` and `stderr` were
36 /// both written to be read by one.
37 var message: String? {
38 let text = error ?? stderr
39 guard let text, !text.isEmpty else { return nil }
40 return text
41 }
42}
43
44/// Placeholder for commands whose `data` we do not decode. It accepts any
45/// JSON, so discarding a payload never fails on its shape.
46nonisolated struct NoPayload: Decodable, Sendable {
47 init(from _: any Decoder) throws {}
48}
gitbay/Networking/GitbayClient.swift added +312
@@ -0,0 +1,312 @@
1import CryptoKit
2import Foundation
3import os
4
5nonisolated private let logger = Logger(subsystem: "org.gitbay.gitbay", category: "GitbayClient")
6
7/// Speaks the two halves of the gitbay API: `GET /api/v1/read` for the
8/// commands the registry marks read-only, `POST /api/v1/cmd` for the rest.
9///
10/// Both dispatch the same command registry server-side, so this client adds
11/// no semantics of its own it carries argv there and an envelope back.
12///
13/// A client is bound to one account on one instance. Signing out means
14/// dropping the client, not mutating it.
15nonisolated final class GitbayClient: Sendable {
16
17 /// How long the client will wait out a 429 by itself. Beyond this the
18 /// wait belongs on screen, not inside a request that looks hung.
19 static let maximumAutomaticWait: TimeInterval = 3
20
21 let instance: GitbayInstance
22
23 private let token: String
24 private let session: URLSession
25 private let etags: ETagStore
26 private let redirectGuard: RedirectGuard
27 /// Cache keys are scoped by account without holding the token.
28 private let accountKey: String
29
30 init(
31 instance: GitbayInstance,
32 token: String,
33 session: URLSession = .shared,
34 etags: ETagStore = ETagStore()
35 ) {
36 self.instance = instance
37 self.token = token
38 self.session = session
39 self.etags = etags
40 self.redirectGuard = RedirectGuard(instance: instance)
41 let digest = SHA256.hash(data: Data(token.utf8))
42 self.accountKey = digest.prefix(8).map { String(format: "%02x", $0) }.joined()
43 }
44
45 // MARK: - Reads
46
47 /// Run a read-only command, decoding `data` as `Payload`.
48 ///
49 /// Revalidates with `If-None-Match` when a previous body is cached: a
50 /// 304 costs a round trip and no body, which is the point on a phone.
51 func read<Payload: Decodable & Sendable>(
52 _ argv: [String],
53 as _: Payload.Type
54 ) async throws -> Payload {
55 let envelope: Envelope<Payload> = try await readEnvelope(argv)
56 guard let data = envelope.data else {
57 throw GitbayError.decoding(MissingData(argv: argv))
58 }
59 return data
60 }
61
62 /// Run a read-only command that returns a list.
63 ///
64 /// An empty result arrives as no `data` key at all Go omits a nil
65 /// slice so absent means empty here rather than a decode failure.
66 func readList<Element: Decodable & Sendable>(
67 _ argv: [String],
68 of _: Element.Type
69 ) async throws -> [Element] {
70 let envelope: Envelope<[Element]> = try await readEnvelope(argv)
71 return envelope.data ?? []
72 }
73
74 /// Run a read-only command that emits raw text rather than JSON
75 /// (`mr diff`, `build log`). The server wraps those as `output`.
76 func readText(_ argv: [String]) async throws -> String {
77 let envelope: Envelope<NoPayload> = try await readEnvelope(argv)
78 return envelope.output ?? ""
79 }
80
81 private func readEnvelope<Payload: Decodable & Sendable>(
82 _ argv: [String],
83 revalidate: Bool = true
84 ) async throws -> Envelope<Payload> {
85 let url = instance.readURL(argv: argv)
86 let cacheKey = ETagStore.key(account: accountKey, argv: argv)
87 let cached = revalidate ? etags.entry(for: cacheKey) : nil
88
89 var request = URLRequest(url: url)
90 // URLSession's own cache would answer some of these transparently
91 // and the revalidation would never be visible here. The ETag layer
92 // in this file is the only one.
93 request.cachePolicy = .reloadIgnoringLocalCacheData
94 if let cached {
95 request.setValue(cached.etag, forHTTPHeaderField: "If-None-Match")
96 }
97
98 let (body, response) = try await perform(request, argv: argv)
99
100 if response.statusCode == 304 {
101 guard let cached else {
102 // Only reachable if the entry was evicted between the read
103 // above and the response. Ask again without a validator,
104 // which cannot come back 304.
105 return try await readEnvelope(argv, revalidate: false)
106 }
107 return try decode(cached.payload, status: 200, argv: argv, surface: .read)
108 }
109
110 if let etag = response.value(forHTTPHeaderField: "ETag"), response.statusCode == 200 {
111 etags.store(ETagEntry(etag: etag, payload: body), for: cacheKey)
112 }
113 return try decode(body, status: response.statusCode, argv: argv, surface: .read)
114 }
115
116 // MARK: - Writes
117
118 /// Run a command that changes state. Long text goes in `stdin`, never
119 /// in argv the same discipline the CLI uses for `--file -`.
120 @discardableResult
121 func run<Payload: Decodable & Sendable>(
122 _ argv: [String],
123 stdin: String? = nil,
124 as _: Payload.Type
125 ) async throws -> Payload? {
126 var request = URLRequest(url: instance.cmdURL)
127 request.httpMethod = "POST"
128 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
129 var payload: [String: Any] = ["argv": argv]
130 if let stdin { payload["stdin"] = stdin }
131 request.httpBody = try JSONSerialization.data(withJSONObject: payload)
132
133 let (body, response) = try await perform(request, argv: argv)
134 let envelope: Envelope<Payload> = try decode(
135 body, status: response.statusCode, argv: argv, surface: .command
136 )
137 return envelope.data
138 }
139
140 /// Run a command whose result carries nothing the caller needs.
141 func run(_ argv: [String], stdin: String? = nil) async throws {
142 _ = try await run(argv, stdin: stdin, as: NoPayload.self)
143 }
144
145 // MARK: - Transport
146
147 private func perform(
148 _ request: URLRequest,
149 argv: [String]
150 ) async throws -> (Data, HTTPURLResponse) {
151 guard let url = request.url, instance.isOwn(url) else {
152 throw GitbayError.unexpectedHost(request.url ?? instance.baseURL)
153 }
154 var request = request
155 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
156
157 // One retry, no more. A phone on a flaky network is exactly the
158 // caller that turns a retry loop into a rate-limit spiral.
159 for attempt in 0...1 {
160 let body: Data
161 let response: HTTPURLResponse
162 do {
163 let (data, urlResponse) = try await session.data(for: request, delegate: redirectGuard)
164 guard let http = urlResponse as? HTTPURLResponse else {
165 throw GitbayError.transport(URLError(.badServerResponse))
166 }
167 (body, response) = (data, http)
168 } catch let error as GitbayError {
169 throw error
170 } catch {
171 throw GitbayError.transport(error)
172 }
173
174 let isLastAttempt = attempt == 1
175
176 if response.statusCode == 429 {
177 let wait = retryAfter(response) ?? Self.maximumAutomaticWait
178 guard !isLastAttempt, wait <= Self.maximumAutomaticWait else {
179 throw GitbayError.rateLimited(retryAfter: wait)
180 }
181 try await Task.sleep(for: .seconds(wait))
182 continue
183 }
184
185 if (500...599).contains(response.statusCode), !isLastAttempt {
186 try await Task.sleep(for: .milliseconds(500))
187 continue
188 }
189 return (body, response)
190 }
191 // The loop returns or throws on every path; this satisfies the
192 // compiler rather than describing a reachable state.
193 throw GitbayError.failure("")
194 }
195
196 /// gitbay sends Retry-After as whole seconds.
197 private func retryAfter(_ response: HTTPURLResponse) -> TimeInterval? {
198 guard let value = response.value(forHTTPHeaderField: "Retry-After"),
199 let seconds = TimeInterval(value.trimmingCharacters(in: .whitespaces)),
200 seconds >= 0 else {
201 return nil
202 }
203 return seconds
204 }
205
206 // MARK: - Envelope
207
208 private enum Surface { case read, command }
209
210 private func decode<Payload: Decodable & Sendable>(
211 _ body: Data,
212 status: Int,
213 argv: [String],
214 surface: Surface
215 ) throws -> Envelope<Payload> {
216 // Probe the envelope before touching the payload, so a failure or
217 // a foreign protocol version is recognised whatever shape `data`
218 // has an error envelope never matches the expected payload.
219 let probe: Envelope<NoPayload>
220 do {
221 probe = try JSONDecoder().decode(Envelope<NoPayload>.self, from: body)
222 } catch {
223 throw GitbayError.decoding(error)
224 }
225 guard probe.protocolVersion == 1 else {
226 throw GitbayError.protocolMismatch(probe.protocolVersion)
227 }
228 if let error = failure(probe, status: status, argv: argv, surface: surface) {
229 throw error
230 }
231 do {
232 return try JSONDecoder().decode(Envelope<Payload>.self, from: body)
233 } catch {
234 throw GitbayError.decoding(error)
235 }
236 }
237
238 private func failure(
239 _ envelope: Envelope<NoPayload>,
240 status: Int,
241 argv: [String],
242 surface: Surface
243 ) -> GitbayError? {
244 let message = envelope.message ?? ""
245
246 // The gate rejections bad token, unknown command, a write sent to
247 // the read surface never reach a command, so they carry no exit
248 // code. Those are read off the status.
249 guard let code = envelope.exitCode.flatMap(ExitCode.init(rawValue:)) else {
250 switch status {
251 case 200: return nil
252 case 401: return .unauthorized(message)
253 case 403: return .denied(message)
254 case 404: return .notFound(message)
255 case 429: return .rateLimited(retryAfter: 0)
256 case 400 where surface == .read:
257 logger.error("read refused: \(message, privacy: .public)")
258 return .notReadable(message)
259 case 400:
260 logger.error("usage error: \(argv, privacy: .private)\(message, privacy: .public)")
261 return .usage(message)
262 default: return .failure(message)
263 }
264 }
265
266 switch code {
267 case .ok:
268 return nil
269 case .usage:
270 // The app built argv wrong. Log it; never put argv on screen.
271 logger.error("usage error: \(argv, privacy: .private)\(message, privacy: .public)")
272 return .usage(message)
273 case .notFound:
274 return .notFound(message)
275 case .denied:
276 return .denied(message)
277 case .failure, .protocolError:
278 return .failure(message)
279 }
280 }
281
282 struct MissingData: LocalizedError, Sendable {
283 let argv: [String]
284 var errorDescription: String? { "The response carried no data." }
285 }
286}
287
288/// Refuses to follow a redirect off the instance. Without this a redirect
289/// would carry the bearer token to whatever host answered.
290private nonisolated final class RedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
291
292 private let instance: GitbayInstance
293
294 init(instance: GitbayInstance) {
295 self.instance = instance
296 }
297
298 func urlSession(
299 _ session: URLSession,
300 task: URLSessionTask,
301 willPerformHTTPRedirection response: HTTPURLResponse,
302 newRequest request: URLRequest,
303 completionHandler: @escaping (URLRequest?) -> Void
304 ) {
305 guard let url = request.url, instance.isOwn(url) else {
306 logger.error("refused redirect off \(self.instance.baseURL.absoluteString, privacy: .public)")
307 completionHandler(nil)
308 return
309 }
310 completionHandler(request)
311 }
312}
gitbay/Networking/GitbayError.swift added +65
@@ -0,0 +1,65 @@
1import Foundation
2
3/// A failure from either API surface, already mapped off the exit code so
4/// callers switch on meaning rather than on numbers.
5///
6/// exit http case
7/// 2 400 usage a bug in the app
8/// 3 404 notFound an empty state, not an error banner
9/// 4 403 denied the message explains the rule; show it verbatim
10/// 1, 5 500 failure retried once by the client, then surfaced
11/// 401 unauthorized the token is invalid, expired or revoked
12/// 429 rateLimited Retry-After was longer than the client will wait
13nonisolated enum GitbayError: LocalizedError, Sendable {
14 case usage(String)
15 case notFound(String)
16 case denied(String)
17 case failure(String)
18 case unauthorized(String)
19 case rateLimited(retryAfter: TimeInterval)
20 /// The command exists but is not read-only, so it cannot be reached
21 /// with GET. Also an app bug: it means we routed a write through `read`.
22 case notReadable(String)
23 case transport(any Error)
24 case decoding(any Error)
25 /// A major protocol version the app does not understand.
26 case protocolMismatch(Int)
27 /// The client refused to send the token somewhere other than the instance.
28 case unexpectedHost(URL)
29
30 /// Text for a person. `denied` and `notFound` come from the server
31 /// verbatim those messages exist to explain a rule, and rewording
32 /// them loses the explanation.
33 var userFacingMessage: String {
34 switch self {
35 case .denied(let message), .notFound(let message):
36 message
37 case .unauthorized:
38 "Your token is no longer valid. Sign in again."
39 case .rateLimited(let seconds):
40 "Too many requests. Try again in \(Int(seconds.rounded()))s."
41 case .failure(let message):
42 message.isEmpty ? "The server could not complete that." : message
43 case .transport:
44 "Check your connection and try again."
45 // A usage or protocol failure is ours, not the user's. argv never
46 // reaches the screen.
47 case .usage, .notReadable, .decoding, .protocolMismatch, .unexpectedHost:
48 "Something went wrong. Please try again."
49 }
50 }
51
52 var errorDescription: String? { userFacingMessage }
53
54 /// True when the right response is an empty screen rather than a banner.
55 var isEmptyState: Bool {
56 if case .notFound = self { return true }
57 return false
58 }
59
60 /// True when the token needs replacing and the app should sign out.
61 var requiresReauthentication: Bool {
62 if case .unauthorized = self { return true }
63 return false
64 }
65}
gitbay/Networking/GitbayInstance.swift added +71
@@ -0,0 +1,71 @@
1import Foundation
2
3/// A gitbay deployment. gitbay is self-hosted, so an account is only
4/// meaningful paired with the instance it lives on.
5nonisolated struct GitbayInstance: Sendable, Hashable, Codable {
6
7 /// Origin of the instance, e.g. `https://gitbay.org`. Path, query and
8 /// fragment are stripped on init so endpoint construction cannot be
9 /// steered by whatever the user pasted.
10 let baseURL: URL
11
12 enum InvalidURL: LocalizedError, Sendable {
13 case notAURL(String)
14 case insecureScheme(String)
15
16 var errorDescription: String? {
17 switch self {
18 case .notAURL(let text):
19 "\(text) is not a server address."
20 case .insecureScheme(let scheme):
21 "\(scheme):// would send your token in the clear. Use https."
22 }
23 }
24 }
25
26 init(url text: String) throws {
27 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
28 let withScheme = trimmed.contains("://") ? trimmed : "https://\(trimmed)"
29 guard var components = URLComponents(string: withScheme),
30 let host = components.host, !host.isEmpty else {
31 throw InvalidURL.notAURL(trimmed)
32 }
33 let scheme = (components.scheme ?? "https").lowercased()
34 // A bearer token over plaintext is a token given away. localhost is
35 // exempt so a self-hoster can point the app at their own machine.
36 guard scheme == "https" || (scheme == "http" && Self.isLoopback(host)) else {
37 throw InvalidURL.insecureScheme(scheme)
38 }
39 components.scheme = scheme
40 components.path = ""
41 components.query = nil
42 components.fragment = nil
43 guard let url = components.url else { throw InvalidURL.notAURL(trimmed) }
44 self.baseURL = url
45 }
46
47 private static func isLoopback(_ host: String) -> Bool {
48 host == "localhost" || host == "127.0.0.1" || host == "::1"
49 }
50
51 /// `GET /api/v1/read` argv arrives as repeated query parameters.
52 func readURL(argv: [String]) -> URL {
53 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
54 components.path = "/api/v1/read"
55 components.queryItems = argv.map { URLQueryItem(name: "argv", value: $0) }
56 return components.url!
57 }
58
59 /// `POST /api/v1/cmd`
60 var cmdURL: URL {
61 baseURL.appending(path: "/api/v1/cmd")
62 }
63
64 /// Whether a URL is on this instance. Checked before the Authorization
65 /// header goes on a request, and again on every redirect.
66 func isOwn(_ url: URL) -> Bool {
67 url.scheme?.lowercased() == baseURL.scheme
68 && url.host()?.lowercased() == baseURL.host()?.lowercased()
69 && url.port == baseURL.port
70 }
71}
gitbay/gitbayApp.swift added +17
@@ -0,0 +1,17 @@
1//
2// gitbayApp.swift
3// gitbay
4//
5// Created by cmc on 2026-08-26.
6//
7
8import SwiftUI
9
10@main
11struct gitbayApp: App {
12 var body: some Scene {
13 WindowGroup {
14 ContentView()
15 }
16 }
17}
gitbayTests/GitbayClientTests.swift added +380
@@ -0,0 +1,380 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// Recorded envelopes, verbatim from gitbay.org v1.0.0.
6private enum Recorded {
7 static let whoami = """
8 {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
9 """
10 static let repoList = """
11 {"protocol_version":1,"data":[\
12 {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\
13 {"path":"krz/secrets","visibility":"private","archived":true}\
14 ],"exit_code":0}
15 """
16 static let emptyList = """
17 {"protocol_version":1,"exit_code":0}
18 """
19 static let notFound = """
20 {"protocol_version":1,"error":"no such issue 99 in krz/gitbay","exit_code":3}
21 """
22 static let denied = """
23 {"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}
24 """
25 static let usage = """
26 {"protocol_version":1,"error":"usage: repo tree <owner/name> [<path>] [--ref <ref>]","exit_code":2}
27 """
28 static let writeOnReadSurface = """
29 {"protocol_version":1,"error":"repo create changes state; POST it to /api/v1/cmd"}
30 """
31 static let badToken = """
32 {"protocol_version":1,"error":"invalid or expired token"}
33 """
34 static let diff = """
35 {"protocol_version":1,"output":"diff --git a/main.go b/main.go\\n--- a/main.go\\n+++ b/main.go\\n","exit_code":0}
36 """
37}
38
39nonisolated private struct WhoamiPayload: Decodable, Sendable, Equatable {
40 let username: String
41 let admin: Bool
42}
43
44nonisolated private struct RepoPayload: Decodable, Sendable, Equatable {
45 let path: String
46 let visibility: String
47 let description: String?
48 let archived: Bool?
49}
50
51private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
52 let box = StubProtocol.box()
53 let client = GitbayClient(
54 instance: try GitbayInstance(url: "https://gitbay.org"),
55 token: "test-token",
56 session: box.session()
57 )
58 return (client, box)
59}
60
61struct GitbayClientReadTests {
62
63 @Test func readDecodesEnvelopeData() async throws {
64 let (client, stub) = try makeClient()
65 stub.enqueue(.init(status: 200, json: Recorded.whoami))
66
67 let who = try await client.read(["whoami"], as: WhoamiPayload.self)
68
69 #expect(who == WhoamiPayload(username: "cmc", admin: false))
70 let seen = try #require(stub.seen.first)
71 #expect(seen.method == "GET")
72 #expect(seen.url.path() == "/api/v1/read")
73 #expect(seen.url.query() == "argv=whoami")
74 #expect(seen.headers["Authorization"] == "Bearer test-token")
75 #expect(seen.headers["If-None-Match"] == nil)
76 }
77
78 @Test func argvArrivesAsRepeatedQueryParameters() async throws {
79 let (client, stub) = try makeClient()
80 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
81
82 _ = try await client.readList(
83 ["repo", "tree", "krz/gitbay", "internal"], of: RepoPayload.self
84 )
85
86 let seen = try #require(stub.seen.first)
87 #expect(seen.url.query() == "argv=repo&argv=tree&argv=krz/gitbay&argv=internal")
88 }
89
90 @Test func secondReadRevalidatesAndA304ServesTheCachedBody() async throws {
91 let (client, stub) = try makeClient()
92 let etag = "\"12e3e2b8914d9abfceec233dac9a7454\""
93 stub.enqueue(.init(status: 200, headers: ["ETag": etag], json: Recorded.repoList))
94 stub.enqueue(.init(status: 304, headers: ["ETag": etag]))
95
96 let first = try await client.readList(["repo", "list"], of: RepoPayload.self)
97 let second = try await client.readList(["repo", "list"], of: RepoPayload.self)
98
99 #expect(first == second)
100 #expect(first.count == 2)
101 #expect(first[0].path == "krz/gitbay")
102 let requests = stub.seen
103 #expect(requests.count == 2)
104 #expect(requests[0].headers["If-None-Match"] == nil)
105 #expect(requests[1].headers["If-None-Match"] == etag)
106 }
107
108 @Test func changedBodyReplacesTheCachedValidator() async throws {
109 let (client, stub) = try makeClient()
110 stub.enqueue(.init(status: 200, headers: ["ETag": "\"aa\""], json: Recorded.repoList))
111 stub.enqueue(.init(status: 200, headers: ["ETag": "\"bb\""], json: Recorded.emptyList))
112 stub.enqueue(.init(status: 304))
113
114 _ = try await client.readList(["repo", "list"], of: RepoPayload.self)
115 let refreshed = try await client.readList(["repo", "list"], of: RepoPayload.self)
116 let revalidated = try await client.readList(["repo", "list"], of: RepoPayload.self)
117
118 #expect(refreshed.isEmpty)
119 #expect(revalidated.isEmpty)
120 #expect(stub.seen[2].headers["If-None-Match"] == "\"bb\"")
121 }
122
123 @Test func absentDataDecodesAsEmptyList() async throws {
124 let (client, stub) = try makeClient()
125 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
126
127 let repos = try await client.readList(["repo", "list"], of: RepoPayload.self)
128
129 #expect(repos.isEmpty)
130 }
131
132 @Test func rawTextCommandsComeBackAsOutput() async throws {
133 let (client, stub) = try makeClient()
134 stub.enqueue(.init(status: 200, json: Recorded.diff))
135
136 let diff = try await client.readText(["mr", "diff", "krz/gitbay", "7"])
137
138 #expect(diff.hasPrefix("diff --git a/main.go"))
139 }
140}
141
142struct GitbayClientWriteTests {
143
144 @Test func writePostsArgvAndStdinAsJSON() async throws {
145 let (client, stub) = try makeClient()
146 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
147
148 try await client.run(
149 ["issue", "comment", "krz/gitbay", "35"],
150 stdin: "looks right to me"
151 )
152
153 let seen = try #require(stub.seen.first)
154 #expect(seen.method == "POST")
155 #expect(seen.url.path() == "/api/v1/cmd")
156 let body = try #require(
157 try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
158 )
159 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "35"])
160 #expect(body["stdin"] as? String == "looks right to me")
161 }
162
163 @Test func stdinIsOmittedWhenAbsent() async throws {
164 let (client, stub) = try makeClient()
165 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
166
167 try await client.run(["mr", "merge", "krz/gitbay", "7"])
168
169 let seen = try #require(stub.seen.first)
170 let body = try #require(
171 try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
172 )
173 #expect(body["stdin"] == nil)
174 }
175}
176
177struct GitbayClientFailureTests {
178
179 @Test func exitThreeIsAnEmptyStateNotAnError() async throws {
180 let (client, stub) = try makeClient()
181 stub.enqueue(.init(status: 404, json: Recorded.notFound))
182
183 await #expect {
184 _ = try await client.read(["issue", "show", "krz/gitbay", "99"], as: WhoamiPayload.self)
185 } throws: { error in
186 guard let error = error as? GitbayError else { return false }
187 return error.isEmptyState
188 && error.userFacingMessage == "no such issue 99 in krz/gitbay"
189 }
190 }
191
192 @Test func exitFourSurfacesTheServersMessageVerbatim() async throws {
193 let (client, stub) = try makeClient()
194 stub.enqueue(.init(status: 403, json: Recorded.denied))
195
196 await #expect {
197 try await client.run(["issue", "comment", "krz/gitbay", "1"], stdin: "hi")
198 } throws: { error in
199 guard case .denied(let message)? = error as? GitbayError else { return false }
200 return message == "krz/gitbay is archived and read-only"
201 }
202 }
203
204 @Test func exitTwoNeverPutsArgvInTheUserFacingMessage() async throws {
205 let (client, stub) = try makeClient()
206 stub.enqueue(.init(status: 400, json: Recorded.usage))
207
208 await #expect {
209 _ = try await client.read(["repo", "tree"], as: WhoamiPayload.self)
210 } throws: { error in
211 guard case .usage? = error as? GitbayError else { return false }
212 let shown = (error as! GitbayError).userFacingMessage
213 return !shown.contains("repo tree") && !shown.contains("usage")
214 }
215 }
216
217 @Test func gateRejectionsWithoutExitCodesMapOffTheStatus() async throws {
218 let (client, stub) = try makeClient()
219 stub.enqueue(.init(status: 401, json: Recorded.badToken))
220
221 await #expect {
222 _ = try await client.read(["whoami"], as: WhoamiPayload.self)
223 } throws: { error in
224 (error as? GitbayError)?.requiresReauthentication == true
225 }
226 }
227
228 @Test func writeSentToTheReadSurfaceIsAnAppBug() async throws {
229 let (client, stub) = try makeClient()
230 stub.enqueue(.init(status: 400, json: Recorded.writeOnReadSurface))
231
232 await #expect {
233 _ = try await client.read(["repo", "create", "krz/new"], as: WhoamiPayload.self)
234 } throws: { error in
235 guard case .notReadable? = error as? GitbayError else { return false }
236 return true
237 }
238 }
239
240 @Test func serverFailureRetriesOnceThenSurfaces() async throws {
241 let (client, stub) = try makeClient()
242 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
243 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
244
245 await #expect {
246 _ = try await client.read(["repo", "list"], as: WhoamiPayload.self)
247 } throws: { error in
248 guard case .failure(let message)? = error as? GitbayError else { return false }
249 return message == "boom"
250 }
251 #expect(stub.seen.count == 2)
252 }
253
254 @Test func serverFailureRecoversWhenTheRetrySucceeds() async throws {
255 let (client, stub) = try makeClient()
256 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
257 stub.enqueue(.init(status: 200, json: Recorded.whoami))
258
259 let who = try await client.read(["whoami"], as: WhoamiPayload.self)
260
261 #expect(who.username == "cmc")
262 #expect(stub.seen.count == 2)
263 }
264
265 @Test func shortRetryAfterIsHonouredThenTheCallSucceeds() async throws {
266 let (client, stub) = try makeClient()
267 stub.enqueue(.init(
268 status: 429,
269 headers: ["Retry-After": "1"],
270 json: #"{"protocol_version":1,"error":"rate limited; retry in 1s"}"#
271 ))
272 stub.enqueue(.init(status: 200, json: Recorded.whoami))
273
274 let start = ContinuousClock.now
275 let who = try await client.read(["whoami"], as: WhoamiPayload.self)
276
277 #expect(who.username == "cmc")
278 #expect(ContinuousClock.now - start >= .seconds(1))
279 #expect(stub.seen.count == 2)
280 }
281
282 @Test func longRetryAfterSurfacesInsteadOfHanging() async throws {
283 let (client, stub) = try makeClient()
284 stub.enqueue(.init(
285 status: 429,
286 headers: ["Retry-After": "30"],
287 json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#
288 ))
289
290 await #expect {
291 _ = try await client.read(["whoami"], as: WhoamiPayload.self)
292 } throws: { error in
293 guard case .rateLimited(let wait)? = error as? GitbayError else { return false }
294 return wait == 30
295 }
296 #expect(stub.seen.count == 1)
297 }
298
299 @Test func unknownProtocolVersionIsRefused() async throws {
300 let (client, stub) = try makeClient()
301 stub.enqueue(.init(status: 200, json: #"{"protocol_version":2,"data":{},"exit_code":0}"#))
302
303 await #expect {
304 _ = try await client.read(["whoami"], as: WhoamiPayload.self)
305 } throws: { error in
306 guard case .protocolMismatch(2)? = error as? GitbayError else { return false }
307 return true
308 }
309 }
310}
311
312struct GitbayInstanceTests {
313
314 @Test func bareHostGetsHTTPS() throws {
315 let instance = try GitbayInstance(url: "gitbay.org")
316 #expect(instance.baseURL.absoluteString == "https://gitbay.org")
317 }
318
319 @Test func pastedPathAndQueryAreStripped() throws {
320 let instance = try GitbayInstance(url: "https://gitbay.org/krz/gitbay?tab=readme")
321 #expect(instance.baseURL.absoluteString == "https://gitbay.org")
322 }
323
324 @Test func plainHTTPIsRefusedExceptLoopback() throws {
325 #expect(throws: GitbayInstance.InvalidURL.self) {
326 _ = try GitbayInstance(url: "http://gitbay.org")
327 }
328 let local = try GitbayInstance(url: "http://localhost:3000")
329 #expect(local.baseURL.absoluteString == "http://localhost:3000")
330 }
331
332 @Test func readURLEncodesArgvInOrder() throws {
333 let instance = try GitbayInstance(url: "https://gitbay.org")
334 let url = instance.readURL(argv: ["repo", "cat", "krz/gitbay", "cmd/main.go"])
335 #expect(url.query() == "argv=repo&argv=cat&argv=krz/gitbay&argv=cmd/main.go")
336 }
337
338 @Test func ownHostCheckCoversSchemeHostAndPort() throws {
339 let instance = try GitbayInstance(url: "https://gitbay.org")
340 #expect(instance.isOwn(URL(string: "https://gitbay.org/api/v1/read")!))
341 #expect(instance.isOwn(URL(string: "https://GITBAY.ORG/api/v1/cmd")!))
342 #expect(!instance.isOwn(URL(string: "https://evil.example/api/v1/read")!))
343 #expect(!instance.isOwn(URL(string: "http://gitbay.org/api/v1/read")!))
344 #expect(!instance.isOwn(URL(string: "https://gitbay.org.evil.example/")!))
345 }
346}
347
348struct ETagStoreTests {
349
350 @Test func keysAreScopedByAccount() {
351 let a = ETagStore.key(account: "aaaa", argv: ["repo", "list"])
352 let b = ETagStore.key(account: "bbbb", argv: ["repo", "list"])
353 #expect(a != b)
354 }
355
356 @Test func joiningCannotCollideAcrossArgvBoundaries() {
357 let a = ETagStore.key(account: "x", argv: ["repo list"])
358 let b = ETagStore.key(account: "x", argv: ["repo", "list"])
359 #expect(a != b)
360 }
361
362 @Test func evictionDropsTheLeastRecentlyUsedEntry() {
363 let store = ETagStore(capacity: 2)
364 store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
365 store.store(ETagEntry(etag: "b", payload: Data()), for: "b")
366 _ = store.entry(for: "a")
367 store.store(ETagEntry(etag: "c", payload: Data()), for: "c")
368
369 #expect(store.entry(for: "a") != nil)
370 #expect(store.entry(for: "b") == nil)
371 #expect(store.entry(for: "c") != nil)
372 }
373
374 @Test func clearEmptiesTheStore() {
375 let store = ETagStore()
376 store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
377 store.clear()
378 #expect(store.count == 0)
379 }
380}
gitbayTests/LiveInstanceTests.swift added +87
@@ -0,0 +1,87 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// Exercises a real instance. Skipped unless a token is provided:
6///
7/// GITBAY_TEST_TOKEN=$(gitbay auth token create --name ci --scope read --ttl 1h)
8///
9/// Optionally GITBAY_TEST_INSTANCE (defaults to gitbay.org). A read-scoped
10/// token is enough nothing here writes.
11struct LiveInstanceTests {
12
13 nonisolated private static var token: String? {
14 ProcessInfo.processInfo.environment["GITBAY_TEST_TOKEN"]
15 }
16
17 nonisolated private struct Whoami: Decodable, Sendable {
18 let username: String
19 }
20
21 @Test(.enabled(if: token != nil, "GITBAY_TEST_TOKEN is not set"))
22 func secondReadOfARealInstanceRevalidatesFor304() async throws {
23 let host = ProcessInfo.processInfo.environment["GITBAY_TEST_INSTANCE"] ?? "gitbay.org"
24 let instance = try GitbayInstance(url: host)
25
26 // A session that counts responses without touching their content,
27 // so the 304 is observed rather than inferred.
28 let client = GitbayClient(
29 instance: instance,
30 token: try #require(Self.token),
31 session: RecordingProtocol.session()
32 )
33 RecordingProtocol.reset()
34
35 let first = try await client.read(["whoami"], as: Whoami.self)
36 let second = try await client.read(["whoami"], as: Whoami.self)
37
38 #expect(first.username == second.username)
39 #expect(!first.username.isEmpty)
40 let statuses = RecordingProtocol.statuses
41 #expect(statuses == [200, 304])
42 }
43}
44
45/// Passes requests through to the network, recording each response status.
46nonisolated final class RecordingProtocol: URLProtocol, @unchecked Sendable {
47
48 private static let recorded = Mutex<[Int]>([])
49 private static let inner = URLSession(configuration: .ephemeral)
50
51 static var statuses: [Int] {
52 recorded.withLock { $0 }
53 }
54
55 static func reset() {
56 recorded.withLock { $0.removeAll() }
57 }
58
59 static func session() -> URLSession {
60 let configuration = URLSessionConfiguration.ephemeral
61 configuration.protocolClasses = [RecordingProtocol.self]
62 return URLSession(configuration: configuration)
63 }
64
65 override static func canInit(with _: URLRequest) -> Bool { true }
66
67 override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
68
69 override func startLoading() {
70 let request = self.request
71 Task {
72 do {
73 let (data, response) = try await Self.inner.data(for: request)
74 if let http = response as? HTTPURLResponse {
75 Self.recorded.withLock { $0.append(http.statusCode) }
76 }
77 self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
78 self.client?.urlProtocol(self, didLoad: data)
79 self.client?.urlProtocolDidFinishLoading(self)
80 } catch {
81 self.client?.urlProtocol(self, didFailWithError: error)
82 }
83 }
84 }
85
86 override func stopLoading() {}
87}
gitbayTests/StubProtocol.swift added +151
@@ -0,0 +1,151 @@
1import Foundation
2
3/// URLProtocol stub: tests enqueue responses, the client sees a real
4/// URLSession. Recorded envelopes go through here.
5///
6/// Swift Testing runs suites in parallel, so state is keyed per box: each
7/// `box()` gets its own queue and request log, matched up by a header the
8/// box's session adds to every request.
9nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable {
10
11 struct Stub: Sendable {
12 let status: Int
13 let headers: [String: String]
14 let body: Data
15
16 init(status: Int, headers: [String: String] = [:], json: String) {
17 self.status = status
18 var headers = headers
19 headers["Content-Type"] = headers["Content-Type"] ?? "application/json"
20 self.headers = headers
21 self.body = Data(json.utf8)
22 }
23
24 init(status: Int, headers: [String: String] = [:], body: Data = Data()) {
25 self.status = status
26 self.headers = headers
27 self.body = body
28 }
29 }
30
31 /// A request as the stub saw it, for asserting on method, URL, headers
32 /// and body.
33 struct Seen: Sendable {
34 let url: URL
35 let method: String
36 let headers: [String: String]
37 let body: Data
38 }
39
40 /// One test's private stub queue and request log.
41 final class Box: Sendable {
42 fileprivate let id = UUID().uuidString
43 private let queue = Mutex<[Stub]>([])
44 private let log = Mutex<[Seen]>([])
45
46 func enqueue(_ stub: Stub) {
47 queue.withLock { $0.append(stub) }
48 }
49
50 var seen: [Seen] {
51 log.withLock { $0 }
52 }
53
54 /// The stubbed session for this box. The marker header is how the
55 /// shared protocol class finds its way back here.
56 func session() -> URLSession {
57 let configuration = URLSessionConfiguration.ephemeral
58 configuration.protocolClasses = [StubProtocol.self]
59 configuration.httpAdditionalHeaders = [StubProtocol.marker: id]
60 return URLSession(configuration: configuration)
61 }
62
63 fileprivate func next() -> Stub? {
64 queue.withLock { $0.isEmpty ? nil : $0.removeFirst() }
65 }
66
67 fileprivate func record(_ request: Seen) {
68 log.withLock { $0.append(request) }
69 }
70 }
71
72 private static let marker = "X-Stub-Box"
73 private static let boxes = Mutex<[String: Box]>([:])
74
75 static func box() -> Box {
76 let box = Box()
77 boxes.withLock { $0[box.id] = box }
78 return box
79 }
80
81 // MARK: - URLProtocol
82
83 override static func canInit(with _: URLRequest) -> Bool { true }
84
85 override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
86
87 override func startLoading() {
88 guard let id = request.value(forHTTPHeaderField: Self.marker),
89 let box = Self.boxes.withLock({ $0[id] }) else {
90 client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
91 return
92 }
93
94 // httpBody is transformed into a stream by URLSession; read it back.
95 var body = Data()
96 if let stream = request.httpBodyStream {
97 stream.open()
98 defer { stream.close() }
99 let size = 4096
100 let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size)
101 defer { buffer.deallocate() }
102 while stream.hasBytesAvailable {
103 let read = stream.read(buffer, maxLength: size)
104 guard read > 0 else { break }
105 body.append(buffer, count: read)
106 }
107 }
108 var headers = request.allHTTPHeaderFields ?? [:]
109 headers.removeValue(forKey: Self.marker)
110 box.record(Seen(
111 url: request.url!,
112 method: request.httpMethod ?? "GET",
113 headers: headers,
114 body: body
115 ))
116
117 guard let stub = box.next() else {
118 client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
119 return
120 }
121 let response = HTTPURLResponse(
122 url: request.url!,
123 statusCode: stub.status,
124 httpVersion: "HTTP/1.1",
125 headerFields: stub.headers
126 )!
127 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
128 if !stub.body.isEmpty {
129 client?.urlProtocol(self, didLoad: stub.body)
130 }
131 client?.urlProtocolDidFinishLoading(self)
132 }
133
134 override func stopLoading() {}
135}
136
137/// Minimal lock wrapper; os.OSAllocatedUnfairLock spelled test-side.
138nonisolated final class Mutex<Value>: @unchecked Sendable {
139 private var value: Value
140 private let lock = NSLock()
141
142 init(_ value: Value) {
143 self.value = value
144 }
145
146 func withLock<R>(_ body: (inout Value) -> R) -> R {
147 lock.lock()
148 defer { lock.unlock() }
149 return body(&value)
150 }
151}