Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 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
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
210 changes: 210 additions & 0 deletions Sources/Gitify/Model/AI/AIAssistantViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
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<Void, Never>?

/// 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
}

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

isStreaming = true
defer { isStreaming = false }

var toolRounds = 0

// Loop: stream a response, execute any tool calls, re-send with results.
while toolRounds < Self.maxToolRounds {
// 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] = []

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
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:
break
}
}
} catch {
if !Task.isCancelled {
self.error = error.localizedDescription
}
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
}
}
114 changes: 114 additions & 0 deletions Sources/Gitify/Model/AI/AIClient.swift
Original file line number Diff line number Diff line change
@@ -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<AIStreamEvent, Error>
}

// 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)
}
}
Loading
Loading