feat(chat): git branch indicator in chat header (Phase 2.4)

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 <path> 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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:08:44 +02:00
parent 1fcd963019
commit 64bcea35a0
5 changed files with 148 additions and 8 deletions
@@ -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 <path> 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
}
}