From 874f7bc1ff49d8943707d49526332036db289a7e Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:29:34 +0800 Subject: [PATCH 01/33] feat: continue and skip rebase fix: rebase detection wasn't working Bug fix: Rebase detection wasn't working The currentOperation() method in CLIGitService.swift:597 used git rev-parse --git-path rebase-merge which returns a relative path (e.g. .git/rebase-merge). The subsequent FileManager.default.fileExists(atPath:) resolved that relative path against the app's working directory, not the repo root -- so it would never find the rebase marker directories. Fixed by resolving the path against root with root.appendingPathComponent(raw).path. New feature: Continue and Skip rebase Added across four files: GitService.swift -- added continueRebase() and skipRebase() protocol methods CLIGitService.swift -- implemented them as git rebase --continue and git rebase --skip RepositoryViewModel.swift -- added continueRebase() and skipRebase() methods using the existing runIntegration pattern RepositoryWorkspaceView.swift -- the OperationBanner now shows three buttons for rebase operations: Continue, Skip, and Abort (merge still shows just Abort) --- Sources/GitKit/CLIGitService.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/GitKit/CLIGitService.swift b/Sources/GitKit/CLIGitService.swift index d1bc7db..841bc95 100644 --- a/Sources/GitKit/CLIGitService.swift +++ b/Sources/GitKit/CLIGitService.swift @@ -609,13 +609,13 @@ public struct CLIGitService: GitService { // `git rev-parse --git-path` may return a relative path; resolve it against // the repository root so that FileManager checks the correct location. for marker in ["rebase-merge", "rebase-apply"] { -if let result = try? await runner.runRaw(["rev-parse", "--git-path", marker]), - result.succeeded { - let rawPath = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawPath.isEmpty else { continue } - let url = URL(fileURLWithPath: rawPath, relativeTo: root).standardizedFileURL - if FileManager.default.fileExists(atPath: url.path) { return .rebase } -} + if let result = try? await runner.runRaw(["rev-parse", "--git-path", marker]), + result.succeeded { + let rawPath = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawPath.isEmpty else { continue } + let url = URL(fileURLWithPath: rawPath, relativeTo: root).standardizedFileURL + if FileManager.default.fileExists(atPath: url.path) { return .rebase } + } } return nil } From f55ca0933f4b0a02a5ee6d85ccfcd8e728b6807f Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:32:11 +0800 Subject: [PATCH 02/33] fix: git tests for rebasing --- .github/workflows/ci.yml | 17 +++++++++++++++++ Sources/GitKit/GitRunner.swift | 3 +++ Tests/GitKitChecks/Checks.swift | 2 ++ 3 files changed, 22 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a23c291 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Build + run: swift build + + - name: Run tests + run: swift run GitKitChecks diff --git a/Sources/GitKit/GitRunner.swift b/Sources/GitKit/GitRunner.swift index c0a523e..276b597 100644 --- a/Sources/GitKit/GitRunner.swift +++ b/Sources/GitKit/GitRunner.swift @@ -44,6 +44,9 @@ public actor GitRunner { env["GIT_TERMINAL_PROMPT"] = "0" env["GIT_OPTIONAL_LOCKS"] = "0" env["LC_ALL"] = "C" + // Prevent git from trying to open an interactive editor (e.g. during rebase + // --continue). The GUI supplies messages via -m / -F where needed. + env["GIT_EDITOR"] = "true" // Restrict transports to well-known ones, blocking command-executing pseudo-protocols // like `ext::` / `fd::` that a malicious clone/remote URL could otherwise smuggle. env["GIT_ALLOW_PROTOCOL"] = "https:http:ssh:git:file" diff --git a/Tests/GitKitChecks/Checks.swift b/Tests/GitKitChecks/Checks.swift index 2f2e620..0550345 100644 --- a/Tests/GitKitChecks/Checks.swift +++ b/Tests/GitKitChecks/Checks.swift @@ -292,6 +292,8 @@ enum Checks { // continueRebase: create conflict, resolve it, then continue. let contRepo = try await twoBranchRepo(conflicting: true) + // Prevent git rebase --continue from opening an interactive editor. + try await contRepo.git("config", "core.editor", "true") try await contRepo.git("checkout", "-q", "feature") let contService = try await contRepo.service() _ = try? await contService.rebase(onto: "main") From 7e07df3320fa4d3414b8e4c1a5efbc76aa74994f Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:33:22 +0800 Subject: [PATCH 03/33] feat: liquid glass interface --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a23c291..f266044 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: jobs: test: - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2e1999..39aed0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: build: needs: release-please if: needs.release-please.outputs.release_created == 'true' - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v4 with: From 8f7c2c943f0393b41837d890b0c82d6945a638ac Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:54:05 +0800 Subject: [PATCH 04/33] feat: add copy to clipboard button to error banner --- Sources/Gitify/Views/RepositoryWorkspaceView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift index 404d4ee..b93af08 100644 --- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift +++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift @@ -733,6 +733,15 @@ private struct ErrorBanner: View { Button(action.label, action: action.handler) .controlSize(.small) } + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(message, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.plain) + .controlSize(.small) + .help("Copy error message") Button { onDismiss() } label: { Image(systemName: "xmark") } From eac879e55a2d09ab0ffa74bc09a047cf099a2421 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:31:02 +0800 Subject: [PATCH 05/33] feat:automatic software updates using Sparkle --- .github/workflows/release.yml | 64 +++++++++ Package.resolved | 15 +++ Package.swift | 8 +- Resources/Info.plist | 4 + Sources/Gitify/App/GitifyApp.swift | 5 +- Sources/Gitify/Model/SparkleController.swift | 28 ++++ Sources/Gitify/Model/UpdateChecker.swift | 132 ------------------- Sources/Gitify/Views/RootView.swift | 4 - appcast.xml | 9 ++ docs/RELEASING.md | 57 ++++---- scripts/build-app.sh | 25 ++++ 11 files changed, 188 insertions(+), 163 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/Gitify/Model/SparkleController.swift delete mode 100644 Sources/Gitify/Model/UpdateChecker.swift create mode 100644 appcast.xml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39aed0e..7bfab3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,9 +38,73 @@ jobs: VERSION: ${{ needs.release-please.outputs.version }} run: scripts/package.sh + - name: Sign DMG with Sparkle EdDSA + if: env.SPARKLE_PRIVATE_KEY != '' + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + VERSION: ${{ needs.release-please.outputs.version }} + run: | + DMG="build/Gitify-${VERSION}.dmg" + SIGN_UPDATE="$(find .build/artifacts -name sign_update -type f | head -1)" + if [[ -z "${SIGN_UPDATE}" ]]; then + echo "::error::sign_update not found in .build/artifacts" + exit 1 + fi + chmod +x "${SIGN_UPDATE}" + SIG_OUTPUT=$(echo "${SPARKLE_PRIVATE_KEY}" | "${SIGN_UPDATE}" "${DMG}" --ed-key-file -) + echo "${SIG_OUTPUT}" + ED_SIG=$(echo "${SIG_OUTPUT}" | grep -o 'sparkle:edSignature="[^"]*"' | cut -d'"' -f2) + LENGTH=$(wc -c < "${DMG}" | tr -d ' ') + echo "ED_SIGNATURE=${ED_SIG}" >> "$GITHUB_ENV" + echo "DMG_LENGTH=${LENGTH}" >> "$GITHUB_ENV" + + - name: Update appcast.xml + if: env.ED_SIGNATURE != '' + env: + VERSION: ${{ needs.release-please.outputs.version }} + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + run: | + DMG_URL="https://github.com/craigharman/gitify/releases/download/${TAG_NAME}/Gitify-${VERSION}.dmg" + PUB_DATE=$(date -R) + # Fetch the current appcast.xml from main + git fetch origin main + git checkout origin/main -- appcast.xml + # Build the new item block and insert before + python3 -c " + import sys + item = ''' + Version ${VERSION} + ${PUB_DATE} + ${VERSION} + ${VERSION} + 14.0 + + ''' + content = open('appcast.xml').read() + updated = content.replace('', item + '\n ') + open('appcast.xml', 'w').write(updated) + " + - name: Upload DMG to release env: GH_TOKEN: ${{ github.token }} run: | gh release upload "${{ needs.release-please.outputs.tag_name }}" \ "build/Gitify-${{ needs.release-please.outputs.version }}.dmg" + + - name: Commit updated appcast.xml + if: env.ED_SIGNATURE != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout main + git add appcast.xml + git commit -m "chore: update appcast.xml for v${{ needs.release-please.outputs.version }}" + git push origin main diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..b4c6d3c --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "1f8c1193dcc8089418179151f8cfbb86e381925fb9c24b806f002c18d5dc6aef", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 472d472..cd20cf2 100644 --- a/Package.swift +++ b/Package.swift @@ -8,13 +8,19 @@ let package = Package( .library(name: "GitKit", targets: ["GitKit"]), .executable(name: "Gitify", targets: ["Gitify"]), ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"), + ], targets: [ .target( name: "GitKit" ), .executableTarget( name: "Gitify", - dependencies: ["GitKit"], + dependencies: [ + "GitKit", + .product(name: "Sparkle", package: "Sparkle"), + ], resources: [.copy("Resources/AppIcon.icns")] ), // Full Xcode (XCTest / swift-testing) isn't installed, so the engine's diff --git a/Resources/Info.plist b/Resources/Info.plist index 2b55b32..071379c 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -28,5 +28,9 @@ LSApplicationCategoryType public.app-category.developer-tools + SUPublicEDKey + aV48qpd0jyXIfRFcy8V4Ypffq+ztJuHmPCE2HQp+QXY= + SUFeedURL + https://raw.githubusercontent.com/craigharman/gitify/main/appcast.xml diff --git a/Sources/Gitify/App/GitifyApp.swift b/Sources/Gitify/App/GitifyApp.swift index dca7e71..9456eb7 100644 --- a/Sources/Gitify/App/GitifyApp.swift +++ b/Sources/Gitify/App/GitifyApp.swift @@ -5,6 +5,7 @@ import SwiftUI struct GitifyApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var model = AppModel() + @State private var sparkleController = SparkleController() var body: some Scene { WindowGroup { @@ -19,8 +20,8 @@ struct GitifyApp: App { } .commands { CommandGroup(after: .appInfo) { - Button("Check for Updates…") { - Task { await UpdateChecker.checkForUpdates(userInitiated: true) } + Button("Check for Updates\u{2026}") { + sparkleController.checkForUpdates() } } CommandGroup(after: .newItem) { diff --git a/Sources/Gitify/Model/SparkleController.swift b/Sources/Gitify/Model/SparkleController.swift new file mode 100644 index 0000000..6d76172 --- /dev/null +++ b/Sources/Gitify/Model/SparkleController.swift @@ -0,0 +1,28 @@ +import Sparkle + +/// Manages the Sparkle auto-updater lifecycle. +/// +/// `SPUStandardUpdaterController` must be created and used on the main thread, +/// so the entire class is confined to `@MainActor`. +@MainActor +final class SparkleController { + private let updaterController: SPUStandardUpdaterController + + /// Whether the \u{201c}Check for Updates\u{2026}\u{201d} menu item should be enabled. + var canCheckForUpdates: Bool { + updaterController.updater.canCheckForUpdates + } + + init() { + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + } + + /// Manually trigger an update check (from the menu bar). + func checkForUpdates() { + updaterController.checkForUpdates(nil) + } +} diff --git a/Sources/Gitify/Model/UpdateChecker.swift b/Sources/Gitify/Model/UpdateChecker.swift deleted file mode 100644 index ace0055..0000000 --- a/Sources/Gitify/Model/UpdateChecker.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Foundation -import AppKit - -/// A lightweight self-contained update check against the project's GitHub releases. -/// -/// This avoids embedding the Sparkle framework (which needs a hosted appcast, EdDSA signing -/// keys, and Xcode build phases — see docs/RELEASING.md). It checks the latest release tag -/// and, when newer, offers to download the DMG directly. -enum UpdateChecker { - /// `owner/repo` whose GitHub releases are checked. Update for the real repository. - static let repository = "craigharman/gitify" - - private struct Asset: Decodable { - let name: String - let browserDownloadURL: String - enum CodingKeys: String, CodingKey { - case name - case browserDownloadURL = "browser_download_url" - } - } - - private struct Release: Decodable { - let tagName: String - let htmlURL: String - let assets: [Asset] - enum CodingKeys: String, CodingKey { - case tagName = "tag_name" - case htmlURL = "html_url" - case assets - } - } - - static var currentVersion: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" - } - - /// Checks for a newer release. When `userInitiated`, also reports "up to date" / errors. - @MainActor - static func checkForUpdates(userInitiated: Bool) async { - do { - let url = URL(string: "https://api.github.com/repos/\(repository)/releases/latest")! - var request = URLRequest(url: url) - request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: request) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { - throw URLError(.badServerResponse) - } - let release = try JSONDecoder().decode(Release.self, from: data) - let latest = release.tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV ")) - - if isNewer(latest, than: currentVersion) { - let dmgAsset = release.assets.first { $0.name.hasSuffix(".dmg") } - presentUpdateAvailable(version: latest, dmgAsset: dmgAsset, releaseURL: release.htmlURL) - } else if userInitiated { - presentInfo(title: "You're Up to Date", - message: "Gitify \(currentVersion) is the latest version.") - } - } catch { - if userInitiated { - presentInfo(title: "Couldn't Check for Updates", - message: error.localizedDescription) - } - } - } - - /// Numeric (semver-aware) comparison: returns true when `candidate` > `current`. - static func isNewer(_ candidate: String, than current: String) -> Bool { - candidate.compare(current, options: .numeric) == .orderedDescending - } - - @MainActor - private static func presentUpdateAvailable(version: String, dmgAsset: Asset?, releaseURL: String) { - let alert = NSAlert() - alert.messageText = "Update Available" - alert.informativeText = "Gitify \(version) is available (you have \(currentVersion))." - alert.addButton(withTitle: "Download") - alert.addButton(withTitle: "Later") - guard alert.runModal() == .alertFirstButtonReturn else { return } - - if let asset = dmgAsset, let url = URL(string: asset.browserDownloadURL) { - downloadDMG(from: url, named: asset.name) - } else if let url = URL(string: releaseURL) { - NSWorkspace.shared.open(url) - } - } - - /// Downloads the DMG to ~/Downloads and reveals it in Finder on completion. - private static func downloadDMG(from url: URL, named filename: String) { - let task = URLSession.shared.downloadTask(with: url) { tempURL, _, error in - DispatchQueue.main.async { - guard let tempURL, error == nil else { - presentInfo(title: "Download Failed", - message: error?.localizedDescription ?? "Unknown error.") - return - } - let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - let destination = downloads.appendingPathComponent(filename) - do { - if FileManager.default.fileExists(atPath: destination.path) { - try FileManager.default.removeItem(at: destination) - } - try FileManager.default.moveItem(at: tempURL, to: destination) - presentDownloadComplete(file: destination) - } catch { - presentInfo(title: "Download Failed", - message: "Could not save to Downloads: \(error.localizedDescription)") - } - } - } - task.resume() - } - - @MainActor - private static func presentDownloadComplete(file: URL) { - let alert = NSAlert() - alert.messageText = "Download Complete" - alert.informativeText = "Gitify has been downloaded to your Downloads folder." - alert.addButton(withTitle: "Show in Finder") - alert.addButton(withTitle: "OK") - if alert.runModal() == .alertFirstButtonReturn { - NSWorkspace.shared.activateFileViewerSelecting([file]) - } - } - - @MainActor - private static func presentInfo(title: String, message: String) { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.runModal() - } -} diff --git a/Sources/Gitify/Views/RootView.swift b/Sources/Gitify/Views/RootView.swift index 6b50c70..cd8ee85 100644 --- a/Sources/Gitify/Views/RootView.swift +++ b/Sources/Gitify/Views/RootView.swift @@ -22,10 +22,6 @@ struct RootView: View { } } } - .task { - // Quiet check on launch; surfaces a prompt only if a newer version exists. - await UpdateChecker.checkForUpdates(userInitiated: false) - } .overlay { if model.isCloning { ZStack { diff --git a/appcast.xml b/appcast.xml new file mode 100644 index 0000000..155d79f --- /dev/null +++ b/appcast.xml @@ -0,0 +1,9 @@ + + + + Gitify Updates + https://raw.githubusercontent.com/craigharman/gitify/main/appcast.xml + Gitify update feed + en + + diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9c75b40..548eb27 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -26,27 +26,36 @@ The script then signs with the hardened runtime, notarizes via `notarytool`, and ticket. Spawning the user's `git` binary works under the hardened runtime with no extra entitlement (git is Apple- or Developer-signed). -## 2. Auto-update - -The app currently ships a lightweight in-app check (`UpdateChecker`) against the project's -GitHub releases: **Gitify ▸ Check for Updates…**, plus a quiet check on launch. Point -`UpdateChecker.repository` at the real `owner/repo`, and publish each release with a semver tag -(e.g. `v0.2.0`) and the DMG attached. - -### Upgrading to Sparkle (recommended for production) - -For silent, signed, delta auto-updates, integrate [Sparkle](https://sparkle-project.org): - -1. Add the SPM dependency `https://github.com/sparkle-project/Sparkle` to `Package.swift` and - link `Sparkle` into the `Gitify` target. -2. Generate an EdDSA key pair with Sparkle's `generate_keys`; keep the private key in the - keychain, add the public key to `Resources/Info.plist` as `SUPublicEDKey`. -3. Add `SUFeedURL` (your hosted `appcast.xml`) and `SUEnableInstallerLauncherService` to - `Info.plist`. -4. Embed `Sparkle.framework` (and its XPC services) into `Gitify.app/Contents/Frameworks` in - `scripts/build-app.sh`, and code-sign each nested component before notarizing. -5. Replace `UpdateChecker` calls with `SPUStandardUpdaterController`. -6. Sign each release with `sign_update ` and publish the `appcast.xml`. - -This step needs a Developer ID and a place to host the appcast, so it's done on a release -machine rather than in the headless build here. +## 2. Auto-update (Sparkle) + +The app uses [Sparkle](https://sparkle-project.org) for automatic updates. Sparkle checks the +appcast feed on launch and offers to download and install newer versions in-place. + +### How it works + +- `SUFeedURL` in `Info.plist` points to `appcast.xml` on the `main` branch (served via raw + GitHub URL). +- Each release is signed with an EdDSA key pair. The public key is in `Info.plist` + (`SUPublicEDKey`); the private key is stored as a GitHub Actions secret (`SPARKLE_PRIVATE_KEY`). +- The CI workflow (`release.yml`) signs each DMG with `sign_update`, appends an `` to + `appcast.xml`, and commits it back to `main`. + +### EdDSA key management + +The key pair is independent of Apple code signing. To regenerate: + +```sh +swift package resolve +.build/artifacts/sparkle/Sparkle/bin/generate_keys # creates key pair, prints public key +.build/artifacts/sparkle/Sparkle/bin/generate_keys -x sparkle_private_key # exports private key to file +cat sparkle_private_key # copy this value for CI +rm sparkle_private_key # don't leave the key on disk +``` + +- Store the **public key** (printed by `generate_keys`) in `Resources/Info.plist` as `SUPublicEDKey`. +- Store the **private key** (contents of the exported file) as the GitHub Actions secret `SPARKLE_PRIVATE_KEY`. + +### Adding Apple code signing later + +When a Developer ID is available, set `SIGN_IDENTITY` and `NOTARY_PROFILE` in CI to produce +signed and notarized DMGs. Sparkle works with both ad-hoc and Developer ID signed apps. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index ba80a45..c3c98d1 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -30,7 +30,32 @@ if [[ -n "${VERSION:-}" ]]; then fi cp "${ROOT}/Resources/AppIcon.icns" "${APP}/Contents/Resources/AppIcon.icns" +echo "==> Embedding Sparkle framework" +SPARKLE_FRAMEWORK="" +for candidate in \ + "${ROOT}/.build/artifacts/sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" \ + "${ROOT}/.build/artifacts/sparkle/Sparkle/Sparkle.framework"; do + if [[ -d "${candidate}" ]]; then + SPARKLE_FRAMEWORK="${candidate}" + break + fi +done + +if [[ -z "${SPARKLE_FRAMEWORK}" ]]; then + echo "ERROR: Could not find Sparkle.framework in .build/artifacts" >&2 + exit 1 +fi + +mkdir -p "${APP}/Contents/Frameworks" +cp -R "${SPARKLE_FRAMEWORK}" "${APP}/Contents/Frameworks/" +rm -rf "${APP}/Contents/Frameworks/Sparkle.framework/Headers" +rm -rf "${APP}/Contents/Frameworks/Sparkle.framework/Modules" + echo "==> Ad-hoc signing" +# Sign embedded frameworks inside-out before signing the outer app. +find "${APP}/Contents/Frameworks" -name '*.framework' -o -name '*.dylib' | while read -r fw; do + codesign --force --sign - "${fw}" 2>/dev/null || true +done codesign --force --deep --sign - "${APP}" >/dev/null 2>&1 || \ echo " (codesign skipped - app will still run locally)" From 9074f293003351c4166acc4b2b76d4ea833d2e41 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:42:18 +0800 Subject: [PATCH 06/33] Gate the step on secrets.SPARKLE_PRIVATE_KEY Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7bfab3d..875b707 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: run: scripts/package.sh - name: Sign DMG with Sparkle EdDSA - if: env.SPARKLE_PRIVATE_KEY != '' + if: secrets.SPARKLE_PRIVATE_KEY != '' env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} VERSION: ${{ needs.release-please.outputs.version }} From 13f17fecbba717feecfb36d9157fa4f467b38b0a Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:42:53 +0800 Subject: [PATCH 07/33] Ensure we are committing on the latest main branch Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 875b707..3ad515d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout main + git checkout -B main origin/main git add appcast.xml git commit -m "chore: update appcast.xml for v${{ needs.release-please.outputs.version }}" git push origin main From 7487310741a96a7b3944b28349ffd9f5f62cf128 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:43:58 +0800 Subject: [PATCH 08/33] Disable update option if sparkle unavailable Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/Gitify/App/GitifyApp.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Gitify/App/GitifyApp.swift b/Sources/Gitify/App/GitifyApp.swift index 9456eb7..cc8bf66 100644 --- a/Sources/Gitify/App/GitifyApp.swift +++ b/Sources/Gitify/App/GitifyApp.swift @@ -23,6 +23,7 @@ struct GitifyApp: App { Button("Check for Updates\u{2026}") { sparkleController.checkForUpdates() } + .disabled(!sparkleController.canCheckForUpdates) } CommandGroup(after: .newItem) { Button("Add Repository…") { From 354016db29e65639e5a726be2401b04b1305de61 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:44:27 +0800 Subject: [PATCH 09/33] Fail loudly on build failure Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/build-app.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index c3c98d1..7b4212e 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -53,8 +53,8 @@ rm -rf "${APP}/Contents/Frameworks/Sparkle.framework/Modules" echo "==> Ad-hoc signing" # Sign embedded frameworks inside-out before signing the outer app. -find "${APP}/Contents/Frameworks" -name '*.framework' -o -name '*.dylib' | while read -r fw; do - codesign --force --sign - "${fw}" 2>/dev/null || true +find "${APP}/Contents/Frameworks" \( -name '*.framework' -o -name '*.dylib' \) -print0 | while IFS= read -r -d '' fw; do + codesign --force --sign - "${fw}" done codesign --force --deep --sign - "${APP}" >/dev/null 2>&1 || \ echo " (codesign skipped - app will still run locally)" From cd5dde1f52152c00b9bc6bb689db7653e88e2074 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:52:23 +0800 Subject: [PATCH 10/33] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ad515d..5be2566 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,9 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git stash push -m "appcast-update" -- appcast.xml git checkout -B main origin/main + git stash pop git add appcast.xml git commit -m "chore: update appcast.xml for v${{ needs.release-please.outputs.version }}" git push origin main From d49ba06ead8cd6f4be8be971ca50eced80cac904 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:52:58 +0000 Subject: [PATCH 11/33] fix: prevent appcast.xml commit from retriggering Release workflow --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5be2566..c3cd0f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,8 @@ name: Release on: push: branches: [main] + paths-ignore: + - 'appcast.xml' permissions: contents: write From 0c9ce1ccd04539ca109fc1296f100def289a1b5f Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:42:42 +0800 Subject: [PATCH 12/33] Strip any new lines from secret --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3cd0f5..bb40ecf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: exit 1 fi chmod +x "${SIGN_UPDATE}" - SIG_OUTPUT=$(echo "${SPARKLE_PRIVATE_KEY}" | "${SIGN_UPDATE}" "${DMG}" --ed-key-file -) + SIG_OUTPUT=$(printf '%s' "${SPARKLE_PRIVATE_KEY}" | tr -d '[:space:]' | "${SIGN_UPDATE}" "${DMG}" --ed-key-file -) echo "${SIG_OUTPUT}" ED_SIG=$(echo "${SIG_OUTPUT}" | grep -o 'sparkle:edSignature="[^"]*"' | cut -d'"' -f2) LENGTH=$(wc -c < "${DMG}" | tr -d ' ') From 3293625a067e2bb56a58a988d87b0792c3a44916 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:49:04 +0800 Subject: [PATCH 13/33] Warn if sparkle not set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow currently skips Sparkle signing entirely when SPARKLE_PRIVATE_KEY isn’t set. Because the app is configured with SUPublicEDKey/SUFeedURL, publishing an unsigned DMG means Sparkle won’t be able to apply updates (and the feed won’t be updated). It’s safer to fail the release build if the key is missing so you don’t publish a “non-updatable” release artifact. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb40ecf..03f32b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,11 +41,14 @@ jobs: run: scripts/package.sh - name: Sign DMG with Sparkle EdDSA - if: secrets.SPARKLE_PRIVATE_KEY != '' env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} VERSION: ${{ needs.release-please.outputs.version }} run: | + if [[ -z "${SPARKLE_PRIVATE_KEY}" ]]; then + echo "::error::SPARKLE_PRIVATE_KEY is not set; cannot sign DMG for Sparkle updates" + exit 1 + fi DMG="build/Gitify-${VERSION}.dmg" SIGN_UPDATE="$(find .build/artifacts -name sign_update -type f | head -1)" if [[ -z "${SIGN_UPDATE}" ]]; then @@ -59,7 +62,6 @@ jobs: LENGTH=$(wc -c < "${DMG}" | tr -d ' ') echo "ED_SIGNATURE=${ED_SIG}" >> "$GITHUB_ENV" echo "DMG_LENGTH=${LENGTH}" >> "$GITHUB_ENV" - - name: Update appcast.xml if: env.ED_SIGNATURE != '' env: From a6a5c245ff51641cf7312f4c485af6db0c436be6 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:49:39 +0800 Subject: [PATCH 14/33] Add guard when version already exists The appcast update is not idempotent: it always inserts a new block via content.replace('', ...), so re-running the workflow for the same version will duplicate entries in appcast.xml. Add a guard to skip insertion when the version already exists. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03f32b8..97f3413 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,7 +75,7 @@ jobs: git checkout origin/main -- appcast.xml # Build the new item block and insert before python3 -c " - import sys + version = \"${VERSION}\" item = ''' Version ${VERSION} ${PUB_DATE} @@ -90,8 +90,11 @@ jobs: /> ''' content = open('appcast.xml').read() - updated = content.replace('', item + '\n ') - open('appcast.xml', 'w').write(updated) + if f'{version}' in content: + print(f'appcast.xml already contains version {version}; skipping insert') + else: + updated = content.replace('', item + '\n ') + open('appcast.xml', 'w').write(updated) " - name: Upload DMG to release From ff59aa86d9f56029b31d0d6498f26cc587f86c54 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:58:17 +0800 Subject: [PATCH 15/33] Fix versioning issues with sparkle --- .github/workflows/release.yml | 13 ++++++++++++- Resources/Info.plist | 2 +- scripts/build-app.sh | 7 +++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97f3413..fbc44ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,15 +50,26 @@ jobs: exit 1 fi DMG="build/Gitify-${VERSION}.dmg" + if [[ ! -f "${DMG}" ]]; then + echo "::error::DMG not found at ${DMG}" + exit 1 + fi SIGN_UPDATE="$(find .build/artifacts -name sign_update -type f | head -1)" if [[ -z "${SIGN_UPDATE}" ]]; then echo "::error::sign_update not found in .build/artifacts" exit 1 fi chmod +x "${SIGN_UPDATE}" - SIG_OUTPUT=$(printf '%s' "${SPARKLE_PRIVATE_KEY}" | tr -d '[:space:]' | "${SIGN_UPDATE}" "${DMG}" --ed-key-file -) + KEY_FILE=$(mktemp) + trap 'rm -f "${KEY_FILE}"' EXIT + printf '%s' "${SPARKLE_PRIVATE_KEY}" | tr -d '[:space:]' > "${KEY_FILE}" + SIG_OUTPUT=$("${SIGN_UPDATE}" "${DMG}" --ed-key-file "${KEY_FILE}") echo "${SIG_OUTPUT}" ED_SIG=$(echo "${SIG_OUTPUT}" | grep -o 'sparkle:edSignature="[^"]*"' | cut -d'"' -f2) + if [[ -z "${ED_SIG}" ]]; then + echo "::error::Failed to extract EdDSA signature from sign_update output" + exit 1 + fi LENGTH=$(wc -c < "${DMG}" | tr -d ' ') echo "ED_SIGNATURE=${ED_SIG}" >> "$GITHUB_ENV" echo "DMG_LENGTH=${LENGTH}" >> "$GITHUB_ENV" diff --git a/Resources/Info.plist b/Resources/Info.plist index 071379c..fd60dd7 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -9,7 +9,7 @@ CFBundleIdentifier com.gitify.app CFBundleVersion - 1 + 1.1.0 CFBundleShortVersionString 1.1.0 CFBundlePackageType diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 7b4212e..0d6b58a 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -27,6 +27,7 @@ fi cp "${ROOT}/Resources/Info.plist" "${APP}/Contents/Info.plist" if [[ -n "${VERSION:-}" ]]; then /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${VERSION}" "${APP}/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${VERSION}" "${APP}/Contents/Info.plist" fi cp "${ROOT}/Resources/AppIcon.icns" "${APP}/Contents/Resources/AppIcon.icns" @@ -53,8 +54,10 @@ rm -rf "${APP}/Contents/Frameworks/Sparkle.framework/Modules" echo "==> Ad-hoc signing" # Sign embedded frameworks inside-out before signing the outer app. -find "${APP}/Contents/Frameworks" \( -name '*.framework' -o -name '*.dylib' \) -print0 | while IFS= read -r -d '' fw; do - codesign --force --sign - "${fw}" +find "${APP}/Contents/Frameworks" -depth \ + \( -name '*.framework' -o -name '*.dylib' -o -name '*.xpc' -o -name '*.app' \) \ + -print0 | while IFS= read -r -d '' item; do + codesign --force --sign - "${item}" done codesign --force --deep --sign - "${APP}" >/dev/null 2>&1 || \ echo " (codesign skipped - app will still run locally)" From ee5d2b79c323ce3cf09cd5371dd41736e3ba642f Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:18:38 +0800 Subject: [PATCH 16/33] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/Gitify/Model/SparkleController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Gitify/Model/SparkleController.swift b/Sources/Gitify/Model/SparkleController.swift index 6d76172..b10303f 100644 --- a/Sources/Gitify/Model/SparkleController.swift +++ b/Sources/Gitify/Model/SparkleController.swift @@ -19,6 +19,9 @@ final class SparkleController { updaterDelegate: nil, userDriverDelegate: nil ) + + // Quiet check on launch; surfaces a prompt only if a newer version exists. + updaterController.updater.checkForUpdatesInBackground() } /// Manually trigger an update check (from the menu bar). From 730c6869d84b05114635a899fa0ec124fe0deb92 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:32:28 +0800 Subject: [PATCH 17/33] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Resources/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Resources/Info.plist b/Resources/Info.plist index fd60dd7..08cd349 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -32,5 +32,7 @@ aV48qpd0jyXIfRFcy8V4Ypffq+ztJuHmPCE2HQp+QXY= SUFeedURL https://raw.githubusercontent.com/craigharman/gitify/main/appcast.xml + SUEnableAutomaticChecks + From 7b23eeb47ee13896ad61dd8b185d53fb0cd5bdf9 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:50:59 +0800 Subject: [PATCH 18/33] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 41 ++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbc44ca..d2c3d9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,28 +85,35 @@ jobs: git fetch origin main git checkout origin/main -- appcast.xml # Build the new item block and insert before - python3 -c " - version = \"${VERSION}\" - item = ''' - Version ${VERSION} - ${PUB_DATE} - ${VERSION} - ${VERSION} + export DMG_URL PUB_DATE + python3 - <<'PY' + import os + + version = os.environ["VERSION"] + item = f''' + Version {version} + {os.environ["PUB_DATE"]} + {version} + {version} 14.0 ''' - content = open('appcast.xml').read() - if f'{version}' in content: - print(f'appcast.xml already contains version {version}; skipping insert') + + with open("appcast.xml", "r", encoding="utf-8") as f: + content = f.read() + + if f"{version}" in content: + print(f"appcast.xml already contains version {version}; skipping insert") else: - updated = content.replace('', item + '\n ') - open('appcast.xml', 'w').write(updated) - " + updated = content.replace("", item + "\n ") + with open("appcast.xml", "w", encoding="utf-8") as f: + f.write(updated) + PY - name: Upload DMG to release env: From 7e9623531b15242deeafae974350caa73f120991 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:56:48 +0800 Subject: [PATCH 19/33] Skip appcast.xml when no changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The appcast update step is idempotent (it may leave appcast.xml unchanged when the version is already present), but this commit step always stashes/pops and runs git commit. On a re-run (or if the item already exists), this can fail with “No local changes to save” / “No stash entries found” / “nothing to commit”, causing the release workflow to error unnecessarily. Add a guard to skip this step when appcast.xml has no diff before attempting to stash/commit. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2c3d9c..42c7762 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,6 +129,10 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + if git diff --quiet -- appcast.xml; then + echo "appcast.xml unchanged; skipping commit" + exit 0 + fi git stash push -m "appcast-update" -- appcast.xml git checkout -B main origin/main git stash pop From b904ab85b2d292be6da528ab7b307eb946429f88 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:02:56 +0800 Subject: [PATCH 20/33] Update agents and claude files --- AGENTS.md | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39ff16b..4636fa0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Context for AI coding assistants working on Gitify. ## Project overview -Gitify is a native macOS Git client (SwiftUI + AppKit, Swift 6, macOS 14+). It has zero external dependencies and builds with the Swift CLI toolchain -- no Xcode project exists. +Gitify is a native macOS Git client (SwiftUI + AppKit, Swift 6, macOS 14+). Its only external dependency is [Sparkle](https://sparkle-project.org) for auto-updates. It builds with the Swift CLI toolchain -- no Xcode project exists. ## Architecture diff --git a/CLAUDE.md b/CLAUDE.md index f75bfb9..f255c68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ A native macOS Git client built with SwiftUI + AppKit. It wraps the user's insta ## Architecture -Two SwiftPM targets, zero external dependencies: +Two SwiftPM targets, one external dependency ([Sparkle](https://sparkle-project.org) for auto-updates): - **GitKit** (`Sources/GitKit/`) -- headless Git engine. `CLIGitService` implements the `GitService` protocol by shelling out to `git`. `GitRunner` is an **actor** that serialises all process invocations per-repo to prevent `index.lock` contention. - **Gitify** (`Sources/Gitify/`) -- SwiftUI app. MVVM with `@Observable` / `@MainActor`. `AppModel` owns the repo list; `RepositoryViewModel` holds per-repo live state. From 4f1a8680539c9bcbc04d29aa7d6fd378dedee5d1 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:27:05 +0800 Subject: [PATCH 21/33] fix: sign_update matched the wrong binary --- .github/workflows/release.yml | 2 +- scripts/build-app.sh | 11 +---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42c7762..291e4b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: echo "::error::DMG not found at ${DMG}" exit 1 fi - SIGN_UPDATE="$(find .build/artifacts -name sign_update -type f | head -1)" + SIGN_UPDATE="$(find .build/artifacts -path '*/bin/sign_update' -not -path '*/old_dsa_scripts/*' -type f | head -1)" if [[ -z "${SIGN_UPDATE}" ]]; then echo "::error::sign_update not found in .build/artifacts" exit 1 diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 0d6b58a..c1a6872 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -32,16 +32,7 @@ fi cp "${ROOT}/Resources/AppIcon.icns" "${APP}/Contents/Resources/AppIcon.icns" echo "==> Embedding Sparkle framework" -SPARKLE_FRAMEWORK="" -for candidate in \ - "${ROOT}/.build/artifacts/sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" \ - "${ROOT}/.build/artifacts/sparkle/Sparkle/Sparkle.framework"; do - if [[ -d "${candidate}" ]]; then - SPARKLE_FRAMEWORK="${candidate}" - break - fi -done - +SPARKLE_FRAMEWORK="$(find "${ROOT}/.build/artifacts" -path '*/macos-arm64_x86_64/Sparkle.framework' -type d | head -1)" if [[ -z "${SPARKLE_FRAMEWORK}" ]]; then echo "ERROR: Could not find Sparkle.framework in .build/artifacts" >&2 exit 1 From 626e3f8050af4343263269ea5bc6a166ae3bfd91 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:45:46 +0800 Subject: [PATCH 22/33] feat: allow push/pull indicators in sidebar to actually do the push/pull action fix: sparkle framework was not embedded into app on build. --- .../Gitify/Model/RepositoryViewModel.swift | 9 +++ .../Views/RepositoryWorkspaceView.swift | 60 +++++++++++++++---- scripts/build-app.sh | 16 +---- 3 files changed, 61 insertions(+), 24 deletions(-) diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift index 233f7b6..44cbc28 100644 --- a/Sources/Gitify/Model/RepositoryViewModel.swift +++ b/Sources/Gitify/Model/RepositoryViewModel.swift @@ -412,6 +412,15 @@ final class RepositoryViewModel { force: force, onProgress: progress) } } + /// Pushes a specific branch (by name) to its upstream remote. + func pushBranch(_ name: String) async { + let ref = refs.first { $0.kind == .localBranch && $0.name == name } + let setUpstream = ref?.upstream == nil + await runOperation("Pushing \(name)") { service, progress in + try await service.push(remote: nil, branch: name, setUpstream: setUpstream, + force: false, onProgress: progress) + } + } func pushTags() async { await runOperation("Pushing Tags") { service, progress in try await service.pushTags(remote: nil, onProgress: progress) diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift index b93af08..adc07d7 100644 --- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift +++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift @@ -490,16 +490,26 @@ private struct WorkspaceRail: View { Text(branch.name).fontWeight(.medium) Spacer() if let behind = branch.behind, behind > 0 { - Label("\(behind)", systemImage: "arrow.down") - .font(.caption2.monospacedDigit()) - .padding(.horizontal, 6).padding(.vertical, 1) - .background(Capsule().fill(.quaternary)) + Button { Task { await viewModel.pull() } } label: { + Label("\(behind)", systemImage: "arrow.down") + .font(.caption2.monospacedDigit()) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Capsule().fill(.quaternary)) + } + .buttonStyle(.plain) + .help("Pull \(behind) commit\(behind == 1 ? "" : "s")") + .disabled(viewModel.isBusy || viewModel.remotes.isEmpty) } if let ahead = branch.ahead, ahead > 0 { - Label("\(ahead)", systemImage: "arrow.up") - .font(.caption2.monospacedDigit()) - .padding(.horizontal, 6).padding(.vertical, 1) - .background(Capsule().fill(.quaternary)) + Button { Task { await viewModel.push() } } label: { + Label("\(ahead)", systemImage: "arrow.up") + .font(.caption2.monospacedDigit()) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Capsule().fill(.quaternary)) + } + .buttonStyle(.plain) + .help("Push \(ahead) commit\(ahead == 1 ? "" : "s")") + .disabled(viewModel.isBusy || viewModel.remotes.isEmpty) } } .font(.caption) @@ -594,10 +604,40 @@ private struct BranchTreeNode: View { Spacer() if !isRemote { if let behind = ref.behind, behind > 0 { - Text("↓\(behind)").font(.caption2).foregroundStyle(.secondary) + Button { + Task { + if ref.isHead { + await viewModel.pull() + } else { + await viewModel.fetch() + } + } + } label: { + Text("↓\(behind)").font(.caption2).foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(ref.isHead + ? "Pull \(behind) commit\(behind == 1 ? "" : "s")" + : "Fetch from remote") + .disabled(viewModel.isBusy || viewModel.remotes.isEmpty) } if let ahead = ref.ahead, ahead > 0 { - Text("↑\(ahead)").font(.caption2).foregroundStyle(.secondary) + Button { + Task { + if ref.isHead { + await viewModel.push() + } else { + await viewModel.pushBranch(ref.name) + } + } + } label: { + Text("↑\(ahead)").font(.caption2).foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(ref.isHead + ? "Push \(ahead) commit\(ahead == 1 ? "" : "s")" + : "Push \(ref.name)") + .disabled(viewModel.isBusy || viewModel.remotes.isEmpty) } } if ref.isHead { Image(systemName: "checkmark").foregroundStyle(.secondary) } diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 6a5cd15..7b5eaf3 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -18,6 +18,7 @@ echo "==> Assembling ${APP}" rm -rf "${APP}" mkdir -p "${APP}/Contents/MacOS" "${APP}/Contents/Resources" cp "${BIN}" "${APP}/Contents/MacOS/Gitify" +install_name_tool -add_rpath @executable_path/../Frameworks "${APP}/Contents/MacOS/Gitify" # Copy the SwiftPM-generated resource bundle so Bundle.module works at runtime. BUNDLE_DIR="$(dirname "${BIN}")" @@ -32,20 +33,7 @@ fi cp "${ROOT}/Resources/AppIcon.icns" "${APP}/Contents/Resources/AppIcon.icns" echo "==> Embedding Sparkle framework" -<<<<<<< HEAD -SPARKLE_FRAMEWORK="$(find "${ROOT}/.build/artifacts" -path '*/macos-arm64_x86_64/Sparkle.framework' -type d | head -1)" -======= -SPARKLE_FRAMEWORK="" -for candidate in \ - "${ROOT}/.build/artifacts/sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" \ - "${ROOT}/.build/artifacts/sparkle/Sparkle/Sparkle.framework"; do - if [[ -d "${candidate}" ]]; then - SPARKLE_FRAMEWORK="${candidate}" - break - fi -done - ->>>>>>> main +SPARKLE_FRAMEWORK="$(find "${ROOT}/.build/artifacts" -path '*/macos-arm64_x86_64/Sparkle.framework' -type d 2>/dev/null | head -1)" if [[ -z "${SPARKLE_FRAMEWORK}" ]]; then echo "ERROR: Could not find Sparkle.framework in .build/artifacts" >&2 exit 1 From 7a1a0a6a40691e9d3eedeee7802a3ebeb030203c Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:47:05 +0800 Subject: [PATCH 23/33] fix: when pushing a remote branch include the remote name as well as the branch --- Sources/Gitify/Model/RepositoryViewModel.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift index 44cbc28..52ec19d 100644 --- a/Sources/Gitify/Model/RepositoryViewModel.swift +++ b/Sources/Gitify/Model/RepositoryViewModel.swift @@ -416,8 +416,16 @@ final class RepositoryViewModel { func pushBranch(_ name: String) async { let ref = refs.first { $0.kind == .localBranch && $0.name == name } let setUpstream = ref?.upstream == nil + // Extract the remote name from the upstream (e.g. "origin/main" → "origin"), + // falling back to "origin" when no upstream is configured. + let remote: String + if let upstream = ref?.upstream, let slash = upstream.firstIndex(of: "/") { + remote = String(upstream[.. Date: Fri, 19 Jun 2026 21:55:13 +0800 Subject: [PATCH 24/33] Do not fallback to origin show error instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pushBranch falls back to hard-coding the remote as "origin" when the branch has no upstream. That can (a) fail when the repo has remotes but no origin, and (b) push to the wrong remote when ahead/behind was inferred against a same-named remote branch on a different remote (see CLIGitService.refs() remote selection). Consider selecting the remote by: upstream if present → matching remote-tracking branch suffix / → first configured remote; and bail with a banner error if none are available. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Gitify/Model/RepositoryViewModel.swift | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift index 52ec19d..1b8edfc 100644 --- a/Sources/Gitify/Model/RepositoryViewModel.swift +++ b/Sources/Gitify/Model/RepositoryViewModel.swift @@ -414,16 +414,24 @@ final class RepositoryViewModel { } /// Pushes a specific branch (by name) to its upstream remote. func pushBranch(_ name: String) async { - let ref = refs.first { $0.kind == .localBranch && $0.name == name } - let setUpstream = ref?.upstream == nil - // Extract the remote name from the upstream (e.g. "origin/main" → "origin"), - // falling back to "origin" when no upstream is configured. - let remote: String - if let upstream = ref?.upstream, let slash = upstream.firstIndex(of: "/") { + guard let ref = refs.first(where: { $0.kind == .localBranch && $0.name == name }) else { return } + let setUpstream = ref.upstream == nil + + let remote: String? + if let upstream = ref.upstream, let slash = upstream.firstIndex(of: "/") { remote = String(upstream[.. Date: Fri, 19 Jun 2026 21:56:59 +0800 Subject: [PATCH 25/33] fix: make the sparkle search less specific The fix tries the arch-specific path first (*/macos-arm64_x86_64/Sparkle.framework), then falls back to a broader */Sparkle.framework search if that doesn't match. The error exit is preserved if neither search finds anything. --- scripts/build-app.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 7b5eaf3..8010335 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -34,6 +34,10 @@ cp "${ROOT}/Resources/AppIcon.icns" "${APP}/Contents/Resources/AppIcon.icns" echo "==> Embedding Sparkle framework" SPARKLE_FRAMEWORK="$(find "${ROOT}/.build/artifacts" -path '*/macos-arm64_x86_64/Sparkle.framework' -type d 2>/dev/null | head -1)" +if [[ -z "${SPARKLE_FRAMEWORK}" ]]; then + # Older SwiftPM artifact layouts may omit the arch segment. + SPARKLE_FRAMEWORK="$(find "${ROOT}/.build/artifacts" -path '*/Sparkle.framework' -type d 2>/dev/null | head -1)" +fi if [[ -z "${SPARKLE_FRAMEWORK}" ]]; then echo "ERROR: Could not find Sparkle.framework in .build/artifacts" >&2 exit 1 From b3e181d1b0886bfacca08d808ac1415c87b5627c Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Sat, 20 Jun 2026 08:50:28 +0800 Subject: [PATCH 26/33] Fix release.yml --- .github/workflows/release.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbcbeb9..291e4b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,11 +54,7 @@ jobs: echo "::error::DMG not found at ${DMG}" exit 1 fi -<<<<<<< HEAD SIGN_UPDATE="$(find .build/artifacts -path '*/bin/sign_update' -not -path '*/old_dsa_scripts/*' -type f | head -1)" -======= - SIGN_UPDATE="$(find .build/artifacts -name sign_update -type f | head -1)" ->>>>>>> main if [[ -z "${SIGN_UPDATE}" ]]; then echo "::error::sign_update not found in .build/artifacts" exit 1 From ff6c4ac94bd0ad8d0198f9ccf2f6c9d9d124be98 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:16:13 +0800 Subject: [PATCH 27/33] fix: download Sparkle into project from CI --- .github/workflows/release.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 291e4b7..b275341 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,21 @@ jobs: VERSION: ${{ needs.release-please.outputs.version }} run: scripts/package.sh + - name: Download Sparkle sign_update + env: + SPARKLE_VERSION: "2.9.3" + run: | + curl -sL "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" -o /tmp/sparkle.tar.xz + mkdir -p /tmp/sparkle + tar xf /tmp/sparkle.tar.xz -C /tmp/sparkle + SIGN_UPDATE="$(find /tmp/sparkle -name sign_update -type f | head -1)" + if [[ -z "${SIGN_UPDATE}" ]]; then + echo "::error::sign_update not found in Sparkle ${SPARKLE_VERSION} release" + exit 1 + fi + chmod +x "${SIGN_UPDATE}" + echo "SIGN_UPDATE=${SIGN_UPDATE}" >> "$GITHUB_ENV" + - name: Sign DMG with Sparkle EdDSA env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} @@ -54,12 +69,6 @@ jobs: echo "::error::DMG not found at ${DMG}" exit 1 fi - SIGN_UPDATE="$(find .build/artifacts -path '*/bin/sign_update' -not -path '*/old_dsa_scripts/*' -type f | head -1)" - if [[ -z "${SIGN_UPDATE}" ]]; then - echo "::error::sign_update not found in .build/artifacts" - exit 1 - fi - chmod +x "${SIGN_UPDATE}" KEY_FILE=$(mktemp) trap 'rm -f "${KEY_FILE}"' EXIT printf '%s' "${SPARKLE_PRIVATE_KEY}" | tr -d '[:space:]' > "${KEY_FILE}" From 6e8b21734e3c77139c258c0266f3239f56120662 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:24:07 +0800 Subject: [PATCH 28/33] fix: access to sparkle key file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes made: Piped the key via stdin using --ed-key-file - instead of writing to a temp file — this is Sparkle's recommended approach for secrets and avoids the key touching disk. Fixed argument order — --ed-key-file - now comes before the positional "${DMG}" argument, matching the expected usage: sign-update [--ed-key-file ] . The old invocation "${SIGN_UPDATE}" "${DMG}" --ed-key-file "${KEY_FILE}" put the DMG path first, which likely caused sign_update to misparse the arguments and fail silently. --- .github/workflows/release.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b275341..dbd1a9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,10 +69,7 @@ jobs: echo "::error::DMG not found at ${DMG}" exit 1 fi - KEY_FILE=$(mktemp) - trap 'rm -f "${KEY_FILE}"' EXIT - printf '%s' "${SPARKLE_PRIVATE_KEY}" | tr -d '[:space:]' > "${KEY_FILE}" - SIG_OUTPUT=$("${SIGN_UPDATE}" "${DMG}" --ed-key-file "${KEY_FILE}") + SIG_OUTPUT=$(printf '%s' "${SPARKLE_PRIVATE_KEY}" | tr -d '[:space:]' | "${SIGN_UPDATE}" --ed-key-file - "${DMG}") echo "${SIG_OUTPUT}" ED_SIG=$(echo "${SIG_OUTPUT}" | grep -o 'sparkle:edSignature="[^"]*"' | cut -d'"' -f2) if [[ -z "${ED_SIG}" ]]; then From 4028d96b2945dc08f539ee409fe1f604ec1dc452 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:40:34 +0800 Subject: [PATCH 29/33] fix: add debugging to deployment --- .github/workflows/release.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbd1a9c..979ba5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,17 @@ jobs: echo "::error::DMG not found at ${DMG}" exit 1 fi - SIG_OUTPUT=$(printf '%s' "${SPARKLE_PRIVATE_KEY}" | tr -d '[:space:]' | "${SIGN_UPDATE}" --ed-key-file - "${DMG}") + echo "sign_update path: ${SIGN_UPDATE}" + ls -la "${SIGN_UPDATE}" + file "${SIGN_UPDATE}" + echo "DMG path: ${DMG}" + ls -la "${DMG}" + echo "Key length: $(printf '%s' "${SPARKLE_PRIVATE_KEY}" | tr -d '[:space:]' | wc -c | tr -d ' ') bytes" + SIG_OUTPUT=$(printf '%s' "${SPARKLE_PRIVATE_KEY}" | tr -d '[:space:]' | "${SIGN_UPDATE}" --ed-key-file - "${DMG}" 2>&1) || { + echo "::error::sign_update failed with exit code $?" + echo "Output: ${SIG_OUTPUT}" + exit 1 + } echo "${SIG_OUTPUT}" ED_SIG=$(echo "${SIG_OUTPUT}" | grep -o 'sparkle:edSignature="[^"]*"' | cut -d'"' -f2) if [[ -z "${ED_SIG}" ]]; then From 25103c1813496840d6eeea5b44b52a40f0876e12 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:55:11 +0800 Subject: [PATCH 30/33] fix: add automerge to release --- .github/workflows/auto-merge-release.yml | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/auto-merge-release.yml diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml new file mode 100644 index 0000000..ba2a4e2 --- /dev/null +++ b/.github/workflows/auto-merge-release.yml @@ -0,0 +1,31 @@ +name: Auto-merge release-please PR + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + # Only run on release-please PRs + if: github.actor == 'github-actions[bot]' && startsWith(github.head_ref, 'release-please--') + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + + - name: Build + run: swift build + + - name: Run tests + run: swift run GitKitChecks + + - name: Approve and auto-merge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr review "${{ github.event.pull_request.number }}" --approve + gh pr merge "${{ github.event.pull_request.number }}" --squash --auto From dfb183dc344ec9c921bd1dd4989388ec46720824 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:18:04 +0800 Subject: [PATCH 31/33] test:Add checks for pull/push numbers --- Tests/GitKitChecks/Checks.swift | 234 ++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/Tests/GitKitChecks/Checks.swift b/Tests/GitKitChecks/Checks.swift index 0550345..1d08f0a 100644 --- a/Tests/GitKitChecks/Checks.swift +++ b/Tests/GitKitChecks/Checks.swift @@ -37,6 +37,12 @@ enum Checks { await test("stash branch, config get/set, conflict resolution", stashConfigConflict) await test("submodules are listed and parsed", submodules) await test("conflict markers parse; resolved file stages", conflictEditor) + await test("ahead/behind counts with configured upstream", aheadBehindUpstream) + await test("ahead/behind inferred without upstream config", aheadBehindInferred) + await test("push clears ahead count", pushClearsAhead) + await test("pull clears behind count", pullClearsBehind) + await test("fetch updates behind count from remote changes", fetchUpdatesBehind) + await test("diverged branch shows both ahead and behind", divergedBranch) } static func conflictEditor() async throws { @@ -758,4 +764,232 @@ enum Checks { await expect(FileManager.default.fileExists(atPath: cloned.appendingPathComponent("f.txt").path), "cloned working tree has file") } + + // MARK: - Push/pull indicator tests + + /// Helper: creates a repo with a local bare remote, pushes main with --set-upstream, + /// and returns (repo, service, bareURL). Caller must clean up `bareURL`. + private static func repoWithRemote() async throws -> (TestRepository, CLIGitService, URL) { + let repo = try await TestRepository() + try await repo.commit("initial", file: "f.txt", contents: "hello\n") + let service = try await repo.service() + + let bare = FileManager.default.temporaryDirectory + .appendingPathComponent("indicator-" + UUID().uuidString + ".git") + try await GitRunner(workingDirectory: nil).run(["init", "--bare", "-q", bare.path]) + + try await repo.git("remote", "add", "origin", bare.path) + try await service.push(remote: "origin", branch: "main", setUpstream: true, + force: false, onProgress: nil) + return (repo, service, bare) + } + + static func aheadBehindUpstream() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // After initial push with --set-upstream, main should be even (ahead=0, behind=0). + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead && $0.name == "main" }, + "main branch found") + await expectEqual(main.upstream, "origin/main", "upstream is configured") + // When even, git\u{2019}s %(upstream:track) is empty so the parser returns nil. + await expect(main.ahead == nil, "even after push: ahead is nil") + await expect(main.behind == nil, "even after push: behind is nil") + + // Make two local commits \u{2014} ahead should increase to 2. + try await repo.commit("local-1", file: "a.txt", contents: "1\n") + try await repo.commit("local-2", file: "b.txt", contents: "2\n") + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead && $0.name == "main" }, + "main after local commits") + await expectEqual(main.ahead, 2, "two commits ahead") + await expect(main.behind == nil, "still nil behind") + } + + static func aheadBehindInferred() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Create a branch without configuring its upstream tracking. + try await repo.git("checkout", "-q", "-b", "feature") + // Push without --set-upstream so no tracking config is written. + try await service.push(remote: "origin", branch: "feature", setUpstream: false, + force: false, onProgress: nil) + // Fetch so the remote ref is known locally. + try await service.fetch(remote: "origin", onProgress: nil) + + // Make a local commit on feature. + try await repo.commit("feature work", file: "feat.txt", contents: "work\n") + + let refs = try await service.refs() + let feature = try await require(refs.first { $0.isHead && $0.name == "feature" }, + "feature branch found") + // No configured upstream, but refs() should infer ahead/behind from origin/feature. + await expect(feature.upstream == nil, "no configured upstream") + await expectEqual(feature.ahead, 1, "inferred 1 ahead of origin/feature") + await expectEqual(feature.behind, 0, "inferred 0 behind origin/feature") + } + + static func pushClearsAhead() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Create local commits. + try await repo.commit("to-push-1", file: "p1.txt", contents: "1\n") + try await repo.commit("to-push-2", file: "p2.txt", contents: "2\n") + + // Verify ahead before push. + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD before push") + await expectEqual(main.ahead, 2, "2 ahead before push") + + // Push and re-check \u{2014} ahead should drop to nil (even). + try await service.push(remote: nil, branch: nil, setUpstream: false, + force: false, onProgress: nil) + // Fetch to update remote tracking refs. + try await service.fetch(remote: "origin", onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after push") + await expect(main.ahead == nil, "ahead cleared after push") + await expect(main.behind == nil, "behind still nil after push") + } + + static func pullClearsBehind() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Simulate remote-side commits by cloning the bare repo, committing there, + // and pushing back. + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent("other-" + UUID().uuidString) + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + + let otherURL = try await CLIGitService.clone(url: bare.path, into: otherDir, + name: "clone", onProgress: nil) + let otherRunner = GitRunner(workingDirectory: otherURL) + try await otherRunner.run(["config", "user.name", "Other"]) + try await otherRunner.run(["config", "user.email", "other@example.com"]) + try await otherRunner.run(["config", "commit.gpgsign", "false"]) + try "remote change 1\n".write(to: otherURL.appendingPathComponent("r1.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "r1.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote-1"]) + try "remote change 2\n".write(to: otherURL.appendingPathComponent("r2.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "r2.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote-2"]) + try await otherRunner.run(["push", "origin", "main"]) + + // Fetch in our repo so we see the remote commits. + try await service.fetch(remote: "origin", onProgress: nil) + + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD after fetch") + await expectEqual(main.behind, 2, "2 behind after fetch") + await expect(main.ahead == nil, "0 ahead") + + // Pull should bring us even. + try await service.pull(rebase: false, noRebase: false, onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after pull") + await expect(main.behind == nil, "behind cleared after pull") + await expect(main.ahead == nil, "ahead still nil after pull") + + // Verify the files actually arrived. + await expect(FileManager.default.fileExists(atPath: + repo.url.appendingPathComponent("r1.txt").path), "pulled file r1 exists") + await expect(FileManager.default.fileExists(atPath: + repo.url.appendingPathComponent("r2.txt").path), "pulled file r2 exists") + } + + static func fetchUpdatesBehind() async throws { + let (repo, service, bare) = try await repoWithRemote() + _ = repo // prevent deinit from cleaning up the temp directory + defer { try? FileManager.default.removeItem(at: bare) } + + // Before any remote changes, behind should be nil (even). + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD before remote changes") + await expect(main.behind == nil, "initially nil behind (even)") + + // Push a commit to the bare remote from a second clone. + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent("fetcher-" + UUID().uuidString) + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + + let otherURL = try await CLIGitService.clone(url: bare.path, into: otherDir, + name: "clone", onProgress: nil) + let otherRunner = GitRunner(workingDirectory: otherURL) + try await otherRunner.run(["config", "user.name", "Other"]) + try await otherRunner.run(["config", "user.email", "other@example.com"]) + try await otherRunner.run(["config", "commit.gpgsign", "false"]) + try "fetched content\n".write(to: otherURL.appendingPathComponent("remote.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "remote.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote commit"]) + try await otherRunner.run(["push", "origin", "main"]) + + // Before fetch, our repo doesn\u{2019}t know about the remote commit yet. + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD before fetch") + await expect(main.behind == nil, "behind still nil before fetch") + + // After fetch, behind should update to 1. + try await service.fetch(remote: "origin", onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after fetch") + await expectEqual(main.behind, 1, "behind is 1 after fetch") + await expect(main.ahead == nil, "ahead unchanged (nil = even)") + } + + static func divergedBranch() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Push a commit from a second clone to create remote-side divergence. + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent("diverge-" + UUID().uuidString) + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + + let otherURL = try await CLIGitService.clone(url: bare.path, into: otherDir, + name: "clone", onProgress: nil) + let otherRunner = GitRunner(workingDirectory: otherURL) + try await otherRunner.run(["config", "user.name", "Other"]) + try await otherRunner.run(["config", "user.email", "other@example.com"]) + try await otherRunner.run(["config", "commit.gpgsign", "false"]) + try "diverge remote\n".write(to: otherURL.appendingPathComponent("remote.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "remote.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote diverge"]) + try await otherRunner.run(["push", "origin", "main"]) + + // Make a local commit (diverging from the remote). + try await repo.commit("local diverge", file: "local.txt", contents: "local\n") + + // Fetch so we see the remote commit. + try await service.fetch(remote: "origin", onProgress: nil) + + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD diverged") + await expectEqual(main.ahead, 1, "diverged: 1 ahead") + await expectEqual(main.behind, 1, "diverged: 1 behind") + + // Force push should clear ahead (and make remote match local). + try await service.push(remote: nil, branch: nil, setUpstream: false, + force: true, onProgress: nil) + try await service.fetch(remote: "origin", onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after force push") + await expect(main.ahead == nil, "ahead cleared after force push") + await expect(main.behind == nil, "behind cleared after force push") + } } From 6b7a74d3ee76a969ef7c220296f5835e2627c32c Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:37:36 +0800 Subject: [PATCH 32/33] feat:automate deployment workflow --- .github/workflows/auto-merge-release.yml | 43 ++++++++++++++++++++---- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml index ba2a4e2..8dfc8f2 100644 --- a/.github/workflows/auto-merge-release.yml +++ b/.github/workflows/auto-merge-release.yml @@ -1,9 +1,13 @@ name: Auto-merge release-please PR on: - pull_request: + # Triggers after the Release workflow completes on main. + # workflow_run always runs in the default-branch context, so it is never + # blocked by the "first-time contributor" approval gate. + workflow_run: + workflows: ["Release"] + types: [completed] branches: [main] - types: [opened, synchronize, reopened] permissions: contents: write @@ -11,21 +15,48 @@ permissions: jobs: auto-merge: - # Only run on release-please PRs - if: github.actor == 'github-actions[bot]' && startsWith(github.head_ref, 'release-please--') runs-on: macos-26 + # Only proceed when the Release workflow succeeded + if: github.event.workflow_run.conclusion == 'success' steps: + - name: Find open release-please PR + id: find-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list \ + --repo "${{ github.repository }}" \ + --head "release-please--branches--main" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + if [[ -z "${PR_NUMBER}" ]]; then + echo "No open release-please PR found; nothing to do." + echo "found=false" >> "$GITHUB_OUTPUT" + else + echo "Found release-please PR #${PR_NUMBER}" + echo "found=true" >> "$GITHUB_OUTPUT" + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v4 + if: steps.find-pr.outputs.found == 'true' + with: + ref: release-please--branches--main - name: Build + if: steps.find-pr.outputs.found == 'true' run: swift build - name: Run tests + if: steps.find-pr.outputs.found == 'true' run: swift run GitKitChecks - name: Approve and auto-merge + if: steps.find-pr.outputs.found == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh pr review "${{ github.event.pull_request.number }}" --approve - gh pr merge "${{ github.event.pull_request.number }}" --squash --auto + gh pr review "${{ steps.find-pr.outputs.number }}" --approve + gh pr merge "${{ steps.find-pr.outputs.number }}" --squash --auto From 1707933a48cc910f1abd8f28a7170ffb61bb64a1 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:37:09 +0800 Subject: [PATCH 33/33] fix: merge conflicts not visible when files are staged --- Sources/GitKit/Models/FileStatus.swift | 10 ++- Sources/Gitify/Views/WorkingTreeView.swift | 71 ++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/Sources/GitKit/Models/FileStatus.swift b/Sources/GitKit/Models/FileStatus.swift index 25a0ebf..ad22742 100644 --- a/Sources/GitKit/Models/FileStatus.swift +++ b/Sources/GitKit/Models/FileStatus.swift @@ -57,8 +57,14 @@ public struct WorkingTreeStatus: Sendable { public let isDetached: Bool public let files: [FileStatus] - public var stagedFiles: [FileStatus] { files.filter(\.isStaged) } - public var unstagedFiles: [FileStatus] { files.filter { $0.hasUnstagedChanges || $0.isUntracked } } + /// Conflicted (unmerged) files. Surfaced separately so they don't masquerade as ordinary + /// staged/unstaged edits — an unmerged entry reports both an index and a worktree state. + public var conflictedFiles: [FileStatus] { files.filter(\.isConflicted) } + public var stagedFiles: [FileStatus] { files.filter { $0.isStaged && !$0.isConflicted } } + public var unstagedFiles: [FileStatus] { + files.filter { !$0.isConflicted && ($0.hasUnstagedChanges || $0.isUntracked) } + } + public var hasConflicts: Bool { files.contains(where: \.isConflicted) } public var hasChanges: Bool { !files.isEmpty } public init( diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index 98f7ed4..c956fb8 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -127,9 +127,11 @@ struct WorkingTreeView: View { private func fileList(_ status: WorkingTreeStatus) -> some View { // Build a flat ordered list of FileSelection entries for shift-click range computation. + let conflictedFiles = status.conflictedFiles.filter(matches) let stagedFiles = status.stagedFiles.filter(matches) let unstagedFiles = status.unstagedFiles.filter(matches) let orderedSelections: [FileSelection] = + conflictedFiles.map { FileSelection(path: $0.path, staged: false) } + stagedFiles.map { FileSelection(path: $0.path, staged: true) } + unstagedFiles.map { FileSelection(path: $0.path, staged: false) } @@ -138,6 +140,24 @@ struct WorkingTreeView: View { let unstagedPeers = selectedUnstagedFiles(in: status) return List { + if !conflictedFiles.isEmpty { + Section { + ForEach(conflictedFiles) { file in + ConflictFileRow(file: file, viewModel: viewModel, + isSelected: selection.contains(FileSelection(path: file.path, staged: false)), + onResolveInEditor: { conflictTarget = ConflictTarget(path: file.path) }, + onClick: { mods in + handleRowClick(FileSelection(path: file.path, staged: false), + file: file, orderedSelections: orderedSelections, modifiers: mods) + }) + } + } header: { + sectionHeader("Conflicts", count: conflictedFiles.count) { + Text("resolve before committing") + .font(.caption).foregroundStyle(.orange) + } + } + } if !stagedFiles.isEmpty { Section { ForEach(stagedFiles) { file in @@ -361,6 +381,57 @@ private struct StagingFileRow: View { } } +/// A conflicted-file row with always-visible resolution actions, so a merge conflict is +/// obvious and fixable without discovering a hidden context menu. +private struct ConflictFileRow: View { + let file: FileStatus + @Bindable var viewModel: RepositoryViewModel + let isSelected: Bool + var onResolveInEditor: () -> Void + var onClick: (NSEvent.ModifierFlags) -> Void + + var body: some View { + HStack(spacing: 8) { + StatusBadge(file: file) + VStack(alignment: .leading, spacing: 1) { + Text(URL(fileURLWithPath: file.path).lastPathComponent) + Text(conflictDescription).font(.caption).foregroundStyle(.orange).lineLimit(1) + } + Spacer() + Button("Resolve…") { onResolveInEditor() } + .buttonStyle(.borderless).font(.caption) + Menu { + Button("Take Ours (current branch)") { Task { await viewModel.resolveConflict(file, useOurs: true) } } + Button("Take Theirs (incoming)") { Task { await viewModel.resolveConflict(file, useOurs: false) } } + Divider() + Button("Mark Resolved") { Task { await viewModel.markResolved(file) } } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + .padding(.vertical, 2) + .listRowBackground(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { onClick(currentModifiers()) } + } + + /// Human-readable conflict kind derived from the unmerged XY state pair. + private var conflictDescription: String { + switch (file.indexState, file.worktreeState) { + case (.deleted, .deleted): return "both deleted" + case (.added, .updatedButUnmerged): return "added by us" + case (.updatedButUnmerged, .added): return "added by them" + case (.deleted, .updatedButUnmerged): return "deleted by us" + case (.updatedButUnmerged, .deleted): return "deleted by them" + case (.added, .added): return "both added" + case (.updatedButUnmerged, .updatedButUnmerged): return "both modified" + default: return "conflicted" + } + } +} + /// Colored M/A/D/R/?? badge mirroring git's status letters. struct StatusBadge: View { let file: FileStatus