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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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 { From c69f4c583439f8b0e272a5b8ee0cdcab48a215e8 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:12:10 +0800 Subject: [PATCH 42/50] fix: check for updates now available in menu --- Sources/Gitify/Model/SparkleController.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Sources/Gitify/Model/SparkleController.swift b/Sources/Gitify/Model/SparkleController.swift index b10303f..b1c76b8 100644 --- a/Sources/Gitify/Model/SparkleController.swift +++ b/Sources/Gitify/Model/SparkleController.swift @@ -4,14 +4,13 @@ import Sparkle /// /// `SPUStandardUpdaterController` must be created and used on the main thread, /// so the entire class is confined to `@MainActor`. -@MainActor +@MainActor @Observable final class SparkleController { private let updaterController: SPUStandardUpdaterController + private var observation: NSKeyValueObservation? /// Whether the \u{201c}Check for Updates\u{2026}\u{201d} menu item should be enabled. - var canCheckForUpdates: Bool { - updaterController.updater.canCheckForUpdates - } + private(set) var canCheckForUpdates = false init() { updaterController = SPUStandardUpdaterController( @@ -19,7 +18,16 @@ final class SparkleController { updaterDelegate: nil, userDriverDelegate: nil ) - + + observation = updaterController.updater.observe( + \.canCheckForUpdates, + options: [.initial, .new] + ) { [weak self] updater, _ in + MainActor.assumeIsolated { + self?.canCheckForUpdates = updater.canCheckForUpdates + } + } + // Quiet check on launch; surfaces a prompt only if a newer version exists. updaterController.updater.checkForUpdatesInBackground() } From dea8b6b7855dc69dde839e94f71c50472112415d Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:14:38 +0800 Subject: [PATCH 43/50] chore: turn of update check in dev builds --- Sources/Gitify/Model/SparkleController.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/Gitify/Model/SparkleController.swift b/Sources/Gitify/Model/SparkleController.swift index b1c76b8..6bfc526 100644 --- a/Sources/Gitify/Model/SparkleController.swift +++ b/Sources/Gitify/Model/SparkleController.swift @@ -13,12 +13,21 @@ final class SparkleController { private(set) var canCheckForUpdates = false init() { + let startUpdater: Bool + #if DEBUG + startUpdater = false + #else + startUpdater = true + #endif + updaterController = SPUStandardUpdaterController( - startingUpdater: true, + startingUpdater: startUpdater, updaterDelegate: nil, userDriverDelegate: nil ) + guard startUpdater else { return } + observation = updaterController.updater.observe( \.canCheckForUpdates, options: [.initial, .new] From f38dedb65319447aa8155b14a6434df129e06257 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:10:15 +0800 Subject: [PATCH 44/50] feat: add what's new popup to show user change log --- Package.swift | 5 +- Sources/Gitify/Model/AppDefaults.swift | 7 + Sources/Gitify/Model/ChangelogParser.swift | 107 +++++++++++++++ Sources/Gitify/Resources/CHANGELOG.md | 150 +++++++++++++++++++++ Sources/Gitify/Views/RootView.swift | 22 +++ Sources/Gitify/Views/WhatsNewView.swift | 113 ++++++++++++++++ scripts/build-app.sh | 3 + 7 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 Sources/Gitify/Model/ChangelogParser.swift create mode 100644 Sources/Gitify/Resources/CHANGELOG.md create mode 100644 Sources/Gitify/Views/WhatsNewView.swift diff --git a/Package.swift b/Package.swift index cd20cf2..cb03fec 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,10 @@ let package = Package( "GitKit", .product(name: "Sparkle", package: "Sparkle"), ], - resources: [.copy("Resources/AppIcon.icns")] + resources: [ + .copy("Resources/AppIcon.icns"), + .copy("Resources/CHANGELOG.md"), + ] ), // Full Xcode (XCTest / swift-testing) isn't installed, so the engine's // verification suite runs as a standalone executable harness instead. diff --git a/Sources/Gitify/Model/AppDefaults.swift b/Sources/Gitify/Model/AppDefaults.swift index 86bf9fd..9e2f078 100644 --- a/Sources/Gitify/Model/AppDefaults.swift +++ b/Sources/Gitify/Model/AppDefaults.swift @@ -7,6 +7,7 @@ enum AppDefaults { private static let terminalKey = "defaultTerminalBundleID" private static let editorKey = "defaultEditorBundleID" + private static let lastSeenVersionKey = "whatsNew.lastSeenVersion" // MARK: - Accessors @@ -22,6 +23,12 @@ enum AppDefaults { set { UserDefaults.standard.set(newValue, forKey: editorKey) } } + /// The app version the user last saw the \u{201c}What\u{2019}s New\u{201d} sheet for. + static var lastSeenVersion: String? { + get { UserDefaults.standard.string(forKey: lastSeenVersionKey) } + set { UserDefaults.standard.set(newValue, forKey: lastSeenVersionKey) } + } + /// Resolves a bundle identifier to an application URL, or `nil` if not installed. static func appURL(for bundleID: String) -> URL? { NSWorkspace.shared.urlsForApplications(withBundleIdentifier: bundleID).first diff --git a/Sources/Gitify/Model/ChangelogParser.swift b/Sources/Gitify/Model/ChangelogParser.swift new file mode 100644 index 0000000..e0ea1b3 --- /dev/null +++ b/Sources/Gitify/Model/ChangelogParser.swift @@ -0,0 +1,107 @@ +import Foundation + +/// A single release entry parsed from CHANGELOG.md. +struct ReleaseEntry: Identifiable, Sendable { + let version: String + let date: String + let sections: [Section] + + var id: String { version } + + struct Section: Sendable { + let heading: String + let items: [String] + } +} + +/// Parses a keep-a-changelog style CHANGELOG.md into structured release entries. +enum ChangelogParser { + + /// Parse the full changelog text and return releases in document order (newest first). + static func parse(_ text: String) -> [ReleaseEntry] { + let lines = text.components(separatedBy: "\n") + var entries: [ReleaseEntry] = [] + + var currentVersion: String? + var currentDate: String? + var currentSections: [ReleaseEntry.Section] = [] + var currentHeading: String? + var currentItems: [String] = [] + + for line in lines { + // Release header: ## [1.12.0](url) (2026-06-30) + if line.hasPrefix("## [") { + // Flush previous entry + if let version = currentVersion, let date = currentDate { + if let heading = currentHeading, !currentItems.isEmpty { + currentSections.append(.init(heading: heading, items: currentItems)) + } + entries.append(.init(version: version, date: date, sections: currentSections)) + } + + // Parse new header + let stripped = line.dropFirst(4) // remove "## [" + if let closeBracket = stripped.firstIndex(of: "]") { + currentVersion = String(stripped[stripped.startIndex.. String { + // Remove patterns like ([#30](url)) and ([hash](url)) + var result = text + // Pattern: ([ ... ]( ... )) — parenthesised markdown links + while let openParen = result.range(of: " \\(\\[[^\\]]*\\]\\([^)]*\\)\\)", options: .regularExpression) { + result.removeSubrange(openParen) + } + result = result.trimmingCharacters(in: .whitespaces) + // Capitalize first letter for display. + guard let first = result.first else { return result } + return first.uppercased() + result.dropFirst() + } +} diff --git a/Sources/Gitify/Resources/CHANGELOG.md b/Sources/Gitify/Resources/CHANGELOG.md new file mode 100644 index 0000000..8f5bd23 --- /dev/null +++ b/Sources/Gitify/Resources/CHANGELOG.md @@ -0,0 +1,150 @@ +# Changelog + +## [1.12.0](https://github.com/craigharman/gitify/compare/v1.11.0...v1.12.0) (2026-06-30) + + +### Features + +* add image diff viewer ([#30](https://github.com/craigharman/gitify/issues/30)) ([a3129f4](https://github.com/craigharman/gitify/commit/a3129f4378918bc1dcd06f9aaa7b094518db1bde)) + +## [1.11.0](https://github.com/craigharman/gitify/compare/v1.10.0...v1.11.0) (2026-06-27) + + +### Features + +* add "scan folders" to allow bulk import of repositories ([#28](https://github.com/craigharman/gitify/issues/28)) ([94f2352](https://github.com/craigharman/gitify/commit/94f2352dd6b5402f8f9ffe5fe5d2f8c5b7737a48)) + +## [1.10.0](https://github.com/craigharman/gitify/compare/v1.9.1...v1.10.0) (2026-06-24) + + +### Features + +* add remote SSH git repository management ([#26](https://github.com/craigharman/gitify/issues/26)) ([4fb4bf1](https://github.com/craigharman/gitify/commit/4fb4bf13db9ec857ed3c10922edbcea5201b5cbf)) + +## [1.9.1](https://github.com/craigharman/gitify/compare/v1.9.0...v1.9.1) (2026-06-23) + + +### Bug Fixes + +* merge conflicts not visible when changes are staged ([#24](https://github.com/craigharman/gitify/issues/24)) ([fa20ee4](https://github.com/craigharman/gitify/commit/fa20ee49ca4cad67e2f875539b1e3df3891deddc)) + +## [1.9.0](https://github.com/craigharman/gitify/compare/v1.8.3...v1.9.0) (2026-06-20) + + +### Features + +* add automerge ([#22](https://github.com/craigharman/gitify/issues/22)) ([6b290b1](https://github.com/craigharman/gitify/commit/6b290b14fee0e41208471fe5068692479abb725d)) + +## [1.8.3](https://github.com/craigharman/gitify/compare/v1.8.2...v1.8.3) (2026-06-20) + + +### Bug Fixes + +* add debugging to deployment script ([#20](https://github.com/craigharman/gitify/issues/20)) ([556fed0](https://github.com/craigharman/gitify/commit/556fed0268261e0115411126588383e4f76ca671)) + +## [1.8.2](https://github.com/craigharman/gitify/compare/v1.8.1...v1.8.2) (2026-06-20) + + +### Bug Fixes + +* access to sparkle key file ([#18](https://github.com/craigharman/gitify/issues/18)) ([723f659](https://github.com/craigharman/gitify/commit/723f6591ea18b14d3e24b7da08efed150c0b0f77)) + +## [1.8.1](https://github.com/craigharman/gitify/compare/v1.8.0...v1.8.1) (2026-06-20) + + +### Bug Fixes + +* download sparkle release as part of CI ([#16](https://github.com/craigharman/gitify/issues/16)) ([251c71a](https://github.com/craigharman/gitify/commit/251c71a3c5ab4b8fc3e3134e505d82f197affe97)) + +## [1.8.0](https://github.com/craigharman/gitify/compare/v1.7.0...v1.8.0) (2026-06-20) + + +### Features + +* add sidebar push/pull buttons ([#13](https://github.com/craigharman/gitify/issues/13)) ([308ef97](https://github.com/craigharman/gitify/commit/308ef976c959ad7c7e734cc82d66904f1fb44287)) +* fix deploy issue ([#12](https://github.com/craigharman/gitify/issues/12)) ([8ffc097](https://github.com/craigharman/gitify/commit/8ffc097a181415b5c9b3274325f5b64215e4d7d6)) + +## [1.7.0](https://github.com/craigharman/gitify/compare/v1.6.0...v1.7.0) (2026-06-19) + + +### Features + +* automatic software updates using sparkle ([#10](https://github.com/craigharman/gitify/issues/10)) ([2276fe0](https://github.com/craigharman/gitify/commit/2276fe007d87d82f7f23ad7b20c5156929081ea2)) + +## [1.6.0](https://github.com/craigharman/gitify/compare/v1.5.0...v1.6.0) (2026-06-19) + + +### Features + +* liquid glass interface added ([#8](https://github.com/craigharman/gitify/issues/8)) ([dc7ccff](https://github.com/craigharman/gitify/commit/dc7ccff1fab3a1dd56574e19249fa822f10fa1a4)) + +## [1.5.0](https://github.com/craigharman/gitify/compare/v1.4.2...v1.5.0) (2026-06-19) + + +### Features + +* continue and skip rebase ([06a5099](https://github.com/craigharman/gitify/commit/06a5099a3e7b6a7e2f87d1ce515bc5ce5ba4ab2f)) +* continue and skip rebase ([#6](https://github.com/craigharman/gitify/issues/6)) ([06a5099](https://github.com/craigharman/gitify/commit/06a5099a3e7b6a7e2f87d1ce515bc5ce5ba4ab2f)) + +## [1.4.2](https://github.com/craigharman/gitify/compare/v1.4.1...v1.4.2) (2026-06-18) + + +### Bug Fixes + +* copy SwiftPM resource bundle into .app to prevent launch crash ([67e9d5b](https://github.com/craigharman/gitify/commit/67e9d5bab42cb16b7815e8f0428a87d26f79be41)) + +## [1.4.1](https://github.com/craigharman/gitify/compare/v1.4.0...v1.4.1) (2026-06-18) + + +### Bug Fixes + +* versioning system ([f8899ee](https://github.com/craigharman/gitify/commit/f8899eedd958da6b8bd5fee1c44f7f6fb8469305)) + +## [1.4.0](https://github.com/craigharman/gitify/compare/v1.3.0...v1.4.0) (2026-06-18) + + +### Features + +* ability to remove repos from gitify ([ee1e39d](https://github.com/craigharman/gitify/commit/ee1e39d2d1e96806fec9e1f3445eacc3ac09e35d)) +* add a push after merge option ([92295ee](https://github.com/craigharman/gitify/commit/92295ee22d135dd8bd7266ec5e7afb893820db4e)) +* add merge rebase option if that merge error occurs ([09ebf84](https://github.com/craigharman/gitify/commit/09ebf84698d513580ed7faad7e0ba2770cfc4cf3)) +* add next/previous arrows to scroll through conflicts in conflict editor ([f9f11f8](https://github.com/craigharman/gitify/commit/f9f11f840b8388eb85ffa584ceb65eb417350ded)) +* add push/pull counter indicators to sidebar. ([b99c241](https://github.com/craigharman/gitify/commit/b99c241718205da8543a303209724f668f808f9a)) +* Add tree view for remote branches. ([58a6b58](https://github.com/craigharman/gitify/commit/58a6b5822ccd8aefc1b645386140e94099156de4)) +* commit inspect, commit context menu, overview stats, remote push, stash diff and conflict resolution ([735f4fb](https://github.com/craigharman/gitify/commit/735f4fb22486f66176624eff2c71ed4c72e9efa0)) +* Markdown rendering of readme.md on overview page ([02c42c9](https://github.com/craigharman/gitify/commit/02c42c94d55abb850aeb29b84e3b1c40babbe0f6)) +* Merge current branch to another branch ([11911fb](https://github.com/craigharman/gitify/commit/11911fb25da85bb82529eab6b89a6b185d2470dd)) +* search filter and submodules ([e67ae2d](https://github.com/craigharman/gitify/commit/e67ae2de92bd7ab5aea666d179096bb2ad449dd1)) +* sidebar status saved on exit and restored on start. ([6daa1e7](https://github.com/craigharman/gitify/commit/6daa1e7f931a54c7b9e905b3a8cd9935a79a71ed)) +* syntax highlighting added in conflict editor ([92151d3](https://github.com/craigharman/gitify/commit/92151d343942b0671c52c5aaf4e9374803e89760)) + + +### Bug Fixes + +* commit diff layout not showing when files staged. ([a884ff8](https://github.com/craigharman/gitify/commit/a884ff86c97d6b616cdecfddbc0c1d4fe7c6513e)) +* delete branch after merge conflict ([d9ed59b](https://github.com/craigharman/gitify/commit/d9ed59bd85889737808bb33ef1d6ecc95a5d8a8a)) +* The merge with push was reloading the merge window. This is now fixed. ([75b7cf9](https://github.com/craigharman/gitify/commit/75b7cf965081b2033c0cde859d8b9adc03eb3a4b)) +* warning banner header colour no longer spills into the sidebar. ([381abe0](https://github.com/craigharman/gitify/commit/381abe0fdb978d8f0af8bbfd0a8fa3cb75aef012)) + +## [1.3.0](https://github.com/craigharman/gitify/compare/v1.2.0...v1.3.0) (2026-06-18) + + +### Features + +* add a push after merge option ([92295ee](https://github.com/craigharman/gitify/commit/92295ee22d135dd8bd7266ec5e7afb893820db4e)) +* add merge rebase option if that merge error occurs ([09ebf84](https://github.com/craigharman/gitify/commit/09ebf84698d513580ed7faad7e0ba2770cfc4cf3)) + + +### Bug Fixes + +* The merge with push was reloading the merge window. This is now fixed. ([75b7cf9](https://github.com/craigharman/gitify/commit/75b7cf965081b2033c0cde859d8b9adc03eb3a4b)) + +## [1.2.0](https://github.com/craigharman/gitify/compare/v1.1.0...v1.2.0) (2026-06-18) + + +### Features + +* ability to remove repos from gitify ([ee1e39d](https://github.com/craigharman/gitify/commit/ee1e39d2d1e96806fec9e1f3445eacc3ac09e35d)) +* add next/previous arrows to scroll through conflicts in conflict editor ([f9f11f8](https://github.com/craigharman/gitify/commit/f9f11f840b8388eb85ffa584ceb65eb417350ded)) +* add push/pull counter indicators to sidebar. ([b99c241](https://github.com/craigharman/gitify/commit/b99c241718205da8543a303209724f668f808f9a)) +* syntax highlighting added in conflict editor ([92151d3](https://github.com/craigharman/gitify/commit/92151d343942b0671c52c5aaf4e9374803e89760)) diff --git a/Sources/Gitify/Views/RootView.swift b/Sources/Gitify/Views/RootView.swift index 1ff0446..851472d 100644 --- a/Sources/Gitify/Views/RootView.swift +++ b/Sources/Gitify/Views/RootView.swift @@ -5,6 +5,7 @@ import SwiftUI struct RootView: View { @Environment(AppModel.self) private var model @State private var showSSHServers = false + @State private var whatsNewEntries: WhatsNewContent? var body: some View { Group { @@ -44,5 +45,26 @@ struct RootView: View { } } .sheet(isPresented: $showSSHServers) { SSHServersView(model: model) } + .sheet(item: $whatsNewEntries) { entries in + WhatsNewView(entries: entries.items) + } + .onChange(of: whatsNewEntries?.id) { old, new in + if old != nil, new == nil { + AppDefaults.lastSeenVersion = currentAppVersion + } + } + .task { + let version = currentAppVersion + guard version != AppDefaults.lastSeenVersion else { return } + guard let url = Bundle.module.url(forResource: "CHANGELOG", withExtension: "md"), + let text = try? String(contentsOf: url, encoding: .utf8) else { return } + let entries = ChangelogParser.parse(text) + guard !entries.isEmpty else { return } + whatsNewEntries = WhatsNewContent(items: entries) + } + } + + private var currentAppVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" } } diff --git a/Sources/Gitify/Views/WhatsNewView.swift b/Sources/Gitify/Views/WhatsNewView.swift new file mode 100644 index 0000000..9657ae6 --- /dev/null +++ b/Sources/Gitify/Views/WhatsNewView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// Wrapper to satisfy `Identifiable` for the `sheet(item:)` API. +struct WhatsNewContent: Identifiable { + let id = UUID() + let items: [ReleaseEntry] +} + +/// Shows release notes for the current version and recent prior releases. +struct WhatsNewView: View { + let entries: [ReleaseEntry] + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + scrollContent + Divider() + footer + } + .frame(width: 520, height: 480) + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 14) { + Image(systemName: "sparkles") + .font(.system(size: 30)) + .foregroundStyle(.tint) + .frame(width: 44, height: 44) + VStack(alignment: .leading, spacing: 4) { + if let latest = entries.first { + Text("What\u{2019}s New in \(latest.version)") + .font(.title2.bold()) + Text(latest.date) + .foregroundStyle(.secondary) + } else { + Text("What\u{2019}s New") + .font(.title2.bold()) + } + } + Spacer() + } + .padding(24) + } + + // MARK: - Scrollable content + + private var scrollContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Latest release + if let latest = entries.first { + releaseSection(latest) + } + + // Recent updates (up to 3 prior releases) + let recent = Array(entries.dropFirst().prefix(3)) + if !recent.isEmpty { + Text("Recent Updates") + .font(.title3.bold()) + .padding(.top, 4) + + ForEach(recent) { entry in + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text(entry.version).font(.headline) + Text(entry.date).font(.caption).foregroundStyle(.secondary) + } + releaseSection(entry) + } + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// Renders the sections (Features, Bug Fixes, etc.) for a single release. + private func releaseSection(_ entry: ReleaseEntry) -> some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(entry.sections.enumerated()), id: \.offset) { _, section in + VStack(alignment: .leading, spacing: 6) { + Label(section.heading, systemImage: section.heading.lowercased().contains("fix") ? "ladybug" : "star") + .font(.subheadline.bold()) + .foregroundStyle(section.heading.lowercased().contains("fix") ? .orange : .blue) + + ForEach(section.items, id: \.self) { item in + HStack(alignment: .top, spacing: 8) { + Text("\u{2022}").foregroundStyle(.secondary) + Text(item) + } + .padding(.leading, 20) + } + } + } + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Spacer() + Button("Continue") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .padding(24) + } +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 8010335..6dc9490 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -9,6 +9,9 @@ CONFIG="${1:-release}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" APP="${ROOT}/build/Gitify.app" +# Keep the bundled changelog in sync with the repo root copy. +cp "${ROOT}/CHANGELOG.md" "${ROOT}/Sources/Gitify/Resources/CHANGELOG.md" + echo "==> Building Gitify (${CONFIG})" swift build --product Gitify -c "${CONFIG}" From 9b644c0916250f672c3ce2590f07c4b67ed2777e Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:49:07 +0800 Subject: [PATCH 45/50] fix: issue with reading changelog for what's new causing app to crash --- Sources/Gitify/App/GitifyApp.swift | 12 +++++++----- Sources/Gitify/Views/RootView.swift | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/Gitify/App/GitifyApp.swift b/Sources/Gitify/App/GitifyApp.swift index cbcb0ef..c0be7e0 100644 --- a/Sources/Gitify/App/GitifyApp.swift +++ b/Sources/Gitify/App/GitifyApp.swift @@ -45,20 +45,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // (e.g. `swift run`), where Info.plist's CFBundleIconFile doesn't apply. // Inside a .app bundle the icon is already set via Info.plist's CFBundleIconFile, // so we only attempt this when the SwiftPM resource bundle is available. - if let url = resourceBundle?.url(forResource: "AppIcon", withExtension: "icns"), + if let url = Bundle.safeModule?.url(forResource: "AppIcon", withExtension: "icns"), let image = NSImage(contentsOf: url) { NSApplication.shared.applicationIconImage = image } } +} - /// Returns the SwiftPM resource bundle if it exists, or nil. - /// `Bundle.module` calls fatalError when the bundle is missing, so we +extension Bundle { + /// Returns the SwiftPM resource bundle if it exists, or `nil`. + /// `Bundle.module` calls `fatalError` when the bundle is missing, so we /// replicate its lookup logic without the fatal trap. - private var resourceBundle: Bundle? { + static var safeModule: Bundle? { let bundleName = "Gitify_Gitify" let candidates = [ Bundle.main.resourceURL, - Bundle(for: AppDelegate.self).resourceURL, + Bundle.main.bundleURL, ] for candidate in candidates { if let url = candidate?.appendingPathComponent(bundleName + ".bundle"), diff --git a/Sources/Gitify/Views/RootView.swift b/Sources/Gitify/Views/RootView.swift index 851472d..7fe0992 100644 --- a/Sources/Gitify/Views/RootView.swift +++ b/Sources/Gitify/Views/RootView.swift @@ -56,7 +56,8 @@ struct RootView: View { .task { let version = currentAppVersion guard version != AppDefaults.lastSeenVersion else { return } - guard let url = Bundle.module.url(forResource: "CHANGELOG", withExtension: "md"), + guard let bundle = Bundle.safeModule, + let url = bundle.url(forResource: "CHANGELOG", withExtension: "md"), let text = try? String(contentsOf: url, encoding: .utf8) else { return } let entries = ChangelogParser.parse(text) guard !entries.isEmpty else { return } From 826aff0d5c6e15d169d8a5479e086c5ddeeb84eb Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:53:56 +0800 Subject: [PATCH 46/50] fix: a crash when runModal() returns, the field is now explicitly unhooked before teardown: --- Sources/Gitify/Resources/CHANGELOG.md | 14 ++++++++++++++ Sources/Gitify/Views/Prompts.swift | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/Gitify/Resources/CHANGELOG.md b/Sources/Gitify/Resources/CHANGELOG.md index 8f5bd23..bb61033 100644 --- a/Sources/Gitify/Resources/CHANGELOG.md +++ b/Sources/Gitify/Resources/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.13.1](https://github.com/craigharman/gitify/compare/v1.13.0...v1.13.1) (2026-06-30) + + +### Bug Fixes + +* read changelog file causing app to crash ([#34](https://github.com/craigharman/gitify/issues/34)) ([380e11f](https://github.com/craigharman/gitify/commit/380e11fafbb27572ec6a5e8f1e23b036935452f5)) + +## [1.13.0](https://github.com/craigharman/gitify/compare/v1.12.0...v1.13.0) (2026-06-30) + + +### Features + +* what's new changelog on update ([#32](https://github.com/craigharman/gitify/issues/32)) ([2e7dc1d](https://github.com/craigharman/gitify/commit/2e7dc1d7f53e01196e75772b92982502afad85c5)) + ## [1.12.0](https://github.com/craigharman/gitify/compare/v1.11.0...v1.12.0) (2026-06-30) diff --git a/Sources/Gitify/Views/Prompts.swift b/Sources/Gitify/Views/Prompts.swift index e81ade5..a82049a 100644 --- a/Sources/Gitify/Views/Prompts.swift +++ b/Sources/Gitify/Views/Prompts.swift @@ -19,7 +19,22 @@ enum Prompt { alert.accessoryView = field alert.window.initialFirstResponder = field - guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let response = alert.runModal() + + // Unhook the accessory field from the alert window's key view loop before + // teardown. `initialFirstResponder` and `nextKeyView` are unretained (assign) + // references, and the alert's internal accessory hosting view is destroyed + // lazily via autorelease -- if the loop still points at the freed field when + // that happens, `-[NSView _removeFromKeyViewLoop]` crashes on a dangling + // pointer (EXC_BAD_ACCESS in objc_msgSend). + alert.window.makeFirstResponder(nil) + alert.window.initialFirstResponder = nil + field.nextKeyView = nil + alert.accessoryView = nil + field.removeFromSuperview() + alert.window.recalculateKeyViewLoop() + + guard response == .alertFirstButtonReturn else { return nil } let value = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) if value.isEmpty { return allowEmpty ? "" : nil } return value From e7cb6f37d3b7f7fed873e3fe4bb5d17432e3f3ee Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:52:03 +0800 Subject: [PATCH 47/50] feat: add AI integration to write commit messages and do git actions via natural language. --- .../Model/AI/AIAssistantViewModel.swift | 207 +++++++++++ Sources/Gitify/Model/AI/AIClient.swift | 114 ++++++ .../Model/AI/AICommitMessageGenerator.swift | 108 ++++++ Sources/Gitify/Model/AI/AIProvider.swift | 96 +++++ .../Gitify/Model/AI/AIToolDefinitions.swift | 227 ++++++++++++ Sources/Gitify/Model/AI/AIToolExecutor.swift | 336 ++++++++++++++++++ Sources/Gitify/Model/AI/AnthropicClient.swift | 188 ++++++++++ Sources/Gitify/Model/AI/OpenAIClient.swift | 184 ++++++++++ Sources/Gitify/Model/Keychain.swift | 12 +- .../Gitify/Model/RepositoryViewModel.swift | 3 + Sources/Gitify/Views/AI/AIAssistantView.swift | 194 ++++++++++ Sources/Gitify/Views/AI/AISettingsTab.swift | 78 ++++ Sources/Gitify/Views/AppSettingsView.swift | 16 +- .../Views/RepositoryWorkspaceView.swift | 7 + Sources/Gitify/Views/WorkingTreeView.swift | 39 ++ 15 files changed, 1801 insertions(+), 8 deletions(-) create mode 100644 Sources/Gitify/Model/AI/AIAssistantViewModel.swift create mode 100644 Sources/Gitify/Model/AI/AIClient.swift create mode 100644 Sources/Gitify/Model/AI/AICommitMessageGenerator.swift create mode 100644 Sources/Gitify/Model/AI/AIProvider.swift create mode 100644 Sources/Gitify/Model/AI/AIToolDefinitions.swift create mode 100644 Sources/Gitify/Model/AI/AIToolExecutor.swift create mode 100644 Sources/Gitify/Model/AI/AnthropicClient.swift create mode 100644 Sources/Gitify/Model/AI/OpenAIClient.swift create mode 100644 Sources/Gitify/Views/AI/AIAssistantView.swift create mode 100644 Sources/Gitify/Views/AI/AISettingsTab.swift diff --git a/Sources/Gitify/Model/AI/AIAssistantViewModel.swift b/Sources/Gitify/Model/AI/AIAssistantViewModel.swift new file mode 100644 index 0000000..5f77ff4 --- /dev/null +++ b/Sources/Gitify/Model/AI/AIAssistantViewModel.swift @@ -0,0 +1,207 @@ +import Foundation +import GitKit + +/// A single message displayed in the AI assistant chat. +struct ChatMessage: Identifiable { + enum Role { case user, assistant, toolCall, toolResult } + + let id = UUID() + let role: Role + var text: String + /// For tool-call messages: the tool name. + var toolName: String? + /// For tool-result messages: the result content. + var toolResultContent: String? + let timestamp = Date() +} + +/// Manages the AI assistant conversation, including streaming responses and the tool-use +/// loop. Each instance is scoped to a single repository. +@MainActor +@Observable +final class AIAssistantViewModel { + var messages: [ChatMessage] = [] + var inputText = "" + private(set) var isStreaming = false + private(set) var error: String? + + private let viewModel: RepositoryViewModel + private let toolExecutor: AIToolExecutor + /// The full message history sent to the API (not the same as `messages`, which is for display). + private var conversationHistory: [AIMessage] = [] + private var streamTask: Task? + + /// Maximum consecutive tool-call rounds before stopping. + private static let maxToolRounds = 10 + + init(viewModel: RepositoryViewModel) { + self.viewModel = viewModel + self.toolExecutor = AIToolExecutor(viewModel: viewModel) + } + + /// Sends the current input as a user message and streams the AI response. + func send() { + let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isStreaming else { return } + inputText = "" + error = nil + + messages.append(ChatMessage(role: .user, text: text)) + conversationHistory.append(.user(text)) + + streamTask = Task { await streamResponse() } + } + + /// Cancels the in-flight stream. + func stop() { + streamTask?.cancel() + streamTask = nil + isStreaming = false + } + + /// Clears the conversation. + func clear() { + stop() + messages.removeAll() + conversationHistory.removeAll() + error = nil + } + + // MARK: - Streaming + tool-use loop + + private func streamResponse() async { + guard let client = makeClient() else { + error = "No API key configured. Open Settings \u{2192} AI to add one." + return + } + + let model = AIDefaults.model + let systemPrompt = buildSystemPrompt() + let tools = AIToolDefinitions.all + + isStreaming = true + defer { isStreaming = false } + + var toolRounds = 0 + + // Loop: stream a response, execute any tool calls, re-send with results. + while toolRounds < Self.maxToolRounds { + // Add a placeholder assistant message for streaming text into. + let assistantIndex = messages.count + messages.append(ChatMessage(role: .assistant, text: "")) + + var accumulatedText = "" + var pendingToolCalls: [AIToolCall] = [] + + do { + let stream = client.stream( + messages: conversationHistory, + systemPrompt: systemPrompt, + tools: tools, + model: model.id + ) + + for try await event in stream { + if Task.isCancelled { return } + switch event { + case .textDelta(let delta): + accumulatedText += delta + messages[assistantIndex].text = accumulatedText + case .toolCall(let call): + pendingToolCalls.append(call) + case .done: + break + } + } + } catch { + if !Task.isCancelled { + self.error = error.localizedDescription + // Remove empty assistant message if nothing was streamed. + if accumulatedText.isEmpty { + messages.remove(at: assistantIndex) + } + } + return + } + + // If no tool calls, we're done. + if pendingToolCalls.isEmpty { + // Record the assistant's text in conversation history. + if !accumulatedText.isEmpty { + conversationHistory.append(.assistant(accumulatedText)) + } + return + } + + // Record the assistant's response (text + tool calls) in history. + conversationHistory.append(.assistantToolCalls(pendingToolCalls, text: accumulatedText)) + + // Execute each tool call and send results back. + for call in pendingToolCalls { + // Show the tool call in the chat. + messages.append(ChatMessage(role: .toolCall, text: call.name, toolName: call.name)) + + let result = await toolExecutor.execute(name: call.name, arguments: call.arguments) + + // Show the tool result. + messages.append(ChatMessage(role: .toolResult, text: result, + toolName: call.name, toolResultContent: result)) + + // Add to conversation history. + conversationHistory.append(.toolResult(callID: call.id, content: result)) + } + + toolRounds += 1 + } + + // If we hit the cap, let the user know. + if toolRounds >= Self.maxToolRounds { + messages.append(ChatMessage(role: .assistant, + text: "Reached the maximum number of tool calls (\(Self.maxToolRounds)). Please continue the conversation if more steps are needed.")) + } + } + + // MARK: - Helpers + + private func makeClient() -> (any AIClient)? { + let provider = AIDefaults.provider + guard let key = AIDefaults.apiKey(for: provider), !key.isEmpty else { return nil } + switch provider { + case .anthropic: return AnthropicClient(apiKey: key) + case .openAI: return OpenAIClient(apiKey: key) + } + } + + private func buildSystemPrompt() -> String { + var prompt = """ + You are a Git expert assistant integrated into Gitify, a macOS Git client. \ + You help the user with Git operations on their repository. + + You have access to tools that let you read repository state and perform Git operations. \ + Always use the tools to check the current state before making changes. \ + For destructive operations (merge, rebase, reset, force push), explain what you are \ + about to do and why before executing the tool call. + + Keep responses concise and focused on the Git task at hand. + """ + + // Add current repo context if available. + if let status = viewModel.status { + prompt += "\n\nCurrent repository context:" + prompt += "\n- Branch: \(status.branch ?? "(detached HEAD)")" + if let upstream = status.upstream { + prompt += "\n- Upstream: \(upstream)" + if status.ahead > 0 { prompt += " (ahead \(status.ahead))" } + if status.behind > 0 { prompt += " (behind \(status.behind))" } + } + let changeCount = status.files.count + if changeCount > 0 { + prompt += "\n- \(changeCount) changed file\(changeCount == 1 ? "" : "s") in working tree" + } else { + prompt += "\n- Working tree clean" + } + } + + return prompt + } +} diff --git a/Sources/Gitify/Model/AI/AIClient.swift b/Sources/Gitify/Model/AI/AIClient.swift new file mode 100644 index 0000000..0baebf9 --- /dev/null +++ b/Sources/Gitify/Model/AI/AIClient.swift @@ -0,0 +1,114 @@ +import Foundation + +/// A tool call requested by the AI. +struct AIToolCall: Sendable { + let id: String + let name: String + let arguments: String // JSON string +} + +/// Events emitted while streaming an AI response. +enum AIStreamEvent: Sendable { + /// A fragment of assistant text. + case textDelta(String) + /// The AI is requesting a tool call. The full arguments JSON is assembled from deltas + /// before this event fires. + case toolCall(AIToolCall) + /// The response is complete (no more events). + case done +} + +/// Role of a message in the conversation. +enum AIRole: String, Codable, Sendable { + case system + case user + case assistant + case tool +} + +/// A single content block within a message. +enum AIContentBlock: Sendable { + case text(String) + case toolCall(AIToolCall) + case toolResult(toolCallID: String, content: String) +} + +/// A message in the conversation history, suitable for sending to either provider. +struct AIMessage: Sendable { + let role: AIRole + let content: [AIContentBlock] + + static func user(_ text: String) -> AIMessage { + AIMessage(role: .user, content: [.text(text)]) + } + + static func assistant(_ text: String) -> AIMessage { + AIMessage(role: .assistant, content: [.text(text)]) + } + + static func assistantToolCalls(_ calls: [AIToolCall], text: String = "") -> AIMessage { + var blocks: [AIContentBlock] = [] + if !text.isEmpty { blocks.append(.text(text)) } + blocks.append(contentsOf: calls.map { .toolCall($0) }) + return AIMessage(role: .assistant, content: blocks) + } + + static func toolResult(callID: String, content: String) -> AIMessage { + AIMessage(role: .tool, content: [.toolResult(toolCallID: callID, content: content)]) + } +} + +/// A tool definition to expose to the AI. Marked `@unchecked Sendable` because the +/// parameters dictionary is set once at init and never mutated. +struct AIToolDefinition: @unchecked Sendable { + let name: String + let description: String + /// JSON Schema object describing the parameters (as a dictionary). + let parameters: [String: Any] + + /// Converts to the Anthropic tool format. + func anthropicJSON() -> [String: Any] { + ["name": name, "description": description, "input_schema": parameters] + } + + /// Converts to the OpenAI function-calling format. + func openAIJSON() -> [String: Any] { + [ + "type": "function", + "function": [ + "name": name, + "description": description, + "parameters": parameters, + ] as [String: Any], + ] + } +} + +/// Protocol for AI provider clients. Implementations must be `Sendable` for use with +/// Swift 6 strict concurrency. +protocol AIClient: Sendable { + /// Sends a conversation and streams back events. The caller is responsible for the + /// tool-use loop (executing tool calls and re-sending with results). + func stream( + messages: [AIMessage], + systemPrompt: String, + tools: [AIToolDefinition], + model: String + ) -> AsyncThrowingStream +} + +// MARK: - SSE line parsing + +/// Extracts the payload from a Server-Sent Events `data:` line. +/// Returns nil for comment lines, empty lines, or non-data fields. +enum SSEParser { + static func dataPayload(from line: String) -> String? { + guard line.hasPrefix("data:") else { return nil } + let payload = line.dropFirst(5) + // Trim a single leading space per the SSE spec. + if payload.hasPrefix(" ") { + return String(payload.dropFirst()) + } + return String(payload) + } +} diff --git a/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift b/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift new file mode 100644 index 0000000..c7f8880 --- /dev/null +++ b/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift @@ -0,0 +1,108 @@ +import Foundation +import GitKit + +/// Generates a conventional-commit-style message from the staged diff using the configured +/// AI provider. +enum AICommitMessageGenerator { + + /// Generates a commit message for the currently staged changes. + @MainActor + static func generate(viewModel: RepositoryViewModel) async throws -> String { + let provider = AIDefaults.provider + guard let key = AIDefaults.apiKey(for: provider), !key.isEmpty else { + throw AIClientError(message: "No API key configured for \(provider.displayName).") + } + + guard let service = viewModel.gitService else { + throw AIClientError(message: "Repository service is not available.") + } + + let diffText = try await collectStagedDiff(service: service) + guard !diffText.isEmpty else { + throw AIClientError(message: "No staged changes to generate a message for.") + } + + let client: any AIClient = switch provider { + case .anthropic: AnthropicClient(apiKey: key) + case .openAI: OpenAIClient(apiKey: key) + } + + let systemPrompt = """ + Generate a concise conventional commit message for the following staged changes. \ + Use the format: type(optional-scope): description + + Where type is one of: feat, fix, chore, refactor, docs, style, test, perf, ci, build. + + Rules: + - The first line must be at most 72 characters. + - Use imperative mood (e.g. "add" not "added"). + - Do not include a body or footer unless the changes are complex enough to warrant it. + - Return ONLY the commit message text, nothing else. + """ + + let messages: [AIMessage] = [.user(diffText)] + let model = AIDefaults.model + + let stream = client.stream( + messages: messages, + systemPrompt: systemPrompt, + tools: [], + model: model.id + ) + + var result = "" + for try await event in stream { + if case .textDelta(let delta) = event { + result += delta + } + } + + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Collects the staged diff for all staged files, truncated to a reasonable size. + private static func collectStagedDiff(service: any GitService) async throws -> String { + let status = try await service.status() + let staged = status.stagedFiles + + guard !staged.isEmpty else { return "" } + + var parts: [String] = [] + var totalLength = 0 + let maxLength = 8000 + + for file in staged { + guard totalLength < maxLength else { break } + do { + let diff = try await service.diff(path: file.path, staged: true) + let text = diff.hunks.map { hunk in + hunk.lines.map { line in + let prefix: String + switch line.kind { + case .addition: prefix = "+" + case .deletion: prefix = "-" + case .context: prefix = " " + } + return "\(prefix)\(line.content)" + }.joined(separator: "\n") + }.joined(separator: "\n") + + if !text.isEmpty { + let header = "--- \(file.path) ---" + parts.append(header + "\n" + text) + totalLength += header.count + text.count + } + } catch { + // Skip files whose diff can't be read (e.g. binary). + continue + } + } + + if totalLength > maxLength { + let joined = parts.joined(separator: "\n\n") + return String(joined.prefix(maxLength)) + "\n\n[Diff truncated]" + } + + return parts.joined(separator: "\n\n") + } +} diff --git a/Sources/Gitify/Model/AI/AIProvider.swift b/Sources/Gitify/Model/AI/AIProvider.swift new file mode 100644 index 0000000..64d02da --- /dev/null +++ b/Sources/Gitify/Model/AI/AIProvider.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Supported AI providers for the Git assistant. +enum AIProvider: String, Codable, CaseIterable, Identifiable { + case anthropic + case openAI + + var id: String { rawValue } + + var displayName: String { + switch self { + case .anthropic: "Anthropic" + case .openAI: "OpenAI" + } + } + + /// The models available for this provider. + var models: [AIModel] { + switch self { + case .anthropic: AIModel.anthropicModels + case .openAI: AIModel.openAIModels + } + } + + /// The default model for a newly-selected provider. + var defaultModel: AIModel { + models[0] + } +} + +/// An AI model that can be used with the assistant. +struct AIModel: Identifiable, Hashable { + let id: String // API model identifier (e.g. "claude-sonnet-4-20250514") + let displayName: String // Human-readable name (e.g. "Claude Sonnet 4") + let provider: AIProvider + + static let anthropicModels: [AIModel] = [ + AIModel(id: "claude-sonnet-4-20250514", displayName: "Claude Sonnet 4", provider: .anthropic), + AIModel(id: "claude-3-5-haiku-20241022", displayName: "Claude 3.5 Haiku", provider: .anthropic), + ] + + static let openAIModels: [AIModel] = [ + AIModel(id: "gpt-4o", displayName: "GPT-4o", provider: .openAI), + AIModel(id: "gpt-4o-mini", displayName: "GPT-4o mini", provider: .openAI), + AIModel(id: "o3-mini", displayName: "o3-mini", provider: .openAI), + ] +} + +/// UserDefaults accessors for AI assistant preferences. +enum AIDefaults { + private static let providerKey = "ai.provider" + private static let modelKey = "ai.model" + + static var provider: AIProvider { + get { + UserDefaults.standard.string(forKey: providerKey) + .flatMap(AIProvider.init(rawValue:)) ?? .anthropic + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: providerKey) + } + } + + static var modelID: String { + get { + UserDefaults.standard.string(forKey: modelKey) ?? AIProvider.anthropic.defaultModel.id + } + set { + UserDefaults.standard.set(newValue, forKey: modelKey) + } + } + + /// Resolves the selected model, falling back to the provider's default if the stored ID + /// doesn't match any known model (e.g. after an app update removes a model). + static var model: AIModel { + let id = modelID + let prov = provider + return prov.models.first { $0.id == id } ?? prov.defaultModel + } + + // MARK: - Keychain (API keys) + + private static let keychainService = "com.gitify.ai" + + static func apiKey(for provider: AIProvider) -> String? { + Keychain.get(account: provider.rawValue, service: keychainService) + } + + static func setAPIKey(_ key: String, for provider: AIProvider) { + Keychain.set(key, account: provider.rawValue, service: keychainService) + } + + static func removeAPIKey(for provider: AIProvider) { + Keychain.delete(account: provider.rawValue, service: keychainService) + } +} diff --git a/Sources/Gitify/Model/AI/AIToolDefinitions.swift b/Sources/Gitify/Model/AI/AIToolDefinitions.swift new file mode 100644 index 0000000..779f698 --- /dev/null +++ b/Sources/Gitify/Model/AI/AIToolDefinitions.swift @@ -0,0 +1,227 @@ +import Foundation + +/// Tool definitions exposed to the AI assistant, mapping to GitService operations. +enum AIToolDefinitions { + + static let all: [AIToolDefinition] = [ + // MARK: Read-only tools + + AIToolDefinition( + name: "get_status", + description: "Get the working tree status: current branch, staged files, unstaged changes, and untracked files.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + AIToolDefinition( + name: "get_log", + description: "Get recent commit history. Returns commit SHA, author, date, and message.", + parameters: [ + "type": "object", + "properties": [ + "limit": ["type": "integer", "description": "Number of commits to return (default 10, max 50)."], + ] as [String: Any], + "required": [] as [String], + ] + ), + AIToolDefinition( + name: "get_refs", + description: "List all branches (local and remote) and tags with their current SHAs.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + AIToolDefinition( + name: "get_diff", + description: "Get the diff for a specific file. Shows what has changed.", + parameters: [ + "type": "object", + "properties": [ + "path": ["type": "string", "description": "File path relative to the repo root."], + "staged": ["type": "boolean", "description": "If true, show the staged (index) diff. Default false."], + ] as [String: Any], + "required": ["path"], + ] + ), + AIToolDefinition( + name: "get_commit_detail", + description: "Get detailed information about a specific commit, including its changed files and line counts.", + parameters: [ + "type": "object", + "properties": [ + "sha": ["type": "string", "description": "The commit SHA (full or abbreviated)."], + ] as [String: Any], + "required": ["sha"], + ] + ), + AIToolDefinition( + name: "get_conflicts", + description: "List files with unresolved merge conflicts.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + AIToolDefinition( + name: "get_file_contents", + description: "Read the contents of a file in the working tree (including any conflict markers).", + parameters: [ + "type": "object", + "properties": [ + "path": ["type": "string", "description": "File path relative to the repo root."], + ] as [String: Any], + "required": ["path"], + ] + ), + AIToolDefinition( + name: "get_stashes", + description: "List all stash entries.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + AIToolDefinition( + name: "get_remotes", + description: "List configured remotes with their fetch and push URLs.", + parameters: ["type": "object", "properties": [:] as [String: Any], "required": [] as [String]] + ), + + // MARK: Mutation tools + + AIToolDefinition( + name: "stage_files", + description: "Stage files for commit (git add). Use [\".\"] to stage all changes.", + parameters: [ + "type": "object", + "properties": [ + "paths": [ + "type": "array", + "items": ["type": "string"], + "description": "File paths to stage. Use [\".\"] to stage all.", + ] as [String: Any], + ] as [String: Any], + "required": ["paths"], + ] + ), + AIToolDefinition( + name: "commit", + description: "Create a commit with the staged changes.", + parameters: [ + "type": "object", + "properties": [ + "message": ["type": "string", "description": "The commit message."], + "amend": ["type": "boolean", "description": "If true, amend the last commit. Default false."], + ] as [String: Any], + "required": ["message"], + ] + ), + AIToolDefinition( + name: "checkout", + description: "Check out an existing branch, tag, or commit.", + parameters: [ + "type": "object", + "properties": [ + "revision": ["type": "string", "description": "Branch name, tag, or commit SHA to check out."], + ] as [String: Any], + "required": ["revision"], + ] + ), + AIToolDefinition( + name: "create_branch", + description: "Create a new branch, optionally checking it out.", + parameters: [ + "type": "object", + "properties": [ + "name": ["type": "string", "description": "Name for the new branch."], + "start_point": ["type": "string", "description": "Starting point (branch, tag, or SHA). Default HEAD."], + "checkout": ["type": "boolean", "description": "Switch to the new branch. Default true."], + ] as [String: Any], + "required": ["name"], + ] + ), + AIToolDefinition( + name: "merge", + description: "Merge a branch into the current branch. This is a destructive operation \u{2014} explain to the user what you are about to do before calling this.", + parameters: [ + "type": "object", + "properties": [ + "branch": ["type": "string", "description": "The branch to merge into the current branch."], + "squash": ["type": "boolean", "description": "Squash all commits into the index without committing. Default false."], + "no_fast_forward": ["type": "boolean", "description": "Always create a merge commit. Default false."], + "no_commit": ["type": "boolean", "description": "Perform merge but stop before committing. Default false."], + ] as [String: Any], + "required": ["branch"], + ] + ), + AIToolDefinition( + name: "rebase", + description: "Rebase the current branch onto another branch. This rewrites history \u{2014} explain to the user what you are about to do before calling this.", + parameters: [ + "type": "object", + "properties": [ + "onto": ["type": "string", "description": "The branch to rebase onto."], + ] as [String: Any], + "required": ["onto"], + ] + ), + AIToolDefinition( + name: "cherry_pick", + description: "Apply a specific commit onto the current branch.", + parameters: [ + "type": "object", + "properties": [ + "sha": ["type": "string", "description": "The commit SHA to cherry-pick."], + ] as [String: Any], + "required": ["sha"], + ] + ), + AIToolDefinition( + name: "revert", + description: "Create a new commit that undoes a specific commit.", + parameters: [ + "type": "object", + "properties": [ + "sha": ["type": "string", "description": "The commit SHA to revert."], + ] as [String: Any], + "required": ["sha"], + ] + ), + AIToolDefinition( + name: "resolve_conflict", + description: "Resolve a merge conflict in a file by choosing ours or theirs.", + parameters: [ + "type": "object", + "properties": [ + "path": ["type": "string", "description": "Path of the conflicted file."], + "use_ours": ["type": "boolean", "description": "If true, take our version. If false, take theirs."], + ] as [String: Any], + "required": ["path", "use_ours"], + ] + ), + AIToolDefinition( + name: "abort_operation", + description: "Abort an in-progress merge or rebase.", + parameters: [ + "type": "object", + "properties": [ + "operation": ["type": "string", "enum": ["merge", "rebase"], "description": "Which operation to abort."], + ] as [String: Any], + "required": ["operation"], + ] + ), + AIToolDefinition( + name: "stash_push", + description: "Stash the current working tree changes.", + parameters: [ + "type": "object", + "properties": [ + "message": ["type": "string", "description": "Optional stash message."], + "include_untracked": ["type": "boolean", "description": "Include untracked files. Default true."], + ] as [String: Any], + "required": [] as [String], + ] + ), + AIToolDefinition( + name: "stash_pop", + description: "Apply and remove the most recent stash (or a specified stash).", + parameters: [ + "type": "object", + "properties": [ + "selector": ["type": "string", "description": "Stash selector (e.g. stash@{0}). Default: most recent."], + ] as [String: Any], + "required": [] as [String], + ] + ), + ] +} diff --git a/Sources/Gitify/Model/AI/AIToolExecutor.swift b/Sources/Gitify/Model/AI/AIToolExecutor.swift new file mode 100644 index 0000000..e1673c7 --- /dev/null +++ b/Sources/Gitify/Model/AI/AIToolExecutor.swift @@ -0,0 +1,336 @@ +import Foundation +import GitKit + +/// Executes AI tool calls against a repository's GitService, formatting results as strings +/// for the AI to consume. Errors are returned as descriptive strings rather than thrown, +/// so the AI can reason about failures. +@MainActor +final class AIToolExecutor { + private let viewModel: RepositoryViewModel + + init(viewModel: RepositoryViewModel) { + self.viewModel = viewModel + } + + /// Dispatches a tool call by name and returns a string result for the AI. + func execute(name: String, arguments: String) async -> String { + let args = parseArgs(arguments) + do { + guard let service = viewModel.gitService else { + return "Error: Repository service is not available." + } + + switch name { + // MARK: Read-only + case "get_status": + return try await executeGetStatus(service) + case "get_log": + return try await executeGetLog(service, args: args) + case "get_refs": + return try await executeGetRefs(service) + case "get_diff": + return try await executeGetDiff(service, args: args) + case "get_commit_detail": + return try await executeGetCommitDetail(service, args: args) + case "get_conflicts": + return try await executeGetConflicts(service) + case "get_file_contents": + return try await executeGetFileContents(service, args: args) + case "get_stashes": + return try await executeGetStashes(service) + case "get_remotes": + return try await executeGetRemotes(service) + + // MARK: Mutations + case "stage_files": + return try await executeStageFiles(service, args: args) + case "commit": + return try await executeCommit(service, args: args) + case "checkout": + return try await executeCheckout(service, args: args) + case "create_branch": + return try await executeCreateBranch(service, args: args) + case "merge": + return try await executeMerge(service, args: args) + case "rebase": + return try await executeRebase(service, args: args) + case "cherry_pick": + return try await executeCherryPick(service, args: args) + case "revert": + return try await executeRevert(service, args: args) + case "resolve_conflict": + return try await executeResolveConflict(service, args: args) + case "abort_operation": + return try await executeAbortOperation(service, args: args) + case "stash_push": + return try await executeStashPush(service, args: args) + case "stash_pop": + return try await executeStashPop(service, args: args) + default: + return "Error: Unknown tool \u{201C}\(name)\u{201D}." + } + } catch { + return "Error: \(error.localizedDescription)" + } + } + + // MARK: - Read-only implementations + + private func executeGetStatus(_ service: any GitService) async throws -> String { + let status = try await service.status() + var lines: [String] = [] + lines.append("Branch: \(status.branch ?? "(detached HEAD)")") + if let upstream = status.upstream { + var tracking = "Upstream: \(upstream)" + if status.ahead > 0 { tracking += " [ahead \(status.ahead)]" } + if status.behind > 0 { tracking += " [behind \(status.behind)]" } + lines.append(tracking) + } + if status.files.isEmpty { + lines.append("Working tree clean.") + } else { + for file in status.files { + let idx = file.indexState == .unmodified ? "." : String(file.indexState.rawValue) + let wt = file.worktreeState == .unmodified ? "." : String(file.worktreeState.rawValue) + lines.append("\(idx)\(wt) \(file.path)") + } + } + return lines.joined(separator: "\n") + } + + private func executeGetLog(_ service: any GitService, args: [String: Any]) async throws -> String { + let limit = min(args["limit"] as? Int ?? 10, 50) + let page = try await service.log(skip: 0, limit: limit, revisions: ["HEAD"]) + if page.commits.isEmpty { return "No commits found." } + return page.commits.map { c in + "\(c.shortID) \(c.authorName) \(c.authorDate) \(c.summary)" + }.joined(separator: "\n") + } + + private func executeGetRefs(_ service: any GitService) async throws -> String { + let refs = try await service.refs() + if refs.isEmpty { return "No refs found." } + var sections: [String] = [] + let local = refs.filter { $0.kind == .localBranch } + if !local.isEmpty { + sections.append("Local branches:\n" + local.map { + " \($0.isHead ? "* " : " ")\($0.name) \($0.targetSHA.prefix(7))" + }.joined(separator: "\n")) + } + let remote = refs.filter { $0.kind == .remoteBranch } + if !remote.isEmpty { + sections.append("Remote branches:\n" + remote.map { + " \($0.name) \($0.targetSHA.prefix(7))" + }.joined(separator: "\n")) + } + let tags = refs.filter { $0.kind == .tag } + if !tags.isEmpty { + sections.append("Tags:\n" + tags.map { + " \($0.name) \($0.targetSHA.prefix(7))" + }.joined(separator: "\n")) + } + return sections.joined(separator: "\n\n") + } + + private func executeGetDiff(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let path = args["path"] as? String else { return "Error: Missing required parameter \u{201C}path\u{201D}." } + let staged = args["staged"] as? Bool ?? false + let diff = try await service.diff(path: path, staged: staged) + if diff.hunks.isEmpty { return "No differences found for \(path)." } + return diff.hunks.map { hunk in + hunk.lines.map { line in + let prefix: String + switch line.kind { + case .addition: prefix = "+" + case .deletion: prefix = "-" + case .context: prefix = " " + } + return "\(prefix)\(line.content)" + }.joined(separator: "\n") + }.joined(separator: "\n---\n") + } + + private func executeGetCommitDetail(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let sha = args["sha"] as? String else { return "Error: Missing required parameter \u{201C}sha\u{201D}." } + let changes = try await service.commitChanges(sha: sha) + if changes.isEmpty { return "No changes found for commit \(sha)." } + return changes.map { c in + "\(c.status.rawValue) \(c.path) (+\(c.additions) -\(c.deletions))" + }.joined(separator: "\n") + } + + private func executeGetConflicts(_ service: any GitService) async throws -> String { + let files = try await service.conflictedFiles() + if files.isEmpty { return "No conflicted files." } + return "Conflicted files:\n" + files.map { " \($0)" }.joined(separator: "\n") + } + + private func executeGetFileContents(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let path = args["path"] as? String else { return "Error: Missing required parameter \u{201C}path\u{201D}." } + guard let contents = await service.fileContents(path: path) else { + return "Error: Could not read file \u{201C}\(path)\u{201D}." + } + // Truncate very large files to avoid blowing up the context. + if contents.count > 50_000 { + return String(contents.prefix(50_000)) + "\n\n[Truncated \u{2014} file is \(contents.count) characters.]" + } + return contents + } + + private func executeGetStashes(_ service: any GitService) async throws -> String { + let stashes = try await service.stashes() + if stashes.isEmpty { return "No stashes." } + return stashes.map { "\($0.id): \($0.message)" }.joined(separator: "\n") + } + + private func executeGetRemotes(_ service: any GitService) async throws -> String { + let remotes = try await service.remotes() + if remotes.isEmpty { return "No remotes configured." } + return remotes.map { "\($0.name) fetch=\($0.fetchURL) push=\($0.pushURL)" }.joined(separator: "\n") + } + + // MARK: - Mutation implementations + + private func executeStageFiles(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let paths = args["paths"] as? [String], !paths.isEmpty else { + return "Error: Missing required parameter \u{201C}paths\u{201D}." + } + if paths == ["."] { + try await service.stageAll() + } else { + try await service.stage(paths: paths) + } + await viewModel.refreshStatus() + return "Staged \(paths == ["."] ? "all changes" : paths.joined(separator: ", "))." + } + + private func executeCommit(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let message = args["message"] as? String, !message.isEmpty else { + return "Error: Missing required parameter \u{201C}message\u{201D}." + } + let amend = args["amend"] as? Bool ?? false + try await service.commit(message: message, amend: amend) + await viewModel.refreshStatus() + return amend ? "Amended the last commit." : "Committed with message: \(message)" + } + + private func executeCheckout(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let revision = args["revision"] as? String else { + return "Error: Missing required parameter \u{201C}revision\u{201D}." + } + try await service.checkout(revision) + await viewModel.refreshStatus() + return "Checked out \u{201C}\(revision)\u{201D}." + } + + private func executeCreateBranch(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let name = args["name"] as? String else { + return "Error: Missing required parameter \u{201C}name\u{201D}." + } + let startPoint = args["start_point"] as? String + let checkout = args["checkout"] as? Bool ?? true + try await service.createBranch(name: name, startPoint: startPoint, checkout: checkout) + await viewModel.refreshStatus() + return "Created branch \u{201C}\(name)\u{201D}\(checkout ? " and checked it out" : "")." + } + + private func executeMerge(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let branch = args["branch"] as? String else { + return "Error: Missing required parameter \u{201C}branch\u{201D}." + } + let squash = args["squash"] as? Bool ?? false + let noFF = args["no_fast_forward"] as? Bool ?? false + let noCommit = args["no_commit"] as? Bool ?? false + try await service.merge(branch: branch, squash: squash, noFastForward: noFF, + noCommit: noCommit, skipHooks: false) + await viewModel.refreshStatus() + let conflicts = try await service.conflictedFiles() + if !conflicts.isEmpty { + return "Merge of \u{201C}\(branch)\u{201D} resulted in conflicts in: \(conflicts.joined(separator: ", ")). Resolve them and commit." + } + return "Merged \u{201C}\(branch)\u{201D} into the current branch." + } + + private func executeRebase(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let onto = args["onto"] as? String else { + return "Error: Missing required parameter \u{201C}onto\u{201D}." + } + try await service.rebase(onto: onto) + await viewModel.refreshStatus() + return "Rebased current branch onto \u{201C}\(onto)\u{201D}." + } + + private func executeCherryPick(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let sha = args["sha"] as? String else { + return "Error: Missing required parameter \u{201C}sha\u{201D}." + } + try await service.cherryPick(sha: sha) + await viewModel.refreshStatus() + return "Cherry-picked commit \(sha)." + } + + private func executeRevert(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let sha = args["sha"] as? String else { + return "Error: Missing required parameter \u{201C}sha\u{201D}." + } + try await service.revert(sha: sha) + await viewModel.refreshStatus() + return "Reverted commit \(sha)." + } + + private func executeResolveConflict(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let path = args["path"] as? String else { + return "Error: Missing required parameter \u{201C}path\u{201D}." + } + guard let useOurs = args["use_ours"] as? Bool else { + return "Error: Missing required parameter \u{201C}use_ours\u{201D}." + } + try await service.resolveConflict(path: path, useOurs: useOurs) + await viewModel.refreshStatus() + return "Resolved \u{201C}\(path)\u{201D} using \(useOurs ? "ours" : "theirs")." + } + + private func executeAbortOperation(_ service: any GitService, args: [String: Any]) async throws -> String { + guard let op = args["operation"] as? String else { + return "Error: Missing required parameter \u{201C}operation\u{201D}." + } + switch op { + case "merge": + try await service.abortMerge() + await viewModel.refreshStatus() + return "Aborted the in-progress merge." + case "rebase": + try await service.abortRebase() + await viewModel.refreshStatus() + return "Aborted the in-progress rebase." + default: + return "Error: Unknown operation \u{201C}\(op)\u{201D}. Use \u{201C}merge\u{201D} or \u{201C}rebase\u{201D}." + } + } + + private func executeStashPush(_ service: any GitService, args: [String: Any]) async throws -> String { + let message = args["message"] as? String + let includeUntracked = args["include_untracked"] as? Bool ?? true + try await service.stashPush(message: message, includeUntracked: includeUntracked) + await viewModel.refreshStatus() + return "Stashed working tree changes\(message.map { ": \($0)" } ?? "")." + } + + private func executeStashPop(_ service: any GitService, args: [String: Any]) async throws -> String { + let selector = args["selector"] as? String ?? "stash@{0}" + try await service.stashPop(selector) + await viewModel.refreshStatus() + return "Applied and removed stash \(selector)." + } + + // MARK: - Helpers + + private func parseArgs(_ json: String) -> [String: Any] { + guard !json.isEmpty, + let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + return obj + } +} diff --git a/Sources/Gitify/Model/AI/AnthropicClient.swift b/Sources/Gitify/Model/AI/AnthropicClient.swift new file mode 100644 index 0000000..1bfa1b1 --- /dev/null +++ b/Sources/Gitify/Model/AI/AnthropicClient.swift @@ -0,0 +1,188 @@ +import Foundation + +/// Anthropic Messages API client with streaming and tool-use support. +struct AnthropicClient: AIClient { + let apiKey: String + + private static let endpoint = URL(string: "https://api.anthropic.com/v1/messages")! + + func stream( + messages: [AIMessage], + systemPrompt: String, + tools: [AIToolDefinition], + model: String + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let request = try buildRequest(messages: messages, systemPrompt: systemPrompt, + tools: tools, model: model) + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard let http = response as? HTTPURLResponse else { + throw AIClientError(message: "No response from Anthropic.") + } + guard (200..<300).contains(http.statusCode) else { + // Read error body. + var body = "" + for try await line in bytes.lines { body += line } + throw AIClientError(message: "Anthropic returned HTTP \(http.statusCode): \(body)") + } + try await parseStream(bytes: bytes, continuation: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Request building + + private func buildRequest( + messages: [AIMessage], systemPrompt: String, + tools: [AIToolDefinition], model: String + ) throws -> URLRequest { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + var body: [String: Any] = [ + "model": model, + "max_tokens": 4096, + "stream": true, + "system": systemPrompt, + "messages": messages.map(encodeMessage), + ] + if !tools.isEmpty { + body["tools"] = tools.map { $0.anthropicJSON() } + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + private func encodeMessage(_ message: AIMessage) -> [String: Any] { + switch message.role { + case .user: + // User messages with tool results use the Anthropic content-array format. + let hasToolResult = message.content.contains { + if case .toolResult = $0 { return true } + return false + } + if hasToolResult { + return [ + "role": "user", + "content": message.content.map(encodeContentBlock), + ] + } + let text = message.content.compactMap { block -> String? in + if case .text(let t) = block { return t } + return nil + }.joined() + return ["role": "user", "content": text] + + case .assistant: + let blocks = message.content.map(encodeContentBlock) + return ["role": "assistant", "content": blocks] + + case .tool: + // Tool results are sent as user messages with tool_result content blocks. + return [ + "role": "user", + "content": message.content.map(encodeContentBlock), + ] + + case .system: + return ["role": "user", "content": ""] + } + } + + private func encodeContentBlock(_ block: AIContentBlock) -> [String: Any] { + switch block { + case .text(let text): + return ["type": "text", "text": text] + case .toolCall(let call): + let input = (try? JSONSerialization.jsonObject(with: Data(call.arguments.utf8))) ?? [:] + return [ + "type": "tool_use", + "id": call.id, + "name": call.name, + "input": input, + ] + case .toolResult(let callID, let content): + return [ + "type": "tool_result", + "tool_use_id": callID, + "content": content, + ] + } + } + + // MARK: - Stream parsing + + private func parseStream( + bytes: URLSession.AsyncBytes, + continuation: AsyncThrowingStream.Continuation + ) async throws { + // Accumulators for tool calls being built across deltas. + var currentToolID: String? + var currentToolName: String? + var currentToolArgs = "" + + for try await line in bytes.lines { + guard let data = SSEParser.dataPayload(from: line) else { continue } + if data == "[DONE]" { break } + + guard let json = try? JSONSerialization.jsonObject(with: Data(data.utf8)) as? [String: Any], + let type = json["type"] as? String else { continue } + + switch type { + case "content_block_start": + if let block = json["content_block"] as? [String: Any], + block["type"] as? String == "tool_use" { + currentToolID = block["id"] as? String + currentToolName = block["name"] as? String + currentToolArgs = "" + } + + case "content_block_delta": + if let delta = json["delta"] as? [String: Any] { + let deltaType = delta["type"] as? String ?? "" + if deltaType == "text_delta", let text = delta["text"] as? String { + continuation.yield(.textDelta(text)) + } else if deltaType == "input_json_delta", let partial = delta["partial_json"] as? String { + currentToolArgs += partial + } + } + + case "content_block_stop": + if let id = currentToolID, let name = currentToolName { + let call = AIToolCall(id: id, name: name, arguments: currentToolArgs) + continuation.yield(.toolCall(call)) + currentToolID = nil + currentToolName = nil + currentToolArgs = "" + } + + case "message_stop": + continuation.yield(.done) + + case "error": + if let error = json["error"] as? [String: Any], + let msg = error["message"] as? String { + throw AIClientError(message: "Anthropic error: \(msg)") + } + + default: + break + } + } + } +} + +struct AIClientError: LocalizedError { + let message: String + var errorDescription: String? { message } +} diff --git a/Sources/Gitify/Model/AI/OpenAIClient.swift b/Sources/Gitify/Model/AI/OpenAIClient.swift new file mode 100644 index 0000000..335b198 --- /dev/null +++ b/Sources/Gitify/Model/AI/OpenAIClient.swift @@ -0,0 +1,184 @@ +import Foundation + +/// OpenAI Chat Completions API client with streaming and function-calling support. +struct OpenAIClient: AIClient { + let apiKey: String + + private static let endpoint = URL(string: "https://api.openai.com/v1/chat/completions")! + + func stream( + messages: [AIMessage], + systemPrompt: String, + tools: [AIToolDefinition], + model: String + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let request = try buildRequest(messages: messages, systemPrompt: systemPrompt, + tools: tools, model: model) + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard let http = response as? HTTPURLResponse else { + throw AIClientError(message: "No response from OpenAI.") + } + guard (200..<300).contains(http.statusCode) else { + var body = "" + for try await line in bytes.lines { body += line } + throw AIClientError(message: "OpenAI returned HTTP \(http.statusCode): \(body)") + } + try await parseStream(bytes: bytes, continuation: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Request building + + private func buildRequest( + messages: [AIMessage], systemPrompt: String, + tools: [AIToolDefinition], model: String + ) throws -> URLRequest { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + // Build the OpenAI messages array with system prompt first. + var apiMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt], + ] + for msg in messages { + apiMessages.append(contentsOf: encodeMessage(msg)) + } + + var body: [String: Any] = [ + "model": model, + "stream": true, + "messages": apiMessages, + ] + if !tools.isEmpty { + body["tools"] = tools.map { $0.openAIJSON() } + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + /// Encodes a message for the OpenAI API. Tool results may produce multiple messages. + private func encodeMessage(_ message: AIMessage) -> [[String: Any]] { + switch message.role { + case .user: + let text = message.content.compactMap { block -> String? in + if case .text(let t) = block { return t } + return nil + }.joined() + return [["role": "user", "content": text]] + + case .assistant: + var msg: [String: Any] = ["role": "assistant"] + let texts = message.content.compactMap { block -> String? in + if case .text(let t) = block { return t } + return nil + } + if !texts.isEmpty { msg["content"] = texts.joined() } + + let toolCalls = message.content.compactMap { block -> [String: Any]? in + guard case .toolCall(let call) = block else { return nil } + return [ + "id": call.id, + "type": "function", + "function": [ + "name": call.name, + "arguments": call.arguments, + ] as [String: Any], + ] + } + if !toolCalls.isEmpty { msg["tool_calls"] = toolCalls } + return [msg] + + case .tool: + return message.content.compactMap { block -> [String: Any]? in + guard case .toolResult(let callID, let content) = block else { return nil } + return [ + "role": "tool", + "tool_call_id": callID, + "content": content, + ] + } + + case .system: + return [["role": "system", "content": ""]] + } + } + + // MARK: - Stream parsing + + private func parseStream( + bytes: URLSession.AsyncBytes, + continuation: AsyncThrowingStream.Continuation + ) async throws { + // OpenAI streams tool calls across multiple deltas; accumulate per index. + var toolCallAccumulators: [Int: (id: String, name: String, args: String)] = [:] + + for try await line in bytes.lines { + guard let data = SSEParser.dataPayload(from: line) else { continue } + if data == "[DONE]" { + // Flush any accumulated tool calls. + for index in toolCallAccumulators.keys.sorted() { + if let acc = toolCallAccumulators[index] { + let call = AIToolCall(id: acc.id, name: acc.name, arguments: acc.args) + continuation.yield(.toolCall(call)) + } + } + toolCallAccumulators.removeAll() + continuation.yield(.done) + break + } + + guard let json = try? JSONSerialization.jsonObject(with: Data(data.utf8)) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let choice = choices.first, + let delta = choice["delta"] as? [String: Any] else { continue } + + // Text content. + if let content = delta["content"] as? String, !content.isEmpty { + continuation.yield(.textDelta(content)) + } + + // Tool calls (function calling). + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for tc in toolCalls { + guard let index = tc["index"] as? Int else { continue } + if let id = tc["id"] as? String { + // Start of a new tool call. + let name = (tc["function"] as? [String: Any])?["name"] as? String ?? "" + let args = (tc["function"] as? [String: Any])?["arguments"] as? String ?? "" + toolCallAccumulators[index] = (id: id, name: name, args: args) + } else if let fn = tc["function"] as? [String: Any] { + // Continuation delta — append arguments. + if let args = fn["arguments"] as? String { + toolCallAccumulators[index]?.args += args + } + if let name = fn["name"] as? String, !(name.isEmpty) { + toolCallAccumulators[index]?.name += name + } + } + } + } + + // Check for finish_reason to flush tool calls. + if let reason = choice["finish_reason"] as? String, reason == "tool_calls" { + for index in toolCallAccumulators.keys.sorted() { + if let acc = toolCallAccumulators[index] { + let call = AIToolCall(id: acc.id, name: acc.name, arguments: acc.args) + continuation.yield(.toolCall(call)) + } + } + toolCallAccumulators.removeAll() + } + } + } +} diff --git a/Sources/Gitify/Model/Keychain.swift b/Sources/Gitify/Model/Keychain.swift index 696670e..49fe3af 100644 --- a/Sources/Gitify/Model/Keychain.swift +++ b/Sources/Gitify/Model/Keychain.swift @@ -1,12 +1,12 @@ import Foundation import Security -/// Minimal Keychain wrapper for storing hosting-provider access tokens. +/// Minimal Keychain wrapper for storing secrets (hosting tokens, AI API keys, etc.). enum Keychain { - private static let service = "com.gitify.accounts" + private static let defaultService = "com.gitify.accounts" - static func set(_ token: String, account: String) { - delete(account: account) + static func set(_ token: String, account: String, service: String = "com.gitify.accounts") { + delete(account: account, service: service) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -16,7 +16,7 @@ enum Keychain { SecItemAdd(query as CFDictionary, nil) } - static func get(account: String) -> String? { + static func get(account: String, service: String = "com.gitify.accounts") -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -30,7 +30,7 @@ enum Keychain { return String(decoding: data, as: UTF8.self) } - static func delete(account: String) { + static func delete(account: String, service: String = "com.gitify.accounts") { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift index 4084883..6b9864c 100644 --- a/Sources/Gitify/Model/RepositoryViewModel.swift +++ b/Sources/Gitify/Model/RepositoryViewModel.swift @@ -53,6 +53,9 @@ final class RepositoryViewModel { private var pendingMergeSourceToDelete: String? private var service: CLIGitService? + + /// Exposes the resolved git service for use by the AI tool executor. + var gitService: (any GitService)? { service } private var nextSkip: Int? = 0 private var watcher: (any RepositoryWatching)? private var autoRefreshTask: Task? diff --git a/Sources/Gitify/Views/AI/AIAssistantView.swift b/Sources/Gitify/Views/AI/AIAssistantView.swift new file mode 100644 index 0000000..ac062ac --- /dev/null +++ b/Sources/Gitify/Views/AI/AIAssistantView.swift @@ -0,0 +1,194 @@ +import SwiftUI + +/// The AI assistant chat sheet. Presents a scrollable conversation with a text input bar. +struct AIAssistantView: View { + @State var assistant: AIAssistantViewModel + @Environment(\.dismiss) private var dismiss + + init(viewModel: RepositoryViewModel) { + _assistant = State(initialValue: AIAssistantViewModel(viewModel: viewModel)) + } + + var body: some View { + VStack(spacing: 0) { + // Toolbar + HStack { + Text("AI Assistant").font(.headline) + Spacer() + Picker("Model", selection: Binding( + get: { AIDefaults.modelID }, + set: { AIDefaults.modelID = $0 } + )) { + ForEach(AIDefaults.provider.models) { model in + Text(model.displayName).tag(model.id) + } + } + .fixedSize() + Button { assistant.clear() } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Clear conversation") + .disabled(assistant.messages.isEmpty) + Button("Done") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + + Divider() + + // Messages + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(assistant.messages) { message in + MessageBubble(message: message) + .id(message.id) + } + } + .padding(16) + } + .onChange(of: assistant.messages.count) { _, _ in + if let last = assistant.messages.last { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + .onChange(of: assistant.messages.last?.text) { _, _ in + if let last = assistant.messages.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + + // Error banner + if let error = assistant.error { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(error) + .font(.caption) + .lineLimit(2) + Spacer() + } + .padding(8) + .background(.orange.opacity(0.1)) + } + + Divider() + + // Input bar + HStack(spacing: 8) { + TextField("Ask about your repository\u{2026}", text: $assistant.inputText, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...5) + .onSubmit { + if !NSEvent.modifierFlags.contains(.shift) { + assistant.send() + } + } + + if assistant.isStreaming { + Button { assistant.stop() } label: { + Image(systemName: "stop.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + .help("Stop generating") + } else { + Button { assistant.send() } label: { + Image(systemName: "arrow.up.circle.fill") + .foregroundStyle(.tint) + } + .buttonStyle(.borderless) + .disabled(assistant.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Send message") + } + } + .padding(12) + .background(.bar) + } + .frame(width: 620, height: 520) + } +} + +/// Renders a single chat message as a styled bubble. +private struct MessageBubble: View { + let message: ChatMessage + + var body: some View { + switch message.role { + case .user: + HStack { + Spacer(minLength: 60) + Text(message.text) + .padding(10) + .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 10)) + .textSelection(.enabled) + } + + case .assistant: + HStack { + Text(LocalizedStringKey(message.text)) + .padding(10) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 10)) + .textSelection(.enabled) + Spacer(minLength: 60) + } + + case .toolCall: + ToolCallCard(name: message.toolName ?? "tool", isResult: false, content: nil) + + case .toolResult: + ToolCallCard(name: message.toolName ?? "tool", isResult: true, + content: message.toolResultContent) + } + } +} + +/// A collapsible card showing a tool call or its result. +private struct ToolCallCard: View { + let name: String + let isResult: Bool + let content: String? + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + if isResult { isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: isResult ? "checkmark.circle.fill" : "gearshape.fill") + .foregroundStyle(isResult ? .green : .secondary) + .font(.caption) + Text(isResult ? "Result: \(name)" : "Calling: \(name)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + if isResult, content != nil { + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .buttonStyle(.plain) + + if isExpanded, let content { + Text(content) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(20) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + .textSelection(.enabled) + } + } + .padding(.horizontal, 4) + } +} diff --git a/Sources/Gitify/Views/AI/AISettingsTab.swift b/Sources/Gitify/Views/AI/AISettingsTab.swift new file mode 100644 index 0000000..8ad1eb0 --- /dev/null +++ b/Sources/Gitify/Views/AI/AISettingsTab.swift @@ -0,0 +1,78 @@ +import SwiftUI + +/// AI configuration tab for the app settings window. Lets the user choose a provider, +/// enter an API key, and select a model. +struct AISettingsTab: View { + @State private var provider: AIProvider = AIDefaults.provider + @State private var apiKey: String = "" + @State private var modelID: String = AIDefaults.modelID + @State private var hasKey = false + + var body: some View { + Form { + Picker("Provider", selection: $provider) { + ForEach(AIProvider.allCases) { p in + Text(p.displayName).tag(p) + } + } + .pickerStyle(.segmented) + + Section { + if hasKey { + HStack { + Label("API key is saved", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Spacer() + Button("Remove", role: .destructive) { + AIDefaults.removeAPIKey(for: provider) + apiKey = "" + hasKey = false + } + .controlSize(.small) + } + } else { + SecureField("API Key", text: $apiKey) + HStack { + Text("Enter your \(provider.displayName) API key. It will be stored in your Keychain.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Save") { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + AIDefaults.setAPIKey(trimmed, for: provider) + hasKey = true + apiKey = "" + } + .controlSize(.small) + .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + + Picker("Model", selection: $modelID) { + ForEach(provider.models) { model in + Text(model.displayName).tag(model.id) + } + } + } + .formStyle(.grouped) + .frame(width: 400) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: provider) { _, newValue in + AIDefaults.provider = newValue + // Reset model to the new provider's default if the current one doesn't belong. + if !newValue.models.contains(where: { $0.id == modelID }) { + modelID = newValue.defaultModel.id + } + // Refresh key state for the new provider. + hasKey = AIDefaults.apiKey(for: newValue) != nil + } + .onChange(of: modelID) { _, newValue in + AIDefaults.modelID = newValue + } + .onAppear { + hasKey = AIDefaults.apiKey(for: provider) != nil + } + } +} diff --git a/Sources/Gitify/Views/AppSettingsView.swift b/Sources/Gitify/Views/AppSettingsView.swift index 562eaed..3f0f49a 100644 --- a/Sources/Gitify/Views/AppSettingsView.swift +++ b/Sources/Gitify/Views/AppSettingsView.swift @@ -1,8 +1,20 @@ import SwiftUI -/// App-level preferences pane (Cmd+,). Provides pickers for the default terminal and editor -/// applications, populated from installed apps on the system. +/// App-level preferences pane (Cmd+,). Provides tabs for General settings and AI configuration. struct AppSettingsView: View { + var body: some View { + TabView { + GeneralSettingsTab() + .tabItem { Label("General", systemImage: "gearshape") } + AISettingsTab() + .tabItem { Label("AI", systemImage: "sparkles") } + } + .frame(width: 420) + } +} + +/// General settings tab: default terminal and editor pickers. +private struct GeneralSettingsTab: View { @State private var terminalID: String = AppDefaults.terminalBundleID @State private var editorID: String = AppDefaults.editorBundleID ?? "" diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift index 83be4e6..10a3672 100644 --- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift +++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift @@ -24,6 +24,7 @@ struct RepositoryWorkspaceView: View { @State private var section: WorkspaceSection @State private var integrationSheet: IntegrationSheet? @State private var showSettings = false + @State private var showAssistant = false // Per-repository key for the last-selected section. private var sectionKey: String { SidebarDefaults.sectionKey(ref.path) } @@ -114,6 +115,11 @@ struct RepositoryWorkspaceView: View { branchMenu moreMenu + Button { showAssistant = true } label: { + Label("AI Assistant", systemImage: "sparkles") + } + .help("AI Assistant") + Button { Task { await viewModel.load() } } label: { Image(systemName: "arrow.clockwise") } @@ -127,6 +133,7 @@ struct RepositoryWorkspaceView: View { } } .sheet(isPresented: $showSettings) { SettingsSheet(viewModel: viewModel) } + .sheet(isPresented: $showAssistant) { AIAssistantView(viewModel: viewModel) } .task { await viewModel.load() } .overlay { if viewModel.isBusy { diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index 5388f17..8b06dda 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -542,6 +542,7 @@ private struct CommitBox: View { @Bindable var viewModel: RepositoryViewModel @FocusState private var editorFocused: Bool @State private var commitAndPush: Bool + @State private var isGeneratingMessage = false init(viewModel: RepositoryViewModel) { self.viewModel = viewModel @@ -549,6 +550,10 @@ private struct CommitBox: View { _commitAndPush = State(initialValue: UserDefaults.standard.bool(forKey: key)) } + private var hasAIKey: Bool { + AIDefaults.apiKey(for: AIDefaults.provider) != nil + } + private var primaryLabel: String { if viewModel.amendMode { return commitAndPush ? "Amend & Push" : "Amend Commit" @@ -572,6 +577,40 @@ private struct CommitBox: View { .focused($editorFocused) .font(.body) .scrollContentBackground(.hidden) + if hasAIKey { + VStack { + HStack { + Spacer() + if isGeneratingMessage { + ProgressView() + .controlSize(.small) + .padding(4) + } else { + Button { + Task { + isGeneratingMessage = true + defer { isGeneratingMessage = false } + do { + let message = try await AICommitMessageGenerator.generate(viewModel: viewModel) + viewModel.commitMessage = message + } catch { + // Silently ignore — the user can write manually. + } + } + } label: { + Image(systemName: "sparkles") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Generate commit message with AI") + .disabled(viewModel.status?.stagedFiles.isEmpty != false) + .padding(4) + } + } + Spacer() + } + } } .padding(8) // inner padding so text/cursor isn't against the border .frame(height: 88) From 26c16afa3242284b2f9d159a3115bd8514f8b57e Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:25:59 +0800 Subject: [PATCH 48/50] chore: make AI model selection dynamic --- .../Model/AI/AIAssistantViewModel.swift | 5 +- .../Model/AI/AICommitMessageGenerator.swift | 4 +- Sources/Gitify/Model/AI/AIProvider.swift | 125 ++++++++++++++---- Sources/Gitify/Views/AI/AIAssistantView.swift | 12 +- Sources/Gitify/Views/AI/AISettingsTab.swift | 81 ++++++++++-- .../Views/RepositoryWorkspaceView.swift | 28 +++- Sources/Gitify/Views/WorkingTreeView.swift | 7 +- 7 files changed, 212 insertions(+), 50 deletions(-) diff --git a/Sources/Gitify/Model/AI/AIAssistantViewModel.swift b/Sources/Gitify/Model/AI/AIAssistantViewModel.swift index 5f77ff4..891cf6d 100644 --- a/Sources/Gitify/Model/AI/AIAssistantViewModel.swift +++ b/Sources/Gitify/Model/AI/AIAssistantViewModel.swift @@ -75,7 +75,10 @@ final class AIAssistantViewModel { return } - let model = AIDefaults.model + guard let model = AIDefaults.model else { + error = "No model selected. Open Settings \u{2192} AI to choose one." + return + } let systemPrompt = buildSystemPrompt() let tools = AIToolDefinitions.all diff --git a/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift b/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift index c7f8880..e50193a 100644 --- a/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift +++ b/Sources/Gitify/Model/AI/AICommitMessageGenerator.swift @@ -41,7 +41,9 @@ enum AICommitMessageGenerator { """ let messages: [AIMessage] = [.user(diffText)] - let model = AIDefaults.model + guard let model = AIDefaults.model else { + throw AIClientError(message: "No model selected.") + } let stream = client.stream( messages: messages, diff --git a/Sources/Gitify/Model/AI/AIProvider.swift b/Sources/Gitify/Model/AI/AIProvider.swift index 64d02da..9123a54 100644 --- a/Sources/Gitify/Model/AI/AIProvider.swift +++ b/Sources/Gitify/Model/AI/AIProvider.swift @@ -14,36 +14,97 @@ enum AIProvider: String, Codable, CaseIterable, Identifiable { } } - /// The models available for this provider. - var models: [AIModel] { + /// Fetches available models from the provider's API. + func fetchModels(apiKey: String) async throws -> [AIModel] { switch self { - case .anthropic: AIModel.anthropicModels - case .openAI: AIModel.openAIModels + case .anthropic: try await Self.fetchAnthropicModels(apiKey: apiKey) + case .openAI: try await Self.fetchOpenAIModels(apiKey: apiKey) } } - /// The default model for a newly-selected provider. - var defaultModel: AIModel { - models[0] + // MARK: - Anthropic + + private static func fetchAnthropicModels(apiKey: String) async throws -> [AIModel] { + var all: [AIModel] = [] + var afterID: String? + + // Paginate through all models. + while true { + var urlString = "https://api.anthropic.com/v1/models?limit=100" + if let after = afterID { urlString += "&after_id=\(after)" } + guard let url = URL(string: urlString) else { break } + + var request = URLRequest(url: url) + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let http = response as? HTTPURLResponse + throw AIClientError(message: "Anthropic returned HTTP \(http?.statusCode ?? 0) when listing models.") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { break } + + for item in items { + guard let id = item["id"] as? String else { continue } + let displayName = item["display_name"] as? String ?? id + all.append(AIModel(id: id, displayName: displayName, provider: .anthropic)) + } + + let hasMore = json["has_more"] as? Bool ?? false + if hasMore, let lastID = json["last_id"] as? String { + afterID = lastID + } else { + break + } + } + + return all + } + + // MARK: - OpenAI + + /// Prefixes that indicate a chat-capable OpenAI model. + private static let openAIChatPrefixes = ["gpt-", "o1-", "o3-", "o4-", "chatgpt-"] + + private static func fetchOpenAIModels(apiKey: String) async throws -> [AIModel] { + guard let url = URL(string: "https://api.openai.com/v1/models") else { + throw AIClientError(message: "Invalid OpenAI URL.") + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let http = response as? HTTPURLResponse + throw AIClientError(message: "OpenAI returned HTTP \(http?.statusCode ?? 0) when listing models.") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { + return [] + } + + // Filter to chat-capable models and sort alphabetically. + return items.compactMap { item -> AIModel? in + guard let id = item["id"] as? String else { return nil } + let isChatModel = openAIChatPrefixes.contains { id.hasPrefix($0) } + guard isChatModel else { return nil } + // Skip fine-tuned models (contain ":ft-" or "::" patterns). + if id.contains(":ft-") || id.contains("::") { return nil } + return AIModel(id: id, displayName: id, provider: .openAI) + }.sorted { $0.id < $1.id } } } /// An AI model that can be used with the assistant. struct AIModel: Identifiable, Hashable { let id: String // API model identifier (e.g. "claude-sonnet-4-20250514") - let displayName: String // Human-readable name (e.g. "Claude Sonnet 4") + let displayName: String // Human-readable name let provider: AIProvider - - static let anthropicModels: [AIModel] = [ - AIModel(id: "claude-sonnet-4-20250514", displayName: "Claude Sonnet 4", provider: .anthropic), - AIModel(id: "claude-3-5-haiku-20241022", displayName: "Claude 3.5 Haiku", provider: .anthropic), - ] - - static let openAIModels: [AIModel] = [ - AIModel(id: "gpt-4o", displayName: "GPT-4o", provider: .openAI), - AIModel(id: "gpt-4o-mini", displayName: "GPT-4o mini", provider: .openAI), - AIModel(id: "o3-mini", displayName: "o3-mini", provider: .openAI), - ] } /// UserDefaults accessors for AI assistant preferences. @@ -63,19 +124,33 @@ enum AIDefaults { static var modelID: String { get { - UserDefaults.standard.string(forKey: modelKey) ?? AIProvider.anthropic.defaultModel.id + UserDefaults.standard.string(forKey: modelKey) ?? "" } set { UserDefaults.standard.set(newValue, forKey: modelKey) } } - /// Resolves the selected model, falling back to the provider's default if the stored ID - /// doesn't match any known model (e.g. after an app update removes a model). - static var model: AIModel { + /// Resolves the selected model. Returns an AIModel with the stored ID, + /// or nil if no model has been selected yet. + static var model: AIModel? { let id = modelID - let prov = provider - return prov.models.first { $0.id == id } ?? prov.defaultModel + guard !id.isEmpty else { return nil } + return AIModel(id: id, displayName: id, provider: provider) + } + + // MARK: - Hide AI features + + private static let hideKey = "ai.hidden" + + static var hideAIFeatures: Bool { + get { UserDefaults.standard.bool(forKey: hideKey) } + set { UserDefaults.standard.set(newValue, forKey: hideKey) } + } + + /// True when an API key is configured for the current provider. + static var hasAPIKey: Bool { + apiKey(for: provider) != nil } // MARK: - Keychain (API keys) diff --git a/Sources/Gitify/Views/AI/AIAssistantView.swift b/Sources/Gitify/Views/AI/AIAssistantView.swift index ac062ac..f65d122 100644 --- a/Sources/Gitify/Views/AI/AIAssistantView.swift +++ b/Sources/Gitify/Views/AI/AIAssistantView.swift @@ -15,15 +15,11 @@ struct AIAssistantView: View { HStack { Text("AI Assistant").font(.headline) Spacer() - Picker("Model", selection: Binding( - get: { AIDefaults.modelID }, - set: { AIDefaults.modelID = $0 } - )) { - ForEach(AIDefaults.provider.models) { model in - Text(model.displayName).tag(model.id) - } + if let model = AIDefaults.model { + Text(model.displayName) + .font(.caption) + .foregroundStyle(.secondary) } - .fixedSize() Button { assistant.clear() } label: { Image(systemName: "trash") } diff --git a/Sources/Gitify/Views/AI/AISettingsTab.swift b/Sources/Gitify/Views/AI/AISettingsTab.swift index 8ad1eb0..a6a3e5e 100644 --- a/Sources/Gitify/Views/AI/AISettingsTab.swift +++ b/Sources/Gitify/Views/AI/AISettingsTab.swift @@ -1,12 +1,16 @@ import SwiftUI /// AI configuration tab for the app settings window. Lets the user choose a provider, -/// enter an API key, and select a model. +/// enter an API key, and select a model (fetched dynamically from the provider's API). struct AISettingsTab: View { @State private var provider: AIProvider = AIDefaults.provider @State private var apiKey: String = "" @State private var modelID: String = AIDefaults.modelID @State private var hasKey = false + @State private var hideAI: Bool = AIDefaults.hideAIFeatures + @State private var availableModels: [AIModel] = [] + @State private var isLoadingModels = false + @State private var modelError: String? var body: some View { Form { @@ -27,6 +31,9 @@ struct AISettingsTab: View { AIDefaults.removeAPIKey(for: provider) apiKey = "" hasKey = false + availableModels = [] + modelID = "" + AIDefaults.modelID = "" } .controlSize(.small) } @@ -43,6 +50,7 @@ struct AISettingsTab: View { AIDefaults.setAPIKey(trimmed, for: provider) hasKey = true apiKey = "" + Task { await loadModels() } } .controlSize(.small) .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -50,29 +58,84 @@ struct AISettingsTab: View { } } - Picker("Model", selection: $modelID) { - ForEach(provider.models) { model in - Text(model.displayName).tag(model.id) + Section { + if isLoadingModels { + HStack { + ProgressView() + .controlSize(.small) + Text("Loading models\u{2026}") + .foregroundStyle(.secondary) + } + } else if availableModels.isEmpty { + if let error = modelError { + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } else if hasKey { + Text("No models available.") + .foregroundStyle(.secondary) + } else { + Text("Add an API key to see available models.") + .foregroundStyle(.secondary) + } + } else { + Picker("Model", selection: $modelID) { + ForEach(availableModels) { model in + Text(model.displayName).tag(model.id) + } + } } } + + Toggle("Hide AI features", isOn: $hideAI) } .formStyle(.grouped) .frame(width: 400) .fixedSize(horizontal: false, vertical: true) .onChange(of: provider) { _, newValue in AIDefaults.provider = newValue - // Reset model to the new provider's default if the current one doesn't belong. - if !newValue.models.contains(where: { $0.id == modelID }) { - modelID = newValue.defaultModel.id - } - // Refresh key state for the new provider. hasKey = AIDefaults.apiKey(for: newValue) != nil + availableModels = [] + modelError = nil + if hasKey { + Task { await loadModels() } + } else { + modelID = "" + AIDefaults.modelID = "" + } } .onChange(of: modelID) { _, newValue in AIDefaults.modelID = newValue } + .onChange(of: hideAI) { _, newValue in + AIDefaults.hideAIFeatures = newValue + } .onAppear { hasKey = AIDefaults.apiKey(for: provider) != nil + if hasKey { + Task { await loadModels() } + } + } + } + + private func loadModels() async { + guard let key = AIDefaults.apiKey(for: provider) else { return } + isLoadingModels = true + modelError = nil + defer { isLoadingModels = false } + + do { + let models = try await provider.fetchModels(apiKey: key) + availableModels = models + + // If the currently selected model isn't in the list, pick the first. + if !models.contains(where: { $0.id == modelID }) { + modelID = models.first?.id ?? "" + AIDefaults.modelID = modelID + } + } catch { + modelError = error.localizedDescription + availableModels = [] } } } diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift index 10a3672..7ce765e 100644 --- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift +++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift @@ -25,6 +25,8 @@ struct RepositoryWorkspaceView: View { @State private var integrationSheet: IntegrationSheet? @State private var showSettings = false @State private var showAssistant = false + @State private var showAIPopover = false + @AppStorage("ai.hidden") private var hideAIFeatures = false // Per-repository key for the last-selected section. private var sectionKey: String { SidebarDefaults.sectionKey(ref.path) } @@ -115,10 +117,30 @@ struct RepositoryWorkspaceView: View { branchMenu moreMenu - Button { showAssistant = true } label: { - Label("AI Assistant", systemImage: "sparkles") + if !hideAIFeatures { + Button { + if AIDefaults.hasAPIKey { + showAssistant = true + } else { + showAIPopover = true + } + } label: { + Label("AI Assistant", systemImage: "sparkles") + } + .help("AI Assistant") + .popover(isPresented: $showAIPopover) { + VStack(alignment: .leading, spacing: 8) { + Text("AI Assistant") + .font(.headline) + Text("Gitify allows you to use AI to explain your Git requirements in plain English and compose commit messages. To use this feature add an API key via settings.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .frame(width: 300) + } } - .help("AI Assistant") Button { Task { await viewModel.load() } } label: { Image(systemName: "arrow.clockwise") diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index 8b06dda..e240e8b 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -543,6 +543,7 @@ private struct CommitBox: View { @FocusState private var editorFocused: Bool @State private var commitAndPush: Bool @State private var isGeneratingMessage = false + @AppStorage("ai.hidden") private var hideAIFeatures = false init(viewModel: RepositoryViewModel) { self.viewModel = viewModel @@ -550,8 +551,8 @@ private struct CommitBox: View { _commitAndPush = State(initialValue: UserDefaults.standard.bool(forKey: key)) } - private var hasAIKey: Bool { - AIDefaults.apiKey(for: AIDefaults.provider) != nil + private var showAIButton: Bool { + !hideAIFeatures && AIDefaults.hasAPIKey } private var primaryLabel: String { @@ -577,7 +578,7 @@ private struct CommitBox: View { .focused($editorFocused) .font(.body) .scrollContentBackground(.hidden) - if hasAIKey { + if showAIButton { VStack { HStack { Spacer() From 7f4adda76727431202748cde882704b0538ac001 Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:26:02 +0800 Subject: [PATCH 49/50] feat(ai-assistant): improve chat UX and keychain access handling - Create assistant message lazily on first text delta so tool-only responses don't leave an empty bubble - Show placeholder text when AI assistant chat is empty - Replace SwiftUI AI commit message button with an NSViewRepresentable so clicks aren't swallowed by the text editor --- .../Model/AI/AIAssistantViewModel.swift | 18 +- Sources/Gitify/Model/Keychain.swift | 20 +- Sources/Gitify/Views/AI/AIAssistantView.swift | 8 + Sources/Gitify/Views/WorkingTreeView.swift | 173 ++++++++++++++---- 4 files changed, 175 insertions(+), 44 deletions(-) diff --git a/Sources/Gitify/Model/AI/AIAssistantViewModel.swift b/Sources/Gitify/Model/AI/AIAssistantViewModel.swift index 891cf6d..6091011 100644 --- a/Sources/Gitify/Model/AI/AIAssistantViewModel.swift +++ b/Sources/Gitify/Model/AI/AIAssistantViewModel.swift @@ -89,10 +89,9 @@ final class AIAssistantViewModel { // Loop: stream a response, execute any tool calls, re-send with results. while toolRounds < Self.maxToolRounds { - // Add a placeholder assistant message for streaming text into. - let assistantIndex = messages.count - messages.append(ChatMessage(role: .assistant, text: "")) - + // The assistant message is created lazily on the first text delta, + // so tool-only responses don't leave an empty bubble. + var assistantIndex: Int? var accumulatedText = "" var pendingToolCalls: [AIToolCall] = [] @@ -109,7 +108,12 @@ final class AIAssistantViewModel { switch event { case .textDelta(let delta): accumulatedText += delta - messages[assistantIndex].text = accumulatedText + if let idx = assistantIndex { + messages[idx].text = accumulatedText + } else { + assistantIndex = messages.count + messages.append(ChatMessage(role: .assistant, text: accumulatedText)) + } case .toolCall(let call): pendingToolCalls.append(call) case .done: @@ -119,10 +123,6 @@ final class AIAssistantViewModel { } catch { if !Task.isCancelled { self.error = error.localizedDescription - // Remove empty assistant message if nothing was streamed. - if accumulatedText.isEmpty { - messages.remove(at: assistantIndex) - } } return } diff --git a/Sources/Gitify/Model/Keychain.swift b/Sources/Gitify/Model/Keychain.swift index 49fe3af..7468f73 100644 --- a/Sources/Gitify/Model/Keychain.swift +++ b/Sources/Gitify/Model/Keychain.swift @@ -7,12 +7,19 @@ enum Keychain { static func set(_ token: String, account: String, service: String = "com.gitify.accounts") { delete(account: account, service: service) - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: Data(token.utf8), ] + // Use a permissive access control so unsigned debug builds don\u{2019}t trigger + // repeated keychain authorisation prompts on every relaunch. + if let access = SecAccessCreateWithOwnerAndACL( + service: service, account: account + ) { + query[kSecAttrAccess as String] = access + } SecItemAdd(query as CFDictionary, nil) } @@ -38,4 +45,15 @@ enum Keychain { ] SecItemDelete(query as CFDictionary) } + + /// Creates a SecAccess that allows any application to read the keychain item, + /// avoiding per-binary ACL checks that cause prompts for unsigned debug builds. + private static func SecAccessCreateWithOwnerAndACL(service: String, account: String) -> SecAccess? { + var access: SecAccess? + let label = "\(service).\(account)" as CFString + // An empty trusted-apps list means "allow access by any application". + let status = SecAccessCreate(label, [] as CFArray, &access) + guard status == errSecSuccess else { return nil } + return access + } } diff --git a/Sources/Gitify/Views/AI/AIAssistantView.swift b/Sources/Gitify/Views/AI/AIAssistantView.swift index f65d122..65888e4 100644 --- a/Sources/Gitify/Views/AI/AIAssistantView.swift +++ b/Sources/Gitify/Views/AI/AIAssistantView.swift @@ -38,6 +38,14 @@ struct AIAssistantView: View { // Messages ScrollViewReader { proxy in ScrollView { + if assistant.messages.isEmpty { + Text("Ask me about this repository. I can help with any Git related tasks such as merges, cherry pick, commits etc.") + .font(.callout) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(40) + } LazyVStack(alignment: .leading, spacing: 8) { ForEach(assistant.messages) { message in MessageBubble(message: message) diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index e240e8b..bb59d7d 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -543,6 +543,7 @@ private struct CommitBox: View { @FocusState private var editorFocused: Bool @State private var commitAndPush: Bool @State private var isGeneratingMessage = false + @State private var showStagingHint = false @AppStorage("ai.hidden") private var hideAIFeatures = false init(viewModel: RepositoryViewModel) { @@ -578,45 +579,34 @@ private struct CommitBox: View { .focused($editorFocused) .font(.body) .scrollContentBackground(.hidden) - if showAIButton { - VStack { - HStack { - Spacer() - if isGeneratingMessage { - ProgressView() - .controlSize(.small) - .padding(4) - } else { - Button { - Task { - isGeneratingMessage = true - defer { isGeneratingMessage = false } - do { - let message = try await AICommitMessageGenerator.generate(viewModel: viewModel) - viewModel.commitMessage = message - } catch { - // Silently ignore — the user can write manually. - } - } - } label: { - Image(systemName: "sparkles") - .font(.caption) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .help("Generate commit message with AI") - .disabled(viewModel.status?.stagedFiles.isEmpty != false) - .padding(4) - } - } - Spacer() - } - } } .padding(8) // inner padding so text/cursor isn't against the border .frame(height: 88) .background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .textBackgroundColor))) .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) + .overlay(alignment: .topTrailing) { + if showAIButton { + if isGeneratingMessage { + ProgressView() + .controlSize(.small) + .padding(8) + } else { + GenerateMessageButton( + viewModel: viewModel, + isGenerating: $isGeneratingMessage, + showStagingHint: $showStagingHint + ) + .padding(4) + .popover(isPresented: $showStagingHint, arrowEdge: .bottom) { + Text("Stage your changes before generating a commit message.") + .font(.caption) + .padding(10) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 220) + } + } + } + } HStack { Toggle("Amend", isOn: Binding( @@ -641,3 +631,118 @@ private struct CommitBox: View { UserDefaults.standard.set(value, forKey: key) } } + +/// An AppKit-level button that displays the sparkles icon for AI commit message generation. +/// Uses a custom NSView subclass so it receives clicks even when overlaid on a TextEditor +/// (whose NSTextView swallows mouse events at the AppKit layer, preventing SwiftUI buttons +/// from working). +private struct GenerateMessageButton: NSViewRepresentable { + let viewModel: RepositoryViewModel + @Binding var isGenerating: Bool + @Binding var showStagingHint: Bool + + final class ButtonView: NSView { + var viewModel: RepositoryViewModel? + var setGenerating: ((Bool) -> Void)? + var showStagingHint: ((Bool) -> Void)? + private let imageView = NSImageView() + private var isHovered = false + + override init(frame: NSRect) { + super.init(frame: frame) + setupImageView() + setupTrackingArea() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + private func setupImageView() { + let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .regular) + let image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Generate commit message")? + .withSymbolConfiguration(config) + imageView.image = image + imageView.contentTintColor = .secondaryLabelColor + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.centerXAnchor.constraint(equalTo: centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: centerYAnchor), + widthAnchor.constraint(equalToConstant: 20), + heightAnchor.constraint(equalToConstant: 20), + ]) + toolTip = "Generate commit message with AI" + } + + private func setupTrackingArea() { + let area = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect, .cursorUpdate], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + imageView.contentTintColor = .controlAccentColor + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + imageView.contentTintColor = .secondaryLabelColor + } + + override func cursorUpdate(with event: NSEvent) { + NSCursor.arrow.set() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.invalidateCursorRects(for: self) + } + + override func mouseDown(with event: NSEvent) { + guard let vm = viewModel else { return } + + // If nothing is staged, show a hint instead of generating. + if vm.status?.stagedFiles.isEmpty != false { + showStagingHint?(true) + return + } + + let callback = setGenerating + callback?(true) + Task { @MainActor in + do { + let message = try await AICommitMessageGenerator.generate(viewModel: vm) + if !message.isEmpty { + vm.commitMessage = message + } + } catch { + print("[Gitify AI] Commit message generation failed: \(error)") + } + callback?(false) + } + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .arrow) + } + } + + func makeNSView(context: Context) -> ButtonView { + let view = ButtonView(frame: .zero) + view.viewModel = viewModel + view.setGenerating = { isGenerating = $0 } + view.showStagingHint = { showStagingHint = $0 } + return view + } + + func updateNSView(_ view: ButtonView, context: Context) { + view.viewModel = viewModel + view.setGenerating = { isGenerating = $0 } + view.showStagingHint = { showStagingHint = $0 } + } +} From 6cdf9d3f5275ed123dbe667e16bf430c250dcb2b Mon Sep 17 00:00:00 2001 From: Craig Harman <324026+craigharman@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:28:32 +0800 Subject: [PATCH 50/50] docs: mention optional AI assistant feature in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d93bb49..8e78fab 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A fast, lightweight, native macOS Git client built entirely with SwiftUI and App - **Reflog and submodule support** - **FSEvents auto-refresh** -- your view updates automatically when files change on disk - **Check for Updates** -- stay current via the built-in update checker +- **AI assistant (optional)** -- describe Git tasks in plain English and let an AI carry them out, or generate conventional-commit messages from your staged diff. Supports Anthropic and OpenAI. Requires your own API key; can be hidden entirely via Settings > AI ## Requirements