Commit 59aa1b9a8f

59aa1b9a8fe12bbed20da974d6ea12a619019509

parent: e576103c5e

Unsigned

cmc <hello@cleberg.net> · 2026-09-06 23:58 UTC

Fork, watch, mute and bookmark a repository (!53)

Three repository actions, each rendered to match what the server
actually reports back.

Bookmarking is a real toggle, because `repo bookmarks` makes the state
derivable. While that state is unknown — not yet loaded, or the listing
failed — the control says so rather than claiming the repository is not
bookmarked, and pull-to-refresh retries it.

Watch and mute are two separate actions, not a toggle. No command reports
which is current, so a toggle would show a state the server never sent.
krz/gitbay#178 tracks adding it to `repo show`, along with bookmark state
and `fork_of`.

Forking prompts for an optional name, trims it, and follows the new
repository on success; a refusal shows the server's own message, which
is usually that the name is taken.

Closes three parity rows: fork; watch, mute; bookmark, bookmark list.

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr07-repo-actions.md added +300
@@ -0,0 +1,300 @@
1# MR 7: Repository actions — fork, watch/mute, bookmark Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close three parity rows — `fork`, `watch, mute`, and `bookmark, bookmark list`.
6
7**Architecture:** Three repository-level actions on one screen, all thin writes over existing commands. The complication is that the server reports back almost none of the resulting state, so each of the three gets a different treatment depending on what is actually readable.
8
9**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
10
11## What the server does and does not report
12
13This shaped every decision below, and it is worth stating plainly because it is not what the spec assumed.
14
15| State | Readable? | Consequence |
16|-------|-----------|-------------|
17| Watch / mute | **No.** `store.RepoWatchState` exists but is internal; no command exposes it | Two stateless actions, not a toggle |
18| Bookmarked | **Indirectly** — `repo bookmarks` lists them, membership is derivable | A real toggle, at the cost of one list read |
19| `fork_of` | **No** on `repo show`; only on `repo fork`'s own response (`internal/control/mr.go:94`) | Cannot show "forked from X" for an existing repository |
20
21Filed upstream as **krz/gitbay#178**. Do not work around any of it by inventing local persistence — the app renders server state; it does not remember its own.
22
23## Global Constraints
24
25- Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s.
26- Swift Testing only — never XCTest.
27- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
28- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit.
29- The label model is `IssueLabel`; the notification model is `InboxNotification`. Neither shadows a framework type, and neither should be renamed back.
30- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
31- Never commit to `main`.
32
33**The commands, verbatim from the registry:**
34
35```
36repo fork <owner/name> [--name <n>]
37repo watch <owner/name>
38repo unwatch <owner/name>
39repo bookmark <owner/name>
40repo unbookmark <owner/name>
41repo bookmarks
42```
43
44Note `repo bookmarks` — **plural, and it takes no argument**. It is not `repo bookmark list`.
45
46`repo fork` returns `{"path": "<new owner/name>", "fork_of": "<source>"}`.
47
48**Counting tests:**
49
50```bash
51grep -cE "^Test case '[^']*' passed" /tmp/out.txt
52grep -cE "^Test case '[^']*' failed" /tmp/out.txt
53grep -cE "^Test case '[^']*' skipped" /tmp/out.txt
54```
55
56Baseline: **297 passed, 1 skipped, 0 failed.**
57
58---
59
60## File Structure
61
62| File | Responsibility |
63|------|----------------|
64| `gitbay/Repos/RepoActionsViewModel.swift` (create) | `ForkResult` model, bookmark state, and the five actions |
65| `gitbayTests/RepoActionTests.swift` (create) | Every test in this plan |
66| `gitbay/Views/Repos/RepoView.swift` (modify) | The actions, in the repository's menu |
67
68---
69
70### Task 1: The actions and the bookmark state
71
72**Files:** Create `gitbay/Repos/RepoActionsViewModel.swift`; test in `gitbayTests/RepoActionTests.swift`
73
74**Interfaces produced:**
75- `nonisolated struct ForkResult: Decodable, Sendable, Hashable` — `path: String`, `forkOf: String`
76- `@Observable @MainActor final class RepoActionsViewModel` — `isBookmarked: Bool?`, `actionError: String?`, `notice: String?`, `working: Bool`, `func loadBookmarkState() async`, `func setBookmarked(_:) async`, `func watch() async`, `func unwatch() async`, `func fork(named:) async -> ForkResult?`
77
78Behaviour, each pinned by a test:
791. `repo bookmarks` takes **no repository argument** — argv is exactly `["repo", "bookmarks"]`. Passing the path would be a usage error.
802. `isBookmarked` is `Bool?`: `nil` until known. It is derived by testing membership of the `repo bookmarks` result, and a failed load leaves it `nil` rather than `false` — "we don't know" and "not bookmarked" are different, and the UI must not claim the latter when it means the former. This is the same three-state lesson MRs 4 and 5 each had to learn.
813. `setBookmarked(true)` sends `repo bookmark`; `setBookmarked(false)` sends `repo unbookmark`. Two commands, not a flag.
824. `watch()` and `unwatch()` send their commands and report the result. **They do not attempt to track state** — nothing reports it back, and inventing a local guess would show the user something the server never said.
835. `fork(named:)` sends `["repo", "fork", repoPath]`, appending `--name <n>` only when a name is given, and returns the decoded `ForkResult` so the caller can navigate to the new repository.
846. A refusal surfaces into `actionError` and does not clear a prior success `notice` misleadingly.
85
86- [ ] **Step 1: Write the failing tests**
87
88```swift
89import Foundation
90import Testing
91@testable import gitbay
92
93private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
94 let box = StubProtocol.box()
95 let client = GitbayClient(
96 instance: try GitbayInstance(url: "https://gitbay.org"),
97 token: "test-token",
98 session: box.session()
99 )
100 return (client, box)
101}
102
103private func argvFrom(_ url: URL) -> [String] {
104 URLComponents(url: url, resolvingAgainstBaseURL: false)?
105 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
106}
107
108private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
109 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
110 return try #require(body["argv"] as? [String])
111}
112
113private let bookmarks = """
114 {"protocol_version":1,"data":[{"path":"krz/gitbay","visibility":"public"},\
115 {"path":"krz/solar","visibility":"public"}],"exit_code":0}
116 """
117private let ok = """
118 {"protocol_version":1,"exit_code":0}
119 """
120
121@MainActor
122struct RepoActionsTests {
123
124 /// `repo bookmarks` is plural and takes NO repository argument.
125 @Test func theBookmarkListingTakesNoArgument() async throws {
126 let (client, stub) = try makeClient()
127 stub.enqueue(.init(status: 200, json: bookmarks))
128 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
129 await model.loadBookmarkState()
130
131 #expect(argvFrom(try #require(stub.seen.last).url) == ["repo", "bookmarks"])
132 #expect(model.isBookmarked == true)
133 }
134
135 @Test func aRepoAbsentFromTheListingIsNotBookmarked() async throws {
136 let (client, stub) = try makeClient()
137 stub.enqueue(.init(status: 200, json: bookmarks))
138 let model = RepoActionsViewModel(client: client, repoPath: "krz/other")
139 await model.loadBookmarkState()
140
141 #expect(model.isBookmarked == false)
142 }
143
144 /// "We don't know" is not "not bookmarked". A failed load must leave
145 /// the state unknown rather than claiming the repository is not
146 /// bookmarked, which would draw the wrong control.
147 @Test func aFailedListingLeavesTheStateUnknown() async throws {
148 let (client, stub) = try makeClient()
149 stub.enqueue(.init(status: 200, json: """
150 {"protocol_version":1,"error":"denied","exit_code":4}
151 """))
152 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
153 await model.loadBookmarkState()
154
155 #expect(model.isBookmarked == nil)
156 }
157
158 @Test func bookmarkingAndUnbookmarkingAreDifferentCommands() async throws {
159 let (client, stub) = try makeClient()
160 stub.enqueue(.init(status: 200, json: bookmarks))
161 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
162 await model.loadBookmarkState()
163
164 stub.enqueue(.init(status: 200, json: ok))
165 stub.enqueue(.init(status: 200, json: bookmarks))
166 await model.setBookmarked(false)
167 var write = try #require(stub.seen.first { $0.method == "POST" })
168 #expect(try argvOf(write) == ["repo", "unbookmark", "krz/gitbay"])
169
170 let (client2, stub2) = try makeClient()
171 stub2.enqueue(.init(status: 200, json: bookmarks))
172 let model2 = RepoActionsViewModel(client: client2, repoPath: "krz/other")
173 await model2.loadBookmarkState()
174 stub2.enqueue(.init(status: 200, json: ok))
175 stub2.enqueue(.init(status: 200, json: bookmarks))
176 await model2.setBookmarked(true)
177 write = try #require(stub2.seen.first { $0.method == "POST" })
178 #expect(try argvOf(write) == ["repo", "bookmark", "krz/other"])
179 }
180
181 @Test func watchAndUnwatchSendTheirOwnCommands() async throws {
182 let (client, stub) = try makeClient()
183 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
184
185 stub.enqueue(.init(status: 200, json: ok))
186 await model.watch()
187 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "watch", "krz/gitbay"])
188
189 stub.enqueue(.init(status: 200, json: ok))
190 await model.unwatch()
191 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "unwatch", "krz/gitbay"])
192 }
193
194 @Test func forkingReturnsTheNewPath() async throws {
195 let (client, stub) = try makeClient()
196 stub.enqueue(.init(status: 200, json: """
197 {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\
198 "exit_code":0}
199 """))
200 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
201 let result = await model.fork(named: nil)
202
203 #expect(result?.path == "cmc/gitbay")
204 #expect(result?.forkOf == "krz/gitbay")
205 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"])
206 }
207
208 @Test func forkingWithANameAppendsTheFlag() async throws {
209 let (client, stub) = try makeClient()
210 stub.enqueue(.init(status: 200, json: """
211 {"protocol_version":1,"data":{"path":"cmc/mine","fork_of":"krz/gitbay"},\
212 "exit_code":0}
213 """))
214 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
215 _ = await model.fork(named: "mine")
216
217 #expect(try argvOf(try #require(stub.seen.last))
218 == ["repo", "fork", "krz/gitbay", "--name", "mine"])
219 }
220
221 /// A blank name must not send an empty flag value.
222 @Test func forkingWithABlankNameOmitsTheFlag() async throws {
223 let (client, stub) = try makeClient()
224 stub.enqueue(.init(status: 200, json: """
225 {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\
226 "exit_code":0}
227 """))
228 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
229 _ = await model.fork(named: " ")
230
231 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"])
232 }
233
234 @Test func aRefusedForkReturnsNilAndSurfaces() async throws {
235 let (client, stub) = try makeClient()
236 stub.enqueue(.init(status: 200, json: """
237 {"protocol_version":1,"error":"a repository named gitbay already exists",\
238 "exit_code":1}
239 """))
240 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
241 let result = await model.fork(named: nil)
242
243 #expect(result == nil)
244 #expect(model.actionError?.isEmpty == false)
245 #expect(model.working == false)
246 }
247}
248```
249
250- [ ] **Step 2: Run to verify failure.**
251- [ ] **Step 3: Implement.** Copy `perform(argv:)` from `RepoSettingsViewModel` for the void writes; `fork` needs `client.run(_:as:)` since it decodes a result. Reuse `RepoSummary` for the bookmarks listing — I checked the live response, `{path, description, visibility, bookmarks}`, and `RepoSummary` decodes it directly (the extra `bookmarks` count is ignored, as Swift's `Decodable` ignores unknown keys). Do not write a new model.
252- [ ] **Step 4: Run the tests.**
253- [ ] **Step 5: Commit** — `git commit -m "Fork, watch, mute and bookmark a repository"`
254
255---
256
257### Task 2: The actions on the repository screen
258
259**Files:** Modify `gitbay/Views/Repos/RepoView.swift`
260
261No unit tests — UI.
262
263Put all five in the repository screen's toolbar menu, beside the existing pin and archive actions. Study how those are presented and match them.
264
265**Each of the three rows gets the treatment its readable state allows:**
266
267- **Bookmark** — a real toggle, because the state is derivable. While `isBookmarked` is `nil` the control must not claim either state: show it disabled or as a spinner, never as "not bookmarked". Load the state with `.task`.
268- **Watch / Mute** — **two separate actions, not a toggle.** Nothing reports which one is current, so a toggle would be lying. Say what each does rather than implying a state: "Watch this repository" and "Mute this repository". A brief caption noting that the current setting is not shown is honest and cheap; krz/gitbay#178 tracks fixing it properly.
269- **Fork** — an action that prompts for an optional name, then navigates to the new repository on success using `RepoRoute.repo(result.path)`. On refusal, surface the server's message: "already exists" is the common one and the user needs to read it.
270
271`RepoView` already surfaces `actionError`-style messages for pin and archive — reuse that, do not add a second notice mechanism.
272
273- [ ] **Step 1: Bookmark toggle with its unknown state**
274- [ ] **Step 2: Watch and Mute as two actions**
275- [ ] **Step 3: Fork with an optional name and navigation on success**
276- [ ] **Step 4: Build and run the full suite.** No drop from 297.
277- [ ] **Step 5: Commit** — `git commit -m "Repository actions in the repo menu"`
278
279---
280
281### Task 3: Flip three parity rows and open the merge request
282
283- [ ] **Step 1:** In the Repositories table set `fork`, `watch, mute` and `bookmark, bookmark list` to `yes` for iOS. Touch no other row.
284
285Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. **Read the merge request number back from `mr create`'s output rather than assuming it.**
286
287- [ ] **Step 2:** Run the full suite; record the real number.
288- [ ] **Step 3:** Open the merge request, noting that watch and mute ship stateless and why.
289
290---
291
292## Notes for whoever executes this
293
294**`repo bookmarks` is plural and takes no argument.** Not `repo bookmark list`, and not `repo bookmarks <owner/name>`.
295
296**Do not invent state the server does not report.** Watch and mute have no readable state; a toggle would show the user something the server never said. Two actions is the honest rendering, and krz/gitbay#178 tracks the fix.
297
298**`nil` is not `false` for the bookmark state.** A failed listing means unknown, and drawing "not bookmarked" would be a claim the app cannot support.
299
300**`fork_of` is not on `repo show`.** Do not try to display "forked from X" for an existing repository; it is only on `repo fork`'s own response, which is why the fork action returns it.
gitbay/Repos/RepoActionsViewModel.swift added +121
@@ -0,0 +1,121 @@
1import Foundation
2import Observation
3
4/// Fork, watch, mute and bookmark — the repository-level actions the
5/// server exposes but does not describe fully back:
6///
7/// - Watch/mute state is reported by no command at all (krz/gitbay#178),
8/// so `watch()`/`unwatch()` only send their command and report the
9/// result; they never track or infer a current state.
10/// - Bookmark state has to be derived by listing `repo bookmarks` and
11/// testing membership — there is no per-repo "is this bookmarked" call.
12/// - `fork_of` is only on `repo fork`'s own response, never on `repo
13/// show`, which is why `fork` hands its result back to the caller.
14@Observable
15@MainActor
16final class RepoActionsViewModel {
17
18 /// `repo fork`'s result — the only place `fork_of` is reported.
19 nonisolated struct ForkResult: Decodable, Sendable, Hashable {
20 let path: String
21 let forkOf: String
22
23 enum CodingKeys: String, CodingKey {
24 case path
25 case forkOf = "fork_of"
26 }
27 }
28
29 /// `nil` until known or after a failed load. Never `false` for "could
30 /// not check" — that would draw the wrong control.
31 private(set) var isBookmarked: Bool?
32 private(set) var actionError: String?
33 private(set) var notice: String?
34 private(set) var working = false
35
36 private let client: GitbayClient
37 let repoPath: String
38
39 init(client: GitbayClient, repoPath: String) {
40 self.client = client
41 self.repoPath = repoPath
42 }
43
44 // MARK: - Bookmarks
45
46 /// `repo bookmarks` — plural, no repository argument. Membership in
47 /// the listing is the only signal the server gives for this repo.
48 func loadBookmarkState() async {
49 do {
50 let bookmarks = try await client.readList(["repo", "bookmarks"], of: RepoSummary.self)
51 isBookmarked = bookmarks.contains { $0.path == repoPath }
52 } catch {
53 // "We don't know" is not "not bookmarked".
54 isBookmarked = nil
55 }
56 }
57
58 func setBookmarked(_ bookmarked: Bool) async {
59 await perform(["repo", bookmarked ? "bookmark" : "unbookmark", repoPath])
60 await loadBookmarkState()
61 }
62
63 // MARK: - Watch / mute
64
65 func watch() async {
66 await perform(["repo", "watch", repoPath])
67 if actionError == nil {
68 notice = "You are now watching this repository."
69 }
70 }
71
72 func unwatch() async {
73 await perform(["repo", "unwatch", repoPath])
74 if actionError == nil {
75 notice = "Notifications for this repository are muted."
76 }
77 }
78
79 // MARK: - Fork
80
81 func fork(named name: String?) async -> ForkResult? {
82 var argv = ["repo", "fork", repoPath]
83 if let name {
84 let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
85 if !trimmed.isEmpty {
86 argv += ["--name", trimmed]
87 }
88 }
89 working = true
90 actionError = nil
91 notice = nil
92 defer { working = false }
93 do {
94 guard let result = try await client.run(argv, as: ForkResult.self) else {
95 actionError = "The fork succeeded, but the server reported nothing back."
96 return nil
97 }
98 return result
99 } catch let error as GitbayError {
100 actionError = error.userFacingMessage
101 return nil
102 } catch {
103 actionError = GitbayError.transport(error).userFacingMessage
104 return nil
105 }
106 }
107
108 private func perform(_ argv: [String]) async {
109 working = true
110 actionError = nil
111 notice = nil
112 defer { working = false }
113 do {
114 try await client.run(argv)
115 } catch let error as GitbayError {
116 actionError = error.userFacingMessage
117 } catch {
118 actionError = GitbayError.transport(error).userFacingMessage
119 }
120 }
121}
gitbay/Views/Repos/RepoView.swift +78 −3
@@ -3,16 +3,23 @@ import SwiftUI
33struct RepoView: View {
44
55 @State private var model: RepoDetailViewModel
6 @State private var actionsModel: RepoActionsViewModel
7 private let client: GitbayClient
68 private let path: String
79 private let host: String
810
911 init(client: GitbayClient, path: String) {
12 self.client = client
1013 self.path = path
1114 self.host = client.instance.baseURL.host() ?? ""
1215 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
16 _actionsModel = State(initialValue: RepoActionsViewModel(client: client, repoPath: path))
1317 }
1418
1519 @State private var confirmingArchive = false
20 @State private var confirmingFork = false
21 @State private var forkName = ""
22 @State private var forkDestination: RepoRoute?
1623
1724 var body: some View {
1825 List {
@@ -22,6 +29,16 @@ struct RepoView: View {
2229 GBNotice(error, .gbWarn)
2330 }
2431 }
32 if let error = actionsModel.actionError {
33 Section {
34 GBNotice(error, .gbWarn)
35 }
36 }
37 if let notice = actionsModel.notice {
38 Section {
39 GBNotice(notice, .gbOK)
40 }
41 }
2542 header(detail)
2643
2744 Section {
@@ -84,7 +101,29 @@ struct RepoView: View {
84101 .navigationBarTitleDisplayMode(.inline)
85102 .toolbar { toolbar }
86103 .task { await model.load() }
87 .refreshable { await model.load() }
104 .task { await actionsModel.loadBookmarkState() }
105 .refreshable {
106 await model.load()
107 await actionsModel.loadBookmarkState()
108 }
109 .navigationDestination(item: $forkDestination) { route in
110 if case .repo(let forkedPath) = route {
111 RepoView(client: client, path: forkedPath)
112 }
113 }
114 .alert("Fork \(path)", isPresented: $confirmingFork) {
115 TextField("New name (optional)", text: $forkName)
116 .autocorrectionDisabled()
117 .textInputAutocapitalization(.never)
118 Button("Fork") {
119 Task {
120 if let result = await actionsModel.fork(named: forkName) {
121 forkDestination = .repo(result.path)
122 }
123 }
124 }
125 Button("Cancel", role: .cancel) {}
126 }
88127 .confirmationDialog(
89128 model.state.value?.isArchived == true
90129 ? "Unarchive \(path)?"
@@ -114,6 +153,42 @@ struct RepoView: View {
114153 systemImage: pinned ? "pin.slash" : "pin")
115154 }
116155 }
156 if let bookmarked = actionsModel.isBookmarked {
157 Button {
158 Task { await actionsModel.setBookmarked(!bookmarked) }
159 } label: {
160 Label(bookmarked ? "Unbookmark" : "Bookmark",
161 systemImage: bookmarked ? "bookmark.slash" : "bookmark")
162 }
163 } else {
164 Label {
165 Text("Checking Bookmark…")
166 } icon: {
167 ProgressView()
168 }
169 }
170 Divider()
171 Section {
172 Button {
173 Task { await actionsModel.watch() }
174 } label: {
175 Label("Watch this repository", systemImage: "eye")
176 }
177 Button {
178 Task { await actionsModel.unwatch() }
179 } label: {
180 Label("Mute this repository", systemImage: "bell.slash")
181 }
182 // Nothing reports which of these is current (krz/gitbay#178).
183 Text("The current setting isn't shown.")
184 }
185 Divider()
186 Button {
187 forkName = ""
188 confirmingFork = true
189 } label: {
190 Label("Fork", systemImage: "tuningfork")
191 }
117192 Divider()
118193 Button(role: detail.isArchived ? nil : .destructive) {
119194 confirmingArchive = true
@@ -122,13 +197,13 @@ struct RepoView: View {
122197 systemImage: "archivebox")
123198 }
124199 } label: {
125 if model.working {
200 if model.working || actionsModel.working {
126201 ProgressView()
127202 } else {
128203 Image(systemName: "ellipsis.circle")
129204 }
130205 }
131 .disabled(model.working)
206 .disabled(model.working || actionsModel.working)
132207 .accessibilityIdentifier("repo-actions-menu")
133208 }
134209 }
gitbayTests/RepoActionTests.swift added +235
@@ -0,0 +1,235 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvFrom(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?
17 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
18}
19
20private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
21 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
22 return try #require(body["argv"] as? [String])
23}
24
25private let bookmarks = """
26 {"protocol_version":1,"data":[{"path":"krz/gitbay","visibility":"public"},\
27 {"path":"krz/solar","visibility":"public"}],"exit_code":0}
28 """
29private let ok = """
30 {"protocol_version":1,"exit_code":0}
31 """
32
33@MainActor
34struct RepoActionsTests {
35
36 /// `repo bookmarks` is plural and takes NO repository argument.
37 @Test func theBookmarkListingTakesNoArgument() async throws {
38 let (client, stub) = try makeClient()
39 stub.enqueue(.init(status: 200, json: bookmarks))
40 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
41 await model.loadBookmarkState()
42
43 #expect(argvFrom(try #require(stub.seen.last).url) == ["repo", "bookmarks"])
44 #expect(model.isBookmarked == true)
45 }
46
47 @Test func aRepoAbsentFromTheListingIsNotBookmarked() async throws {
48 let (client, stub) = try makeClient()
49 stub.enqueue(.init(status: 200, json: bookmarks))
50 let model = RepoActionsViewModel(client: client, repoPath: "krz/other")
51 await model.loadBookmarkState()
52
53 #expect(model.isBookmarked == false)
54 }
55
56 /// "We don't know" is not "not bookmarked". A failed load must reset
57 /// the state to unknown rather than leaving a stale prior value in
58 /// place — this proves the reset by establishing a known `true`
59 /// first, then failing a second load.
60 @Test func aFailedListingLeavesTheStateUnknown() async throws {
61 let (client, stub) = try makeClient()
62 stub.enqueue(.init(status: 200, json: bookmarks))
63 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
64 await model.loadBookmarkState()
65 #expect(model.isBookmarked == true)
66
67 stub.enqueue(.init(status: 200, json: """
68 {"protocol_version":1,"error":"denied","exit_code":4}
69 """))
70 await model.loadBookmarkState()
71
72 #expect(model.isBookmarked == nil)
73 }
74
75 @Test func bookmarkingAndUnbookmarkingAreDifferentCommands() async throws {
76 let (client, stub) = try makeClient()
77 stub.enqueue(.init(status: 200, json: bookmarks))
78 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
79 await model.loadBookmarkState()
80
81 stub.enqueue(.init(status: 200, json: ok))
82 stub.enqueue(.init(status: 200, json: bookmarks))
83 await model.setBookmarked(false)
84 var write = try #require(stub.seen.first { $0.method == "POST" })
85 #expect(try argvOf(write) == ["repo", "unbookmark", "krz/gitbay"])
86
87 let (client2, stub2) = try makeClient()
88 stub2.enqueue(.init(status: 200, json: bookmarks))
89 let model2 = RepoActionsViewModel(client: client2, repoPath: "krz/other")
90 await model2.loadBookmarkState()
91 stub2.enqueue(.init(status: 200, json: ok))
92 stub2.enqueue(.init(status: 200, json: bookmarks))
93 await model2.setBookmarked(true)
94 write = try #require(stub2.seen.first { $0.method == "POST" })
95 #expect(try argvOf(write) == ["repo", "bookmark", "krz/other"])
96 }
97
98 @Test func watchAndUnwatchSendTheirOwnCommands() async throws {
99 let (client, stub) = try makeClient()
100 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
101
102 stub.enqueue(.init(status: 200, json: ok))
103 await model.watch()
104 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "watch", "krz/gitbay"])
105
106 stub.enqueue(.init(status: 200, json: ok))
107 await model.unwatch()
108 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "unwatch", "krz/gitbay"])
109 }
110
111 @Test func forkingReturnsTheNewPath() async throws {
112 let (client, stub) = try makeClient()
113 stub.enqueue(.init(status: 200, json: """
114 {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\
115 "exit_code":0}
116 """))
117 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
118 let result = await model.fork(named: nil)
119
120 #expect(result?.path == "cmc/gitbay")
121 #expect(result?.forkOf == "krz/gitbay")
122 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"])
123 }
124
125 @Test func forkingWithANameAppendsTheFlag() async throws {
126 let (client, stub) = try makeClient()
127 stub.enqueue(.init(status: 200, json: """
128 {"protocol_version":1,"data":{"path":"cmc/mine","fork_of":"krz/gitbay"},\
129 "exit_code":0}
130 """))
131 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
132 _ = await model.fork(named: "mine")
133
134 #expect(try argvOf(try #require(stub.seen.last))
135 == ["repo", "fork", "krz/gitbay", "--name", "mine"])
136 }
137
138 /// Surrounding whitespace must not reach the server — the name is
139 /// trimmed, not just tested for blankness.
140 @Test func forkingWithSurroundingWhitespaceTrimsTheName() async throws {
141 let (client, stub) = try makeClient()
142 stub.enqueue(.init(status: 200, json: """
143 {"protocol_version":1,"data":{"path":"cmc/mine","fork_of":"krz/gitbay"},\
144 "exit_code":0}
145 """))
146 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
147 _ = await model.fork(named: " mine ")
148
149 #expect(try argvOf(try #require(stub.seen.last))
150 == ["repo", "fork", "krz/gitbay", "--name", "mine"])
151 }
152
153 /// A blank name must not send an empty flag value.
154 @Test func forkingWithABlankNameOmitsTheFlag() async throws {
155 let (client, stub) = try makeClient()
156 stub.enqueue(.init(status: 200, json: """
157 {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\
158 "exit_code":0}
159 """))
160 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
161 _ = await model.fork(named: " ")
162
163 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"])
164 }
165
166 @Test func aRefusedForkReturnsNilAndSurfaces() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 200, json: """
169 {"protocol_version":1,"error":"a repository named gitbay already exists",\
170 "exit_code":1}
171 """))
172 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
173 let result = await model.fork(named: nil)
174
175 #expect(result == nil)
176 #expect(model.actionError?.isEmpty == false)
177 #expect(model.working == false)
178 }
179
180 /// A stale `notice` from an earlier success must not survive a later
181 /// failure — the screen must never show a success banner and a
182 /// failure banner at once.
183 @Test func aFailedActionClearsAStaleNotice() async throws {
184 let (client, stub) = try makeClient()
185 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
186
187 stub.enqueue(.init(status: 200, json: ok))
188 await model.watch()
189 #expect(model.notice != nil)
190
191 stub.enqueue(.init(status: 200, json: """
192 {"protocol_version":1,"error":"a repository named gitbay already exists",\
193 "exit_code":1}
194 """))
195 _ = await model.fork(named: nil)
196
197 #expect(model.notice == nil)
198 #expect(model.actionError?.isEmpty == false)
199 }
200
201 /// A 0-exit fork whose envelope carries no `data` must not fail
202 /// silently — the caller needs to know nothing came back.
203 @Test func aForkThatReportsNothingBackSetsAnError() async throws {
204 let (client, stub) = try makeClient()
205 stub.enqueue(.init(status: 200, json: """
206 {"protocol_version":1,"exit_code":0}
207 """))
208 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
209 let result = await model.fork(named: nil)
210
211 #expect(result == nil)
212 #expect(model.actionError?.isEmpty == false)
213 }
214
215 /// A bookmark write must be followed by a re-read of `repo
216 /// bookmarks` — that re-read is what keeps `isBookmarked` honest
217 /// instead of an optimistic local guess.
218 @Test func bookmarkingRereadsTheListingAfterTheWrite() async throws {
219 let (client, stub) = try makeClient()
220 stub.enqueue(.init(status: 200, json: bookmarks))
221 let model = RepoActionsViewModel(client: client, repoPath: "krz/other")
222 await model.loadBookmarkState()
223 #expect(model.isBookmarked == false)
224
225 stub.enqueue(.init(status: 200, json: ok))
226 stub.enqueue(.init(status: 200, json: bookmarks))
227 await model.setBookmarked(true)
228
229 let calls = stub.seen.suffix(2)
230 let write = try #require(calls.first)
231 let reread = try #require(calls.last)
232 #expect(try argvOf(write) == ["repo", "bookmark", "krz/other"])
233 #expect(argvFrom(reread.url) == ["repo", "bookmarks"])
234 }
235}