docs/superpowers/plans/2026-09-07-mr12-repo-state.md
195 lines · 10482 bytes
MR 12: Render the repository state the server now reports
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.
Goal: Replace the stateless watch/mute buttons with a control that shows where you actually are, drop the extra request the bookmark state costs, and say when a repository is a fork.
Why now: krz/gitbay#178 — filed while building !53, because watch, bookmark and fork state were readable from no command — has been implemented upstream. repo show now carries all three. !53 shipped a weaker affordance purely because of that gap, and the gap is gone.
No parity row moves. All three rows are already yes; this improves what closed them.
What the server now reports
From internal/control/repo.go:288-299, all three omitempty:
// ForkOf names the parent only when the caller can read it: a
// private parent is not confirmed to exist, here as anywhere.
ForkOf string `json:"fork_of,omitempty"`
// Watch and Bookmarked are the caller's own state, so a client
// can draw a toggle rather than two stateless buttons (#178).
Watch string `json:"watch,omitempty"` // watching, muted, or absent
Bookmarked bool `json:"bookmarked,omitempty"`
Three consequences that shape the whole design:
watchhas three states, and only two are reachable.repo watchsetswatching;repo unwatchsetsmuted(internal/control/notifications.go:177-181). No command returns you to the absent default. So the control must show the current state and offer only the transitions that exist — never a "Default" option with no command behind it.bookmarkedisomitemptyon aBool, so absent means false. Decoding must default it, not require it.- Absent
fork_ofdoes not mean "not a fork." The server withholds it when the caller cannot read the parent — a private parent is not confirmed to exist. So the UI may say "forked from X" when present, and must say nothing when absent. It must never say "not a fork".
Global Constraints
- Swift 6 language mode, default
MainActorisolation. Wire models arenonisolated structs. - Swift Testing only — never XCTest.
gitbayTestsis hermetic and offline; network goes throughStubProtocol.- New files under
gitbay/andgitbayTests/need noproject.pbxprojedit. - The label model is
IssueLabel; the notification model isInboxNotification. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No
Co-Authored-Bytrailer. - Never commit to
main. - Test discipline: use the
until(_:_:)helper ingitbayTests/StubProtocol.swiftrather than fixed sleeps; assert on the request identified by its argv, notstub.seen.last; never indexstub.seendirectly, go through#require; provision a stub for every request each flow makes.
Baseline: 365 total, 364 passed, 1 skipped, 0 failed.
File Structure
| File | Responsibility |
|---|---|
gitbay/Repos/RepoModels.swift (modify) |
Decode watch, bookmarked, fork_of on RepoDetail |
gitbay/Repos/RepoActionsViewModel.swift (modify) |
Delete loadBookmarkState; take state from RepoDetail |
gitbayTests/RepoActionTests.swift (modify) |
Rewrite the bookmark-state tests |
gitbayTests/RepoStateTests.swift (create) |
Decoding and transition tests |
gitbay/Views/Repos/RepoView.swift (modify) |
The watch control, the bookmark toggle, the fork line |
Task 1: Decode the state, and stop fetching it separately
Files: RepoModels.swift, RepoActionsViewModel.swift; tests in gitbayTests/RepoStateTests.swift and RepoActionTests.swift
Interfaces produced:
RepoDetail.watch: String?,.bookmarked: Bool,.forkOf: String?nonisolated enum WatchState: Sendable—.watching,.muted,.default;init(_ raw: String?);var available: [WatchAction]giving only the transitions that existRepoActionsViewModellosesisBookmarkedandloadBookmarkState(); the screen reads state from the loadedRepoDetailinstead
What to delete, and why. !53 added loadBookmarkState(), which calls repo bookmarks and tests membership — one extra read per repository screen, plus a .refreshable hook and a three-state Bool? to cover its failure. All of that existed only because the state was unreadable. repo show is already fetched by that screen, so the whole mechanism goes. Delete it; do not leave it dual-sourced.
Behaviour, each pinned by a test:
bookmarkedisomitemptyon a Bool — absent means false, and must decode, not throw.watchabsent means the default state, which is neither watching nor muted.fork_ofabsent means either not-a-fork or a parent you cannot read. The model exposes it asString?and says nothing more.WatchState.availablereturns[.mute]when watching,[.watch]when muted, and both when default. It never offers a transition to the default, because no command performs one.watch()andunwatch()still sendrepo watch/repo unwatchunchanged, and still reload so the new state is read back from the server rather than assumed.
- Step 1: Write the failing tests
import Foundation
import Testing
@testable import gitbay
struct RepoStateDecodingTests {
private func decode(_ json: String) throws -> RepoDetail {
try JSONDecoder().decode(RepoDetail.self, from: Data(json.utf8))
}
private let base = """
"path":"krz/gitbay","visibility":"public","default_branch":"main"
"""
/// All three are omitempty. A repository you have expressed no
/// opinion about, which is not a fork, carries none of them.
@Test func aRepositoryWithNoStateDecodes() throws {
let repo = try decode("{\(base)}")
#expect(repo.watch == nil)
#expect(repo.bookmarked == false)
#expect(repo.forkOf == nil)
}
@Test func theCallersOwnStateDecodes() throws {
let repo = try decode("""
{\(base),"watch":"watching","bookmarked":true,"fork_of":"upstream/thing"}
""")
#expect(repo.watch == "watching")
#expect(repo.bookmarked)
#expect(repo.forkOf == "upstream/thing")
}
@Test func mutedDecodes() throws {
#expect(try decode("{\(base),\"watch\":\"muted\"}").watch == "muted")
}
}
struct WatchStateTests {
@Test func theThreeStatesMapFromTheWire() {
#expect(WatchState("watching") == .watching)
#expect(WatchState("muted") == .muted)
#expect(WatchState(nil) == .default)
// An unknown value the server might add later must not crash or
// masquerade as watching.
#expect(WatchState("something-new") == .default)
}
/// Only two transitions exist. Nothing returns you to the default,
/// so nothing may offer it.
@Test func onlyTheReachableTransitionsAreOffered() {
#expect(WatchState.watching.available == [.mute])
#expect(WatchState.muted.available == [.watch])
#expect(WatchState.default.available == [.watch, .mute])
}
@Test func noStateOffersAReturnToTheDefault() {
for state in [WatchState.watching, .muted, .default] {
#expect(state.available.allSatisfy { $0 == .watch || $0 == .mute })
}
}
}
Add view-model tests confirming watch()/unwatch() still send their commands and reload, and rewrite RepoActionTests's bookmark tests: the ones covering loadBookmarkState and the repo bookmarks argv go, since that path no longer exists. setBookmarked still sends repo bookmark/repo unbookmark and must keep its tests.
- Step 2: Run to verify failure.
- Step 3: Implement, deleting
loadBookmarkStateandisBookmarkedentirely. - Step 4: Run the tests.
- Step 5: Commit —
git commit -m "Take watch, bookmark and fork state from repo show"
Task 2: The controls
Files: gitbay/Views/Repos/RepoView.swift
No unit tests — UI.
The watch control replaces !53's two stateless buttons. Show the current state and offer only what WatchState.available gives:
- Watching → "Watching" with a Mute action
- Muted → "Muted" with a Watch action
- Default → neither label claimed, both actions offered
Do not render a three-segment picker: selecting the default segment would have no command behind it. And drop !53's caption saying the current setting is not shown — it is now.
The bookmark toggle reads RepoDetail.bookmarked directly. Remove the .task and .refreshable hooks that fed loadBookmarkState, and the "Checking Bookmark…" state, which existed only for a read that no longer happens. setBookmarked still reloads, so the toggle follows the server.
The fork line: when forkOf is present, show "Forked from <path>", linking to RepoRoute.repo(forkOf). When absent, show nothing — absence means either not-a-fork or a parent you cannot see, and the screen must not claim the former.
- Step 1: The watch control
- Step 2: The bookmark toggle, with the old load path removed
- Step 3: The fork line
- Step 4: Build and run the full suite.
- Step 5: Commit —
git commit -m "Show watch, bookmark and fork state on the repository screen"
Task 3: Open the merge request
No parity rows move — all three are already yes. Do not touch the wiki.
- Step 1: Run the full suite via
xcresulttool; record the real numbers. - Step 2: Open the merge request, noting it closes the gap
krz/gitbay#178described and that the issue is already closed upstream.
Notes for whoever executes this
No command returns you to the neutral watch state. Only repo watch and repo unwatch exist. Any control offering a third option would be offering something the server cannot do.
Absent fork_of is not "not a fork." The server withholds it when you cannot read the parent. Say nothing rather than saying the wrong thing.
bookmarked absent means false — it is omitempty on a Bool.
Delete the old path, do not dual-source it. loadBookmarkState and isBookmarked existed only because the state was unreadable. Leaving both would mean two answers to one question.