diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml index b9ec015..5f599e5 100644 --- a/.github/workflows/auto-merge-release.yml +++ b/.github/workflows/auto-merge-release.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00a64d6..b42c3c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 != '' @@ -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 diff --git a/README.md b/README.md index 8e78fab..534acbc 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/Resources/Info.plist b/Resources/Info.plist index 08cd349..75bd40a 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -7,7 +7,7 @@ CFBundleDisplayName Gitify CFBundleIdentifier - com.gitify.app + com.craigharman.gitify CFBundleVersion 1.1.0 CFBundleShortVersionString diff --git a/Sources/GitKit/RepositoryWatcher.swift b/Sources/GitKit/RepositoryWatcher.swift index 2aa2229..73932c9 100644 --- a/Sources/GitKit/RepositoryWatcher.swift +++ b/Sources/GitKit/RepositoryWatcher.swift @@ -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) @@ -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) + } } } diff --git a/Sources/Gitify/App/GitifyApp.swift b/Sources/Gitify/App/GitifyApp.swift index c0be7e0..34d15f4 100644 --- a/Sources/Gitify/App/GitifyApp.swift +++ b/Sources/Gitify/App/GitifyApp.swift @@ -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) { diff --git a/Sources/Gitify/Model/AppDefaults.swift b/Sources/Gitify/Model/AppDefaults.swift index 9e2f078..ccb29bc 100644 --- a/Sources/Gitify/Model/AppDefaults.swift +++ b/Sources/Gitify/Model/AppDefaults.swift @@ -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" diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift index 6b9864c..f19b41b 100644 --- a/Sources/Gitify/Model/RepositoryViewModel.swift +++ b/Sources/Gitify/Model/RepositoryViewModel.swift @@ -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 } } @@ -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 @@ -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 @@ -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, diff --git a/Sources/Gitify/Resources/CHANGELOG.md b/Sources/Gitify/Resources/CHANGELOG.md index bb61033..81cd19f 100644 --- a/Sources/Gitify/Resources/CHANGELOG.md +++ b/Sources/Gitify/Resources/CHANGELOG.md @@ -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) diff --git a/Sources/Gitify/Views/AppSettingsView.swift b/Sources/Gitify/Views/AppSettingsView.swift index 3f0f49a..32780f5 100644 --- a/Sources/Gitify/Views/AppSettingsView.swift +++ b/Sources/Gitify/Views/AppSettingsView.swift @@ -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) @@ -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 } diff --git a/Sources/Gitify/Views/HistoryView.swift b/Sources/Gitify/Views/HistoryView.swift index 7817626..0cd520a 100644 --- a/Sources/Gitify/Views/HistoryView.swift +++ b/Sources/Gitify/Views/HistoryView.swift @@ -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) } diff --git a/Sources/Gitify/Views/IntegrationSheets.swift b/Sources/Gitify/Views/IntegrationSheets.swift index b7949fe..a31bb69 100644 --- a/Sources/Gitify/Views/IntegrationSheets.swift +++ b/Sources/Gitify/Views/IntegrationSheets.swift @@ -19,6 +19,12 @@ struct MergeSheet: View { @State private var preview: MergePreview? @State private var loadingPreview = false + @State private var commitAfterSquash = true + @State private var squashMessage = "" + @State private var useAIMessage = false + @FocusState private var messageEditorFocused: Bool + @AppStorage("ai.hidden") private var hideAIFeatures = false + /// Any branch except the target is a valid source (you can't merge a branch into itself). private var branches: [String] { (viewModel.localBranches.map(\.name) + viewModel.remoteBranches.map(\.name)) @@ -28,6 +34,7 @@ struct MergeSheet: View { /// HEAD) is meaningful and no branch switch is needed. private var mergesIntoCurrent: Bool { viewModel.currentBranch?.name == target } private var sourceIsLocal: Bool { viewModel.localBranches.contains { $0.name == source } } + private var showAIButton: Bool { !hideAIFeatures && AIDefaults.hasAPIKey } var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -75,6 +82,70 @@ struct MergeSheet: View { } .padding(.leading, FormMetrics.contentInset) // align checkboxes under the fields + if squash { + VStack(alignment: .leading, spacing: 8) { + option("Commit After Squash", + "Automatically commit the squashed changes with the message below.", + isOn: $commitAfterSquash) + + if commitAfterSquash { + ZStack(alignment: .topLeading) { + if squashMessage.isEmpty && !messageEditorFocused && !useAIMessage { + Text("Squash commit message") + .font(.body) + .foregroundStyle(.tertiary) + .padding(.leading, 5) + .allowsHitTesting(false) + } + if useAIMessage { + Text("AI will generate a commit message after merge") + .font(.body).italic() + .foregroundStyle(.secondary) + .padding(.leading, 5) + .allowsHitTesting(false) + } + TextEditor(text: $squashMessage) + .focused($messageEditorFocused) + .font(.body) + .scrollContentBackground(.hidden) + .disabled(useAIMessage) + .opacity(useAIMessage ? 0 : 1) + } + .padding(8) + .frame(height: 68) + .background(RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .textBackgroundColor))) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) + .overlay(alignment: .topTrailing) { + if showAIButton { + Button { + useAIMessage.toggle() + if useAIMessage { squashMessage = "" } + } label: { + Image(systemName: "sparkles") + .font(.system(size: 10)) + .foregroundStyle(useAIMessage ? Color.accentColor : .secondary) + } + .buttonStyle(.plain) + .help(useAIMessage + ? "Disable AI commit message generation" + : "Generate commit message with AI after merge") + .padding(6) + } + } + .padding(.leading, 20) + + if squashMessage.isEmpty && !useAIMessage { + Text("Leave empty for default: \u{201c}Squash merge branch \u{2018}\(source)\u{2019} into \u{2018}\(target)\u{2019}\u{201d}") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 20) + } + } + } + .padding(.leading, FormMetrics.contentInset) + } + Spacer(minLength: 0) HStack { Spacer() @@ -86,7 +157,11 @@ struct MergeSheet: View { noFastForward: noFastForward, noCommit: noCommit, skipHooks: skipHooks, deleteSource: deleteSource && sourceIsLocal, - pushAfterMerge: pushAfterMerge && !noCommit) + pushAfterMerge: pushAfterMerge && !noCommit, + squashCommitMessage: squash && commitAfterSquash + ? squashMessage : nil, + generateSquashMessage: squash && commitAfterSquash + && useAIMessage) } } .keyboardShortcut(.defaultAction) @@ -94,7 +169,7 @@ struct MergeSheet: View { } } .padding(24) - .frame(width: 600, height: 480) + .frame(width: 600, height: squash && commitAfterSquash ? 600 : 480) .task(id: source) { await loadPreview() } } diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift index 7ce765e..89c4f1d 100644 --- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift +++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift @@ -44,6 +44,7 @@ struct RepositoryWorkspaceView: View { NavigationSplitView { WorkspaceRail(viewModel: viewModel, section: $section, integrationSheet: $integrationSheet) .navigationSplitViewColumnWidth(min: 220, ideal: 240, max: 300) + .layoutPriority(1) } detail: { VStack(spacing: 0) { if let operation = viewModel.operation { @@ -115,8 +116,9 @@ struct RepositoryWorkspaceView: View { .help("Push current branch").disabled(viewModel.isBusy || viewModel.remotes.isEmpty) branchMenu - moreMenu + } + ToolbarItemGroup { if !hideAIFeatures { Button { if AIDefaults.hasAPIKey { @@ -146,6 +148,9 @@ struct RepositoryWorkspaceView: View { Image(systemName: "arrow.clockwise") } .help("Refresh").disabled(viewModel.isLoading) + + openInMenu + moreMenu } } .sheet(item: $integrationSheet) { sheet in @@ -157,6 +162,7 @@ struct RepositoryWorkspaceView: View { .sheet(isPresented: $showSettings) { SettingsSheet(viewModel: viewModel) } .sheet(isPresented: $showAssistant) { AIAssistantView(viewModel: viewModel) } .task { await viewModel.load() } + .onDisappear { viewModel.tearDown() } .overlay { if viewModel.isBusy { OperationOverlay(title: viewModel.operationTitle ?? "Working", @@ -165,6 +171,35 @@ struct RepositoryWorkspaceView: View { } } + /// Toolbar "Open in…" menu: launch the repository in an installed editor. + @ViewBuilder + private var openInMenu: some View { + let editors = AppDefaults.installedEditors + Menu { + if editors.isEmpty { + Text("No supported editors found") + } else { + ForEach(editors) { editor in + Button { viewModel.openProject(in: editor) } label: { + if let icon = editor.icon { + Label { + Text(editor.name) + } icon: { + Image(nsImage: icon) + } + } else { + Text(editor.name) + } + } + } + } + } label: { + Label("Open in…", systemImage: "square.and.arrow.up") + } + .help("Open repository in an editor") + .disabled(editors.isEmpty) + } + /// Toolbar "Branch" menu: checkout, new branch, merge, rebase. @ViewBuilder private var branchMenu: some View { diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index bb59d7d..0a9d305 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -41,9 +41,10 @@ struct WorkingTreeView: View { CommitBox(viewModel: viewModel) } .frame(minWidth: 300, idealWidth: 360, maxHeight: .infinity) + .layoutPriority(1) DiffPane(viewModel: viewModel) - .frame(minWidth: 360, maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: viewModel.selectedPath) { _, path in diff --git a/scripts/package.sh b/scripts/package.sh index 3206781..a990796 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -46,8 +46,19 @@ fi if [[ -n "${SIGN_IDENTITY:-}" && -n "${NOTARY_PROFILE:-}" ]]; then echo "==> Submitting to Apple notary service (this can take a few minutes)" xcrun notarytool submit "${DMG}" --keychain-profile "${NOTARY_PROFILE}" --wait - echo "==> Stapling notarization ticket" - xcrun stapler staple "${DMG}" + echo "==> Stapling notarization ticket (retrying up to 5 times for propagation delay)" + STAPLE_ATTEMPT=0 + STAPLE_MAX=5 + STAPLE_DELAY=30 + until xcrun stapler staple "${DMG}" 2>/dev/null; do + STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1)) + if [[ ${STAPLE_ATTEMPT} -ge ${STAPLE_MAX} ]]; then + echo "ERROR: stapler failed after ${STAPLE_MAX} attempts" >&2 + exit 1 + fi + echo " Staple attempt ${STAPLE_ATTEMPT}/${STAPLE_MAX} failed; retrying in ${STAPLE_DELAY}s..." + sleep "${STAPLE_DELAY}" + done xcrun stapler validate "${DMG}" else echo "==> Skipping notarization (needs SIGN_IDENTITY + NOTARY_PROFILE)"