Skip to content

Commit ba8a7ef

Browse files
committed
fix: add warning for protected branch
1 parent 92478e5 commit ba8a7ef

13 files changed

Lines changed: 347 additions & 5 deletions
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import Foundation
3+
import Combine
4+
5+
@MainActor
6+
final class ProtectedBranchCommitController: ObservableObject {
7+
struct Warning: Identifiable {
8+
let id = UUID()
9+
let branch: String
10+
let remoteBranch: String
11+
let status: BranchProtectionService.Status
12+
}
13+
14+
enum Decision {
15+
case cancel
16+
case commitAnyway
17+
case newBranch(String)
18+
}
19+
20+
@Published var warning: Warning?
21+
private var continuation: CheckedContinuation<Decision, Never>?
22+
private var isChecking = false
23+
24+
func authorize(
25+
repositoryURL: URL,
26+
settings: RepoSettings,
27+
credentials: GitProviderCredentialResolver,
28+
syncState: SyncState,
29+
undoManager: GitUndoManager?
30+
) async -> Bool {
31+
guard !isChecking else { return false }
32+
isChecking = true
33+
defer { isChecking = false }
34+
guard !settings.skipProtectedBranchCommitWarnings else { return true }
35+
let git = GitStatusService.shared
36+
guard let branch = await git.currentBranch(in: repositoryURL), !branch.isEmpty else { return true }
37+
let oldHead = await git.tipHash(for: "HEAD", in: repositoryURL)
38+
let remotes = await git.remotes(in: repositoryURL)
39+
guard !remotes.isEmpty else { return true }
40+
let upstream = await git.upstreamBranch(for: branch, in: repositoryURL)
41+
let target = Self.target(branch: branch, upstream: upstream, remotes: remotes, preferredRemote: settings.defaultRemoteName)
42+
guard let target else { return true }
43+
let remoteURL = await git.remoteURL(remote: target.remote, in: repositoryURL)
44+
guard let identity = credentials.remoteIdentity(for: remoteURL) else { return true }
45+
// API credentials also apply to SSH remotes; never send SSH keys to a provider API.
46+
let matching = credentials.accounts.filter {
47+
$0.provider == identity.provider && $0.hostURL.host()?.lowercased() == identity.hostURL.host()?.lowercased()
48+
&& ($0.transportProtocol == .https || !$0.scopes.isEmpty)
49+
}
50+
let preferenceKey = GitProviderAccountPreferenceKey.make(for: identity)
51+
let preferredID = credentials.preferredAccountIDsByRemoteIdentity[preferenceKey]
52+
let account = matching.first { $0.id == preferredID } ?? (matching.count == 1 ? matching.first : nil)
53+
let token = account.flatMap { try? credentials.tokenVault.readToken(for: $0) }
54+
let status = await BranchProtectionService().status(branch: target.branch, identity: identity, token: token)
55+
guard await git.currentBranch(in: repositoryURL) == branch,
56+
await git.tipHash(for: "HEAD", in: repositoryURL) == oldHead else {
57+
syncState.showInfo("The current branch changed. Review your changes and commit again.")
58+
return false
59+
}
60+
guard status == .protected || status == .unavailable else { return true }
61+
let decision = await withCheckedContinuation { continuation in
62+
self.continuation = continuation
63+
warning = Warning(branch: branch, remoteBranch: "\(target.remote)/\(target.branch)", status: status)
64+
}
65+
guard case .cancel = decision else {
66+
guard await git.currentBranch(in: repositoryURL) == branch,
67+
await git.tipHash(for: "HEAD", in: repositoryURL) == oldHead else {
68+
syncState.showInfo("The current branch changed. Review your changes and commit again.")
69+
return false
70+
}
71+
if case .newBranch(let name) = decision {
72+
guard await git.isValidBranchName(name, in: repositoryURL) else {
73+
syncState.showError("Enter a valid new branch name and try committing again.")
74+
return false
75+
}
76+
do {
77+
_ = try await git.createBranch(name: name, checkout: true, commit: nil, in: repositoryURL)
78+
if let oldHead {
79+
undoManager?.register(GitUndoEntry(
80+
repositoryURL: repositoryURL,
81+
label: "Create branch \(name)",
82+
undoOperation: .deleteLocalBranch(name: name, force: true, expectedTip: oldHead),
83+
redoOperation: .createLocalBranch(name: name, startPoint: oldHead, checkout: true)
84+
))
85+
}
86+
await syncState.refresh(repositoryURL: repositoryURL)
87+
NotificationCenter.default.post(name: .repositoryDidChange, object: nil, userInfo: ["repositoryURL": repositoryURL])
88+
} catch {
89+
syncState.showError(error.localizedDescription)
90+
return false
91+
}
92+
}
93+
return true
94+
}
95+
return false
96+
}
97+
98+
func finish(_ decision: Decision) {
99+
let pending = continuation
100+
continuation = nil
101+
warning = nil
102+
pending?.resume(returning: decision)
103+
}
104+
105+
static func target(branch: String, upstream: String?, remotes: [String], preferredRemote: String?) -> (remote: String, branch: String)? {
106+
if let upstream, let remote = remotes.sorted(by: { $0.count > $1.count }).first(where: { upstream.hasPrefix($0 + "/") }) {
107+
return (remote, String(upstream.dropFirst(remote.count + 1)))
108+
}
109+
let remote = preferredRemote.flatMap { remotes.contains($0) ? $0 : nil }
110+
?? (remotes.contains("origin") ? "origin" : remotes.first)
111+
return remote.map { ($0, branch) }
112+
}
113+
}

‎macgit/Models/RepoSettings.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ struct RepoSettings: Codable, Equatable {
3535
var refreshOnAppActiveOverride: Bool?
3636
var confirmDetachedHeadCheckout: Bool
3737
var confirmDestructiveStashActions: Bool
38+
var skipProtectedBranchCommitWarnings: Bool
3839
var useGlobalUserSettings: Bool
3940
var userName: String
4041
var userEmail: String
@@ -47,6 +48,7 @@ struct RepoSettings: Codable, Equatable {
4748
refreshOnAppActiveOverride: Bool? = nil,
4849
confirmDetachedHeadCheckout: Bool = true,
4950
confirmDestructiveStashActions: Bool = true,
51+
skipProtectedBranchCommitWarnings: Bool = false,
5052
useGlobalUserSettings: Bool = true,
5153
userName: String = "",
5254
userEmail: String = ""
@@ -58,6 +60,7 @@ struct RepoSettings: Codable, Equatable {
5860
self.refreshOnAppActiveOverride = refreshOnAppActiveOverride
5961
self.confirmDetachedHeadCheckout = confirmDetachedHeadCheckout
6062
self.confirmDestructiveStashActions = confirmDestructiveStashActions
63+
self.skipProtectedBranchCommitWarnings = skipProtectedBranchCommitWarnings
6164
self.useGlobalUserSettings = useGlobalUserSettings
6265
self.userName = userName
6366
self.userEmail = userEmail
@@ -72,6 +75,7 @@ struct RepoSettings: Codable, Equatable {
7275
refreshOnAppActiveOverride = try container.decodeIfPresent(Bool.self, forKey: .refreshOnAppActiveOverride)
7376
confirmDetachedHeadCheckout = try container.decodeIfPresent(Bool.self, forKey: .confirmDetachedHeadCheckout) ?? true
7477
confirmDestructiveStashActions = try container.decodeIfPresent(Bool.self, forKey: .confirmDestructiveStashActions) ?? true
78+
skipProtectedBranchCommitWarnings = try container.decodeIfPresent(Bool.self, forKey: .skipProtectedBranchCommitWarnings) ?? false
7579
useGlobalUserSettings = try container.decodeIfPresent(Bool.self, forKey: .useGlobalUserSettings) ?? true
7680
userName = try container.decodeIfPresent(String.self, forKey: .userName) ?? ""
7781
userEmail = try container.decodeIfPresent(String.self, forKey: .userEmail) ?? ""
@@ -100,6 +104,7 @@ struct RepoSettings: Codable, Equatable {
100104
case refreshOnAppActiveOverride = "refreshOnAppActive"
101105
case confirmDetachedHeadCheckout
102106
case confirmDestructiveStashActions
107+
case skipProtectedBranchCommitWarnings
103108
case useGlobalUserSettings
104109
case userName
105110
case userEmail
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import Foundation
3+
4+
struct BranchProtectionService {
5+
enum Status: Equatable {
6+
case protected
7+
case unprotected
8+
case unavailable
9+
case unsupported
10+
}
11+
12+
var httpClient: GitProviderHTTPClient = URLSessionGitProviderHTTPClient()
13+
14+
func status(
15+
branch: String,
16+
identity: GitRemoteIdentity,
17+
token: GitProviderToken?
18+
) async -> Status {
19+
let encode: (String) -> String = {
20+
$0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0
21+
}
22+
let endpoint: String
23+
switch identity.provider {
24+
case .github:
25+
endpoint = "https://api.github.com/repos/\(encode(identity.ownerPath))/\(encode(identity.repositoryName))/branches/\(encode(branch))"
26+
case .gitlab:
27+
endpoint = "\(identity.hostURL.absoluteString)/api/v4/projects/\(encode(identity.ownerPath + "/" + identity.repositoryName))/repository/branches/\(encode(branch))"
28+
case .bitbucket:
29+
return .unsupported
30+
}
31+
guard let url = URL(string: endpoint) else { return .unavailable }
32+
var request = URLRequest(url: url)
33+
request.timeoutInterval = 10
34+
request.setValue("application/json", forHTTPHeaderField: "Accept")
35+
if let token, !token.accessToken.isEmpty {
36+
request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization")
37+
}
38+
do {
39+
let (data, response) = try await httpClient.data(for: request)
40+
guard response.statusCode == 200 else { return .unavailable }
41+
let payload = try JSONDecoder().decode(Payload.self, from: data)
42+
return payload.protected ? .protected : .unprotected
43+
} catch {
44+
return .unavailable
45+
}
46+
}
47+
48+
private struct Payload: Decodable {
49+
let protected: Bool
50+
}
51+
}

‎macgit/ViewModels/RepositorySettingsDraft.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ struct RepositorySettingsDraft: Equatable {
3737
var refreshOnAppActiveOverride: Bool?
3838
var confirmDetachedHeadCheckout: Bool
3939
var confirmDestructiveStashActions: Bool
40+
var skipProtectedBranchCommitWarnings: Bool
4041
var useGlobalUserSettings: Bool
4142
var userName: String
4243
var userEmail: String
@@ -75,6 +76,7 @@ struct RepositorySettingsDraft: Equatable {
7576
refreshOnAppActiveOverride = settings.refreshOnAppActiveOverride
7677
confirmDetachedHeadCheckout = settings.confirmDetachedHeadCheckout
7778
confirmDestructiveStashActions = settings.confirmDestructiveStashActions
79+
skipProtectedBranchCommitWarnings = settings.skipProtectedBranchCommitWarnings
7880
useGlobalUserSettings = settings.useGlobalUserSettings
7981
userName = settings.userName
8082
userEmail = settings.userEmail
@@ -89,6 +91,7 @@ struct RepositorySettingsDraft: Equatable {
8991
refreshOnAppActiveOverride: refreshOnAppActiveOverride,
9092
confirmDetachedHeadCheckout: confirmDetachedHeadCheckout,
9193
confirmDestructiveStashActions: confirmDestructiveStashActions,
94+
skipProtectedBranchCommitWarnings: skipProtectedBranchCommitWarnings,
9295
useGlobalUserSettings: useGlobalUserSettings,
9396
userName: userName,
9497
userEmail: userEmail
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import SwiftUI
3+
4+
struct ProtectedBranchCommitSheet: View {
5+
let warning: ProtectedBranchCommitController.Warning
6+
@Binding var skipWarnings: Bool
7+
let onDecision: (ProtectedBranchCommitController.Decision) -> Void
8+
@State private var enteringBranchName = false
9+
@State private var branchName = ""
10+
@FocusState private var branchNameFocused: Bool
11+
12+
var body: some View {
13+
VStack(alignment: .leading, spacing: 16) {
14+
Label(warning.status == .protected ? "Commit to a protected branch?" : "Branch protection could not be checked", systemImage: "lock.trianglebadge.exclamationmark")
15+
.font(.headline)
16+
Text(warning.status == .protected
17+
? "\(warning.remoteBranch) has branch protection rules. You can commit your changes on a new branch or continue on \(warning.branch)."
18+
: "Commit+ could not read the rules for \(warning.remoteBranch). Check your connection and provider account access, or choose how to continue.")
19+
.foregroundStyle(.secondary)
20+
if enteringBranchName {
21+
TextField("New branch name", text: $branchName)
22+
.textFieldStyle(.roundedBorder)
23+
.focused($branchNameFocused)
24+
Text("The new branch starts from your current commit and keeps your staged and unstaged changes.")
25+
.font(.caption)
26+
.foregroundStyle(.secondary)
27+
}
28+
HStack {
29+
Button("Cancel", role: .cancel) { onDecision(.cancel) }
30+
.keyboardShortcut(.cancelAction)
31+
Spacer()
32+
Button("Commit Anyway") { onDecision(.commitAnyway) }
33+
Button(enteringBranchName ? "Create Branch and Commit" : "Commit in a New Branch") {
34+
if enteringBranchName {
35+
onDecision(.newBranch(branchName.trimmingCharacters(in: .whitespacesAndNewlines)))
36+
} else {
37+
enteringBranchName = true
38+
branchNameFocused = true
39+
}
40+
}
41+
.keyboardShortcut(.defaultAction)
42+
.disabled(enteringBranchName && branchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
43+
}
44+
Toggle("Skip protected branch commit warnings for this repository", isOn: $skipWarnings)
45+
.toggleStyle(.checkbox)
46+
Text("You can change this in Repository Settings → Advanced. Remote push rules still apply.")
47+
.font(.caption)
48+
.foregroundStyle(.secondary)
49+
}
50+
.padding(24)
51+
.frame(width: 560)
52+
}
53+
}

‎macgit/Views/Common/RepositorySettingsSheetView.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,11 @@ struct RepositorySettingsSheetView: View {
472472
.toggleStyle(.checkbox)
473473
.font(.system(size: 13))
474474

475+
Toggle("Skip protected branch commit warnings", isOn: binding(\.skipProtectedBranchCommitWarnings))
476+
.toggleStyle(.checkbox)
477+
.font(.system(size: 13))
478+
.help("Skip local commit warnings for this repository. Remote branch protection still applies when pushing.")
479+
475480
Toggle("Confirm destructive stash actions", isOn: binding(\.confirmDestructiveStashActions))
476481
.toggleStyle(.checkbox)
477482
.font(.system(size: 13))

‎macgit/Views/FileStatus/CommitSheetView.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ struct CommitSheetView: View {
2626
@Environment(\.dismiss) private var dismiss
2727
@AppStorage("commit.allChanges") private var commitAllChanges = false
2828
@ObservedObject var aiProviderController: AIProviderController
29-
@State private var message: String = ""
29+
@Binding var message: String
3030
@State private var messageSelection: TextSelection?
3131
@State private var errorMessage: String?
3232
@State private var showingError = false

‎macgit/Views/FileStatus/FileStatusView.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ struct FileStatusView: View {
4040
var canUpdateCurrentBranch = false
4141
var onRequestUpdateCurrentBranch: (CurrentBranchIntegrationStatus) -> Void = { _ in }
4242
var onRequestApplyStash: (String) -> Void = { _ in }
43+
var onAuthorizeCommit: () async -> Bool = { true }
4344
var onRequestPushAfterCommit: (String, String) async throws -> Void
4445
var onRunRepositoryOperation: RepositoryOperationRunner
4546

@@ -1245,6 +1246,7 @@ struct FileStatusView: View {
12451246
) async {
12461247
let message = commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)
12471248
guard !message.isEmpty || allowEmptyMessage else { return }
1249+
guard await onAuthorizeCommit() else { return }
12481250
do {
12491251
if commitChangedFiles {
12501252
try await GitStatusService.shared.stageAllChanges(in: repositoryURL)
@@ -1581,6 +1583,7 @@ struct FileStatusView: View {
15811583
}
15821584

15831585
private func commit(message: String) async {
1586+
guard await onAuthorizeCommit() else { return }
15841587
do {
15851588
let oldHead = await GitStatusService.shared.tipHash(for: "HEAD", in: repositoryURL)
15861589
try await GitStatusService.shared.commit(message: message, in: repositoryURL)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import SwiftUI
3+
4+
extension MainWindowView {
5+
func authorizeProtectedBranchCommit() async -> Bool {
6+
await protectedBranchCommitController.authorize(
7+
repositoryURL: repositoryURL,
8+
settings: repoSettingsStore.settings(for: repositoryURL.path, currentBranch: nil, remotes: []),
9+
credentials: providerCredentialResolver,
10+
syncState: syncState,
11+
undoManager: undoManager
12+
)
13+
}
14+
15+
func performPendingToolbarCommit() {
16+
guard let pending = pendingToolbarCommit else { return }
17+
pendingToolbarCommit = nil
18+
runRepositoryOperation("Committing changes...") {
19+
await commitFromToolbar(message: pending.message, commitAllChanges: pending.commitAllChanges)
20+
}
21+
}
22+
}

‎macgit/Views/MainWindow/MainWindowView+Sheets.swift‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ extension MainWindowView {
2121
var commitSheet: some View {
2222
CommitSheetView(
2323
aiProviderController: aiProviderController,
24+
message: $toolbarCommitMessage,
2425
repositoryURL: repositoryURL,
2526
hasStagedChanges: syncState.stagedBadgeCount > 0
2627
) { message, commitAllChanges in
27-
runRepositoryOperation("Committing changes...") {
28-
await commitFromToolbar(message: message, commitAllChanges: commitAllChanges)
29-
}
28+
pendingToolbarCommit = (message, commitAllChanges)
3029
}
3130
}
3231

0 commit comments

Comments
 (0)