GitWorkbench

1.3.0

gpambrozio/GitWorkbench

What's New

GitWorkbench 1.3.0

2026-06-14T00:11:01Z

What's new in 1.3.0

All remote branches in the rail. The REMOTES section now lists every remote-tracking branch, grouped under its remote, instead of only the current branch's upstream:

  • Branches show without the remote prefix (develop, feat/auto-sync — not origin/develop), grouped under each remote, so multiple remotes are supported (not just a hardcoded origin).
  • The branch your current branch tracks is shown bold + accent-colored.
  • Single-click a remote branch to view its history; double-click to check it out locally as a tracking branch (or switch to an existing local branch of the same name).

The section is hidden entirely when there are no remote-tracking branches, and the remote-name sub-headers are non-interactive labels (no hover or tap affordance).

Provider changes

GitWorkbenchProvider gains two methods, mirroring the existing loadBranches() / switchBranch(to:) pair:

func loadRemoteBranches() async throws -> [RemoteBranch]
func checkoutRemoteBranch(_ branch: RemoteBranch) async throws

A new RemoteBranch value (Sendable, Hashable, Identifiable) carries the full ref as its id (origin/feat/auto-sync, unique across remotes), the remote name, and the prefix-stripped name for display.

The bundled MockGitProvider and GitWorkbenchGitKit's CLIGitProvider both implement the new methods. CLIGitProvider reads remotes via git for-each-ref … refs/remotes and checks out with git switch --track.

Upgrading from 1.2.0

These two methods are required (no default implementations). If your host implements GitWorkbenchProvider itself, add loadRemoteBranches() and checkoutRemoteBranch(_:) to your conformance — return [] from the former (the REMOTES section then stays hidden) if you don't want remote-branch support. Hosts using the bundled providers need no changes. No existing method signatures or state shapes changed.

Requirements

  • macOS 15+
  • Swift 6 (language mode v6)

Installation

.package(url: "https://github.com/gpambrozio/GitWorkbench.git", from: "1.3.0"),

GitWorkbench

A native macOS git-changes UI for SwiftUI — Changes, History, and Stash views with a full unified/side-by-side diff renderer — shipped as a dependency-free Swift package. You supply the repository data; GitWorkbench renders it and turns user actions back into calls you handle.

GitWorkbench — Changes view

Designed in Claude and implemented with Claude Code — read how it was built.

What it is

GitWorkbench is a UI + state component: it never runs git itself. Your app (the host) feeds it repository data and performs git operations through a single provider protocol. That keeps the core library completely dependency-free and lets you back it with anything — the system git CLI, libgit2, a remote API, or fixtures for SwiftUI previews.

The package ships two libraries:

  • GitWorkbench — the SwiftUI component and its state store. Zero dependencies.
  • GitWorkbenchGitKit — an optional, ready-made provider backed by the system git CLI (via Foundation Process). Pull it in and you have a working git UI in a few lines. It's a separate target on purpose, so UI-only consumers never pull a git backend.

Features

  • Changes — stage / unstage / discard, commit, unified and side-by-side diffs, live filesystem refresh.
  • History — commit graph with refs, per-commit changed-files and diffs; single-click any branch to browse its history.
  • Stash — apply / pop / drop.
  • Resizable columns (persisted via a host-supplied store), customizable light + dark themes (swappable at runtime), and a draggable horizontal scroll for long diff lines.
  • Custom file actions — right-click a Changes-tab file to run an action or show your own popover, or double-click to run an action; each callback receives the file's URL.
  • macOS 15+, Swift 6 (language mode v6).

Screenshots

History Side-by-side diff
History Split diff

Dark mode is built in (resolved from the SwiftUI color scheme):

Dark mode

Installation

Swift Package Manager:

// Package.swift
dependencies: [
    .package(url: "https://github.com/gpambrozio/GitWorkbench.git", from: "1.0.0"),
    // …or a local checkout: .package(path: "../GitWorkbench"),
],
targets: [
    .target(name: "MyApp", dependencies: [
        "GitWorkbench",        // the UI component
        "GitWorkbenchGitKit",  // optional: the ready-made git-CLI provider
    ]),
]

Quick start

Real repository (using the bundled git-CLI provider)

import SwiftUI
import GitWorkbench
import GitWorkbenchGitKit

struct RepoView: View {
    @StateObject private var store = GitWorkbenchStore(
        provider: CLIGitProvider(repositoryURL: URL(fileURLWithPath: "/path/to/repo"))
    )

    var body: some View {
        GitWorkbenchView(store: store)   // loads on appear; that's a working git UI
    }
}

SwiftUI previews (mock data, no repo needed)

#Preview { GitWorkbenchView(store: .preview) }   // backed by bundled fixtures

How it works

GitWorkbenchStore is an @MainActor ObservableObject holding a single WorkbenchState value. The views are a pure function of that state; user actions are store intents that optimistically update the state and call your provider, rolling back (and surfacing a toast) on error. The whole flow is unidirectional, and the only public view is GitWorkbenchView(store:).

The host boundary is one protocol, GitWorkbenchProvider, composed of a read side and a write side:

public protocol GitWorkbenchDataSource: Sendable {     // reads
    func loadStatus() async throws -> RepositoryStatus
    func loadHistory(of ref: String?, before: Commit.ID?, limit: Int) async throws -> [Commit]
    func loadStashes() async throws -> [Stash]
    func loadBranches() async throws -> [Branch]
    func loadDiff(_ request: DiffRequest) async throws -> FileDiff
}

public protocol GitWorkbenchActionHandler: Sendable {  // writes
    func stage(_ files: [FileChange]) async throws
    func unstage(_ files: [FileChange]) async throws
    func discard(_ file: FileChange) async throws
    func commit(message: String, staged: [FileChange]) async throws -> Commit
    func pull() async throws -> SyncResult
    func push() async throws -> SyncResult
    func fetch() async throws -> SyncResult
    func switchBranch(to branch: Branch) async throws
    func applyStash(_ stash: Stash) async throws
    func popStash(_ stash: Stash) async throws
    func dropStash(_ stash: Stash) async throws
}

All provider methods are async and run off the main actor; the models are Sendable value types. CLIGitProvider (in GitWorkbenchGitKit) is one implementation; conform your own type to GitWorkbenchProvider to back the UI with libgit2, a service, etc.

Configuration

Pass a WorkbenchConfiguration when creating the store:

var config = WorkbenchConfiguration()
config.initialView = .changes        // .changes | .history | .stashes
config.defaultDiffMode = .split      // .unified | .split
config.showsToolbar = true

let store = GitWorkbenchStore(provider: myProvider, configuration: config)

Custom file actions (Changes tab)

Attach actions to file rows in the Changes tab with view modifiers on GitWorkbenchView. Each callback receives the clicked file's URL — absolute when you set config.repositoryURL to the repository's working-tree root (typically the same URL you gave CLIGitProvider), otherwise a path-only URL.

var config = WorkbenchConfiguration()
config.repositoryURL = repoURL          // so callbacks get absolute file URLs

GitWorkbenchView(store: store)
    .onChangesDoubleClick { url in       // run an action on double-click
        NSWorkspace.shared.open(url)
    }
    .onChangesRightClick { url in        // run an action on right-click
        NSWorkspace.shared.activateFileViewerSelecting([url])
    }
    .onChangesRightClickPopover { url in // …or return a View to show as a popover (nil = nothing)
        FileActionsMenu(url: url)
    }

Everything is opt-in: with no modifier attached, rows behave exactly as before (single-click still selects, the stage box and hover are untouched).

Theming

Every color is a token on WorkbenchTheme. Recolor just the accent, override specific tokens, or build one from scratch — for light and dark independently:

config.theme     = .standard.withAccent(.pink)        // light
config.darkTheme = .darkStandard.withAccent(.pink)    // dark

// override specific tokens (everything else falls back to the light identity):
config.theme = WorkbenchTheme(accent: .pink, winBg: .black)

// or follow the macOS system accent:
config.theme.adoptsSystemAccent = true

Themes can be swapped at runtime with no reload:

store.setTheme(light: .standard.withAccent(.green), dark: .darkStandard.withAccent(.green))

Persisting column widths

The rail and list columns are draggable. To make widths survive relaunches, give the component a persistenceKey and a layoutStore (you own the storage — UserDefaults, a file, iCloud, …). Distinct keys give each embedding of the component its own saved layout:

config.persistenceKey = "main"
config.layoutStore = WorkbenchLayoutStore(
    load: { key in
        (UserDefaults.standard.dictionary(forKey: "columns.\(key)") as? [String: Double])?
            .mapValues { CGFloat($0) }
    },
    save: { key, widths in
        UserDefaults.standard.set(widths.mapValues { Double($0) }, forKey: "columns.\(key)")
    }
)

Demo apps

Two runnable demos are included:

swift run GitWorkbenchDemo                    # mock-backed, no repo needed
swift run GitWorkbenchLiveDemo /path/to/repo  # real git via CLIGitProvider

GitWorkbenchLiveDemo is a full sample host: open a different repository (⌘O), switch sample themes from the Theme menu, and watch it refresh live as the working tree changes on disk.

Requirements

  • macOS 15+
  • Swift 6 / Xcode 16+

Build & test

swift build
swift test

Description

  • Swift Tools 6.0.0
View More Packages from this Author

Dependencies

  • None
Last updated: Sun Jun 14 2026 12:22:06 GMT-0900 (Hawaii-Aleutian Daylight Time)