mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -385,11 +385,23 @@ struct ChatView: View {
|
|||||||
Text("Project chat")
|
Text("Project chat")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 6) {
|
||||||
Text(projectName)
|
Text(projectName)
|
||||||
.font(.callout.weight(.medium))
|
.font(.callout.weight(.medium))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.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()
|
Spacer()
|
||||||
if !controller.vm.projectScopedCommands.isEmpty {
|
if !controller.vm.projectScopedCommands.isEmpty {
|
||||||
@@ -492,6 +504,12 @@ final class ChatController {
|
|||||||
/// the resumed session is attributed to a registered project.
|
/// the resumed session is attributed to a registered project.
|
||||||
private(set) var currentProjectName: String?
|
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 let context: ServerContext
|
||||||
private var client: ACPClient?
|
private var client: ACPClient?
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
@@ -656,6 +674,7 @@ final class ChatController {
|
|||||||
await stop()
|
await stop()
|
||||||
vm.reset()
|
vm.reset()
|
||||||
currentProjectName = nil
|
currentProjectName = nil
|
||||||
|
currentGitBranch = nil
|
||||||
// Quick-chat sessions don't have a project; clear any leftover
|
// Quick-chat sessions don't have a project; clear any leftover
|
||||||
// project-scoped slash commands from a prior session.
|
// project-scoped slash commands from a prior session.
|
||||||
vm.loadProjectScopedCommands(at: nil)
|
vm.loadProjectScopedCommands(at: nil)
|
||||||
@@ -671,18 +690,27 @@ final class ChatController {
|
|||||||
await stop()
|
await stop()
|
||||||
vm.reset()
|
vm.reset()
|
||||||
currentProjectName = project.name
|
currentProjectName = project.name
|
||||||
|
currentGitBranch = nil
|
||||||
// Pull any project-authored slash commands at
|
// Pull any project-authored slash commands at
|
||||||
// <project.path>/.scarf/slash-commands/ into the chat menu.
|
// <project.path>/.scarf/slash-commands/ into the chat menu.
|
||||||
// Async + non-fatal — degrades cleanly on SFTP failures (logged).
|
// Async + non-fatal — degrades cleanly on SFTP failures (logged).
|
||||||
vm.loadProjectScopedCommands(at: project.path)
|
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
|
// Synchronously load the slash command NAMES so we can list them
|
||||||
// in the AGENTS.md block (the agent needs to know what commands
|
// in the AGENTS.md block (the agent needs to know what commands
|
||||||
// are available). This is a separate read from the async one
|
// are available). This is a separate read from the async one
|
||||||
// above because the block has to land on disk BEFORE `hermes acp`
|
// above because the block has to land on disk BEFORE `hermes acp`
|
||||||
// boots — async loads might lose the race. Blocking load on a
|
// boots — async loads might lose the race. Blocking load on a
|
||||||
// detached task to keep the MainActor responsive.
|
// detached task to keep the MainActor responsive.
|
||||||
let ctx = context
|
|
||||||
let projectPath = project.path
|
|
||||||
let slashNames: [String] = await Task.detached {
|
let slashNames: [String] = await Task.detached {
|
||||||
ProjectSlashCommandService(context: ctx)
|
ProjectSlashCommandService(context: ctx)
|
||||||
.loadCommands(at: projectPath)
|
.loadCommands(at: projectPath)
|
||||||
@@ -845,7 +873,20 @@ final class ChatController {
|
|||||||
return (path: path, name: name)
|
return (path: path, name: name)
|
||||||
}.value
|
}.value
|
||||||
currentProjectName = resolved?.name
|
currentProjectName = resolved?.name
|
||||||
|
currentGitBranch = nil
|
||||||
vm.loadProjectScopedCommands(at: resolved?.path)
|
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
|
state = .connecting
|
||||||
let client = ACPClient.forIOSApp(
|
let client = ACPClient.forIOSApp(
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ final class ChatViewModel {
|
|||||||
/// indicator in SessionInfoBar + the `Chat · <Name>` nav title.
|
/// indicator in SessionInfoBar + the `Chat · <Name>` nav title.
|
||||||
private(set) var currentProjectPath: String?
|
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
|
/// Human-readable name of the active project, resolved from the
|
||||||
/// projects registry at session-start time. Stored alongside the
|
/// projects registry at session-start time. Stored alongside the
|
||||||
/// path so the view renders without hitting disk on every update.
|
/// 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
|
// the menu degrades to ACP + quick commands only on
|
||||||
// any failure (logged inside the service).
|
// any failure (logged inside the service).
|
||||||
self.richChatViewModel.loadProjectScopedCommands(at: path)
|
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 {
|
} else {
|
||||||
// Explicit clear on non-project sessions so the
|
// Explicit clear on non-project sessions so the
|
||||||
// indicator doesn't leak from a previous chat.
|
// indicator doesn't leak from a previous chat.
|
||||||
self.currentProjectPath = nil
|
self.currentProjectPath = nil
|
||||||
self.currentProjectName = nil
|
self.currentProjectName = nil
|
||||||
|
self.currentGitBranch = nil
|
||||||
self.richChatViewModel.loadProjectScopedCommands(at: nil)
|
self.richChatViewModel.loadProjectScopedCommands(at: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ struct RichChatView: View {
|
|||||||
// ChatViewModel.currentProjectName which is set in
|
// ChatViewModel.currentProjectName which is set in
|
||||||
// startACPSession on both new project chats and
|
// startACPSession on both new project chats and
|
||||||
// resumed project-attributed sessions.
|
// resumed project-attributed sessions.
|
||||||
projectName: chatViewModel.currentProjectName
|
projectName: chatViewModel.currentProjectName,
|
||||||
|
// v2.5: git branch indicator alongside the project chip.
|
||||||
|
gitBranch: chatViewModel.currentGitBranch
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ struct SessionInfoBar: View {
|
|||||||
/// `ChatViewModel.currentProjectName` — the view just passes it
|
/// `ChatViewModel.currentProjectName` — the view just passes it
|
||||||
/// through.
|
/// through.
|
||||||
var projectName: String? = nil
|
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 {
|
var body: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
@@ -28,6 +33,13 @@ struct SessionInfoBar: View {
|
|||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.help("Chat is scoped to Scarf project \"\(projectName)\"")
|
.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) {
|
HStack(spacing: 4) {
|
||||||
|
|||||||
Reference in New Issue
Block a user