Files
nucleic/Sources/NucleicCore/GitCommandSummary.swift
T
abkslmandnucleic 7ac4e84459 nvrsion: promote trunk to dev
Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
2026-06-27 22:06:37 -07:00

486 lines
24 KiB
Swift

import Foundation
/// Turns a shell command that's really a git operation into a short, human phrase for the
/// approval UI — so a permission request for an autoship-style pipeline reads "Commit:
/// Render bash summary lines …" instead of the raw
///
/// cd "/path" && git add -A && git commit -q -F - <<'EOF'
/// Render bash summary lines as inline Markdown (code spans)
/// …
/// EOF
/// git log --oneline -1
///
/// `summary(for:)` returns nil for anything that isn't a recognized git op, so the caller
/// falls back to showing the command verbatim. A pipeline of several git ops collapses to
/// its single most salient one (the commit above, not the `cd`, `add`, or `log`).
///
/// The command-agnostic lexing (segment splitting, tokenizing, heredocs) lives in
/// ``ShellLexer``; this type only adds the git-specific subcommand model on top. For a view
/// that also covers non-git ops (`rm`) across a mixed pipeline, see ``ShellCommandSummary``.
public enum GitCommandSummary {
/// A friendly, imperative one-liner for the git operation a command performs, or nil
/// when the command isn't a git op we recognize. The phrasing matches the approval
/// title's register ("Commit: …", "Push to origin/main", "Check git status").
public static func summary(for command: String) -> String? {
let parsed = parse(command)
// Across a multi-command pipeline, describe the one op that matters most — a commit
// outweighs the `add` that staged it and the `log` that echoed it back.
guard let op = parsed.ops.max(by: { $0.kind.salience < $1.kind.salience }) else { return nil }
return label(for: op, heredoc: parsed.firstHeredocBody)
}
// MARK: - Structured block
/// A shell command decomposed into the git operations it runs, for rendering as a GUI
/// element instead of a raw monospaced blob. `commit` carries the parsed commit message
/// (subject + body) when the block commits; `steps` lists every recognized git op in run
/// order so the pipeline reads as a sequence. Nil from `block(for:)` when no git op is found.
public struct Block: Equatable, Sendable {
/// A commit's message, split into the subject (first line) and the remaining body
/// (which may be empty, and may contain Markdown the renderer formats). `amend` is
/// true for `git commit --amend`, so the UI can flag a history rewrite.
public struct Commit: Equatable, Sendable {
public let subject: String
public let body: String
public let amend: Bool
}
/// One git op in the pipeline, as a concise verb label plus an SF Symbol name for its
/// glyph — the unit a step list renders ("Stage all changes", "Commit", "Show recent
/// commits").
public struct Step: Equatable, Sendable, Identifiable {
public let id: Int
public let label: String
public let symbol: String
}
public let steps: [Step]
public let commit: Commit?
/// Whether this block is worth rendering as a commit card — it has a commit with a
/// non-empty subject (or an amend, which may carry none).
public var isCommit: Bool { commit != nil }
}
/// Decomposes a command into its git operations for the GUI, or nil when it contains no
/// recognized git op. See ``Block``.
public static func block(for command: String) -> Block? {
let parsed = parse(command)
guard !parsed.ops.isEmpty else { return nil }
var steps: [Block.Step] = []
var commit: Block.Commit?
for (index, op) in parsed.ops.enumerated() {
steps.append(Block.Step(id: index, label: stepLabel(for: op), symbol: symbol(for: op.kind)))
if op.kind == .commit, commit == nil {
commit = commitDetail(op.args, heredoc: parsed.firstHeredocBody)
}
}
return Block(steps: steps, commit: commit)
}
/// A git op observed with certainty from a raw argv — the ground-truth counterpart to
/// `summary(for:)` (which heuristically parses a Bash command string). Reported by the
/// in-container `git` interceptor shim and classified here so Nucleic detects merges and
/// other state changes without guessing.
public struct ObservedGitOp: Equatable, Sendable {
/// The recognized git subcommand, normalized (`merge`, `commit`, `switchBranch`, …).
public let kind: String
/// Human one-liner (e.g. "Merge feature/x", "Commit: Fix the bug").
public let label: String
/// SF Symbol name for the op's glyph, for a GUI step list.
public let symbol: String
/// How strongly this op defines a command's purpose (see `Kind.salience`) — lets a
/// mixed-command pipeline pick the op that matters most across git and non-git ops.
public let salience: Int
/// True for `git merge` specifically — the op autoship/lock-release care about most.
public let isMerge: Bool
/// True for ops that change repo/worktree/ref state (vs read-only status/log/diff).
public let isMutating: Bool
}
/// Classify a raw git argv — the tokens after `git`, as the interceptor shim reports them
/// (it may still include global options like `-C <path>`/`-c <cfg>` before the subcommand).
/// Returns nil when it isn't a git subcommand we model.
public static func classify(argv: [String]) -> ObservedGitOp? {
classify(argv: argv, heredoc: nil)
}
/// As `classify(argv:)`, but threading a heredoc body so a `git commit -F -` reading its
/// message from stdin can title itself with the message's subject. Used by
/// ``ShellCommandSummary`` when it has already pulled the pipeline's heredoc out.
public static func classify(argv: [String], heredoc: String?) -> ObservedGitOp? {
guard let op = parseGit(["git"] + argv) else { return nil }
return ObservedGitOp(
kind: op.kind.rawValue,
label: label(for: op, heredoc: heredoc),
symbol: symbol(for: op.kind),
salience: op.kind.salience,
isMerge: op.kind == .merge,
isMutating: op.kind.isMutating)
}
/// The concise verb label for a step list — like `label(for:)` but without a commit's
/// subject (the subject lives in ``Block/Commit`` and would only duplicate it here).
private static func stepLabel(for op: Op) -> String {
if op.kind == .commit { return op.args.contains("--amend") ? "Amend commit" : "Commit" }
return label(for: op, heredoc: nil)
}
/// The parsed commit message (subject + body) for a commit op, or — for an `--amend`
/// with no new message — an amend marker with empty text. Nil for a non-message commit.
private static func commitDetail(_ args: [String], heredoc: String?) -> Block.Commit? {
let amend = args.contains("--amend")
guard let message = commitMessage(args, heredoc: heredoc),
let (subject, body) = splitMessage(message) else {
return amend ? Block.Commit(subject: "", body: "", amend: true) : nil
}
return Block.Commit(subject: subject, body: body, amend: amend)
}
/// An SF Symbol name for a git op's glyph in the step list.
private static func symbol(for kind: Kind) -> String {
switch kind {
case .commit: return "checkmark.seal"
case .merge: return "arrow.triangle.merge"
case .rebase, .cherryPick, .checkout, .switchBranch, .branch: return "arrow.triangle.branch"
case .revert, .reset: return "arrow.uturn.backward"
case .push: return "arrow.up.circle"
case .pull, .fetch, .clone: return "arrow.down.circle"
case .tag: return "tag"
case .stash: return "tray"
case .restore: return "arrow.counterclockwise"
case .remove: return "trash"
case .move: return "arrow.left.arrow.right"
case .add: return "plus.circle"
case .initialize: return "sparkles"
case .worktree: return "square.split.2x1"
case .status: return "info.circle"
case .log: return "list.bullet.rectangle"
case .diff: return "plusminus"
case .show: return "doc.text.magnifyingglass"
case .blame: return "person.crop.circle.badge.questionmark"
case .revParse: return "number"
}
}
// MARK: - Parsing
/// One recognized git invocation within a command line: its subcommand and the tokens
/// that followed it (flags + operands), so a label builder can pull out a branch, remote,
/// or message without re-tokenizing.
private struct Op {
let kind: Kind
let args: [String]
}
private struct Parsed {
var ops: [Op] = []
/// The body of the first heredoc in the command — the commit message when a `git
/// commit -F -` reads its message from stdin.
var firstHeredocBody: String?
}
/// The git subcommands we phrase, ordered by `salience` so a pipeline reports the op
/// that carries the most intent. Anything outside this set isn't recognized as a git op.
private enum Kind: String {
case commit, merge, rebase, cherryPick, revert, reset, push, pull, fetch, clone
case checkout, switchBranch, branch, tag, stash, restore, remove, move, add, initialize
case worktree, status, log, diff, show, blame, revParse
/// How strongly this op defines the command's purpose. The highest-salience op in a
/// pipeline wins, so write-class ops (a commit) beat read-class ones (a log/status)
/// and the plumbing (`add`) that set them up.
var salience: Int {
switch self {
case .commit: return 100
case .merge, .rebase, .cherryPick, .revert: return 90
case .reset: return 85
case .push: return 80
case .pull, .fetch: return 75
case .clone: return 70
case .checkout, .switchBranch: return 60
case .worktree: return 58
case .branch, .tag: return 55
case .stash: return 50
case .restore, .remove, .move: return 45
case .add: return 40
case .initialize: return 35
case .status, .diff, .show, .blame: return 20
case .log: return 15
case .revParse: return 12
}
}
/// Whether this op changes repo/worktree/ref state — everything except the pure reads
/// (status, log, diff, show, blame, rev-parse). Lets the git-interceptor consumer
/// ignore the agent's read-only git without re-deriving intent.
var isMutating: Bool {
switch self {
case .status, .log, .diff, .show, .blame, .revParse: return false
default: return true
}
}
}
/// Maps a git subcommand token to its `Kind`. Returns nil for an unknown subcommand so
/// the op is ignored rather than mislabeled.
private static func kind(forSubcommand sub: String) -> Kind? {
switch sub {
case "commit": return .commit
case "merge": return .merge
case "rebase": return .rebase
case "cherry-pick": return .cherryPick
case "revert": return .revert
case "reset": return .reset
case "push": return .push
case "pull": return .pull
case "fetch": return .fetch
case "clone": return .clone
case "checkout": return .checkout
case "switch": return .switchBranch
case "branch": return .branch
case "tag": return .tag
case "stash": return .stash
case "restore": return .restore
case "rm": return .remove
case "mv": return .move
case "add": return .add
case "init": return .initialize
case "worktree": return .worktree
case "status": return .status
case "log": return .log
case "diff": return .diff
case "show": return .show
case "blame": return .blame
case "rev-parse": return .revParse
default: return nil
}
}
/// Splits the command into segments, pulls any heredoc bodies out first so their contents
/// can't be mistaken for commands, and parses each segment that begins with `git`. The
/// lexing is delegated to ``ShellLexer``.
private static func parse(_ command: String) -> Parsed {
var result = Parsed()
let (flattened, heredocs) = ShellLexer.stripHeredocs(command)
result.firstHeredocBody = heredocs.first
for segment in ShellLexer.splitSegments(flattened) {
let tokens = ShellLexer.tokenize(segment)
guard let op = parseGit(tokens) else { continue }
result.ops.append(op)
}
return result
}
/// Parses a single tokenized segment into an `Op` when it's a git invocation, skipping a
/// leading `sudo`/env-style prefix and git's pre-subcommand global options (`-C <path>`,
/// `-c <cfg>`, `--git-dir=…`). Returns nil for a non-git segment or an unknown subcommand.
private static func parseGit(_ tokens: [String]) -> Op? {
var rest = ShellLexer.stripLeadingPrefixes(tokens)[...]
guard let command = rest.first, command == "git" || command.hasSuffix("/git") else { return nil }
rest = rest.dropFirst()
// Skip git's global options that precede the subcommand. `-C` and `-c` take a value
// as the following token; `--foo` / `--foo=bar` are self-contained.
while let opt = rest.first, opt.hasPrefix("-") {
rest = rest.dropFirst()
if opt == "-C" || opt == "-c" { rest = rest.dropFirst() } // consume its argument
}
guard let sub = rest.first, let kind = kind(forSubcommand: sub) else { return nil }
return Op(kind: kind, args: Array(rest.dropFirst()))
}
// MARK: - Labels
/// The human phrase for a parsed op. `heredoc` carries the first heredoc body so a commit
/// reading its message from stdin (`-F -`) can title itself with the message's subject.
private static func label(for op: Op, heredoc: String?) -> String {
switch op.kind {
case .commit: return commitLabel(op.args, heredoc: heredoc)
case .merge: return ref(op.args).map { "Merge \($0)" } ?? "Merge branches"
case .rebase: return ref(op.args).map { "Rebase onto \($0)" } ?? "Rebase commits"
case .cherryPick: return "Cherry-pick commits"
case .revert: return "Revert commits"
case .reset: return "Reset the working tree"
case .push: return remoteLabel("Push", preposition: "to", op.args, fallback: "Push changes")
case .pull: return remoteLabel("Pull", preposition: "from", op.args, fallback: "Pull changes")
case .fetch: return ref(op.args).map { "Fetch from \($0)" } ?? "Fetch changes"
case .clone: return ref(op.args).map { "Clone \(repoName($0))" } ?? "Clone a repository"
case .checkout: return branchLabel(op.args, verb: "Check out", fallback: "Check out a branch")
case .switchBranch: return branchLabel(op.args, verb: "Switch to", fallback: "Switch branches")
case .branch: return gitBranchLabel(op.args)
case .tag: return ref(op.args).map { "Tag \($0)" } ?? "List tags"
case .stash: return stashLabel(op.args)
case .restore: return "Restore files"
case .remove: return "Remove files"
case .move: return "Move files"
case .add: return addLabel(op.args)
case .initialize: return "Initialize a git repository"
case .worktree: return worktreeLabel(op.args)
case .status: return "Check git status"
case .log: return "Show recent commits"
case .diff: return "Show changes"
case .show: return "Show a commit"
case .blame: return "Show file blame"
case .revParse: return "Resolve a git revision"
}
}
/// "Commit: <subject>" when a message is available — from `-m`/`--message` or, for `git
/// commit -F -`, the first line of the piped heredoc — else a bare "Commit changes".
/// Amends read as "Amend" so the user sees the history rewrite.
private static func commitLabel(_ args: [String], heredoc: String?) -> String {
let verb = args.contains("--amend") ? "Amend commit" : "Commit"
guard let subject = commitSubject(args, heredoc: heredoc) else {
return args.contains("--amend") ? "Amend commit" : "Commit changes"
}
return "\(verb): \(subject)"
}
/// The commit's subject line, clipped for the one-line title: the first line of the
/// message from `-m`/`--message` or, for `git commit -F -`, the piped heredoc.
private static func commitSubject(_ args: [String], heredoc: String?) -> String? {
guard let message = commitMessage(args, heredoc: heredoc),
let (subject, _) = splitMessage(message) else { return nil }
return clipSubject(subject)
}
/// The raw commit message text: an inline `-m`/`--message` value, or the heredoc body
/// when the commit reads its message from stdin (`-F -`). Nil when neither is present.
private static func commitMessage(_ args: [String], heredoc: String?) -> String? {
if let inline = flagValue(args, short: "-m", long: "--message") { return inline }
if readsMessageFromStdin(args), let heredoc { return heredoc }
return nil
}
/// Splits a commit message into its subject (the first non-empty line, trimmed) and body
/// (everything after it, trimmed of surrounding blank lines — interior blanks are kept so
/// the renderer can format paragraphs and bullet lists). Nil when the message is blank.
private static func splitMessage(_ message: String) -> (subject: String, body: String)? {
let lines = message.components(separatedBy: "\n")
guard let index = lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
else { return nil }
let subject = lines[index].trimmingCharacters(in: .whitespaces)
let body = lines[(index + 1)...].joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return (subject, body)
}
/// Whether a `git commit` takes its message from stdin — `-F -`, `--file -`, or
/// `--file=-` — in which case the heredoc body is that message.
private static func readsMessageFromStdin(_ args: [String]) -> Bool {
for (i, a) in args.enumerated() {
if (a == "-F" || a == "--file"), i + 1 < args.count, args[i + 1] == "-" { return true }
if a == "--file=-" || a == "-F-" { return true }
}
return false
}
/// "Push to origin/main" / "Pull from origin/main" from a `[remote] [branch]`, falling
/// back to the remote alone or a bare verb. Skips flags to find the positional operands.
private static func remoteLabel(_ verb: String, preposition: String, _ args: [String], fallback: String) -> String {
let operands = positionals(args)
guard let remote = operands.first else { return fallback }
if operands.count >= 2 { return "\(verb) \(preposition) \(remote)/\(operands[1])" }
return "\(verb) \(preposition) \(remote)"
}
/// "Check out <branch>" / "Switch to <branch>" / "Create branch <name>" (the last when
/// `-b`/`-c` creates one), falling back to the bare verb when no branch operand is present.
private static func branchLabel(_ args: [String], verb: String, fallback: String) -> String {
if let created = flagValue(args, short: "-b", long: "-c") ?? flagValue(args, short: "-B", long: "-C") {
return "Create branch \(created)"
}
guard let branch = positionals(args).first else { return fallback }
return "\(verb) \(branch)"
}
/// `git branch` itself: "Delete branch <name>" for `-d`/`-D`/`--delete`, "Create branch
/// <name>" when given a name to create, else "List branches".
private static func gitBranchLabel(_ args: [String]) -> String {
let deleteFlags: Set<String> = ["-d", "-D", "--delete"]
let isDelete = args.contains { deleteFlags.contains($0) }
guard let name = positionals(args).first else { return "List branches" }
return isDelete ? "Delete branch \(name)" : "Create branch \(name)"
}
/// `git worktree <sub>`: a concise phrase per sub-subcommand, naming the path for an
/// `add`/`remove` when one is given ("Add worktree at /tmp/x", "List worktrees").
private static func worktreeLabel(_ args: [String]) -> String {
let operands = positionals(args)
guard let sub = operands.first else { return "Manage worktrees" }
let target = operands.count >= 2 ? operands[1] : nil
switch sub {
case "add": return target.map { "Add worktree at \($0)" } ?? "Add a worktree"
case "remove": return target.map { "Remove worktree \($0)" } ?? "Remove a worktree"
case "move": return "Move a worktree"
case "prune": return "Prune worktrees"
case "lock": return "Lock a worktree"
case "unlock": return "Unlock a worktree"
case "repair": return "Repair worktrees"
case "list": return "List worktrees"
default: return "Manage worktrees"
}
}
/// "Stage all changes" for `add -A`/`add .`, "Stage <path>" for a single path, else a
/// plain "Stage changes".
private static func addLabel(_ args: [String]) -> String {
if args.contains("-A") || args.contains("--all") || args.contains(".") { return "Stage all changes" }
let paths = positionals(args)
if paths.count == 1 { return "Stage \(paths[0])" }
return "Stage changes"
}
private static func stashLabel(_ args: [String]) -> String {
guard let sub = positionals(args).first else { return "Stash changes" }
switch sub {
case "pop": return "Pop stashed changes"
case "apply": return "Apply stashed changes"
case "drop": return "Drop a stash"
case "list": return "List stashes"
case "push", "save": return "Stash changes"
default: return "Stash changes"
}
}
// MARK: - Token helpers
/// git's value-taking flags — the ones whose following token is a value, not an operand —
/// so `positionals` doesn't mistake a message/branch/remote value for a positional. Includes
/// the `clone`/`fetch` value options that commonly precede the repo URL (`--depth 1`,
/// `--branch x`, `--filter …`), without which their value is read as the first positional —
/// e.g. `git clone --depth 1 <url>` would mislabel as "Clone 1" instead of "Clone <repo>".
/// `--recurse-submodules` is deliberately omitted: its value is optional, so consuming the
/// next token would swallow the URL when it's given bare.
private static let valueFlags: Set<String> = ["-m", "--message", "-F", "--file", "-b", "-B",
"-c", "-C", "-u", "--set-upstream", "-t", "--track",
"--depth", "--branch", "-j", "--jobs", "--filter",
"-o", "--origin", "--reference", "--reference-if-able"]
/// The value of a flag given either as `--flag value` / `-f value` or `--flag=value`.
private static func flagValue(_ args: [String], short: String, long: String) -> String? {
ShellLexer.flagValue(args, short: short, long: long)
}
/// The positional operands of a git subcommand — tokens that aren't flags or flag values.
private static func positionals(_ args: [String]) -> [String] {
ShellLexer.positionals(args, valueFlags: valueFlags)
}
/// The first ref-like positional operand (a branch, remote, tag, or repo URL).
private static func ref(_ args: [String]) -> String? { positionals(args).first }
/// The repository name from a clone URL/path — its last path component, minus a `.git`
/// suffix ("https://host/org/repo.git" → "repo").
private static func repoName(_ url: String) -> String {
let last = url.split(separator: "/").last.map(String.init) ?? url
return last.hasSuffix(".git") ? String(last.dropLast(4)) : last
}
/// A commit subject trimmed to a glanceable length without splitting a word.
private static func clipSubject(_ subject: String?) -> String? {
guard let subject, !subject.isEmpty else { return nil }
return HeuristicTitle.clip(subject, max: 60)
}
}