137 lines
4.2 KiB
Go
137 lines
4.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"os/exec"
|
|
"strings"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
)
|
|
|
|
// gitInfoMsg is the result of an async git probe (fetchGitCmd). It is defined
|
|
// here rather than in msgs.go to avoid conflicting with the event-bridge message
|
|
// set (#387). The status bar consumes it to render the branch + working-tree
|
|
// state segment.
|
|
//
|
|
// - branch: the current branch name (empty when detached / unknown).
|
|
// - ahead: commits the branch is ahead of its upstream (0 when unknown or no
|
|
// upstream). Derived cheaply from `git status --porcelain -b`.
|
|
// - dirty: number of changed/untracked entries reported by `git status
|
|
// --porcelain` (staged, unstaged, and untracked all count).
|
|
// - ok: false when the cwd is not a git repository or any git command
|
|
// failed; the status bar hides the git segment in that case.
|
|
type gitInfoMsg struct {
|
|
branch string
|
|
ahead int
|
|
dirty int
|
|
ok bool
|
|
}
|
|
|
|
// fetchGitCmd returns a tea.Cmd that probes the git working tree rooted at cwd
|
|
// and reports a gitInfoMsg. It runs read-only git plumbing with fixed arguments
|
|
// (no user interpolation, so no command-injection surface) off the tea
|
|
// goroutine. Any error — not a repo, git missing, detached parse failure —
|
|
// collapses to gitInfoMsg{ok:false}, which the status bar renders as "no git".
|
|
func fetchGitCmd(cwd string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// One porcelain call with the branch header gives us branch name, ahead
|
|
// count, and every dirty/untracked entry in a single stable, parseable
|
|
// format (-z would drop the header line, so we keep the newline form).
|
|
out, err := runGit(cwd, "status", "--porcelain", "-b")
|
|
if err != nil {
|
|
return gitInfoMsg{ok: false}
|
|
}
|
|
return parseGitStatus(out)
|
|
}
|
|
}
|
|
|
|
// runGit executes a read-only git subcommand in dir and returns its stdout. The
|
|
// argument list is always caller-controlled constants (see fetchGitCmd), never
|
|
// user input.
|
|
func runGit(dir string, args ...string) (string, error) {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// parseGitStatus parses the output of `git status --porcelain -b` into a
|
|
// gitInfoMsg. The first line is the branch header ("## branch...upstream [ahead N,
|
|
// behind M]"); every subsequent non-empty line is one changed or untracked entry.
|
|
// A parsed result always has ok=true, since reaching this point means git ran.
|
|
func parseGitStatus(out string) gitInfoMsg {
|
|
info := gitInfoMsg{ok: true}
|
|
lines := strings.Split(out, "\n")
|
|
for i, line := range lines {
|
|
if i == 0 {
|
|
info.branch, info.ahead = parseBranchHeader(line)
|
|
continue
|
|
}
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
info.dirty++
|
|
}
|
|
return info
|
|
}
|
|
|
|
// parseBranchHeader extracts the branch name and ahead count from a porcelain
|
|
// branch header line, e.g.:
|
|
//
|
|
// ## master...origin/master [ahead 4, behind 1]
|
|
// ## feature-x
|
|
// ## HEAD (no branch)
|
|
//
|
|
// It returns the branch name and ahead count (0 when absent). A line that is not
|
|
// a branch header yields ("", 0).
|
|
func parseBranchHeader(line string) (string, int) {
|
|
const prefix = "## "
|
|
if !strings.HasPrefix(line, prefix) {
|
|
return "", 0
|
|
}
|
|
rest := strings.TrimPrefix(line, prefix)
|
|
|
|
// Split off the optional " [ahead N, behind M]" tracking suffix.
|
|
branchPart := rest
|
|
ahead := 0
|
|
if idx := strings.Index(rest, " ["); idx >= 0 {
|
|
branchPart = rest[:idx]
|
|
ahead = parseAhead(rest[idx:])
|
|
}
|
|
|
|
// "## HEAD (no branch)" — detached; keep the raw token as the branch label.
|
|
branchPart = strings.TrimSpace(branchPart)
|
|
|
|
// Trim the "...upstream" tracking-branch tail if present.
|
|
if idx := strings.Index(branchPart, "..."); idx >= 0 {
|
|
branchPart = branchPart[:idx]
|
|
}
|
|
return branchPart, ahead
|
|
}
|
|
|
|
// parseAhead pulls the integer following "ahead " out of a tracking suffix such
|
|
// as "[ahead 4, behind 1]". It returns 0 when no ahead count is present.
|
|
func parseAhead(suffix string) int {
|
|
const marker = "ahead "
|
|
idx := strings.Index(suffix, marker)
|
|
if idx < 0 {
|
|
return 0
|
|
}
|
|
digits := suffix[idx+len(marker):]
|
|
n := 0
|
|
found := false
|
|
for _, r := range digits {
|
|
if r < '0' || r > '9' {
|
|
break
|
|
}
|
|
n = n*10 + int(r-'0')
|
|
found = true
|
|
}
|
|
if !found {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|