486 lines
24 KiB
Swift
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)
|
|
}
|
|
}
|