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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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
From 7a88a41de8708346e8924d304fd9458a7426c143 Mon Sep 17 00:00:00 2001
From: Craig Harman <324026+craigharman@users.noreply.github.com>
Date: Tue, 23 Jun 2026 16:29:07 +0800
Subject: [PATCH 34/41] fix: use token for deployment
---
.github/workflows/release.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 979ba5c..01d978c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,6 +22,7 @@ jobs:
id: release
with:
release-type: simple
+ token: ${{ secrets.RELEASE_TOKEN }}
build:
needs: release-please
From a0addf8762edb7abcb8d6ef011cfbb434cf2b355 Mon Sep 17 00:00:00 2001
From: Craig Harman <324026+craigharman@users.noreply.github.com>
Date: Wed, 24 Jun 2026 14:48:56 +0800
Subject: [PATCH 35/41] feat: add remote SSH git repository management
---
Sources/GitKit/CLIGitService.swift | 17 +-
Sources/GitKit/GitRunner.swift | 2 +-
Sources/GitKit/GitRunnerProtocol.swift | 36 +++
Sources/GitKit/RepositoryWatcher.swift | 8 +-
Sources/GitKit/SSHGitRunner.swift | 216 ++++++++++++++
Sources/GitKit/SSHRepositoryWatcher.swift | 64 +++++
Sources/Gitify/Model/AppModel.swift | 60 ++++
Sources/Gitify/Model/RepositoryRef.swift | 16 +-
.../Gitify/Model/RepositoryViewModel.swift | 28 +-
Sources/Gitify/Model/SSHRepo.swift | 12 +
Sources/Gitify/Model/SSHScanner.swift | 264 ++++++++++++++++++
Sources/Gitify/Model/SSHServer.swift | 51 ++++
Sources/Gitify/Views/AddSSHServerSheet.swift | 122 ++++++++
.../Views/RepositoryWorkspaceView.swift | 5 +-
Sources/Gitify/Views/RootView.swift | 3 +
Sources/Gitify/Views/SSHServersView.swift | 161 +++++++++++
16 files changed, 1052 insertions(+), 13 deletions(-)
create mode 100644 Sources/GitKit/GitRunnerProtocol.swift
create mode 100644 Sources/GitKit/SSHGitRunner.swift
create mode 100644 Sources/GitKit/SSHRepositoryWatcher.swift
create mode 100644 Sources/Gitify/Model/SSHRepo.swift
create mode 100644 Sources/Gitify/Model/SSHScanner.swift
create mode 100644 Sources/Gitify/Model/SSHServer.swift
create mode 100644 Sources/Gitify/Views/AddSSHServerSheet.swift
create mode 100644 Sources/Gitify/Views/SSHServersView.swift
diff --git a/Sources/GitKit/CLIGitService.swift b/Sources/GitKit/CLIGitService.swift
index 841bc95..7b63978 100644
--- a/Sources/GitKit/CLIGitService.swift
+++ b/Sources/GitKit/CLIGitService.swift
@@ -9,10 +9,10 @@ private let recordSeparator = "\u{1e}" // between records
/// `GitService` backed by the user's installed `git` binary.
public struct CLIGitService: GitService {
public let root: URL
- private let runner: GitRunner
+ private let runner: any GitRunnerProtocol
- /// Creates a service rooted at `directory`. Resolves to the repository top-level so
- /// that all paths are reported relative to a stable root.
+ /// Creates a service rooted at a local `directory`. Resolves to the repository
+ /// top-level so that all paths are reported relative to a stable root.
public init(directory: URL, executablePath: String? = nil) async throws {
let probe = GitRunner(workingDirectory: directory, executablePath: executablePath)
let result = try await probe.runRaw(["rev-parse", "--show-toplevel"])
@@ -24,6 +24,17 @@ public struct CLIGitService: GitService {
self.runner = GitRunner(workingDirectory: self.root, executablePath: executablePath)
}
+ /// Creates a service for a remote repository accessed over SSH.
+ public init(sshRunner: SSHGitRunner) async throws {
+ let result = try await sshRunner.runRaw(["rev-parse", "--show-toplevel"])
+ guard result.succeeded else {
+ throw GitError.notARepository(sshRunner.remotePath)
+ }
+ let top = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.root = URL(fileURLWithPath: top)
+ self.runner = sshRunner
+ }
+
/// Discovers the repository top-level for an arbitrary path, returning nil if none.
public static func repositoryRoot(for directory: URL, executablePath: String? = nil) async -> URL? {
let probe = GitRunner(workingDirectory: directory, executablePath: executablePath)
diff --git a/Sources/GitKit/GitRunner.swift b/Sources/GitKit/GitRunner.swift
index 276b597..0079486 100644
--- a/Sources/GitKit/GitRunner.swift
+++ b/Sources/GitKit/GitRunner.swift
@@ -32,7 +32,7 @@ private final class CaptureBox: @unchecked Sendable {
///
/// An actor enforces one mutating invocation at a time per repository, avoiding
/// `index.lock` contention while still allowing concurrency across repositories.
-public actor GitRunner {
+public actor GitRunner: GitRunnerProtocol {
/// Working directory for invocations. `nil` for repo-independent commands (e.g. clone, version).
public let workingDirectory: URL?
private let executableURL: URL
diff --git a/Sources/GitKit/GitRunnerProtocol.swift b/Sources/GitKit/GitRunnerProtocol.swift
new file mode 100644
index 0000000..426cde8
--- /dev/null
+++ b/Sources/GitKit/GitRunnerProtocol.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+/// Abstraction over local and SSH-based git command execution.
+///
+/// Both `GitRunner` (local) and `SSHGitRunner` (remote) conform to this protocol,
+/// allowing `CLIGitService` to execute git commands transparently against either.
+public protocol GitRunnerProtocol: Actor {
+ /// Working directory for the repository. For local runners this is a filesystem URL;
+ /// for SSH runners it\u{2019}s a synthetic URL representing the remote path.
+ var workingDirectory: URL? { get }
+
+ /// Runs `git ` and returns the result without throwing on non-zero exit.
+ func runRaw(_ arguments: [String]) async throws -> GitProcessResult
+
+ /// Runs `git `, throwing on non-zero exit. Returns stdout as `Data`.
+ @discardableResult
+ func run(_ arguments: [String]) async throws -> Data
+
+ /// Runs `git `, throwing on non-zero exit. Returns stdout as a string.
+ func runString(_ arguments: [String]) async throws -> String
+
+ /// Runs `git `, streaming stderr progress lines to `onProgress`.
+ /// Throws on non-zero exit.
+ @discardableResult
+ func runStreaming(
+ _ arguments: [String],
+ onProgress: (@Sendable (String) -> Void)?
+ ) async throws -> GitProcessResult
+}
+
+public extension GitRunnerProtocol {
+ @discardableResult
+ func runStreaming(_ arguments: [String]) async throws -> GitProcessResult {
+ try await runStreaming(arguments, onProgress: nil)
+ }
+}
diff --git a/Sources/GitKit/RepositoryWatcher.swift b/Sources/GitKit/RepositoryWatcher.swift
index f0fd43f..2aa2229 100644
--- a/Sources/GitKit/RepositoryWatcher.swift
+++ b/Sources/GitKit/RepositoryWatcher.swift
@@ -1,9 +1,15 @@
import Foundation
+/// Common interface for local (FSEvents) and remote (SSH polling) repository watchers.
+public protocol RepositoryWatching: AnyObject, Sendable {
+ func start()
+ func stop()
+}
+
/// Watches a repository's working tree (and its `.git` directory) with FSEvents, invoking
/// `onChange` when anything changes on disk. Events are coalesced by FSEvents' latency
/// window; callers should debounce further before doing expensive work.
-public final class RepositoryWatcher: @unchecked Sendable {
+public final class RepositoryWatcher: RepositoryWatching, @unchecked Sendable {
private let path: String
private let onChange: @Sendable () -> Void
private let queue = DispatchQueue(label: "com.gitify.repository-watcher", qos: .utility)
diff --git a/Sources/GitKit/SSHGitRunner.swift b/Sources/GitKit/SSHGitRunner.swift
new file mode 100644
index 0000000..923a88a
--- /dev/null
+++ b/Sources/GitKit/SSHGitRunner.swift
@@ -0,0 +1,216 @@
+import Foundation
+
+/// Mutable storage shared across pipe-reading dispatch closures. Access is synchronized
+/// by the surrounding `DispatchGroup`, so `@unchecked Sendable` is sound.
+private final class SSHCaptureBox: @unchecked Sendable {
+ var stdout = Data()
+ var stderr = Data()
+ var stderrText = ""
+}
+
+/// Executes git commands on a remote server over SSH.
+///
+/// Drop-in replacement for `GitRunner` that wraps every git invocation in
+/// `ssh user@host git -C /path `. The user\u{2019}s SSH config, agent, and keys
+/// handle authentication transparently.
+///
+/// Like `GitRunner`, the actor serialises invocations to prevent `index.lock` contention
+/// on the remote repository.
+public actor SSHGitRunner: GitRunnerProtocol {
+ public let workingDirectory: URL?
+ public let remotePath: String
+ private let host: String
+ private let user: String
+ private let port: Int
+ private let sshURL: URL
+
+ public init(host: String, user: String = "git", port: Int = 22, remotePath: String) {
+ self.host = host
+ self.user = user
+ self.port = port
+ self.remotePath = remotePath
+ // Synthetic URL for identity/display purposes only.
+ self.workingDirectory = URL(fileURLWithPath: remotePath)
+ self.sshURL = URL(fileURLWithPath: SSHGitRunner.sshPath)
+ }
+
+ private static let sshPath: String = {
+ let candidates = ["/usr/bin/ssh", "/opt/homebrew/bin/ssh", "/usr/local/bin/ssh"]
+ for path in candidates where FileManager.default.isExecutableFile(atPath: path) {
+ return path
+ }
+ return "/usr/bin/ssh"
+ }()
+
+ /// Builds the SSH arguments for running a git command remotely.
+ ///
+ /// The remote command is constructed as a single shell string so that git format
+ /// strings containing control characters (\u{1f}, \u{1e}) survive the SSH transport.
+ /// Environment variables are set via `env` to match the local `GitRunner` behaviour.
+ private func sshArgs(gitArguments: [String]) -> [String] {
+ let quotedPath = shellQuote(remotePath)
+ let quotedGitArgs = gitArguments.map { shellQuote($0) }.joined(separator: " ")
+ let remoteCommand = "LC_ALL=C GIT_TERMINAL_PROMPT=0 GIT_OPTIONAL_LOCKS=0 GIT_EDITOR=true GIT_ALLOW_PROTOCOL=https:http:ssh:git:file git -C \(quotedPath) \(quotedGitArgs)"
+
+ return [
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=10",
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-p", String(port),
+ "-l", user,
+ host,
+ remoteCommand,
+ ]
+ }
+
+ public func runRaw(_ arguments: [String]) async throws -> GitProcessResult {
+ let sshURL = self.sshURL
+ let args = sshArgs(gitArguments: arguments)
+
+ return try await withCheckedThrowingContinuation { continuation in
+ DispatchQueue.global(qos: .userInitiated).async {
+ let process = Process()
+ process.executableURL = sshURL
+ process.arguments = args
+
+ let stdoutPipe = Pipe()
+ let stderrPipe = Pipe()
+ process.standardOutput = stdoutPipe
+ process.standardError = stderrPipe
+
+ let box = SSHCaptureBox()
+ let group = DispatchGroup()
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ box.stdout = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
+ group.leave()
+ }
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ box.stderr = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
+ group.leave()
+ }
+
+ do {
+ try process.run()
+ } catch {
+ continuation.resume(throwing: GitError.gitUnavailable(
+ "SSH connection failed: \(error.localizedDescription)"))
+ return
+ }
+
+ process.waitUntilExit()
+ group.wait()
+
+ continuation.resume(returning: GitProcessResult(
+ exitCode: process.terminationStatus,
+ stdout: box.stdout,
+ stderr: String(decoding: box.stderr, as: UTF8.self)
+ ))
+ }
+ }
+ }
+
+ @discardableResult
+ public func run(_ arguments: [String]) async throws -> Data {
+ let result = try await runRaw(arguments)
+ guard result.succeeded else {
+ throw GitError.commandFailed(
+ command: arguments.joined(separator: " "),
+ exitCode: result.exitCode,
+ stderr: result.stderr
+ )
+ }
+ return result.stdout
+ }
+
+ public func runString(_ arguments: [String]) async throws -> String {
+ String(decoding: try await run(arguments), as: UTF8.self)
+ }
+
+ @discardableResult
+ public func runStreaming(
+ _ arguments: [String],
+ onProgress: (@Sendable (String) -> Void)? = nil
+ ) async throws -> GitProcessResult {
+ let sshURL = self.sshURL
+ let args = sshArgs(gitArguments: arguments)
+
+ let result: GitProcessResult = try await withCheckedThrowingContinuation { continuation in
+ DispatchQueue.global(qos: .userInitiated).async {
+ let process = Process()
+ process.executableURL = sshURL
+ process.arguments = args
+
+ let stdoutPipe = Pipe()
+ let stderrPipe = Pipe()
+ process.standardOutput = stdoutPipe
+ process.standardError = stderrPipe
+
+ let box = SSHCaptureBox()
+ let group = DispatchGroup()
+
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ box.stdout = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
+ group.leave()
+ }
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ let handle = stderrPipe.fileHandleForReading
+ var pending = ""
+ while case let chunk = handle.availableData, !chunk.isEmpty {
+ let text = String(decoding: chunk, as: UTF8.self)
+ box.stderrText += text
+ pending += text
+ var line = ""
+ for character in pending {
+ if character == "\r" || character == "\n" {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if !trimmed.isEmpty { onProgress?(trimmed) }
+ line = ""
+ } else {
+ line.append(character)
+ }
+ }
+ pending = line
+ }
+ group.leave()
+ }
+
+ do {
+ try process.run()
+ } catch {
+ continuation.resume(throwing: GitError.gitUnavailable(
+ "SSH connection failed: \(error.localizedDescription)"))
+ return
+ }
+
+ process.waitUntilExit()
+ group.wait()
+ continuation.resume(returning: GitProcessResult(
+ exitCode: process.terminationStatus, stdout: box.stdout, stderr: box.stderrText))
+ }
+ }
+
+ guard result.succeeded else {
+ throw GitError.commandFailed(command: arguments.joined(separator: " "),
+ exitCode: result.exitCode, stderr: result.stderr)
+ }
+ return result
+ }
+
+ /// Shell-quotes a string for safe inclusion in the remote command.
+ /// Preserves tilde expansion for paths starting with `~/` or bare `~`.
+ private func shellQuote(_ string: String) -> String {
+ if string == "~" { return "~" }
+ if string.hasPrefix("~/") {
+ return "~/" + singleQuote(String(string.dropFirst(2)))
+ }
+ return singleQuote(string)
+ }
+
+ private func singleQuote(_ string: String) -> String {
+ "'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+}
diff --git a/Sources/GitKit/SSHRepositoryWatcher.swift b/Sources/GitKit/SSHRepositoryWatcher.swift
new file mode 100644
index 0000000..402467d
--- /dev/null
+++ b/Sources/GitKit/SSHRepositoryWatcher.swift
@@ -0,0 +1,64 @@
+import Foundation
+
+/// Watches a remote repository by polling git state over SSH.
+/// Fires `onChange` when the HEAD commit, working-tree status, or ref tips change.
+///
+/// Polls every 3 seconds. This is less responsive than FSEvents (0.5 s) but avoids the
+/// overhead of a persistent SSH connection.
+public final class SSHRepositoryWatcher: RepositoryWatching, @unchecked Sendable {
+ private let runner: SSHGitRunner
+ private let onChange: @Sendable () -> Void
+ private var pollTask: Task?
+ /// Fingerprint of the last observed state.
+ private var lastFingerprint = ""
+
+ public init(host: String, user: String, port: Int, remotePath: String,
+ onChange: @escaping @Sendable () -> Void) {
+ self.runner = SSHGitRunner(host: host, user: user, port: port, remotePath: remotePath)
+ self.onChange = onChange
+ }
+
+ public func start() {
+ guard pollTask == nil else { return }
+ pollTask = Task.detached { [weak self] in
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(3))
+ guard !Task.isCancelled else { break }
+ await self?.poll()
+ }
+ }
+ }
+
+ public func stop() {
+ pollTask?.cancel()
+ pollTask = nil
+ }
+
+ deinit {
+ pollTask?.cancel()
+ }
+
+ private func poll() async {
+ // Build a fingerprint from three sources to catch all types of changes:
+ // 1. HEAD sha \u{2014} detects commits, checkouts, resets on the current branch
+ // 2. Status byte count \u{2014} detects working-tree / staging area changes
+ // 3. Ref tips hash \u{2014} detects pushed/deleted branches, tags, remote updates
+ let headResult = try? await runner.runRaw(["rev-parse", "HEAD"])
+ let statusResult = try? await runner.runRaw(["status", "--porcelain=v2", "-z"])
+ let refsResult = try? await runner.runRaw(["for-each-ref", "--format=%(refname) %(objectname)"])
+
+ let head = headResult?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let statusLen = statusResult?.stdout.count ?? 0
+ let refsHash = refsResult?.stdout.count ?? 0 // Byte length as cheap change detector
+
+ let fingerprint = "\(head):\(statusLen):\(refsHash)"
+
+ if lastFingerprint.isEmpty {
+ // First poll \u{2014} seed the fingerprint without firing.
+ lastFingerprint = fingerprint
+ } else if fingerprint != lastFingerprint {
+ lastFingerprint = fingerprint
+ onChange()
+ }
+ }
+}
diff --git a/Sources/Gitify/Model/AppModel.swift b/Sources/Gitify/Model/AppModel.swift
index a0e6fcf..2a19b72 100644
--- a/Sources/Gitify/Model/AppModel.swift
+++ b/Sources/Gitify/Model/AppModel.swift
@@ -21,12 +21,17 @@ final class AppModel {
// Hosting accounts (GitHub/GitLab) authenticated by personal access token.
private(set) var accounts: [HostingAccount]
+ // SSH server connections for browsing and cloning remote repositories.
+ private(set) var sshServers: [SSHServer]
+
private let store = RepositoryStore()
private let accountStore = AccountStore()
+ private let sshServerStore = SSHServerStore()
init() {
repositories = store.load()
accounts = accountStore.load()
+ sshServers = sshServerStore.load()
// Restore the repository open in the previous session if it still exists,
// otherwise fall back to the first in the list.
let savedID = UserDefaults.standard.string(forKey: Self.lastSelectedKey)
@@ -101,6 +106,61 @@ final class AppModel {
return "https://\(user)@\(body)"
}
+ // MARK: - SSH servers
+
+ func addSSHServer(_ server: SSHServer) {
+ guard !sshServers.contains(where: { $0.id == server.id }) else { return }
+ sshServers.append(server)
+ sshServerStore.save(sshServers)
+ }
+
+ func removeSSHServer(_ server: SSHServer) {
+ sshServers.removeAll { $0.id == server.id }
+ sshServerStore.save(sshServers)
+ }
+
+ /// Clones a repository discovered on an SSH server.
+ func clone(_ repo: SSHRepo) async {
+ guard !isCloning else { return }
+ guard let parent = Prompt.chooseDirectory(prompt: "Clone Here",
+ message: "Choose where to clone \(repo.name)") else { return }
+ cloneProgress = "Starting\u{2026}"
+ defer { cloneProgress = nil }
+ let progress: @Sendable (String) -> Void = { [weak self] line in
+ Task { @MainActor in self?.cloneProgress = line }
+ }
+ do {
+ let dest = try await CLIGitService.clone(url: repo.cloneURL, into: parent, onProgress: progress)
+ await add(directory: dest)
+ } catch {
+ presentError("Clone failed: \(error)")
+ }
+ }
+
+ /// Opens a remote repository over SSH without cloning it locally.
+ func openRemote(_ repo: SSHRepo, server: SSHServer) async {
+ // Check for duplicates.
+ if let existing = repositories.first(where: { $0.path == repo.path && $0.sshHost == server.host }) {
+ selectedRepositoryID = existing.id
+ return
+ }
+ // Validate the remote repo is accessible.
+ let runner = SSHGitRunner(host: server.host, user: server.user,
+ port: server.port, remotePath: repo.path)
+ let result = try? await runner.runRaw(["rev-parse", "--show-toplevel"])
+ guard let result, result.succeeded else {
+ presentError("Could not open remote repository.")
+ return
+ }
+ let ref = RepositoryRef(
+ path: repo.path, name: repo.name,
+ sshHost: server.host, sshUser: server.user, sshPort: server.port
+ )
+ repositories.append(ref)
+ store.save(repositories)
+ selectedRepositoryID = ref.id
+ }
+
var selectedRepository: RepositoryRef? {
repositories.first { $0.id == selectedRepositoryID }
}
diff --git a/Sources/Gitify/Model/RepositoryRef.swift b/Sources/Gitify/Model/RepositoryRef.swift
index 98395d5..d8c8780 100644
--- a/Sources/Gitify/Model/RepositoryRef.swift
+++ b/Sources/Gitify/Model/RepositoryRef.swift
@@ -3,15 +3,27 @@ import Foundation
/// A persisted reference to a repository the user has added to Gitify.
struct RepositoryRef: Identifiable, Codable, Hashable {
let id: UUID
- /// Absolute path to the working-tree root.
+ /// Absolute path to the working-tree root (local or remote).
var path: String
/// Display name (defaults to the directory name).
var name: String
- init(id: UUID = UUID(), path: String, name: String? = nil) {
+ // SSH connection details \u{2014} nil for local repos.
+ var sshHost: String?
+ var sshUser: String?
+ var sshPort: Int?
+
+ /// Whether this repository is accessed over SSH rather than the local filesystem.
+ var isRemote: Bool { sshHost != nil }
+
+ init(id: UUID = UUID(), path: String, name: String? = nil,
+ sshHost: String? = nil, sshUser: String? = nil, sshPort: Int? = nil) {
self.id = id
self.path = path
self.name = name ?? URL(fileURLWithPath: path).lastPathComponent
+ self.sshHost = sshHost
+ self.sshUser = sshUser
+ self.sshPort = sshPort
}
var url: URL { URL(fileURLWithPath: path) }
diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift
index 1b8edfc..6c9da93 100644
--- a/Sources/Gitify/Model/RepositoryViewModel.swift
+++ b/Sources/Gitify/Model/RepositoryViewModel.swift
@@ -53,7 +53,7 @@ final class RepositoryViewModel {
private var service: CLIGitService?
private var nextSkip: Int? = 0
- private var watcher: RepositoryWatcher?
+ private var watcher: (any RepositoryWatching)?
private var autoRefreshTask: Task?
init(ref: RepositoryRef) {
@@ -291,7 +291,14 @@ final class RepositoryViewModel {
private func resolveService() async throws -> CLIGitService {
if let service { return service }
- let service = try await CLIGitService(directory: ref.url)
+ let service: CLIGitService
+ if let host = ref.sshHost {
+ let runner = SSHGitRunner(host: host, user: ref.sshUser ?? "git",
+ port: ref.sshPort ?? 22, remotePath: ref.path)
+ service = try await CLIGitService(sshRunner: runner)
+ } else {
+ service = try await CLIGitService(directory: ref.url)
+ }
self.service = service
startWatching(root: service.root)
return service
@@ -303,11 +310,22 @@ final class RepositoryViewModel {
/// commands) refresh the UI automatically.
private func startWatching(root: URL) {
guard watcher == nil else { return }
- let watcher = RepositoryWatcher(root: root) { [weak self] in
+ let onChange: @Sendable () -> Void = { [weak self] in
Task { @MainActor in self?.scheduleAutoRefresh() }
}
- watcher.start()
- self.watcher = watcher
+ if let host = ref.sshHost {
+ let sshWatcher = SSHRepositoryWatcher(
+ host: host, user: ref.sshUser ?? "git",
+ port: ref.sshPort ?? 22, remotePath: ref.path,
+ onChange: onChange
+ )
+ sshWatcher.start()
+ self.watcher = sshWatcher
+ } else {
+ let fsWatcher = RepositoryWatcher(root: root, onChange: onChange)
+ fsWatcher.start()
+ self.watcher = fsWatcher
+ }
}
/// Debounces filesystem events into a single reload, skipping while a load or network
diff --git a/Sources/Gitify/Model/SSHRepo.swift b/Sources/Gitify/Model/SSHRepo.swift
new file mode 100644
index 0000000..f988c49
--- /dev/null
+++ b/Sources/Gitify/Model/SSHRepo.swift
@@ -0,0 +1,12 @@
+import Foundation
+
+/// A repository discovered on a remote SSH server.
+struct SSHRepo: Identifiable, Hashable {
+ var id: String { path }
+ /// Absolute path on the server (e.g. \u{201c}/srv/git/project.git\u{201d}).
+ let path: String
+ /// Display name derived from the path (directory name without .git suffix).
+ let name: String
+ /// Full SSH clone URL (e.g. \u{201c}ssh://git@host:22/srv/git/project.git\u{201d}).
+ let cloneURL: String
+}
diff --git a/Sources/Gitify/Model/SSHScanner.swift b/Sources/Gitify/Model/SSHScanner.swift
new file mode 100644
index 0000000..fd47bda
--- /dev/null
+++ b/Sources/Gitify/Model/SSHScanner.swift
@@ -0,0 +1,264 @@
+import Foundation
+
+/// Runs SSH commands to test connectivity and discover repositories on a remote server.
+/// For local servers (localhost/127.0.0.1), commands run directly without SSH.
+///
+/// Uses the user\u{2019}s installed `ssh` binary and their `~/.ssh/config`, agent, and keys.
+/// All commands run with `BatchMode=yes` (no interactive prompts) and `ConnectTimeout=5`
+/// to avoid hanging when keys are missing or the host is unreachable.
+enum SSHScanner {
+
+ /// Tests whether an SSH connection can be established.
+ /// Returns `nil` on success, or an error description on failure.
+ static func testConnection(_ server: SSHServer) async -> String? {
+ if isLocal(server) { return nil }
+ do {
+ let result = try await runSSH(server: server, remoteCommand: "echo ok")
+ if result.exitCode == 0 { return nil }
+ let stderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
+ return stderr.isEmpty ? "Connection failed (exit code \(result.exitCode))." : stderr
+ } catch {
+ return error.localizedDescription
+ }
+ }
+
+ /// Discovers git repositories under the server\u{2019}s configured base path.
+ ///
+ /// Finds non-bare repos (directories containing a `.git` subdirectory) and bare repos
+ /// (directories ending in `.git` that contain a `HEAD` file). Returns the list sorted
+ /// by path.
+ static func discoverRepositories(_ server: SSHServer) async throws -> [SSHRepo] {
+ let basePath = server.basePath.isEmpty ? "~" : server.basePath
+ // Two-pronged search:
+ // 1. Non-bare repos: find directories named ".git" (working-tree repos).
+ // 2. Bare repos: find directories whose name ends in ".git" (but isn\u{2019}t exactly ".git")
+ // that contain a HEAD file.
+ let command = "{ find \(shellQuote(basePath)) -maxdepth 3 -type d -name .git 2>/dev/null; find \(shellQuote(basePath)) -maxdepth 3 -type d -name '*.git' ! -name .git 2>/dev/null; } | head -200"
+
+ let result: ProcessResult
+ if isLocal(server) {
+ result = try await runLocal(command: command)
+ } else {
+ result = try await runSSH(server: server, remoteCommand: command)
+ }
+
+ if result.exitCode != 0 && result.exitCode != 1 {
+ let stderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
+ throw SSHScannerError.scanFailed(stderr.isEmpty ? "Scan failed (exit code \(result.exitCode))." : stderr)
+ }
+
+ let stdout = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !stdout.isEmpty else { return [] }
+
+ var repos: [SSHRepo] = []
+ for line in stdout.components(separatedBy: "\n") {
+ let path = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !path.isEmpty else { continue }
+
+ // A ".git" directory means the parent is a non-bare working tree.
+ // A "something.git" directory is a bare repo.
+ let effectivePath: String
+ if (path as NSString).lastPathComponent == ".git" {
+ effectivePath = (path as NSString).deletingLastPathComponent
+ } else {
+ effectivePath = path
+ }
+
+ let name = displayName(for: effectivePath)
+ let cloneURL = buildCloneURL(server: server, remotePath: effectivePath)
+ repos.append(SSHRepo(path: effectivePath, name: name, cloneURL: cloneURL))
+ }
+
+ // Deduplicate and sort.
+ var seen = Set()
+ repos = repos.filter { seen.insert($0.path).inserted }
+ repos.sort { $0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending }
+ return repos
+ }
+
+ // MARK: - Private
+
+ private struct ProcessResult {
+ let exitCode: Int32
+ let stdout: String
+ let stderr: String
+ }
+
+ /// Mutable storage shared across pipe-reading dispatch closures. Access is synchronized
+ /// by the surrounding `DispatchGroup`, so `@unchecked Sendable` is sound.
+ private final class CaptureBox: @unchecked Sendable {
+ var stdout = Data()
+ var stderr = Data()
+ }
+
+ /// Whether the server points to the local machine.
+ private static func isLocal(_ server: SSHServer) -> Bool {
+ let host = server.host.lowercased()
+ return host == "localhost" || host == "127.0.0.1" || host == "::1"
+ }
+
+ /// Runs a shell command locally via `/bin/sh -c`.
+ private static func runLocal(command: String) async throws -> ProcessResult {
+ try await withCheckedThrowingContinuation { continuation in
+ DispatchQueue.global(qos: .userInitiated).async {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/bin/sh")
+ process.arguments = ["-c", command]
+
+ let stdoutPipe = Pipe()
+ let stderrPipe = Pipe()
+ process.standardOutput = stdoutPipe
+ process.standardError = stderrPipe
+
+ let box = CaptureBox()
+ let group = DispatchGroup()
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ box.stdout = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
+ group.leave()
+ }
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ box.stderr = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
+ group.leave()
+ }
+
+ do {
+ try process.run()
+ } catch {
+ continuation.resume(throwing: SSHScannerError.sshUnavailable(error.localizedDescription))
+ return
+ }
+
+ process.waitUntilExit()
+ group.wait()
+
+ continuation.resume(returning: ProcessResult(
+ exitCode: process.terminationStatus,
+ stdout: String(decoding: box.stdout, as: UTF8.self),
+ stderr: String(decoding: box.stderr, as: UTF8.self)
+ ))
+ }
+ }
+ }
+
+ /// Runs an SSH command against the given server.
+ private static func runSSH(server: SSHServer, remoteCommand: String) async throws -> ProcessResult {
+ let sshPath = sshExecutablePath()
+ // Validate arguments against injection.
+ for value in [server.host, server.user] {
+ guard !value.hasPrefix("-") else {
+ throw SSHScannerError.invalidArgument("\(value) may not begin with \u{201c}-\u{201d}.")
+ }
+ }
+ guard server.port > 0, server.port <= 65535 else {
+ throw SSHScannerError.invalidArgument("Port must be between 1 and 65535.")
+ }
+
+ let args = [
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=5",
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-p", String(server.port),
+ "-l", server.user,
+ server.host,
+ remoteCommand,
+ ]
+
+ return try await withCheckedThrowingContinuation { continuation in
+ DispatchQueue.global(qos: .userInitiated).async {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: sshPath)
+ process.arguments = args
+
+ let stdoutPipe = Pipe()
+ let stderrPipe = Pipe()
+ process.standardOutput = stdoutPipe
+ process.standardError = stderrPipe
+
+ let box = CaptureBox()
+ let group = DispatchGroup()
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ box.stdout = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
+ group.leave()
+ }
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ box.stderr = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
+ group.leave()
+ }
+
+ do {
+ try process.run()
+ } catch {
+ continuation.resume(throwing: SSHScannerError.sshUnavailable(error.localizedDescription))
+ return
+ }
+
+ process.waitUntilExit()
+ group.wait()
+
+ continuation.resume(returning: ProcessResult(
+ exitCode: process.terminationStatus,
+ stdout: String(decoding: box.stdout, as: UTF8.self),
+ stderr: String(decoding: box.stderr, as: UTF8.self)
+ ))
+ }
+ }
+ }
+
+ /// Best-effort discovery of the `ssh` binary.
+ private static func sshExecutablePath() -> String {
+ let candidates = ["/usr/bin/ssh", "/opt/homebrew/bin/ssh", "/usr/local/bin/ssh"]
+ for path in candidates where FileManager.default.isExecutableFile(atPath: path) {
+ return path
+ }
+ return "/usr/bin/ssh"
+ }
+
+ /// Derives a display name from a remote path (e.g. \u{201c}/srv/git/project.git\u{201d} \u{2192} \u{201c}project\u{201d}).
+ private static func displayName(for path: String) -> String {
+ var name = (path as NSString).lastPathComponent
+ if name.hasSuffix(".git") { name = String(name.dropLast(4)) }
+ return name.isEmpty ? path : name
+ }
+
+ /// Builds a clone URL for the given server and remote path.
+ /// Local servers return a file path; remote servers return an SSH URL.
+ private static func buildCloneURL(server: SSHServer, remotePath: String) -> String {
+ if isLocal(server) { return remotePath }
+ if server.port == 22 {
+ return "\(server.user)@\(server.host):\(remotePath)"
+ }
+ return "ssh://\(server.user)@\(server.host):\(server.port)\(remotePath)"
+ }
+
+ /// Shell-quotes a string for safe inclusion in a shell command.
+ /// Preserves tilde expansion for paths starting with `~/` or bare `~`.
+ private static func shellQuote(_ string: String) -> String {
+ if string == "~" { return "~" }
+ if string.hasPrefix("~/") {
+ return "~/" + singleQuote(String(string.dropFirst(2)))
+ }
+ return singleQuote(string)
+ }
+
+ private static func singleQuote(_ string: String) -> String {
+ "'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+}
+
+enum SSHScannerError: LocalizedError {
+ case sshUnavailable(String)
+ case scanFailed(String)
+ case invalidArgument(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .sshUnavailable(let detail): "SSH is not available: \(detail)"
+ case .scanFailed(let detail): detail
+ case .invalidArgument(let detail): detail
+ }
+ }
+}
diff --git a/Sources/Gitify/Model/SSHServer.swift b/Sources/Gitify/Model/SSHServer.swift
new file mode 100644
index 0000000..e1f8351
--- /dev/null
+++ b/Sources/Gitify/Model/SSHServer.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+/// A saved SSH server connection used to browse and clone remote repositories.
+struct SSHServer: Identifiable, Codable, Hashable {
+ let id: UUID
+ /// User-chosen display name (e.g. \u{201c}My VPS\u{201d}).
+ var label: String
+ /// Hostname or IP address.
+ var host: String
+ /// SSH username (typically \u{201c}git\u{201d} for hosting services).
+ var user: String
+ /// SSH port (default 22).
+ var port: Int
+ /// Base path on the server to scan for repositories.
+ var basePath: String
+
+ init(id: UUID = UUID(), label: String, host: String, user: String = "git",
+ port: Int = 22, basePath: String = "~") {
+ self.id = id
+ self.label = label
+ self.host = host
+ self.user = user
+ self.port = port
+ self.basePath = basePath
+ }
+}
+
+/// Persists the list of SSH servers to Application Support as JSON.
+struct SSHServerStore {
+ private let fileURL: URL
+
+ init() {
+ let base = FileManager.default
+ .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("Gitify", isDirectory: true)
+ try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ self.fileURL = base.appendingPathComponent("ssh-servers.json")
+ }
+
+ func load() -> [SSHServer] {
+ guard let data = try? Data(contentsOf: fileURL) else { return [] }
+ return (try? JSONDecoder().decode([SSHServer].self, from: data)) ?? []
+ }
+
+ func save(_ servers: [SSHServer]) {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ guard let data = try? encoder.encode(servers) else { return }
+ try? data.write(to: fileURL, options: .atomic)
+ }
+}
diff --git a/Sources/Gitify/Views/AddSSHServerSheet.swift b/Sources/Gitify/Views/AddSSHServerSheet.swift
new file mode 100644
index 0000000..1fd5b9c
--- /dev/null
+++ b/Sources/Gitify/Views/AddSSHServerSheet.swift
@@ -0,0 +1,122 @@
+import SwiftUI
+
+/// Sheet to add an SSH server connection by entering host, user, port, and base path.
+/// Layout follows the same hand-laid-out pattern as `AddAccountSheet`.
+struct AddSSHServerSheet: View {
+ let model: AppModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var label = ""
+ @State private var host = ""
+ @State private var user = "git"
+ @State private var portString = "22"
+ @State private var basePath = "~"
+ @State private var testing = false
+ @State private var testResult: String?
+ @State private var testPassed = false
+
+ private var port: Int { Int(portString) ?? 22 }
+ private var isValid: Bool {
+ !host.trimmingCharacters(in: .whitespaces).isEmpty &&
+ !user.trimmingCharacters(in: .whitespaces).isEmpty &&
+ port > 0 && port <= 65535
+ }
+
+ private let labelWidth: CGFloat = 70
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Add SSH Server").font(.title2.bold())
+
+ Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 10) {
+ GridRow {
+ Text("Label").frame(width: labelWidth, alignment: .trailing)
+ TextField("My Server", text: $label)
+ .textFieldStyle(.roundedBorder)
+ }
+ GridRow {
+ Text("Host").frame(width: labelWidth, alignment: .trailing)
+ TextField("example.com", text: $host)
+ .textFieldStyle(.roundedBorder)
+ }
+ GridRow {
+ Text("User").frame(width: labelWidth, alignment: .trailing)
+ TextField("git", text: $user)
+ .textFieldStyle(.roundedBorder)
+ }
+ GridRow {
+ Text("Port").frame(width: labelWidth, alignment: .trailing)
+ TextField("22", text: $portString)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 80)
+ }
+ GridRow {
+ Text("Base Path").frame(width: labelWidth, alignment: .trailing)
+ TextField("~", text: $basePath)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+
+ Text("Gitify uses your SSH config and keys. No credentials are stored.")
+ .font(.caption).foregroundStyle(.secondary)
+
+ if let testResult {
+ HStack(spacing: 6) {
+ Image(systemName: testPassed ? "checkmark.circle.fill" : "xmark.circle.fill")
+ .foregroundStyle(testPassed ? .green : .red)
+ Text(testResult)
+ .font(.caption)
+ .foregroundStyle(testPassed ? Color.primary : Color.red)
+ .lineLimit(2)
+ }
+ }
+
+ Spacer()
+
+ HStack {
+ Button(testing ? "Testing\u{2026}" : "Test Connection") {
+ Task { await testConnection() }
+ }
+ .disabled(!isValid || testing)
+ Spacer()
+ Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
+ Button("Add") { addServer() }
+ .keyboardShortcut(.defaultAction)
+ .disabled(!isValid)
+ }
+ }
+ .padding(24)
+ .frame(width: 460, height: 380)
+ }
+
+ private func testConnection() async {
+ let server = buildServer()
+ testing = true
+ testResult = nil
+ defer { testing = false }
+ if let error = await SSHScanner.testConnection(server) {
+ testResult = error
+ testPassed = false
+ } else {
+ testResult = "Connection successful."
+ testPassed = true
+ }
+ }
+
+ private func addServer() {
+ let server = buildServer()
+ model.addSSHServer(server)
+ dismiss()
+ }
+
+ private func buildServer() -> SSHServer {
+ let effectiveLabel = label.trimmingCharacters(in: .whitespaces)
+ let effectiveHost = host.trimmingCharacters(in: .whitespaces)
+ return SSHServer(
+ label: effectiveLabel.isEmpty ? effectiveHost : effectiveLabel,
+ host: effectiveHost,
+ user: user.trimmingCharacters(in: .whitespaces),
+ port: port,
+ basePath: basePath.trimmingCharacters(in: .whitespaces)
+ )
+ }
+}
diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
index adc07d7..356edb4 100644
--- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift
+++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
@@ -277,6 +277,7 @@ private struct RepoSwitcher: View {
let model: AppModel
let current: RepositoryRef
@State private var showAccounts = false
+ @State private var showSSHServers = false
var body: some View {
Menu {
@@ -296,9 +297,10 @@ private struct RepoSwitcher: View {
Button("Clone Repository…") { Task { await model.promptToClone() } }
Divider()
Button("Accounts…") { showAccounts = true }
+ Button("SSH Servers…") { showSSHServers = true }
} label: {
HStack(spacing: 8) {
- Image(systemName: "shippingbox.fill")
+ Image(systemName: current.isRemote ? "cloud.fill" : "shippingbox.fill")
.foregroundStyle(.tint)
Text(current.name)
.fontWeight(.semibold)
@@ -317,6 +319,7 @@ private struct RepoSwitcher: View {
.padding(.top, 8)
.padding(.bottom, 6)
.sheet(isPresented: $showAccounts) { AccountsView(model: model) }
+ .sheet(isPresented: $showSSHServers) { SSHServersView(model: model) }
}
}
diff --git a/Sources/Gitify/Views/RootView.swift b/Sources/Gitify/Views/RootView.swift
index cd8ee85..5971bd5 100644
--- a/Sources/Gitify/Views/RootView.swift
+++ b/Sources/Gitify/Views/RootView.swift
@@ -4,6 +4,7 @@ import SwiftUI
/// sidebar + detail split), or an empty state when no repository is open.
struct RootView: View {
@Environment(AppModel.self) private var model
+ @State private var showSSHServers = false
var body: some View {
Group {
@@ -19,6 +20,7 @@ struct RootView: View {
} actions: {
Button("Add Repository…") { Task { await model.promptToAddRepository() } }
Button("Clone Repository…") { Task { await model.promptToClone() } }
+ Button("SSH Servers…") { showSSHServers = true }
}
}
}
@@ -40,5 +42,6 @@ struct RootView: View {
}
}
}
+ .sheet(isPresented: $showSSHServers) { SSHServersView(model: model) }
}
}
diff --git a/Sources/Gitify/Views/SSHServersView.swift b/Sources/Gitify/Views/SSHServersView.swift
new file mode 100644
index 0000000..306f70a
--- /dev/null
+++ b/Sources/Gitify/Views/SSHServersView.swift
@@ -0,0 +1,161 @@
+import SwiftUI
+
+/// Browse SSH servers, discover repositories, and clone them.
+/// Layout follows the same `NavigationSplitView` pattern as `AccountsView`.
+struct SSHServersView: View {
+ let model: AppModel
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var selection: SSHServer.ID?
+ @State private var repos: [SSHRepo] = []
+ @State private var loading = false
+ @State private var error: String?
+ @State private var search = ""
+ @State private var addingServer = false
+
+ private var selectedServer: SSHServer? {
+ model.sshServers.first { $0.id == selection }
+ }
+ private var filteredRepos: [SSHRepo] {
+ let q = search.trimmingCharacters(in: .whitespaces).lowercased()
+ return q.isEmpty ? repos : repos.filter {
+ $0.name.lowercased().contains(q) || $0.path.lowercased().contains(q)
+ }
+ }
+
+ var body: some View {
+ NavigationSplitView {
+ List(selection: $selection) {
+ Section("SSH Servers") {
+ ForEach(model.sshServers) { server in
+ Label {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(server.label)
+ Text("\(server.user)@\(server.host)")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ } icon: {
+ Image(systemName: "server.rack")
+ }
+ .tag(server.id)
+ .contextMenu {
+ Button("Remove Server", role: .destructive) {
+ model.removeSSHServer(server)
+ }
+ }
+ }
+ }
+ }
+ .safeAreaInset(edge: .bottom) {
+ HStack {
+ Button { addingServer = true } label: {
+ Label("Add Server", systemImage: "plus")
+ }
+ .buttonStyle(.borderless)
+ Spacer()
+ }
+ .padding(.horizontal, 12).padding(.vertical, 8)
+ .background(.bar)
+ }
+ .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280)
+ } detail: {
+ detail
+ }
+ .frame(width: 760, height: 520)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ .sheet(isPresented: $addingServer) { AddSSHServerSheet(model: model) }
+ .onChange(of: selection) { _, _ in Task { await loadRepos() } }
+ }
+
+ @ViewBuilder
+ private var detail: some View {
+ if let server = selectedServer {
+ VStack(spacing: 0) {
+ HStack(spacing: 6) {
+ SearchField(text: $search, prompt: "Filter repositories")
+ Button { Task { await loadRepos() } } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .buttonStyle(.borderless)
+ .help("Rescan for repositories")
+ .disabled(loading)
+ .padding(.trailing, 8)
+ }
+ if loading {
+ ProgressView("Scanning for repositories\u{2026}")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if let error {
+ VStack(spacing: 8) {
+ Spacer().frame(height: 40)
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 36))
+ .foregroundStyle(.orange)
+ Text("Couldn\u{2019}t Scan Server")
+ .font(.title3).foregroundStyle(.secondary)
+ Text(error)
+ .font(.caption).foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 24)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if repos.isEmpty {
+ VStack(spacing: 8) {
+ Spacer().frame(height: 40)
+ Image(systemName: "shippingbox")
+ .font(.system(size: 36))
+ .foregroundStyle(.secondary)
+ Text("No Repositories Found")
+ .font(.title3).foregroundStyle(.secondary)
+ Text("No git repositories were found under \u{201c}\(server.basePath)\u{201d}.")
+ .font(.caption).foregroundStyle(.tertiary)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ List(filteredRepos) { repo in
+ HStack {
+ Image(systemName: "shippingbox").foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(repo.name)
+ Text(repo.path)
+ .font(.caption).foregroundStyle(.secondary)
+ .lineLimit(1).truncationMode(.middle)
+ }
+ Spacer()
+ Button("Open") {
+ Task { await model.openRemote(repo, server: server); dismiss() }
+ }
+ Button("Clone") {
+ Task { await model.clone(repo); dismiss() }
+ }
+ }
+ }
+ .listStyle(.inset)
+ }
+ }
+ } else {
+ ContentUnavailableView(
+ "No Server Selected",
+ systemImage: "server.rack",
+ description: Text("Add or select an SSH server to browse its repositories.")
+ )
+ }
+ }
+
+ private func loadRepos() async {
+ guard let server = selectedServer else { return }
+ loading = true; error = nil; repos = []
+ defer { loading = false }
+ do {
+ repos = try await SSHScanner.discoverRepositories(server)
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+}
From 03f28371ac19deaeb204b6f2465d523d25a77845 Mon Sep 17 00:00:00 2001
From: Craig Harman <324026+craigharman@users.noreply.github.com>
Date: Wed, 24 Jun 2026 14:49:27 +0800
Subject: [PATCH 36/41] fix: do not allow banner colour to bleed into sidebar
---
Sources/Gitify/Views/RepositoryWorkspaceView.swift | 1 +
1 file changed, 1 insertion(+)
diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
index 356edb4..a89273d 100644
--- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift
+++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
@@ -72,6 +72,7 @@ struct RepositoryWorkspaceView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle(sectionTitle)
+ .toolbarBackground(.visible, for: .windowToolbar)
.background(.background)
}
.onChange(of: section) { _, new in
From 133bb9c5209d9f739500aabcecc8ccda054833be Mon Sep 17 00:00:00 2001
From: Craig Harman <324026+craigharman@users.noreply.github.com>
Date: Wed, 24 Jun 2026 15:59:43 +0800
Subject: [PATCH 37/41] chore: updated clipping on main panel
---
Sources/Gitify/Views/RepositoryWorkspaceView.swift | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
index a89273d..2fdb00a 100644
--- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift
+++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
@@ -72,7 +72,6 @@ struct RepositoryWorkspaceView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle(sectionTitle)
- .toolbarBackground(.visible, for: .windowToolbar)
.background(.background)
}
.onChange(of: section) { _, new in
@@ -797,6 +796,7 @@ private struct ErrorBanner: View {
.frame(maxWidth: .infinity)
.background(.red.opacity(0.15))
.overlay(Rectangle().frame(height: 1).foregroundStyle(.red.opacity(0.3)), alignment: .bottom)
+ .clipped()
}
}
@@ -835,5 +835,6 @@ private struct OperationBanner: View {
.frame(maxWidth: .infinity)
.background(.orange.opacity(0.15))
.overlay(Rectangle().frame(height: 1).foregroundStyle(.orange.opacity(0.4)), alignment: .bottom)
+ .clipped()
}
}
From 0b5e9f31637782542a178e0595c902df651962fe Mon Sep 17 00:00:00 2001
From: Craig Harman <324026+craigharman@users.noreply.github.com>
Date: Wed, 24 Jun 2026 16:17:14 +0800
Subject: [PATCH 38/41] ci:remove duplicate tests and use the release token to
trigger the build pipeline
---
.github/workflows/auto-merge-release.yml | 2 +-
.github/workflows/release.yml | 3 ---
2 files changed, 1 insertion(+), 4 deletions(-)
diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml
index 8dfc8f2..b9ec015 100644
--- a/.github/workflows/auto-merge-release.yml
+++ b/.github/workflows/auto-merge-release.yml
@@ -56,7 +56,7 @@ jobs:
- name: Approve and auto-merge
if: steps.find-pr.outputs.found == 'true'
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
gh pr review "${{ steps.find-pr.outputs.number }}" --approve
gh pr merge "${{ steps.find-pr.outputs.number }}" --squash --auto
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 01d978c..00a64d6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,9 +33,6 @@ jobs:
with:
ref: ${{ needs.release-please.outputs.tag_name }}
- - name: Run tests
- run: swift run GitKitChecks
-
- name: Build DMG
env:
VERSION: ${{ needs.release-please.outputs.version }}
From 6359e7184ec79bbe16cad20a986d5fec2ec9a2e7 Mon Sep 17 00:00:00 2001
From: Craig Harman <324026+craigharman@users.noreply.github.com>
Date: Sat, 27 Jun 2026 18:49:37 +0800
Subject: [PATCH 39/41] feat: add scan folder feature to allow bulk import of
repositories
---
Sources/Gitify/App/GitifyApp.swift | 4 ++
Sources/Gitify/Model/AppModel.swift | 42 +++++++++++++++++++
.../Views/RepositoryWorkspaceView.swift | 1 +
Sources/Gitify/Views/RootView.swift | 1 +
4 files changed, 48 insertions(+)
diff --git a/Sources/Gitify/App/GitifyApp.swift b/Sources/Gitify/App/GitifyApp.swift
index cc8bf66..cbcb0ef 100644
--- a/Sources/Gitify/App/GitifyApp.swift
+++ b/Sources/Gitify/App/GitifyApp.swift
@@ -30,6 +30,10 @@ struct GitifyApp: App {
Task { await model.promptToAddRepository() }
}
.keyboardShortcut("o", modifiers: [.command])
+ Button("Scan Folder…") {
+ Task { await model.promptToScanFolder() }
+ }
+ .keyboardShortcut("o", modifiers: [.command, .shift])
}
}
}
diff --git a/Sources/Gitify/Model/AppModel.swift b/Sources/Gitify/Model/AppModel.swift
index 2a19b72..fb42e89 100644
--- a/Sources/Gitify/Model/AppModel.swift
+++ b/Sources/Gitify/Model/AppModel.swift
@@ -215,6 +215,48 @@ final class AppModel {
}
}
+ /// Prompts for a parent folder, scans its immediate subdirectories for Git repositories,
+ /// and adds all that are found.
+ func promptToScanFolder() async {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.allowsMultipleSelection = false
+ panel.prompt = "Scan"
+ panel.message = "Choose a folder to scan for Git repositories"
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+
+ let fm = FileManager.default
+ guard let contents = try? fm.contentsOfDirectory(
+ at: url, includingPropertiesForKeys: [.isDirectoryKey],
+ options: [.skipsHiddenFiles]
+ ) else { return }
+
+ let dirs = contents.filter {
+ (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
+ }
+
+ var added: [RepositoryRef] = []
+ for dir in dirs {
+ guard let root = await CLIGitService.repositoryRoot(for: dir) else { continue }
+ guard !repositories.contains(where: { $0.path == root.path }) else { continue }
+ let ref = RepositoryRef(path: root.path)
+ repositories.append(ref)
+ added.append(ref)
+ }
+
+ if added.isEmpty {
+ let alert = NSAlert()
+ alert.messageText = "No repositories found"
+ alert.informativeText = "No Git repositories were found in the immediate subfolders of \u{201c}\(url.lastPathComponent)\u{201d}."
+ alert.alertStyle = .informational
+ alert.runModal()
+ } else {
+ store.save(repositories)
+ selectedRepositoryID = added.first?.id
+ }
+ }
+
func remove(_ ref: RepositoryRef) {
repositories.removeAll { $0.id == ref.id }
store.save(repositories)
diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
index 2fdb00a..83be4e6 100644
--- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift
+++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
@@ -295,6 +295,7 @@ private struct RepoSwitcher: View {
Divider()
Button("Add Existing Repository…") { Task { await model.promptToAddRepository() } }
Button("Clone Repository…") { Task { await model.promptToClone() } }
+ Button("Scan Folder…") { Task { await model.promptToScanFolder() } }
Divider()
Button("Accounts…") { showAccounts = true }
Button("SSH Servers…") { showSSHServers = true }
diff --git a/Sources/Gitify/Views/RootView.swift b/Sources/Gitify/Views/RootView.swift
index 5971bd5..1ff0446 100644
--- a/Sources/Gitify/Views/RootView.swift
+++ b/Sources/Gitify/Views/RootView.swift
@@ -20,6 +20,7 @@ struct RootView: View {
} actions: {
Button("Add Repository…") { Task { await model.promptToAddRepository() } }
Button("Clone Repository…") { Task { await model.promptToClone() } }
+ Button("Scan Folder…") { Task { await model.promptToScanFolder() } }
Button("SSH Servers…") { showSSHServers = true }
}
}
From 1300bb73d9dcf479160b209dc6e2aaa006794bd1 Mon Sep 17 00:00:00 2001
From: Craig Harman <324026+craigharman@users.noreply.github.com>
Date: Tue, 30 Jun 2026 09:42:54 +0800
Subject: [PATCH 40/41] feat: image diff viewer
---
Sources/GitKit/CLIGitService.swift | 6 +
Sources/GitKit/GitService.swift | 4 +
Sources/GitKit/Models/Diff.swift | 20 ++
.../Gitify/Model/RepositoryViewModel.swift | 38 ++-
Sources/Gitify/Views/CommitDetailView.swift | 22 +-
Sources/Gitify/Views/DiffView.swift | 7 +-
Sources/Gitify/Views/ImageDiffView.swift | 288 ++++++++++++++++++
Sources/Gitify/Views/WorkingTreeView.swift | 1 +
8 files changed, 378 insertions(+), 8 deletions(-)
create mode 100644 Sources/Gitify/Views/ImageDiffView.swift
diff --git a/Sources/GitKit/CLIGitService.swift b/Sources/GitKit/CLIGitService.swift
index 7b63978..27ae4af 100644
--- a/Sources/GitKit/CLIGitService.swift
+++ b/Sources/GitKit/CLIGitService.swift
@@ -461,6 +461,12 @@ public struct CLIGitService: GitService {
try? String(contentsOf: root.appendingPathComponent(path), encoding: .utf8)
}
+ public func fileData(at path: String, revision: String) async throws -> Data {
+ try Self.requireSafe(path, "path")
+ if !revision.isEmpty { try Self.requireSafe(revision, "revision") }
+ return try await runner.run(["show", "\(revision):\(path)"])
+ }
+
public func resolveFile(path: String, contents: String) async throws {
try Self.requireSafe(path, "path")
try contents.write(to: root.appendingPathComponent(path), atomically: true, encoding: .utf8)
diff --git a/Sources/GitKit/GitService.swift b/Sources/GitKit/GitService.swift
index cce661d..debfbc4 100644
--- a/Sources/GitKit/GitService.swift
+++ b/Sources/GitKit/GitService.swift
@@ -234,6 +234,10 @@ public protocol GitService: Sendable {
/// The working-tree contents of `path` (including any conflict markers).
func fileContents(path: String) async -> String?
+ /// Raw file content at `revision` (e.g. `"HEAD"`, a SHA, or `""` for the index).
+ /// Used for binary file previews such as image diffs.
+ func fileData(at path: String, revision: String) async throws -> Data
+
/// Writes `contents` to `path` and stages it (marking a conflict resolved).
func resolveFile(path: String, contents: String) async throws
}
diff --git a/Sources/GitKit/Models/Diff.swift b/Sources/GitKit/Models/Diff.swift
index 65e9139..35ffc55 100644
--- a/Sources/GitKit/Models/Diff.swift
+++ b/Sources/GitKit/Models/Diff.swift
@@ -78,7 +78,27 @@ public struct FileDiff: Sendable {
self.header = header
}
+ /// Whether the file has a recognised image extension.
+ public var isImage: Bool {
+ Self.imageExtensions.contains((path as NSString).pathExtension.lowercased())
+ }
+
+ private static let imageExtensions: Set = [
+ "png", "jpg", "jpeg", "gif", "webp", "tiff", "tif", "bmp", "svg", "ico", "heic", "heif",
+ ]
+
public static func empty(path: String) -> FileDiff {
FileDiff(path: path, oldPath: nil, isBinary: false, isNew: false, isDeleted: false, hunks: [])
}
}
+
+/// The old and new image data for a binary image diff.
+public struct ImageDiffData: Sendable {
+ public let oldImage: Data?
+ public let newImage: Data?
+
+ public init(oldImage: Data?, newImage: Data?) {
+ self.oldImage = oldImage
+ self.newImage = newImage
+ }
+}
diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift
index 6c9da93..4084883 100644
--- a/Sources/Gitify/Model/RepositoryViewModel.swift
+++ b/Sources/Gitify/Model/RepositoryViewModel.swift
@@ -42,6 +42,7 @@ final class RepositoryViewModel {
var selectedPath: String?
private(set) var selectedStaged = false
private(set) var currentDiff: FileDiff?
+ private(set) var imageDiffData: ImageDiffData?
var commitMessage: String = ""
var amendMode: Bool = false
private(set) var isCommitting = false
@@ -132,7 +133,28 @@ final class RepositoryViewModel {
private func loadDiff(path: String, staged: Bool) async {
guard let service else { return }
- currentDiff = (try? await service.diff(path: path, staged: staged)) ?? .empty(path: path)
+ let diff = (try? await service.diff(path: path, staged: staged)) ?? .empty(path: path)
+ currentDiff = diff
+ if diff.isBinary && diff.isImage {
+ imageDiffData = await loadImageDiffData(diff: diff, staged: staged)
+ } else {
+ imageDiffData = nil
+ }
+ }
+
+ private func loadImageDiffData(diff: FileDiff, staged: Bool) async -> ImageDiffData {
+ guard let service else { return ImageDiffData(oldImage: nil, newImage: nil) }
+ let oldPath = diff.oldPath ?? diff.path
+ let oldImage: Data? = diff.isNew ? nil : (try? await service.fileData(at: oldPath, revision: "HEAD"))
+ let newImage: Data?
+ if diff.isDeleted {
+ newImage = nil
+ } else if staged {
+ newImage = try? await service.fileData(at: diff.path, revision: "")
+ } else {
+ newImage = try? Data(contentsOf: service.root.appendingPathComponent(diff.path))
+ }
+ return ImageDiffData(oldImage: oldImage, newImage: newImage)
}
/// Stages or unstages a single hunk of the currently-displayed diff. The direction is
@@ -280,6 +302,7 @@ final class RepositoryViewModel {
} else {
selectedPath = nil
currentDiff = nil
+ imageDiffData = nil
}
}
// Refresh history so amend/commit changes are reflected.
@@ -511,6 +534,18 @@ final class RepositoryViewModel {
return try? await service.commitFileDiff(sha: sha, path: path)
}
+ func commitImageDiffData(sha: String, parentSHA: String?, diff: FileDiff) async -> ImageDiffData? {
+ guard let service, diff.isBinary, diff.isImage else { return nil }
+ let oldPath = diff.oldPath ?? diff.path
+ let oldImage: Data? = if let parentSHA, !diff.isNew {
+ try? await service.fileData(at: oldPath, revision: parentSHA)
+ } else {
+ nil
+ }
+ let newImage: Data? = diff.isDeleted ? nil : (try? await service.fileData(at: diff.path, revision: sha))
+ return ImageDiffData(oldImage: oldImage, newImage: newImage)
+ }
+
func merge(branch: String, squash: Bool, noFastForward: Bool, noCommit: Bool, skipHooks: Bool) async {
await runIntegration("Merge") {
try await $0.merge(branch: branch, squash: squash, noFastForward: noFastForward,
@@ -650,6 +685,7 @@ final class RepositoryViewModel {
} else {
selectedPath = nil
currentDiff = nil
+ imageDiffData = nil
}
}
}
diff --git a/Sources/Gitify/Views/CommitDetailView.swift b/Sources/Gitify/Views/CommitDetailView.swift
index 0a4036b..f7323f5 100644
--- a/Sources/Gitify/Views/CommitDetailView.swift
+++ b/Sources/Gitify/Views/CommitDetailView.swift
@@ -11,7 +11,7 @@ struct CommitDetailView: View {
VStack(alignment: .leading, spacing: 0) {
metadata
Divider()
- ChangesetDiffPane(ref: commit.id, viewModel: viewModel)
+ ChangesetDiffPane(ref: commit.id, parentSHA: commit.parents.first, viewModel: viewModel)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -46,11 +46,13 @@ struct CommitDetailView: View {
/// changed files with +/- counts on top, and the selected file's diff below.
struct ChangesetDiffPane: View {
let ref: String
+ var parentSHA: String? = nil
let viewModel: RepositoryViewModel
@State private var changes: [FileChange] = []
@State private var selectedFile: String?
@State private var fileDiff: FileDiff?
+ @State private var imageDiffData: ImageDiffData?
@State private var loading = false
var body: some View {
@@ -64,14 +66,23 @@ struct ChangesetDiffPane: View {
}
.frame(maxWidth: .infinity, minHeight: 140)
- CommitFileDiffPane(fileDiff: fileDiff)
+ CommitFileDiffPane(fileDiff: fileDiff, imageDiffData: imageDiffData)
.frame(maxWidth: .infinity, minHeight: 120)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task(id: ref) { await load() }
.onChange(of: selectedFile) { _, path in
- guard let path else { fileDiff = nil; return }
- Task { fileDiff = await viewModel.commitFileDiff(ref, path: path) }
+ guard let path else { fileDiff = nil; imageDiffData = nil; return }
+ Task {
+ let diff = await viewModel.commitFileDiff(ref, path: path)
+ fileDiff = diff
+ if let diff {
+ imageDiffData = await viewModel.commitImageDiffData(
+ sha: ref, parentSHA: parentSHA, diff: diff)
+ } else {
+ imageDiffData = nil
+ }
+ }
}
}
@@ -122,13 +133,14 @@ struct ChangesetDiffPane: View {
/// Concrete (not an inline if/else) so selecting a changed file doesn't reset the dragged divider.
private struct CommitFileDiffPane: View {
let fileDiff: FileDiff?
+ var imageDiffData: ImageDiffData? = nil
var body: some View {
// GeometryReader so the pane keeps a constant (greedy) size whether the diff or the
// placeholder is shown — otherwise VSplitView shifts the divider on file selection.
GeometryReader { _ in
if let fileDiff {
- DiffView(diff: fileDiff)
+ DiffView(diff: fileDiff, imageDiffData: imageDiffData)
} else {
ContentUnavailableView("No File Selected", systemImage: "doc.text.magnifyingglass",
description: Text("Select a changed file to view its diff."))
diff --git a/Sources/Gitify/Views/DiffView.swift b/Sources/Gitify/Views/DiffView.swift
index d9dda85..aee1089 100644
--- a/Sources/Gitify/Views/DiffView.swift
+++ b/Sources/Gitify/Views/DiffView.swift
@@ -9,6 +9,7 @@ private struct LineRef: Hashable { let hunk: Int; let index: Int }
/// `onApplyLines` is provided, individual added/removed lines can be selected and staged.
struct DiffView: View {
let diff: FileDiff
+ var imageDiffData: ImageDiffData? = nil
var actionLabel: String = "Stage Hunk"
var lineActionLabel: String = "Stage Lines"
var onApplyHunk: ((DiffHunk) -> Void)? = nil
@@ -21,9 +22,11 @@ struct DiffView: View {
private var language: String { (diff.path as NSString).pathExtension.lowercased() }
var body: some View {
- if diff.isBinary {
+ if diff.isBinary, let imageDiffData {
+ ImageDiffView(data: imageDiffData, isNew: diff.isNew, isDeleted: diff.isDeleted)
+ } else if diff.isBinary {
ContentUnavailableView("Binary File", systemImage: "doc.badge.gearshape",
- description: Text("Binary content can't be displayed."))
+ description: Text("Binary content can\u{2019}t be displayed."))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if diff.hunks.isEmpty {
ContentUnavailableView("No Changes", systemImage: "doc.plaintext",
diff --git a/Sources/Gitify/Views/ImageDiffView.swift b/Sources/Gitify/Views/ImageDiffView.swift
new file mode 100644
index 0000000..7318b48
--- /dev/null
+++ b/Sources/Gitify/Views/ImageDiffView.swift
@@ -0,0 +1,288 @@
+import SwiftUI
+import GitKit
+
+/// Image comparison view with side-by-side and swipe scrubber modes.
+struct ImageDiffView: View {
+ let data: ImageDiffData
+ let isNew: Bool
+ let isDeleted: Bool
+
+ private enum Mode: String, CaseIterable { case sideBySide = "Side by Side", swipe = "Swipe" }
+ @State private var mode: Mode = .sideBySide
+ @State private var scrubberFraction: CGFloat = 0.5
+
+ /// Whether both images are available (scrubbing only makes sense with two images).
+ private var hasBothImages: Bool {
+ data.oldImage != nil && data.newImage != nil
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if hasBothImages {
+ header
+ }
+ if mode == .swipe && hasBothImages {
+ swipeBody
+ } else {
+ sideBySideBody
+ }
+ }
+ .background(Color(nsColor: .controlBackgroundColor))
+ }
+
+ // MARK: - Header
+
+ private var header: some View {
+ HStack {
+ Picker("", selection: $mode) {
+ ForEach(Mode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ .fixedSize()
+ Spacer()
+ }
+ .padding(.horizontal, 10).padding(.vertical, 6)
+ .background(.bar)
+ .overlay(Rectangle().frame(height: 1).foregroundStyle(.separator), alignment: .bottom)
+ }
+
+ // MARK: - Side by Side
+
+ private var sideBySideBody: some View {
+ GeometryReader { geo in
+ let panelWidth = (geo.size.width - 1) / 2
+ let panelContentHeight = geo.size.height - 30 // label bar height
+ let sharedSize: CGSize? = {
+ guard let oldData = data.oldImage, let oldImg = NSImage(data: oldData),
+ let newData = data.newImage, let newImg = NSImage(data: newData) else { return nil }
+ let padding: CGFloat = 16
+ let available = CGSize(width: panelWidth - padding * 2,
+ height: panelContentHeight - padding * 2 - 24)
+ return fittedSize(old: oldImg, new: newImg, within: available)
+ }()
+
+ HStack(spacing: 1) {
+ panel(label: "Before", imageData: data.oldImage,
+ placeholder: isNew ? "New File" : nil, sharedSize: sharedSize)
+ panel(label: "After", imageData: data.newImage,
+ placeholder: isDeleted ? "Deleted" : nil, sharedSize: sharedSize)
+ }
+ }
+ }
+
+ private func panel(label: String, imageData: Data?, placeholder: String?,
+ sharedSize: CGSize?) -> some View {
+ VStack(spacing: 0) {
+ Text(label)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 6)
+ .frame(maxWidth: .infinity)
+ .background(.bar)
+ .overlay(Rectangle().frame(height: 1).foregroundStyle(.separator), alignment: .bottom)
+
+ if let placeholder {
+ ContentUnavailableView(placeholder, systemImage: "photo",
+ description: Text("No image to display."))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if let imageData, let nsImage = NSImage(data: imageData) {
+ imageContent(nsImage, data: imageData, sharedSize: sharedSize)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ContentUnavailableView("Unable to Load", systemImage: "exclamationmark.triangle",
+ description: Text("The image could not be loaded."))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ }
+
+ private func imageContent(_ nsImage: NSImage, data: Data, sharedSize: CGSize?) -> some View {
+ VStack(spacing: 4) {
+ Spacer()
+ if let sharedSize {
+ Image(nsImage: nsImage)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(maxWidth: sharedSize.width, maxHeight: sharedSize.height)
+ .padding(16)
+ .background {
+ checkerboard
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ }
+ } else {
+ Image(nsImage: nsImage)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .padding(16)
+ .background {
+ checkerboard
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ }
+ }
+ Spacer()
+ imageMetadata(nsImage, data: data)
+ }
+ }
+
+ // MARK: - Swipe / Scrubber
+
+ private var swipeBody: some View {
+ GeometryReader { geo in
+ let padding: CGFloat = 16
+ let available = CGSize(width: geo.size.width - padding * 2,
+ height: geo.size.height - padding * 2 - 28)
+ // Both images must exist (checked by hasBothImages).
+ let oldImage = NSImage(data: data.oldImage!)!
+ let newImage = NSImage(data: data.newImage!)!
+ let imageSize = fittedSize(old: oldImage, new: newImage, within: available)
+ let originX = (geo.size.width - imageSize.width) / 2
+ let originY = padding
+ let dividerX = originX + imageSize.width * scrubberFraction
+
+ ZStack(alignment: .topLeading) {
+ checkerboard
+
+ // Old image (full, behind)
+ Image(nsImage: oldImage)
+ .resizable()
+ .frame(width: imageSize.width, height: imageSize.height)
+ .position(x: originX + imageSize.width / 2, y: originY + imageSize.height / 2)
+
+ // New image (clipped from the scrubber rightward)
+ Image(nsImage: newImage)
+ .resizable()
+ .frame(width: imageSize.width, height: imageSize.height)
+ .clipShape(
+ ClipFromLeft(fraction: scrubberFraction)
+ )
+ .position(x: originX + imageSize.width / 2, y: originY + imageSize.height / 2)
+
+ // Scrubber divider line
+ Rectangle()
+ .fill(Color.white)
+ .frame(width: 2, height: imageSize.height)
+ .shadow(color: .black.opacity(0.4), radius: 2, x: 0, y: 0)
+ .overlay(scrubberHandle, alignment: .center)
+ .position(x: dividerX, y: originY + imageSize.height / 2)
+
+ // Labels
+ Text("Before")
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.white)
+ .shadow(color: .black.opacity(0.6), radius: 2)
+ .position(x: originX + 36, y: originY + 14)
+
+ Text("After")
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.white)
+ .shadow(color: .black.opacity(0.6), radius: 2)
+ .position(x: originX + imageSize.width - 30, y: originY + 14)
+
+ // Metadata bar
+ swipeMetadata(old: oldImage, new: newImage)
+ .position(x: geo.size.width / 2,
+ y: originY + imageSize.height + 18)
+ }
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { value in
+ let fraction = (value.location.x - originX) / imageSize.width
+ scrubberFraction = min(max(fraction, 0), 1)
+ }
+ )
+ }
+ }
+
+ private var scrubberHandle: some View {
+ Capsule()
+ .fill(Color.white)
+ .shadow(color: .black.opacity(0.3), radius: 3)
+ .frame(width: 8, height: 36)
+ .overlay {
+ HStack(spacing: 2) {
+ ForEach(0..<2, id: \.self) { _ in
+ RoundedRectangle(cornerRadius: 0.5)
+ .fill(Color.gray)
+ .frame(width: 1, height: 12)
+ }
+ }
+ }
+ }
+
+ private func swipeMetadata(old: NSImage, new: NSImage) -> some View {
+ HStack(spacing: 20) {
+ HStack(spacing: 8) {
+ Circle().fill(Color.red.opacity(0.7)).frame(width: 6, height: 6)
+ if let rep = old.representations.first {
+ Text("\(rep.pixelsWide) \u{00d7} \(rep.pixelsHigh)")
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ Text(byteCountDescription(data.oldImage!.count))
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ HStack(spacing: 8) {
+ Circle().fill(Color.green.opacity(0.7)).frame(width: 6, height: 6)
+ if let rep = new.representations.first {
+ Text("\(rep.pixelsWide) \u{00d7} \(rep.pixelsHigh)")
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ Text(byteCountDescription(data.newImage!.count))
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ /// Computes the largest size that fits both images at the same scale within `bounds`.
+ private func fittedSize(old: NSImage, new: NSImage, within bounds: CGSize) -> CGSize {
+ let maxW = max(old.size.width, new.size.width)
+ let maxH = max(old.size.height, new.size.height)
+ guard maxW > 0, maxH > 0 else { return .zero }
+ let scale = min(bounds.width / maxW, bounds.height / maxH, 1)
+ return CGSize(width: maxW * scale, height: maxH * scale)
+ }
+
+ // MARK: - Shared
+
+ private func imageMetadata(_ nsImage: NSImage, data: Data) -> some View {
+ HStack(spacing: 12) {
+ if let rep = nsImage.representations.first {
+ Text("\(rep.pixelsWide) \u{00d7} \(rep.pixelsHigh)")
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ Text(byteCountDescription(data.count))
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ .padding(.bottom, 8)
+ }
+
+ private var checkerboard: some View {
+ Canvas { context, size in
+ let step: CGFloat = 8
+ let light = Color(white: 0.9)
+ let dark = Color(white: 0.75)
+ for row in 0.. String {
+ ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file)
+ }
+}
+
+/// Clips to the right portion of the view, starting at `fraction` of the width.
+private struct ClipFromLeft: Shape {
+ var fraction: CGFloat
+
+ func path(in rect: CGRect) -> Path {
+ Path(CGRect(x: rect.minX + rect.width * fraction, y: rect.minY,
+ width: rect.width * (1 - fraction), height: rect.height))
+ }
+}
diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift
index c956fb8..5388f17 100644
--- a/Sources/Gitify/Views/WorkingTreeView.swift
+++ b/Sources/Gitify/Views/WorkingTreeView.swift
@@ -224,6 +224,7 @@ private struct DiffPane: View {
GeometryReader { _ in
if let diff = viewModel.currentDiff {
DiffView(diff: diff,
+ imageDiffData: viewModel.imageDiffData,
actionLabel: viewModel.selectedStaged ? "Unstage Hunk" : "Stage Hunk",
lineActionLabel: viewModel.selectedStaged ? "Unstage Lines" : "Stage Lines",
onApplyHunk: { hunk in Task { await viewModel.applyHunk(hunk) } },
From cc46caf8d637f2fdc62bac645289bd6b8a175f0a Mon Sep 17 00:00:00 2001
From: Craig Harman <324026+craigharman@users.noreply.github.com>
Date: Tue, 30 Jun 2026 09:46:50 +0800
Subject: [PATCH 41/41] chore: add before/after image comparison options
---
Sources/Gitify/Views/ImageDiffView.swift | 24 +++++++++++++++++++++---
1 file changed, 21 insertions(+), 3 deletions(-)
diff --git a/Sources/Gitify/Views/ImageDiffView.swift b/Sources/Gitify/Views/ImageDiffView.swift
index 7318b48..12d2705 100644
--- a/Sources/Gitify/Views/ImageDiffView.swift
+++ b/Sources/Gitify/Views/ImageDiffView.swift
@@ -7,7 +7,12 @@ struct ImageDiffView: View {
let isNew: Bool
let isDeleted: Bool
- private enum Mode: String, CaseIterable { case sideBySide = "Side by Side", swipe = "Swipe" }
+ private enum Mode: String, CaseIterable {
+ case sideBySide = "Side by Side"
+ case swipe = "Swipe"
+ case before = "Before"
+ case after = "After"
+ }
@State private var mode: Mode = .sideBySide
@State private var scrubberFraction: CGFloat = 0.5
@@ -21,9 +26,16 @@ struct ImageDiffView: View {
if hasBothImages {
header
}
- if mode == .swipe && hasBothImages {
+ switch mode {
+ case .swipe where hasBothImages:
swipeBody
- } else {
+ case .before:
+ singlePanel(label: "Before", imageData: data.oldImage,
+ placeholder: isNew ? "New File" : nil)
+ case .after:
+ singlePanel(label: "After", imageData: data.newImage,
+ placeholder: isDeleted ? "Deleted" : nil)
+ default:
sideBySideBody
}
}
@@ -125,6 +137,12 @@ struct ImageDiffView: View {
}
}
+ // MARK: - Single Image (Before / After)
+
+ private func singlePanel(label: String, imageData: Data?, placeholder: String?) -> some View {
+ panel(label: label, imageData: imageData, placeholder: placeholder, sharedSize: nil)
+ }
+
// MARK: - Swipe / Scrubber
private var swipeBody: some View {