From f24455185c75458f8803836c4c609c3040775da4 Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Fri, 9 Oct 2026 07:50:25 +0200 Subject: [PATCH 1/5] Ask which forge a project uses when its remotes are on GitHub and GitLab The answer goes to .bloom/settings.local.toml in the project's checkout, which every workspace already reads. Local because remotes are this machine's git config: a teammate with only the GitHub remote must not inherit it. --- Sources/Bloom/State/AppModel+Projects.swift | 11 +++++ Sources/Bloom/State/AppModel.swift | 3 ++ Sources/Bloom/Views/RootView.swift | 14 +++++++ Sources/BloomCore/GitHub/ForgeRouting.swift | 42 +++++++++++++++++++- Tests/BloomCoreTests/ForgeRoutingTests.swift | 29 ++++++++++++++ docs/GITLAB.md | 6 +++ 6 files changed, 103 insertions(+), 2 deletions(-) diff --git a/Sources/Bloom/State/AppModel+Projects.swift b/Sources/Bloom/State/AppModel+Projects.swift index f8369408b..8d8cbe439 100644 --- a/Sources/Bloom/State/AppModel+Projects.swift +++ b/Sources/Bloom/State/AppModel+Projects.swift @@ -34,6 +34,8 @@ extension AppModel { case .alreadyRepository(let root): guard let repo = await addKnownRepository(at: root) else { return nil } if focusing { await focus(repo) } + // Not awaited: a glab probe to a host behind a VPN that is down takes seconds. + Task { if await ForgeResolver.offersBoth(repo.path) { forgeQuestion = repo } } return repo case .refuse(let refusal): @@ -113,6 +115,15 @@ extension AppModel { )) } + /// The answer to `forgeQuestion`, kept for every workspace of the project. + func chooseForge(_ forge: Forge, for repo: Repo) { + do { + try ForgeResolver.remember(forge, for: repo.path) + } catch { + alert = BloomAlert(title: "Could not save the forge", message: error.readableMessage) + } + } + /// Called by `ProjectSetupSheet` once the folder really is a repository. func finishProjectSetup(_ path: String?) async { ProjectSetup.shared.dismiss() diff --git a/Sources/Bloom/State/AppModel.swift b/Sources/Bloom/State/AppModel.swift index 773a92125..ce5f8e344 100644 --- a/Sources/Bloom/State/AppModel.swift +++ b/Sources/Bloom/State/AppModel.swift @@ -282,6 +282,9 @@ final class AppModel { /// Non-nil after an archive script failed, while the owner decides between keeping the /// workspace and archiving it without the script. RootView presents it. var pendingScriptFailure: ScriptFailureRequest? + /// A project just added with remotes on both GitHub and GitLab. RootView asks which one it + /// uses. See `ForgeResolver.offersBoth`. + var forgeQuestion: Repo? /// The transcript half of a search, one row per workspace. Held here rather than in `HomeView` /// for the same reason `homeFilter` is: the pane is destroyed and rebuilt every time the /// selection leaves Home, and a result list that had to be fetched again on the way back would diff --git a/Sources/Bloom/Views/RootView.swift b/Sources/Bloom/Views/RootView.swift index ed32ecc35..0bafdda85 100644 --- a/Sources/Bloom/Views/RootView.swift +++ b/Sources/Bloom/Views/RootView.swift @@ -152,6 +152,20 @@ struct RootView: View { // `ArchiveRequest` in the core, where it can be tested. Text(request.message) } + // Asked when a project with remotes on both forges is added and no forge is declared. + // Cancelling leaves detection in charge, which picks GitHub, until the next time. + .confirmationDialog( + "Which forge does this project use?", + isPresented: $app.forgeQuestion.isPresent(), + titleVisibility: .visible, + presenting: app.forgeQuestion + ) { repo in + Button(Forge.gitLab.name) { app.chooseForge(.gitLab, for: repo) } + Button(Forge.gitHub.name) { app.chooseForge(.gitHub, for: repo) } + Button("Decide later", role: .cancel) {} + } message: { repo in + Text("\(repo.name) has remotes on GitHub and GitLab. Every workspace of the project will use the one you choose. You can change it in the project settings.") + } // The question asked before a session that is still working is closed. On the window for // the reason the archive confirmation above is: it is raised from the tab strip's close // button and from Cmd+W in the menu bar, and there is no one control both of those could diff --git a/Sources/BloomCore/GitHub/ForgeRouting.swift b/Sources/BloomCore/GitHub/ForgeRouting.swift index 9eb9c9f5a..c67802079 100644 --- a/Sources/BloomCore/GitHub/ForgeRouting.swift +++ b/Sources/BloomCore/GitHub/ForgeRouting.swift @@ -29,6 +29,13 @@ public enum ForgeRouting { static func isGitLabCom(_ host: String) -> Bool { host == "gitlab.com" || host.hasSuffix(".gitlab.com") } + + /// The hosts beside a GitHub remote that could make the repository GitLab's as well. Empty + /// without a GitHub remote, because then `decide` answers without anybody being asked. + static func hostsBesideGitHub(_ hosts: [String]) -> Set { + guard hosts.contains(where: isGitHub) else { return [] } + return Set(hosts.filter { !isGitHub($0) }) + } } /// `ForgeRouting` applied to a directory, remembered until git's config or a settings file changes. @@ -66,15 +73,46 @@ public enum ForgeResolver { if hosts.contains(where: ForgeRouting.isGitHub) { return .gitHub } if !hosts.contains(where: ForgeRouting.isGitLabCom), GlabSignIn.isInstalled() == false { return .gitHub } - let config = (try? await Git.repositoryConfiguration(in: directory)) ?? [:] let context = try? await Git.repositoryContext(in: directory) - let remotes = config.filter { $0.key.hasPrefix("remote.") && $0.key.hasSuffix(".url") }.map(\.value) + let remotes = await configuredRemoteURLs(in: directory) switch ForgeRouting.decide(declared: nil, baseRemoteURL: context?.baseRemoteURL, remoteURLs: remotes) { case .forge(let decided): return decided case .askGlab(let host): return await GlabSignIn.shared.isSignedIn(to: host) ? .gitLab : .gitHub } } + /// Whether a repository has remotes on both forges and no `git.forge` settling it. `decide` + /// would quietly pick GitHub there, which is wrong for a GitLab project mirrored to GitHub, + /// so the owner is asked instead and the answer goes through `remember`. Never without glab: + /// GitLab is no answer there, and the settings screen hides the choice for the same reason. + public static func offersBoth(_ directory: String) async -> Bool { + guard GlabSignIn.isInstalled() else { return false } + let checkout = mainCheckout(Git.repositoryPaths(in: directory)) ?? directory + guard SettingsLoader.load(repo: checkout).forge == nil else { return false } + let hosts = await configuredRemoteURLs(in: directory).compactMap { GitHub.repositoryHost($0) } + let others = ForgeRouting.hostsBesideGitHub(hosts) + if others.contains(where: ForgeRouting.isGitLabCom) { return true } + for host in others where await GlabSignIn.shared.isSignedIn(to: host) { return true } + return false + } + + /// Writes the owner's answer to `.bloom/settings.local.toml` in the project's own checkout, + /// which `forge(for:)` reads for every workspace. Not the shared file: remotes are this + /// machine's git config, so a teammate with only the GitHub remote must not inherit it. + public static func remember(_ forge: Forge, for directory: String) throws { + let checkout = mainCheckout(Git.repositoryPaths(in: directory)) ?? directory + let path = (checkout as NSString).appendingPathComponent(".bloom/settings.local.toml") + var document = SettingsDocument(contentsOf: path) + SettingsWriter.apply(.forge(forge), to: &document) + SettingsWriter.prepareFolder(for: path, repo: checkout) + try document.write(to: path) + } + + private static func configuredRemoteURLs(in directory: String) async -> [String] { + let config = (try? await Git.repositoryConfiguration(in: directory)) ?? [:] + return config.filter { $0.key.hasPrefix("remote.") && $0.key.hasSuffix(".url") }.map(\.value) + } + /// The `url = ` values of a git config file, without git. Includes and `insteadOf` are not /// followed; anything they hide reaches the full resolution instead. static func remoteURLs(in config: String) -> [String] { diff --git a/Tests/BloomCoreTests/ForgeRoutingTests.swift b/Tests/BloomCoreTests/ForgeRoutingTests.swift index bbc5f9327..4605211d3 100644 --- a/Tests/BloomCoreTests/ForgeRoutingTests.swift +++ b/Tests/BloomCoreTests/ForgeRoutingTests.swift @@ -58,6 +58,35 @@ struct ForgeRoutingTests { #expect(await ForgeResolver.forge(for: repo.path) == .gitLab) } + @Test("only hosts beside a GitHub remote can make a repository ambiguous", arguments: [ + (hosts: ["github.com", "gitlab.com"], others: Set(["gitlab.com"])), + (hosts: ["github-work", "git.example.fr"], others: Set(["git.example.fr"])), + (hosts: ["gitlab.com", "git.example.fr"], others: Set()), + (hosts: ["github.com"], others: Set()), + ]) + func hostsBesideGitHub(hosts: [String], others: Set) { + #expect(ForgeRouting.hostsBesideGitHub(hosts) == others) + } + + @Test("a repository on both forges asks once, and the answer stays on this machine", .tags(.git)) + func choice() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["remote", "add", "origin", "git@gitlab.com:group/app.git"], cwd: repo.path) + try await Shell.check("git", ["remote", "add", "mirror", "git@github.com:acme/app.git"], cwd: repo.path) + let glabInstalled: @Sendable ([String], String?) async throws -> ShellResult = { _, _ in ShellResult(status: 1, stdout: "", stderr: "") } + let asked = await GitLab.$commandOverride.withValue(glabInstalled) { await ForgeResolver.offersBoth(repo.path) } + #expect(asked) + #expect(await ForgeResolver.forge(for: repo.path) == .gitHub) + + try ForgeResolver.remember(.gitLab, for: repo.path) + #expect(await ForgeResolver.forge(for: repo.path) == .gitLab) + let askedAgain = await GitLab.$commandOverride.withValue(glabInstalled) { await ForgeResolver.offersBoth(repo.path) } + #expect(askedAgain == false) + #expect(FileManager.default.fileExists(atPath: "\(repo.path)/.bloom/settings.local.toml")) + #expect(FileManager.default.fileExists(atPath: "\(repo.path)/.bloom/settings.toml") == false) + } + @Test("remote addresses are read from git's config file without git") func remoteURLs() { let config = "[remote \"origin\"]\n\turl = git@github.com:acme/app.git\n\tpushurl = x\n[remote \"up\"]\n\turl=\"https://gitlab.com/g/a\"\n" diff --git a/docs/GITLAB.md b/docs/GITLAB.md index bd7adba11..27ad41b53 100644 --- a/docs/GITLAB.md +++ b/docs/GITLAB.md @@ -21,6 +21,12 @@ It never changes the verdict or its sentence. | Another host, `glab` installed and `glab auth status --hostname ` succeeds | GitLab | | Anything else, including no remote | GitHub | +A project added with a GitHub remote and a GitLab one (gitlab.com, or a host `glab` is signed in +to) and no `git.forge` is asked which forge it uses. The answer is written as `git.forge` to +`.bloom/settings.local.toml` in the project's checkout, which every workspace reads: remotes are +this machine's git config, so the answer is not committed for the team. "Decide later" leaves the +table above in charge. + The answer is cached per directory until git's config or a settings file is written. Where the config file's own text settles it, which is every GitHub remote and every machine without `glab`, no process runs at all. The `glab` probe runs at most once per host at a time, a yes is kept for From ffac10025a8a9e155c346066f14895a4d7b6908f Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Fri, 9 Oct 2026 08:05:12 +0200 Subject: [PATCH 2/5] Say that GitHub is used until a forge is chosen --- Sources/Bloom/Views/RootView.swift | 6 +++--- docs/GITLAB.md | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/Bloom/Views/RootView.swift b/Sources/Bloom/Views/RootView.swift index 0bafdda85..078b30d57 100644 --- a/Sources/Bloom/Views/RootView.swift +++ b/Sources/Bloom/Views/RootView.swift @@ -153,7 +153,7 @@ struct RootView: View { Text(request.message) } // Asked when a project with remotes on both forges is added and no forge is declared. - // Cancelling leaves detection in charge, which picks GitHub, until the next time. + // Not now writes nothing: detection stays in charge, which picks GitHub, and asks again. .confirmationDialog( "Which forge does this project use?", isPresented: $app.forgeQuestion.isPresent(), @@ -162,9 +162,9 @@ struct RootView: View { ) { repo in Button(Forge.gitLab.name) { app.chooseForge(.gitLab, for: repo) } Button(Forge.gitHub.name) { app.chooseForge(.gitHub, for: repo) } - Button("Decide later", role: .cancel) {} + Button("Not now", role: .cancel) {} } message: { repo in - Text("\(repo.name) has remotes on GitHub and GitLab. Every workspace of the project will use the one you choose. You can change it in the project settings.") + Text("\(repo.name) has remotes on GitHub and GitLab. Every workspace of the project will use the one you choose, and you can change it in the project settings. Until you choose, Bloom uses GitHub.") } // The question asked before a session that is still working is closed. On the window for // the reason the archive confirmation above is: it is raised from the tab strip's close diff --git a/docs/GITLAB.md b/docs/GITLAB.md index 27ad41b53..a47967230 100644 --- a/docs/GITLAB.md +++ b/docs/GITLAB.md @@ -24,8 +24,9 @@ It never changes the verdict or its sentence. A project added with a GitHub remote and a GitLab one (gitlab.com, or a host `glab` is signed in to) and no `git.forge` is asked which forge it uses. The answer is written as `git.forge` to `.bloom/settings.local.toml` in the project's checkout, which every workspace reads: remotes are -this machine's git config, so the answer is not committed for the team. "Decide later" leaves the -table above in charge. +this machine's git config, so the answer is not committed for the team. "Not now" writes nothing: the +table above stays in charge, so GitHub, and the question comes back the next time the folder is +added. The answer is cached per directory until git's config or a settings file is written. Where the config file's own text settles it, which is every GitHub remote and every machine without `glab`, From a6b0e17d5a77f6c572aa5f55cbcb733d54f55f34 Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Fri, 9 Oct 2026 08:41:46 +0200 Subject: [PATCH 3/5] Ask for the forge from the new project window too, before New Workspace opens --- Sources/Bloom/State/AppModel+Projects.swift | 29 ++++++++++++++----- Sources/Bloom/State/AppModel.swift | 7 ++++- Sources/Bloom/Views/RootView.swift | 6 ++-- .../Views/Sidebar/StartProjectView.swift | 2 ++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Sources/Bloom/State/AppModel+Projects.swift b/Sources/Bloom/State/AppModel+Projects.swift index 8d8cbe439..e9d14850a 100644 --- a/Sources/Bloom/State/AppModel+Projects.swift +++ b/Sources/Bloom/State/AppModel+Projects.swift @@ -35,7 +35,7 @@ extension AppModel { guard let repo = await addKnownRepository(at: root) else { return nil } if focusing { await focus(repo) } // Not awaited: a glab probe to a host behind a VPN that is down takes seconds. - Task { if await ForgeResolver.offersBoth(repo.path) { forgeQuestion = repo } } + Task { await askForgeIfBoth(repo) } return repo case .refuse(let refusal): @@ -115,13 +115,28 @@ extension AppModel { )) } - /// The answer to `forgeQuestion`, kept for every workspace of the project. - func chooseForge(_ forge: Forge, for repo: Repo) { - do { - try ForgeResolver.remember(forge, for: repo.path) - } catch { - alert = BloomAlert(title: "Could not save the forge", message: error.readableMessage) + /// Asks which forge `repo` uses when its remotes are on both, and returns once it is answered. + func askForgeIfBoth(_ repo: Repo) async { + guard forgeQuestion == nil, await ForgeResolver.offersBoth(repo.path), forgeQuestion == nil + else { return } + await withCheckedContinuation { answered in + forgeQuestionAnswered = answered + forgeQuestion = repo + } + } + + /// The answer to `forgeQuestion`, kept for every workspace of the project. `nil` is Not now. + func answerForgeQuestion(_ forge: Forge?, for repo: Repo? = nil) { + if let forge, let repo { + do { + try ForgeResolver.remember(forge, for: repo.path) + } catch { + alert = BloomAlert(title: "Could not save the forge", message: error.readableMessage) + } } + let answered = forgeQuestionAnswered + forgeQuestionAnswered = nil + answered?.resume() } /// Called by `ProjectSetupSheet` once the folder really is a repository. diff --git a/Sources/Bloom/State/AppModel.swift b/Sources/Bloom/State/AppModel.swift index ce5f8e344..c7b6a482b 100644 --- a/Sources/Bloom/State/AppModel.swift +++ b/Sources/Bloom/State/AppModel.swift @@ -284,7 +284,12 @@ final class AppModel { var pendingScriptFailure: ScriptFailureRequest? /// A project just added with remotes on both GitHub and GitLab. RootView asks which one it /// uses. See `ForgeResolver.offersBoth`. - var forgeQuestion: Repo? + var forgeQuestion: Repo? { + // A dialog dismissed without its buttons must still let `askForgeIfBoth` return. + didSet { if forgeQuestion == nil { Task { self.answerForgeQuestion(nil) } } } + } + /// Resumed once `forgeQuestion` is answered, so the create window can wait for it. + @ObservationIgnored var forgeQuestionAnswered: CheckedContinuation? /// The transcript half of a search, one row per workspace. Held here rather than in `HomeView` /// for the same reason `homeFilter` is: the pane is destroyed and rebuilt every time the /// selection leaves Home, and a result list that had to be fetched again on the way back would diff --git a/Sources/Bloom/Views/RootView.swift b/Sources/Bloom/Views/RootView.swift index 078b30d57..25d412016 100644 --- a/Sources/Bloom/Views/RootView.swift +++ b/Sources/Bloom/Views/RootView.swift @@ -160,9 +160,9 @@ struct RootView: View { titleVisibility: .visible, presenting: app.forgeQuestion ) { repo in - Button(Forge.gitLab.name) { app.chooseForge(.gitLab, for: repo) } - Button(Forge.gitHub.name) { app.chooseForge(.gitHub, for: repo) } - Button("Not now", role: .cancel) {} + Button(Forge.gitLab.name) { app.answerForgeQuestion(.gitLab, for: repo) } + Button(Forge.gitHub.name) { app.answerForgeQuestion(.gitHub, for: repo) } + Button("Not now", role: .cancel) { app.answerForgeQuestion(nil) } } message: { repo in Text("\(repo.name) has remotes on GitHub and GitLab. Every workspace of the project will use the one you choose, and you can change it in the project settings. Until you choose, Bloom uses GitHub.") } diff --git a/Sources/Bloom/Views/Sidebar/StartProjectView.swift b/Sources/Bloom/Views/Sidebar/StartProjectView.swift index ad824a23a..1cd7521e1 100644 --- a/Sources/Bloom/Views/Sidebar/StartProjectView.swift +++ b/Sources/Bloom/Views/Sidebar/StartProjectView.swift @@ -702,6 +702,8 @@ struct StartProjectView: View { // asking its root view to present it. openWindow(id: BloomApp.mainWindowID) guard let repo else { return } + // Answered before New Workspace goes up: the main window shows one sheet at a time. + await app.askForgeIfBoth(repo) Task { @MainActor in await Task.yield() NotificationCenter.default.post(name: .bloomNewWorkspace, object: repo) From 4b20d5136c92613f52bc6d25887e77d76faf7b16 Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Tue, 13 Oct 2026 13:48:29 +0200 Subject: [PATCH 4/5] Ask glab again after it refused instead of settling on GitHub A self-hosted GitLab project opened with the VPN down, or before glab was signed in, was routed to GitHub until git's config or a settings file changed. A GitHub decided by a glab refusal now lasts as long as the refusal does. --- Sources/BloomCore/GitHub/ForgeRouting.swift | 36 +++++++++++++-------- docs/GITLAB.md | 3 +- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/Sources/BloomCore/GitHub/ForgeRouting.swift b/Sources/BloomCore/GitHub/ForgeRouting.swift index c67802079..6b55876ec 100644 --- a/Sources/BloomCore/GitHub/ForgeRouting.swift +++ b/Sources/BloomCore/GitHub/ForgeRouting.swift @@ -38,7 +38,8 @@ public enum ForgeRouting { } } -/// `ForgeRouting` applied to a directory, remembered until git's config or a settings file changes. +/// `ForgeRouting` applied to a directory, remembered until git's config or a settings file changes, +/// or for as long as a glab refusal lasts when that refusal is what made it GitHub. public enum ForgeResolver { private static let resolutions = ForgeResolutions() @@ -60,24 +61,30 @@ public enum ForgeResolver { let fingerprint = fingerprint(of: [configPath].compactMap { $0 } + SettingsLoader.candidatePaths(repo: checkout)) if let known = await resolutions.forge(for: directory, fingerprint: fingerprint) { return known } - let forge = await resolve(directory: directory, checkout: checkout, configPath: configPath) - await resolutions.store(forge, for: directory, fingerprint: fingerprint) + let (forge, lasting) = await resolve(directory: directory, checkout: checkout, configPath: configPath) + // GitHub because glab said no is kept no longer than that no: a VPN that was down comes back. + let expiry = lasting ? nil : ContinuousClock.now + GlabSignIn.refusalLifetime + await resolutions.store(forge, for: directory, fingerprint: fingerprint, expiry: expiry) return forge } - private static func resolve(directory: String, checkout: String, configPath: String?) async -> Forge { - if let declared = SettingsLoader.load(repo: checkout).forge { return declared } + private static func resolve( + directory: String, checkout: String, configPath: String? + ) async -> (forge: Forge, lasting: Bool) { + if let declared = SettingsLoader.load(repo: checkout).forge { return (declared, true) } // Settled from the config file's text where possible, so a GitHub user pays no process. let written = configPath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) } ?? "" let hosts = remoteURLs(in: written).compactMap { GitHub.repositoryHost($0) } - if hosts.contains(where: ForgeRouting.isGitHub) { return .gitHub } - if !hosts.contains(where: ForgeRouting.isGitLabCom), GlabSignIn.isInstalled() == false { return .gitHub } + if hosts.contains(where: ForgeRouting.isGitHub) { return (.gitHub, true) } + if !hosts.contains(where: ForgeRouting.isGitLabCom), GlabSignIn.isInstalled() == false { return (.gitHub, true) } let context = try? await Git.repositoryContext(in: directory) let remotes = await configuredRemoteURLs(in: directory) switch ForgeRouting.decide(declared: nil, baseRemoteURL: context?.baseRemoteURL, remoteURLs: remotes) { - case .forge(let decided): return decided - case .askGlab(let host): return await GlabSignIn.shared.isSignedIn(to: host) ? .gitLab : .gitHub + case .forge(let decided): return (decided, true) + case .askGlab(let host): + let signedIn = await GlabSignIn.shared.isSignedIn(to: host) + return (signedIn ? .gitLab : .gitHub, signedIn) } } @@ -135,15 +142,16 @@ public enum ForgeResolver { } private actor ForgeResolutions { - private var entries: [String: (fingerprint: [Date?], forge: Forge)] = [:] + private var entries: [String: (fingerprint: [Date?], forge: Forge, expiry: ContinuousClock.Instant?)] = [:] func forge(for directory: String, fingerprint: [Date?]) -> Forge? { - guard let entry = entries[directory], entry.fingerprint == fingerprint else { return nil } + guard let entry = entries[directory], entry.fingerprint == fingerprint, + entry.expiry.map({ ContinuousClock.now < $0 }) ?? true else { return nil } return entry.forge } - func store(_ forge: Forge, for directory: String, fingerprint: [Date?]) { - entries[directory] = (fingerprint, forge) + func store(_ forge: Forge, for directory: String, fingerprint: [Date?], expiry: ContinuousClock.Instant?) { + entries[directory] = (fingerprint, forge, expiry) } } @@ -152,7 +160,7 @@ private actor ForgeResolutions { actor GlabSignIn { static let shared = GlabSignIn() private static let timeout = Duration.seconds(10) - private static let refusalLifetime = Duration.seconds(300) + static let refusalLifetime = Duration.seconds(300) private var answers: [String: (signedIn: Bool, at: ContinuousClock.Instant)] = [:] private var probes: [String: Task] = [:] diff --git a/docs/GITLAB.md b/docs/GITLAB.md index a47967230..297e44f1b 100644 --- a/docs/GITLAB.md +++ b/docs/GITLAB.md @@ -28,7 +28,8 @@ this machine's git config, so the answer is not committed for the team. "Not now table above stays in charge, so GitHub, and the question comes back the next time the folder is added. -The answer is cached per directory until git's config or a settings file is written. Where the +The answer is cached per directory until git's config or a settings file is written, or for five +minutes when it is GitHub only because `glab` said no. Where the config file's own text settles it, which is every GitHub remote and every machine without `glab`, no process runs at all. The `glab` probe runs at most once per host at a time, a yes is kept for the launch and a no for five minutes. From d1a5032b1b6c100e107b709def15d13d817e8c81 Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Tue, 13 Oct 2026 14:28:07 +0200 Subject: [PATCH 5/5] Name a GitLab merge request !12 in the strip, the hover card and workspace_merge Five strings and the merge tool's answer still said #12, gh pr merge and GitHub for a merge request, where #12 is an issue. GitHub's wording is unchanged. --- .../Chrome/Window/WorkspaceHoverCardView.swift | 2 +- .../Views/Inspector/PullRequestSummary.swift | 8 ++++---- .../BloomCore/Bridge/WorkspaceMergeTool.swift | 10 ++++++---- .../Presentation/WorkspaceHoverCard.swift | 6 ++++-- .../WorkspaceMergeToolTests.swift | 18 ++++++++++++++++++ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Sources/Bloom/Views/Chrome/Window/WorkspaceHoverCardView.swift b/Sources/Bloom/Views/Chrome/Window/WorkspaceHoverCardView.swift index bfedee43c..9205ec27a 100644 --- a/Sources/Bloom/Views/Chrome/Window/WorkspaceHoverCardView.swift +++ b/Sources/Bloom/Views/Chrome/Window/WorkspaceHoverCardView.swift @@ -176,7 +176,7 @@ struct WorkspaceHoverCardView: View { // `verbatim`, for the reason `PullRequestBadge` writes down: an interpolated // `Int` inside a `LocalizedStringKey` is formatted for the locale, and a // machine set to Dutch drew "#2.631" for a pull request GitHub calls 2631. - Text(verbatim: "#\(pullRequest.number)") + Text(verbatim: pullRequest.forge.reference(pullRequest.number)) .font(Typo.caption) .monospacedDigit() .foregroundStyle(Palette.textSecondary) diff --git a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift index 1801d1626..b54f7ae5e 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift @@ -401,7 +401,7 @@ struct PullRequestSummary: View { } private var archiveHelp: String { - return "Remove this workspace's worktree. #\(pullRequest.number) is merged, so " + return "Remove this workspace's worktree. \(pullRequest.forge.reference(pullRequest.number)) is merged, so " + "this asks first only when something here exists nowhere else." } @@ -434,7 +434,7 @@ struct PullRequestSummary: View { branchActions.reason ?? "Ask this workspace's agent to \(pushLabel.lowercased()) branch " + "\(pullRequest.branch.isEmpty ? "this branch" : pullRequest.branch), so " - + "#\(pullRequest.number) is what is on this disk." + + "\(pullRequest.forge.reference(pullRequest.number)) is what is on this disk." ) } @@ -502,7 +502,7 @@ struct PullRequestSummary: View { .help( branchActions.reason ?? "Ask this workspace's agent to bring \(baseBranch) into this worktree and " - + "resolve the conflicts here. Nothing is pushed and #\(pullRequest.number)" + + "resolve the conflicts here. Nothing is pushed and \(pullRequest.forge.reference(pullRequest.number))" + " is not merged." ) } @@ -582,7 +582,7 @@ struct PullRequestSummary: View { /// request is this" without spending any of the strip's width on an answer the reader already /// has from the workspace name. private var helpText: String { - var text = "#\(pullRequest.number) \(pullRequest.title)" + var text = "\(pullRequest.forge.reference(pullRequest.number)) \(pullRequest.title)" if let detail = status.detail { text += "\n\(detail)" } if let reason = status.blockedReason { text += "\n\(reason)" } return text diff --git a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift index 03905d191..9342c5427 100644 --- a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift @@ -306,14 +306,16 @@ public struct WorkspaceMergeTool: BridgeToolHandling { } private func note(workspace: Workspace, pullRequest: PullRequest, chat: String) -> String { - """ + let forge = pullRequest.forge + return """ Nothing is merged. A turn has begun in '\(workspace.name)', in the chat '\(chat)': its \ - agent has been asked to merge #\(pullRequest.number) and it runs `gh pr merge` itself, \ + agent has been asked to merge \(forge.reference(pullRequest.number)) and it runs \ + `\(forge.command("merge"))` itself, \ where the owner can watch it and answer anything it asks. Bloom does not wait for that \ turn and there is no way to wait for it from here, so do not sit idle. The merge may still \ - not happen: GitHub is allowed to refuse, and the agent is told to stop and report a \ + not happen: \(forge.name) is allowed to refuse, and the agent is told to stop and report a \ refusal rather than force it. To find out what became of it, call workspace_list with \ - include_github and read the pull request's state. + include_github and read the \(forge.request)'s state. """ } diff --git a/Sources/BloomCore/Presentation/WorkspaceHoverCard.swift b/Sources/BloomCore/Presentation/WorkspaceHoverCard.swift index 0ca5b1dbe..416926ec6 100644 --- a/Sources/BloomCore/Presentation/WorkspaceHoverCard.swift +++ b/Sources/BloomCore/Presentation/WorkspaceHoverCard.swift @@ -33,11 +33,13 @@ public struct WorkspaceHoverCard: Sendable, Hashable { public var number: Int public var title: String public var url: String + public var forge: Forge - public init(number: Int, title: String, url: String) { + public init(number: Int, title: String, url: String, forge: Forge = .gitHub) { self.number = number self.title = title self.url = url + self.forge = forge } } @@ -100,7 +102,7 @@ public struct WorkspaceHoverCard: Sendable, Hashable { /// `PullRequestOwnership` drops it upstream, which is where that rule lives. static func reference(to pullRequest: PullRequest?) -> PullRequestRef? { pullRequest.map { - PullRequestRef(number: $0.number, title: $0.title, url: $0.url) + PullRequestRef(number: $0.number, title: $0.title, url: $0.url, forge: $0.forge) } } } diff --git a/Tests/BloomCoreTests/WorkspaceMergeToolTests.swift b/Tests/BloomCoreTests/WorkspaceMergeToolTests.swift index df30e0f8b..62d49d85d 100644 --- a/Tests/BloomCoreTests/WorkspaceMergeToolTests.swift +++ b/Tests/BloomCoreTests/WorkspaceMergeToolTests.swift @@ -565,6 +565,24 @@ struct WorkspaceMergeToolTests { #expect(!json["state"]!.stringValue!.contains("merge")) } + @Test("on GitLab the answer names the merge request, glab and GitLab") + func theAnswerOnGitLab() async throws { + let store = try makeTestStore("merge-answer-gitlab") + let target = try await workspace(in: store) + var mergeRequest = Self.open() + mergeRequest.forge = .gitLab + + let result = await tool(reading: .found(mergeRequest, local: nil)).call( + request(["workspace": .string(target.id.rawValue)]), as: .owner, store: store + ) + + let note = try #require(try answer(result)["note"]?.stringValue) + #expect(note.contains("asked to merge !42 and it runs `glab mr merge` itself")) + #expect(note.contains("GitLab is allowed to refuse")) + #expect(note.contains("read the merge request's state")) + #expect(!note.contains("gh ")) + } + @Test("squash is what it proposes, and the other two are carried through unaltered") func methods() async throws { let store = try makeTestStore("merge-methods")