From 64bcea35a07e95e6945e43222cc45927d801b0e8 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 25 Apr 2026 09:08:44 +0200 Subject: [PATCH] feat(chat): git branch indicator in chat header (Phase 2.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes v2026.4.23's TUI shows the project's current git branch as a sidebar pill. Mirror it in the chat header on both platforms. ScarfCore GitBranchService: - branch(at projectPath: String) async -> String? — runs `git -C rev-parse --abbrev-ref HEAD` via the transport (works on local + remote SSH projects). Returns nil for non-git dirs, missing git, detached HEAD, or transport errors. No throwing — chat header omits the chip on any failure. Mac: - ChatViewModel.currentGitBranch populated alongside currentProjectPath in startACPSession's resolution branch. - SessionInfoBar gains gitBranch: String? — renders a tinted `arrow.triangle.branch` chip after the project chip when set. - RichChatView wires chatViewModel.currentGitBranch through. iOS: - ChatController.currentGitBranch on the same lifecycle hooks (resetAndStartInProject + startResuming + cleared on resetAndStartNewSession). - projectContextBar renders the chip inline next to the project name. Verified: ScarfCore + Mac + iOS builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Services/GitBranchService.swift | 68 +++++++++++++++++++ scarf/Scarf iOS/Chat/ChatView.swift | 55 +++++++++++++-- .../Chat/ViewModels/ChatViewModel.swift | 17 +++++ .../Features/Chat/Views/RichChatView.swift | 4 +- .../Features/Chat/Views/SessionInfoBar.swift | 12 ++++ 5 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/GitBranchService.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/GitBranchService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/GitBranchService.swift new file mode 100644 index 0000000..0ff048b --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/GitBranchService.swift @@ -0,0 +1,68 @@ +import Foundation +#if canImport(os) +import os +#endif + +/// Resolves the current git branch of a project directory via the +/// transport (so it works against local + remote SSH projects without +/// any platform-specific branching). The result is informational — +/// surfaced in the chat header alongside the project name as a small +/// `branch` chip. No write operations. +/// +/// Per-session caching lives on the chat view models (one read per chat +/// session start); this service is stateless. +/// +/// **Failure model.** Returns `nil` when the directory isn't a git +/// repo, when `git` is missing on the host, or when the SSH connection +/// drops. Never throws — the chat header simply omits the branch chip +/// on any error. +public struct GitBranchService: Sendable { + #if canImport(os) + private static let logger = Logger( + subsystem: "com.scarf", + category: "GitBranchService" + ) + #endif + + public let context: ServerContext + + public nonisolated init(context: ServerContext = .local) { + self.context = context + } + + /// Resolve the current branch name at `projectPath`. Returns nil + /// for non-git directories, missing `git`, or transport errors — + /// callers treat nil as "no branch chip to render." + /// + /// Internally runs `git -C rev-parse --abbrev-ref HEAD`. + /// On a clean checkout that's a branch name like "main"; on a + /// detached HEAD it's literally "HEAD" (which we then return as + /// nil, since "HEAD" isn't a useful branch label). + public nonisolated func branch(at projectPath: String) async -> String? { + let ctx = context + return await Task.detached { + let transport = ctx.makeTransport() + do { + let result = try transport.runProcess( + executable: "git", + args: ["-C", projectPath, "rev-parse", "--abbrev-ref", "HEAD"], + stdin: nil, + timeout: 5 + ) + guard result.exitCode == 0 else { + return nil + } + let raw = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty || raw == "HEAD" { return nil } + return raw + } catch { + #if canImport(os) + Self.logger.warning( + "git branch lookup failed at \(projectPath, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + #endif + return nil + } + }.value + } +} diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 483b1a3..184d6d6 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -385,11 +385,23 @@ struct ChatView: View { Text("Project chat") .font(.caption2) .foregroundStyle(.secondary) - Text(projectName) - .font(.callout.weight(.medium)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) + HStack(spacing: 6) { + Text(projectName) + .font(.callout.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + if let branch = controller.currentGitBranch, !branch.isEmpty { + Label(branch, systemImage: "arrow.triangle.branch") + .font(.caption2) + .foregroundStyle(.tint) + .labelStyle(.titleAndIcon) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.tint.opacity(0.15), in: Capsule()) + .lineLimit(1) + } + } } Spacer() if !controller.vm.projectScopedCommands.isEmpty { @@ -492,6 +504,12 @@ final class ChatController { /// the resumed session is attributed to a registered project. private(set) var currentProjectName: String? + /// Git branch of the project's working directory at session start + /// (v2.5). Nil for non-project sessions and projects that aren't + /// git repos / have git missing on the host. Surfaced as a small + /// chip on the right side of the project context bar. + private(set) var currentGitBranch: String? + private let context: ServerContext private var client: ACPClient? private var eventTask: Task? @@ -656,6 +674,7 @@ final class ChatController { await stop() vm.reset() currentProjectName = nil + currentGitBranch = nil // Quick-chat sessions don't have a project; clear any leftover // project-scoped slash commands from a prior session. vm.loadProjectScopedCommands(at: nil) @@ -671,18 +690,27 @@ final class ChatController { await stop() vm.reset() currentProjectName = project.name + currentGitBranch = nil // Pull any project-authored slash commands at // /.scarf/slash-commands/ into the chat menu. // Async + non-fatal — degrades cleanly on SFTP failures (logged). vm.loadProjectScopedCommands(at: project.path) + // v2.5 git branch indicator. Async + nil on failure — the chip + // simply doesn't render if the project isn't a git repo. + let ctx = context + let projectPath = project.path + Task { @MainActor [weak self] in + let branch = await GitBranchService(context: ctx).branch(at: projectPath) + if self?.currentProjectName == project.name { + self?.currentGitBranch = branch + } + } // Synchronously load the slash command NAMES so we can list them // in the AGENTS.md block (the agent needs to know what commands // are available). This is a separate read from the async one // above because the block has to land on disk BEFORE `hermes acp` // boots — async loads might lose the race. Blocking load on a // detached task to keep the MainActor responsive. - let ctx = context - let projectPath = project.path let slashNames: [String] = await Task.detached { ProjectSlashCommandService(context: ctx) .loadCommands(at: projectPath) @@ -845,7 +873,20 @@ final class ChatController { return (path: path, name: name) }.value currentProjectName = resolved?.name + currentGitBranch = nil vm.loadProjectScopedCommands(at: resolved?.path) + // v2.5 git branch indicator for the resumed-session header. + if let resumePath = resolved?.path { + let resolvedName = resolved?.name + Task { @MainActor [weak self] in + let branch = await GitBranchService(context: ctx).branch(at: resumePath) + // Guard against a project switch landing while we + // were resolving — only set if the chat hasn't moved. + if self?.currentProjectName == resolvedName { + self?.currentGitBranch = branch + } + } + } state = .connecting let client = ACPClient.forIOSApp( diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 69f24de..955c982 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -49,6 +49,13 @@ final class ChatViewModel { /// indicator in SessionInfoBar + the `Chat · ` nav title. private(set) var currentProjectPath: String? + /// Git branch the project's working directory is currently on, or + /// nil when the dir isn't a git repo / git isn't installed / the + /// resolution failed. Populated alongside `currentProjectPath`; + /// surfaced as a small chip after the project name in + /// `SessionInfoBar`. v2.5. + private(set) var currentGitBranch: String? + /// Human-readable name of the active project, resolved from the /// projects registry at session-start time. Stored alongside the /// path so the view renders without hitting disk on every update. @@ -477,11 +484,21 @@ final class ChatViewModel { // the menu degrades to ACP + quick commands only on // any failure (logged inside the service). self.richChatViewModel.loadProjectScopedCommands(at: path) + // Resolve the project's current git branch (v2.5) + // for the chat header chip. Async + nil on failure + // (not a git repo / git missing / SSH error) — the + // chip just doesn't render. + let svc = GitBranchService(context: context) + Task { @MainActor [weak self] in + let branch = await svc.branch(at: path) + self?.currentGitBranch = branch + } } else { // Explicit clear on non-project sessions so the // indicator doesn't leak from a previous chat. self.currentProjectPath = nil self.currentProjectName = nil + self.currentGitBranch = nil self.richChatViewModel.loadProjectScopedCommands(at: nil) } diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index 2f431aa..3221324 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -30,7 +30,9 @@ struct RichChatView: View { // ChatViewModel.currentProjectName which is set in // startACPSession on both new project chats and // resumed project-attributed sessions. - projectName: chatViewModel.currentProjectName + projectName: chatViewModel.currentProjectName, + // v2.5: git branch indicator alongside the project chip. + gitBranch: chatViewModel.currentGitBranch ) Divider() diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift index d1a3ad1..fcc78fa 100644 --- a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift +++ b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift @@ -14,6 +14,11 @@ struct SessionInfoBar: View { /// `ChatViewModel.currentProjectName` — the view just passes it /// through. var projectName: String? = nil + /// Current git branch of the project's working directory, when + /// resolved (v2.5). Renders as a tinted chip after the project + /// name. Nil for non-project chats and for projects that aren't + /// git repos. + var gitBranch: String? = nil var body: some View { HStack(spacing: 16) { @@ -28,6 +33,13 @@ struct SessionInfoBar: View { .foregroundStyle(.tint) .lineLimit(1) .help("Chat is scoped to Scarf project \"\(projectName)\"") + if let gitBranch { + Label(gitBranch, systemImage: "arrow.triangle.branch") + .font(.caption) + .foregroundStyle(.tint) + .lineLimit(1) + .help("Project's current git branch") + } } HStack(spacing: 4) {