Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
874f7bc
feat: continue and skip rebase
craigharman Jun 19, 2026
f55ca09
fix: git tests for rebasing
craigharman Jun 19, 2026
7e07df3
feat: liquid glass interface
craigharman Jun 19, 2026
8f7c2c9
feat: add copy to clipboard button to error banner
craigharman Jun 19, 2026
3852ef3
Merge branch 'main' into develop
craigharman Jun 19, 2026
eac879e
feat:automatic software updates using Sparkle
craigharman Jun 19, 2026
9074f29
Gate the step on secrets.SPARKLE_PRIVATE_KEY
craigharman Jun 19, 2026
13f17fe
Ensure we are committing on the latest main branch
craigharman Jun 19, 2026
7487310
Disable update option if sparkle unavailable
craigharman Jun 19, 2026
354016d
Fail loudly on build failure
craigharman Jun 19, 2026
cd5dde1
Potential fix for pull request finding
craigharman Jun 19, 2026
d49ba06
fix: prevent appcast.xml commit from retriggering Release workflow
Copilot Jun 19, 2026
0c9ce1c
Strip any new lines from secret
craigharman Jun 19, 2026
3293625
Warn if sparkle not set
craigharman Jun 19, 2026
a6a5c24
Add guard when version already exists
craigharman Jun 19, 2026
ff59aa8
Fix versioning issues with sparkle
craigharman Jun 19, 2026
ee5d2b7
Potential fix for pull request finding
craigharman Jun 19, 2026
730c686
Potential fix for pull request finding
craigharman Jun 19, 2026
7b23eeb
Potential fix for pull request finding
craigharman Jun 19, 2026
7e96235
Skip appcast.xml when no changes
craigharman Jun 19, 2026
b904ab8
Update agents and claude files
craigharman Jun 19, 2026
4f1a868
fix: sign_update matched the wrong binary
craigharman Jun 19, 2026
895e093
Merge develop changes
craigharman Jun 19, 2026
a9338d0
Merge branch 'main' into develop
craigharman Jun 19, 2026
626e3f8
feat: allow push/pull indicators in sidebar to actually do the push/p…
craigharman Jun 19, 2026
7a1a0a6
fix: when pushing a remote branch include the remote name as well as …
craigharman Jun 19, 2026
70df5fd
Do not fallback to origin show error instead
craigharman Jun 19, 2026
ff5ed2c
fix: make the sparkle search less specific
craigharman Jun 19, 2026
0f530b7
Merge branch 'main' into develop
craigharman Jun 20, 2026
b3e181d
Fix release.yml
craigharman Jun 20, 2026
528f9e7
Merge branch 'main' into develop
craigharman Jun 20, 2026
ff6c4ac
fix: download Sparkle into project from CI
craigharman Jun 20, 2026
6e8b217
fix: access to sparkle key file
craigharman Jun 20, 2026
505d31a
Merge main into develop
craigharman Jun 20, 2026
93cafbd
Merge branch 'main' into develop
craigharman Jun 20, 2026
4028d96
fix: add debugging to deployment
craigharman Jun 20, 2026
fef2820
Merge branch 'main' into develop
craigharman Jun 20, 2026
25103c1
fix: add automerge to release
craigharman Jun 20, 2026
dfb183d
test:Add checks for pull/push numbers
craigharman Jun 21, 2026
6b7a74d
feat:automate deployment workflow
craigharman Jun 22, 2026
1707933
fix: merge conflicts not visible when files are staged
craigharman Jun 23, 2026
563c1d9
Complete merge
craigharman Jun 23, 2026
7a88a41
fix: use token for deployment
craigharman Jun 23, 2026
a0addf8
feat: add remote SSH git repository management
craigharman Jun 24, 2026
03f2837
fix: do not allow banner colour to bleed into sidebar
craigharman Jun 24, 2026
133bb9c
chore: updated clipping on main panel
craigharman Jun 24, 2026
0b5e9f3
ci:remove duplicate tests and use the release token to trigger the bu…
craigharman Jun 24, 2026
c3c82c2
Merge from main
craigharman Jun 24, 2026
57de7ef
Merge branch 'main' into develop
craigharman Jun 24, 2026
6359e71
feat: add scan folder feature to allow bulk import of repositories
craigharman Jun 27, 2026
1300bb7
feat: image diff viewer
craigharman Jun 30, 2026
cc46caf
chore: add before/after image comparison options
craigharman Jun 30, 2026
f0e800d
Merge branch 'main' into develop
craigharman Jun 30, 2026
c5b6d01
Merge branch 'main' into develop
craigharman Jun 30, 2026
c69f4c5
fix: check for updates now available in menu
craigharman Jun 30, 2026
dea8b6b
chore: turn of update check in dev builds
craigharman Jun 30, 2026
f38dedb
feat: add what's new popup to show user change log
craigharman Jun 30, 2026
9b644c0
fix: issue with reading changelog for what's new causing app to crash
craigharman Jun 30, 2026
4bcf233
Merge main into develop
craigharman Jun 30, 2026
c5a96c9
Merge branch 'main' into develop
craigharman Jun 30, 2026
826aff0
fix: a crash when runModal() returns, the field is now explicitly unh…
craigharman Jul 2, 2026
e7cb6f3
feat: add AI integration to write commit messages and do git actions …
craigharman Jul 2, 2026
26c16af
chore: make AI model selection dynamic
craigharman Jul 3, 2026
7f4adda
feat(ai-assistant): improve chat UX and keychain access handling
craigharman Jul 3, 2026
6cdf9d3
docs: mention optional AI assistant feature in README
craigharman Jul 3, 2026
1c34151
Merge branch 'main' into develop
craigharman Jul 3, 2026
ccb806c
chore: update auto-merge-release.yml for improved PR handling
craigharman Jul 3, 2026
c9f003e
chore: enhance release workflow with notarization steps
craigharman Jul 23, 2026
94839b5
ci: Duplicate latest release to Gitify.dmg so there is a constant dow…
craigharman Jul 23, 2026
1b038cb
doc: remove gatekeeper workaround that is no loonger required
craigharman Jul 23, 2026
211ac25
fix: anchor sidebar to the left no matter window size
craigharman Jul 24, 2026
a848954
feat: allow manual dark mode selection in settings
craigharman Jul 24, 2026
d1a9622
docs: add website link to readme
craigharman Jul 24, 2026
35783ba
refactor: improve cleanup of file-system watcher in RepositoryWatcher
craigharman Jul 29, 2026
26c3d12
feat: add commit after squash with message prompt
craigharman Jul 31, 2026
fa571f8
feat: add "open in" option to toolbar and group icons
craigharman Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/auto-merge-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,16 @@ jobs:
if: steps.find-pr.outputs.found == 'true'
run: swift run GitKitChecks

- name: Approve and auto-merge
- name: Approve PR
if: steps.find-pr.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr review "${{ steps.find-pr.outputs.number }}" --approve

- name: Auto-merge PR
if: steps.find-pr.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
gh pr merge "${{ steps.find-pr.outputs.number }}" --squash --auto
41 changes: 41 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,42 @@ jobs:
with:
ref: ${{ needs.release-please.outputs.tag_name }}

- name: Import code signing certificate
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/certificate.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -base64 32)"

echo -n "$APPLE_CERTIFICATE_BASE64" | base64 --decode -o "$CERT_PATH"

security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db

- name: Store notarization credentials
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
xcrun notarytool store-credentials "GITIFY_NOTARY" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_ID_PASSWORD"

- name: Build DMG
env:
VERSION: ${{ needs.release-please.outputs.version }}
SIGN_IDENTITY: ${{ secrets.SIGN_IDENTITY }}
NOTARY_PROFILE: "GITIFY_NOTARY"
run: scripts/package.sh

- name: Download Sparkle sign_update
Expand Down Expand Up @@ -135,6 +168,9 @@ jobs:
run: |
gh release upload "${{ needs.release-please.outputs.tag_name }}" \
"build/Gitify-${{ needs.release-please.outputs.version }}.dmg"
cp "build/Gitify-${{ needs.release-please.outputs.version }}.dmg" build/Gitify.dmg
gh release upload "${{ needs.release-please.outputs.tag_name }}" \
build/Gitify.dmg

- name: Commit updated appcast.xml
if: env.ED_SIGNATURE != ''
Expand All @@ -153,3 +189,8 @@ jobs:
git add appcast.xml
git commit -m "chore: update appcast.xml for v${{ needs.release-please.outputs.version }}"
git push origin main

- name: Clean up keychain
if: always()
run: |
security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Gitify

**Website: [gitify.mrmasonic.com](https://gitify.mrmasonic.com)**

A fast, lightweight, native macOS Git client built entirely with SwiftUI and AppKit. No Electron, no embedded runtimes -- just a clean, responsive app that wraps your installed `git` binary so every operation matches your exact git config.

![Gitify screenshot](docs/screenshot.png)
Expand Down Expand Up @@ -29,11 +31,6 @@ A fast, lightweight, native macOS Git client built entirely with SwiftUI and App
1. Go to the [Releases](https://github.com/craigharman/gitify/releases) page
2. Download the latest `.dmg` file
3. Open the `.dmg` and drag **Gitify.app** into your Applications folder
4. On first launch, macOS will block the app because it is not signed with an Apple Developer ID. To allow it:
- Open **System Settings** > **Privacy & Security**
- Scroll down to the **Security** section
- You will see a message that "Gitify" was blocked -- click **Open Anyway**
- Confirm in the dialog that appears

## Building from source

Expand Down
2 changes: 1 addition & 1 deletion Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<key>CFBundleDisplayName</key>
<string>Gitify</string>
<key>CFBundleIdentifier</key>
<string>com.gitify.app</string>
<string>com.craigharman.gitify</string>
<key>CFBundleVersion</key>
<string>1.1.0</string>
<key>CFBundleShortVersionString</key>
Expand Down
20 changes: 15 additions & 5 deletions Sources/GitKit/RepositoryWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ public final class RepositoryWatcher: RepositoryWatching, @unchecked Sendable {
}

public func stop() {
queue.async { [weak self] in
guard let self, let stream = self.stream else { return }
// Must run on the stream's dispatch queue so no callback is in-flight when we
// invalidate. Uses sync so the caller can safely release the watcher afterward.
queue.sync {
guard let stream else { return }
FSEventStreamStop(stream)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
Expand All @@ -60,9 +62,17 @@ public final class RepositoryWatcher: RepositoryWatching, @unchecked Sendable {
}

deinit {
// If stop() was already called, stream is nil and this is a no-op.
// Otherwise, schedule cleanup on the stream's queue. We can't use queue.sync
// from deinit (risks deadlock), so capture the values and dispatch async.
guard let stream else { return }
FSEventStreamStop(stream)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
let q = queue
// OpaquePointer is not Sendable but FSEventStreamRef is safe to pass across queues.
nonisolated(unsafe) let s = stream
q.async {
FSEventStreamStop(s)
FSEventStreamInvalidate(s)
FSEventStreamRelease(s)
}
}
}
11 changes: 11 additions & 0 deletions Sources/Gitify/App/GitifyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,28 @@ struct GitifyApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var model = AppModel()
@State private var sparkleController = SparkleController()
@AppStorage("appearance.mode") private var appearanceMode: String = "system"

private var colorScheme: ColorScheme? {
switch appearanceMode {
case "light": .light
case "dark": .dark
default: nil
}
}

var body: some Scene {
WindowGroup {
RootView()
.environment(model)
.frame(minWidth: 900, minHeight: 560)
.preferredColorScheme(colorScheme)
}
.windowStyle(.titleBar)
.windowToolbarStyle(.unified)
Settings {
AppSettingsView()
.preferredColorScheme(colorScheme)
}
.commands {
CommandGroup(after: .appInfo) {
Expand Down
14 changes: 14 additions & 0 deletions Sources/Gitify/Model/AppDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import AppKit
/// App-level preferences stored in UserDefaults.
enum AppDefaults {

// MARK: - Appearance

/// The user\u{2019}s preferred appearance mode.
enum AppearanceMode: String, CaseIterable {
case system, light, dark
}

private static let appearanceKey = "appearance.mode"

static var appearanceMode: AppearanceMode {
get { UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppearanceMode.init(rawValue:)) ?? .system }
set { UserDefaults.standard.set(newValue.rawValue, forKey: appearanceKey) }
}

// MARK: - Keys

private static let terminalKey = "defaultTerminalBundleID"
Expand Down
42 changes: 39 additions & 3 deletions Sources/Gitify/Model/RepositoryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,17 @@ final class RepositoryViewModel {
init(ref: RepositoryRef) {
self.ref = ref
}
// Cleanup is automatic: releasing `watcher` invalidates the FSEvents stream via its
// own deinit, and the debounced refresh task holds only a weak self.

/// Explicitly tears down the file-system watcher and cancels the debounce task. Called
/// from the view's `onDisappear` so cleanup happens deterministically before the view
/// model is released (relying solely on `deinit` is racy when FSEvents callbacks are
/// still in-flight on the watcher's dispatch queue).
func tearDown() {
autoRefreshTask?.cancel()
autoRefreshTask = nil
watcher?.stop()
watcher = nil
}

var localBranches: [Ref] { refs.filter { $0.kind == .localBranch } }
var remoteBranches: [Ref] { refs.filter { $0.kind == .remoteBranch } }
Expand Down Expand Up @@ -228,6 +237,15 @@ final class RepositoryViewModel {
}
}

func openProject(in app: AppDefaults.KnownApp) {
guard let appURL = AppDefaults.appURL(for: app.bundleID) else { return }
NSWorkspace.shared.open(
[ref.url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration()
)
}

func openInTerminal(_ file: FileStatus) {
let dir = ref.url.appendingPathComponent(file.path).deletingLastPathComponent()
let bundleID = AppDefaults.terminalBundleID
Expand Down Expand Up @@ -562,7 +580,9 @@ final class RepositoryViewModel {
/// deletes the now-merged `source` branch.
func merge(source: String, into target: String, squash: Bool, noFastForward: Bool,
noCommit: Bool, skipHooks: Bool, deleteSource: Bool,
pushAfterMerge: Bool = false) async {
pushAfterMerge: Bool = false,
squashCommitMessage: String? = nil,
generateSquashMessage: Bool = false) async {
// Park the deletion intent before attempting the merge so it survives a conflict: a
// conflicting merge throws below and is finished later by a manual commit, which calls
// finalizePendingMergeDeletion(). Don't delete when stopping before commit — nothing
Expand All @@ -577,6 +597,22 @@ final class RepositoryViewModel {
try await service.merge(branch: source, squash: squash, noFastForward: noFastForward,
noCommit: noCommit, skipHooks: skipHooks)
// Reaching here means the merge completed cleanly (a conflicting merge throws above).

// Auto-commit after a squash merge when the user opted in.
if squash, let requestedMessage = squashCommitMessage {
var message = requestedMessage
if generateSquashMessage {
if let ai = try? await AICommitMessageGenerator.generate(viewModel: self),
!ai.isEmpty {
message = ai
}
}
if message.isEmpty {
message = "Squash merge branch \u{2018}\(source)\u{2019} into \u{2018}\(target)\u{2019}"
}
try await service.commit(message: message, amend: false)
}

await self.finalizePendingMergeDeletion()
}
// If the merge didn't leave a conflicted merge in progress (it finalized cleanly above,
Expand Down
7 changes: 7 additions & 0 deletions Sources/Gitify/Resources/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [1.14.0](https://github.com/craigharman/gitify/compare/v1.13.1...v1.14.0) (2026-07-03)


### Features

* add (optional) ai assistant ([#36](https://github.com/craigharman/gitify/issues/36)) ([225e2d1](https://github.com/craigharman/gitify/commit/225e2d13f620547cff3fe997ff9f9b2d544ab102))

## [1.13.1](https://github.com/craigharman/gitify/compare/v1.13.0...v1.13.1) (2026-06-30)


Expand Down
10 changes: 10 additions & 0 deletions Sources/Gitify/Views/AppSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@ private struct GeneralSettingsTab: View {
@State private var terminalID: String = AppDefaults.terminalBundleID
@State private var editorID: String = AppDefaults.editorBundleID ?? ""

@State private var appearanceMode: AppDefaults.AppearanceMode = AppDefaults.appearanceMode
@State private var installedTerminals: [AppDefaults.KnownApp] = []
@State private var installedEditors: [AppDefaults.KnownApp] = []

var body: some View {
Form {
Picker("Appearance", selection: $appearanceMode) {
Text("System").tag(AppDefaults.AppearanceMode.system)
Text("Light").tag(AppDefaults.AppearanceMode.light)
Text("Dark").tag(AppDefaults.AppearanceMode.dark)
}

Picker("Terminal", selection: $terminalID) {
ForEach(installedTerminals) { app in
AppLabel(app: app).tag(app.bundleID)
Expand All @@ -39,6 +46,9 @@ private struct GeneralSettingsTab: View {
.formStyle(.grouped)
.frame(width: 400)
.fixedSize(horizontal: false, vertical: true)
.onChange(of: appearanceMode) { _, newValue in
AppDefaults.appearanceMode = newValue
}
.onChange(of: terminalID) { _, newValue in
AppDefaults.terminalBundleID = newValue
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/Gitify/Views/HistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ struct HistoryView: View {
}
}
.frame(minWidth: 380, idealWidth: 480, maxHeight: .infinity)
.layoutPriority(1)
CommitDetailPane(selection: selection, viewModel: viewModel)
.frame(minWidth: 320, maxWidth: .infinity, maxHeight: .infinity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
Expand Down
Loading
Loading