blob: 17f3ff2b6a53a7b9904d7891a4eb0b0fc11940d9 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package termui
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "os/exec"
12 "os/signal"
13 "strings"
14 "sync"
15 "syscall"
16 "text/template"
17 "time"
18
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +000019 "github.com/dustin/go-humanize"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "github.com/fatih/color"
21 "golang.org/x/term"
22 "sketch.dev/loop"
23)
24
25var (
26 // toolUseTemplTxt defines how tool invocations appear in the terminal UI.
27 // Keep this template in sync with the tools defined in claudetool package
28 // and registered in loop/agent.go.
29 // Add formatting for new tools as they are created.
30 // TODO: should this be part of tool definition to make it harder to forget to set up?
Josh Bleecher Snyderc3c20232025-05-07 05:46:04 -070031 toolUseTemplTxt = `{{if .msg.ToolError}}ć€°ļø {{end -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070032{{if eq .msg.ToolName "think" -}}
33 🧠 {{.input.thoughts -}}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070034{{else if eq .msg.ToolName "todo_read" -}}
35 šŸ“‹ Reading todo list
36{{else if eq .msg.ToolName "todo_write" }}
37{{range .input.tasks}}{{if eq .status "queued"}}⚪{{else if eq .status "in-progress"}}šŸ¦‰{{else if eq .status "completed"}}āœ…{{end}} {{.task}}
38{{end}}
Earl Lee2e463fb2025-04-17 11:22:22 -070039{{else if eq .msg.ToolName "keyword_search" -}}
Josh Bleecher Snyder453a62f2025-05-01 10:14:33 -070040 šŸ” {{ .input.query}}: {{.input.search_terms -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070041{{else if eq .msg.ToolName "bash" -}}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000042 šŸ–„ļø{{if .input.background}}šŸ”„{{end}} {{ .input.command -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070043{{else if eq .msg.ToolName "patch" -}}
44 āŒØļø {{.input.path -}}
45{{else if eq .msg.ToolName "done" -}}
46{{/* nothing to show here, the agent will write more in its next message */}}
47{{else if eq .msg.ToolName "title" -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000048šŸ·ļø {{.input.title}}
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +000049{{else if eq .msg.ToolName "precommit" -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000050🌱 git branch: sketch/{{.input.branch_name}}
Josh Bleecher Snyder74d690e2025-05-14 18:16:03 -070051{{else if eq .msg.ToolName "about_sketch" -}}
52šŸ“š About Sketch
Earl Lee2e463fb2025-04-17 11:22:22 -070053{{else if eq .msg.ToolName "str_replace_editor" -}}
54 āœļø {{.input.file_path -}}
55{{else if eq .msg.ToolName "codereview" -}}
56 šŸ› Running automated code review, may be slow
Sean McCullough485afc62025-04-28 14:28:39 -070057{{else if eq .msg.ToolName "multiplechoice" -}}
58 šŸ“ {{.input.question}}
59{{ range .input.responseOptions -}}
60 - {{ .caption}}: {{.responseText}}
61{{end -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070062{{else -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000063 šŸ› ļø {{ .msg.ToolName}}: {{.msg.ToolInput -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070064{{end -}}
65`
66 toolUseTmpl = template.Must(template.New("tool_use").Parse(toolUseTemplTxt))
67)
68
David Crawshaw93fec602025-05-05 08:40:06 -070069type TermUI struct {
Earl Lee2e463fb2025-04-17 11:22:22 -070070 stdin *os.File
71 stdout *os.File
72 stderr *os.File
73
74 agent loop.CodingAgent
75 httpURL string
76
77 trm *term.Terminal
78
79 // the chatMsgCh channel is for "conversation" messages, like responses to user input
80 // from the LLM, or output from executing slash-commands issued by the user.
81 chatMsgCh chan chatMessage
82
83 // the log channel is for secondary messages, like logging, errors, and debug information
84 // from local and remove subproceses.
85 termLogCh chan string
86
87 // protects following
88 mu sync.Mutex
89 oldState *term.State
90 // Tracks branches that were pushed during the session
91 pushedBranches map[string]struct{}
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +000092
93 // Pending message count, for graceful shutdown
94 messageWaitGroup sync.WaitGroup
Earl Lee2e463fb2025-04-17 11:22:22 -070095}
96
97type chatMessage struct {
98 idx int
99 sender string
100 content string
101 thinking bool
102}
103
David Crawshaw93fec602025-05-05 08:40:06 -0700104func New(agent loop.CodingAgent, httpURL string) *TermUI {
105 return &TermUI{
Earl Lee2e463fb2025-04-17 11:22:22 -0700106 agent: agent,
107 stdin: os.Stdin,
108 stdout: os.Stdout,
109 stderr: os.Stderr,
110 httpURL: httpURL,
111 chatMsgCh: make(chan chatMessage, 1),
112 termLogCh: make(chan string, 1),
113 pushedBranches: make(map[string]struct{}),
114 }
115}
116
David Crawshaw93fec602025-05-05 08:40:06 -0700117func (ui *TermUI) Run(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700118 fmt.Println(`🌐 ` + ui.httpURL + `/`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700119 fmt.Println(`šŸ’¬ type 'help' for help`)
120 fmt.Println()
121
122 // Start up the main terminal UI:
123 if err := ui.initializeTerminalUI(ctx); err != nil {
124 return err
125 }
126 go ui.receiveMessagesLoop(ctx)
127 if err := ui.inputLoop(ctx); err != nil {
128 return err
129 }
130 return nil
131}
132
David Crawshaw93fec602025-05-05 08:40:06 -0700133func (ui *TermUI) LogToolUse(resp *loop.AgentMessage) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700134 inputData := map[string]any{}
135 if err := json.Unmarshal([]byte(resp.ToolInput), &inputData); err != nil {
136 ui.AppendSystemMessage("error: %v", err)
137 return
138 }
139 buf := bytes.Buffer{}
140 if err := toolUseTmpl.Execute(&buf, map[string]any{"msg": resp, "input": inputData, "output": resp.ToolResult}); err != nil {
141 ui.AppendSystemMessage("error: %v", err)
142 return
143 }
144 ui.AppendSystemMessage("%s\n", buf.String())
145}
146
David Crawshaw93fec602025-05-05 08:40:06 -0700147func (ui *TermUI) receiveMessagesLoop(ctx context.Context) {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700148 it := ui.agent.NewIterator(ctx, 0)
Earl Lee2e463fb2025-04-17 11:22:22 -0700149 bold := color.New(color.Bold).SprintFunc()
150 for {
151 select {
152 case <-ctx.Done():
153 return
154 default:
155 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700156 resp := it.Next()
157 if resp == nil {
158 return
159 }
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000160 if resp.HideOutput {
161 continue
162 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700163 // Typically a user message will start the thinking and a (top-level
164 // conversation) end of turn will stop it.
165 thinking := !(resp.EndOfTurn && resp.ParentConversationID == nil)
166
167 switch resp.Type {
168 case loop.AgentMessageType:
Josh Bleecher Snyder2978ab22025-04-30 10:29:32 -0700169 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "šŸ•“ļø ", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700170 case loop.ToolUseMessageType:
171 ui.LogToolUse(resp)
172 case loop.ErrorMessageType:
173 ui.AppendSystemMessage("āŒ %s", resp.Content)
174 case loop.BudgetMessageType:
175 ui.AppendSystemMessage("šŸ’° %s", resp.Content)
176 case loop.AutoMessageType:
177 ui.AppendSystemMessage("🧐 %s", resp.Content)
178 case loop.UserMessageType:
Josh Bleecher Snyderc2d26102025-04-30 06:19:43 -0700179 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "🦸", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700180 case loop.CommitMessageType:
181 // Display each commit in the terminal
182 for _, commit := range resp.Commits {
183 if commit.PushedBranch != "" {
Sean McCullough43664f62025-04-20 16:13:03 -0700184 ui.AppendSystemMessage("šŸ”„ new commit: [%s] %s\npushed to: %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch))
Earl Lee2e463fb2025-04-17 11:22:22 -0700185
186 // Track the pushed branch in our map
187 ui.mu.Lock()
188 ui.pushedBranches[commit.PushedBranch] = struct{}{}
189 ui.mu.Unlock()
190 } else {
191 ui.AppendSystemMessage("šŸ”„ new commit: [%s] %s", commit.Hash[:8], commit.Subject)
192 }
193 }
194 default:
195 ui.AppendSystemMessage("āŒ Unexpected Message Type %s %v", resp.Type, resp)
196 }
197 }
198}
199
David Crawshaw93fec602025-05-05 08:40:06 -0700200func (ui *TermUI) inputLoop(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700201 for {
202 line, err := ui.trm.ReadLine()
203 if errors.Is(err, io.EOF) {
204 ui.AppendSystemMessage("\n")
205 line = "exit"
206 } else if err != nil {
207 return err
208 }
209
210 line = strings.TrimSpace(line)
211
212 switch line {
213 case "?", "help":
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700214 ui.AppendSystemMessage(`General use:
215Use chat to ask sketch to tackle a task or answer a question about this repo.
216
217Special commands:
218- help, ? : Show this help message
219- budget : Show original budget
220- usage, cost : Show current token usage and cost
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000221- browser, open, b : Open current conversation in browser
Earl Lee2e463fb2025-04-17 11:22:22 -0700222- stop, cancel, abort : Cancel the current operation
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700223- exit, quit, q : Exit sketch
224- ! <command> : Execute a shell command (e.g. !ls -la)`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700225 case "budget":
226 originalBudget := ui.agent.OriginalBudget()
227 ui.AppendSystemMessage("šŸ’° Budget summary:")
228 if originalBudget.MaxResponses > 0 {
229 ui.AppendSystemMessage("- Max responses: %d", originalBudget.MaxResponses)
230 }
231 if originalBudget.MaxWallTime > 0 {
232 ui.AppendSystemMessage("- Max wall time: %v", originalBudget.MaxWallTime)
233 }
234 ui.AppendSystemMessage("- Max total cost: %0.2f", originalBudget.MaxDollars)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000235 case "browser", "open", "b":
236 if ui.httpURL != "" {
237 ui.AppendSystemMessage("🌐 Opening %s in browser", ui.httpURL)
238 go ui.agent.OpenBrowser(ui.httpURL)
239 } else {
240 ui.AppendSystemMessage("āŒ No web URL available for this session")
241 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700242 case "usage", "cost":
243 totalUsage := ui.agent.TotalUsage()
244 ui.AppendSystemMessage("šŸ’° Current usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000245 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
246 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700247 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
248 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
249 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
250 case "bye", "exit", "q", "quit":
251 ui.trm.SetPrompt("")
252 // Display final usage stats
253 totalUsage := ui.agent.TotalUsage()
254 ui.AppendSystemMessage("šŸ’° Final usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000255 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
256 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700257 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
258 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
259 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
260
261 // Display pushed branches
262 ui.mu.Lock()
263 if len(ui.pushedBranches) > 0 {
264 // Convert map keys to a slice for display
265 branches := make([]string, 0, len(ui.pushedBranches))
266 for branch := range ui.pushedBranches {
267 branches = append(branches, branch)
268 }
269
Philip Zeyliger49edc922025-05-14 09:45:45 -0700270 initialCommitRef := getShortSHA(ui.agent.SketchGitBase())
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 if len(branches) == 1 {
272 ui.AppendSystemMessage("\nšŸ”„ Branch pushed during session: %s", branches[0])
Josh Bleecher Snyder956626d2025-05-15 21:24:07 +0000273 ui.AppendSystemMessage("šŸ’ Cherry-pick those changes: git cherry-pick %s..%s", initialCommitRef, branches[0])
274 ui.AppendSystemMessage("šŸ”€ Merge those changes: git merge %s", branches[0])
275 ui.AppendSystemMessage("šŸ—‘ļø Delete the branch: git branch -D %s", branches[0])
Earl Lee2e463fb2025-04-17 11:22:22 -0700276 } else {
277 ui.AppendSystemMessage("\nšŸ”„ Branches pushed during session:")
278 for _, branch := range branches {
279 ui.AppendSystemMessage("- %s", branch)
280 }
281 ui.AppendSystemMessage("\nšŸ’ To add all those changes to your branch:")
282 for _, branch := range branches {
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000283 ui.AppendSystemMessage("git cherry-pick %s..%s", initialCommitRef, branch)
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700285 ui.AppendSystemMessage("\nšŸ”€ or:")
286 for _, branch := range branches {
287 ui.AppendSystemMessage("git merge %s", branch)
288 }
Josh Bleecher Snyder956626d2025-05-15 21:24:07 +0000289
290 ui.AppendSystemMessage("\nšŸ—‘ļø To delete branches:")
291 for _, branch := range branches {
292 ui.AppendSystemMessage("git branch -D %s", branch)
293 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700294 }
295 }
296 ui.mu.Unlock()
297
298 ui.AppendSystemMessage("\nšŸ‘‹ Goodbye!")
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000299 // Wait for all pending messages to be processed before exiting
300 ui.messageWaitGroup.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -0700301 return nil
302 case "stop", "cancel", "abort":
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000303 ui.agent.CancelTurn(fmt.Errorf("user canceled the operation"))
Earl Lee2e463fb2025-04-17 11:22:22 -0700304 case "panic":
305 panic("user forced a panic")
306 default:
307 if line == "" {
308 continue
309 }
310 if strings.HasPrefix(line, "!") {
311 // Execute as shell command
312 line = line[1:] // remove the '!' prefix
313 sendToLLM := strings.HasPrefix(line, "!")
314 if sendToLLM {
315 line = line[1:] // remove the second '!'
316 }
317
318 // Create a cmd and run it
319 // TODO: ui.trm contains a mutex inside its write call.
320 // It is potentially safe to attach ui.trm directly to this
321 // cmd object's Stdout/Stderr and stream the output.
322 // That would make a big difference for, e.g. wget.
323 cmd := exec.Command("bash", "-c", line)
324 out, err := cmd.CombinedOutput()
325 ui.AppendSystemMessage("%s", out)
326 if err != nil {
327 ui.AppendSystemMessage("āŒ Command error: %v", err)
328 }
329 if sendToLLM {
330 // Send the command and its output to the agent
331 message := fmt.Sprintf("I ran the command: `%s`\nOutput:\n```\n%s```", line, out)
332 if err != nil {
333 message += fmt.Sprintf("\n\nError: %v", err)
334 }
335 ui.agent.UserMessage(ctx, message)
336 }
337 continue
338 }
339
340 // Send it to the LLM
341 // chatMsg := chatMessage{sender: "you", content: line}
342 // ui.sendChatMessage(chatMsg)
343 ui.agent.UserMessage(ctx, line)
344 }
345 }
346}
347
David Crawshaw93fec602025-05-05 08:40:06 -0700348func (ui *TermUI) updatePrompt(thinking bool) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700349 var t string
350
351 if thinking {
352 // Emoji don't seem to work here? Messes up my terminal.
353 t = "*"
354 }
Josh Bleecher Snyder23b6a2d2025-04-30 04:07:52 +0000355 p := fmt.Sprintf("%s ($%0.2f/%0.2f)%s> ",
356 ui.httpURL, ui.agent.TotalUsage().TotalCostUSD, ui.agent.OriginalBudget().MaxDollars, t)
Earl Lee2e463fb2025-04-17 11:22:22 -0700357 ui.trm.SetPrompt(p)
358}
359
David Crawshaw93fec602025-05-05 08:40:06 -0700360func (ui *TermUI) initializeTerminalUI(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700361 ui.mu.Lock()
362 defer ui.mu.Unlock()
363
364 if !term.IsTerminal(int(ui.stdin.Fd())) {
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000365 return fmt.Errorf("this command requires terminal I/O when termui=true")
Earl Lee2e463fb2025-04-17 11:22:22 -0700366 }
367
368 oldState, err := term.MakeRaw(int(ui.stdin.Fd()))
369 if err != nil {
370 return err
371 }
372 ui.oldState = oldState
373 ui.trm = term.NewTerminal(ui.stdin, "")
374 width, height, err := term.GetSize(int(ui.stdin.Fd()))
375 if err != nil {
376 return fmt.Errorf("Error getting terminal size: %v\n", err)
377 }
378 ui.trm.SetSize(width, height)
379 // Handle terminal resizes...
380 sig := make(chan os.Signal, 1)
381 signal.Notify(sig, syscall.SIGWINCH)
382 go func() {
383 for {
384 <-sig
385 newWidth, newHeight, err := term.GetSize(int(ui.stdin.Fd()))
386 if err != nil {
387 continue
388 }
389 if newWidth != width || newHeight != height {
390 width, height = newWidth, newHeight
391 ui.trm.SetSize(width, height)
392 }
393 }
394 }()
395
396 ui.updatePrompt(false)
397
398 // This is the only place where we should call fe.trm.Write:
399 go func() {
Sean McCullougha4b19f82025-05-05 10:22:59 -0700400 var lastMsg *chatMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700401 for {
402 select {
403 case <-ctx.Done():
404 return
405 case msg := <-ui.chatMsgCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000406 func() {
407 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700408 // Update prompt before writing, because otherwise it doesn't redraw the prompt.
409 ui.updatePrompt(msg.thinking)
410 lastMsg = &msg
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000411 // Sometimes claude doesn't say anything when it runs tools.
412 // No need to output anything in that case.
413 if strings.TrimSpace(msg.content) == "" {
414 return
415 }
416 s := fmt.Sprintf("%s %s\n", msg.sender, msg.content)
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000417 ui.trm.Write([]byte(s))
418 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700419 case logLine := <-ui.termLogCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000420 func() {
421 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700422 if lastMsg != nil {
423 ui.updatePrompt(lastMsg.thinking)
424 } else {
425 ui.updatePrompt(false)
426 }
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000427 b := []byte(logLine + "\n")
428 ui.trm.Write(b)
429 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700430 }
431 }
432 }()
433
434 return nil
435}
436
David Crawshaw93fec602025-05-05 08:40:06 -0700437func (ui *TermUI) RestoreOldState() error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700438 ui.mu.Lock()
439 defer ui.mu.Unlock()
440 return term.Restore(int(ui.stdin.Fd()), ui.oldState)
441}
442
443// AppendChatMessage is for showing responses the user's request, conversational dialog etc
David Crawshaw93fec602025-05-05 08:40:06 -0700444func (ui *TermUI) AppendChatMessage(msg chatMessage) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000445 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700446 ui.chatMsgCh <- msg
447}
448
449// AppendSystemMessage is for debug information, errors and such that are not part of the "conversation" per se,
450// but still need to be shown to the user.
David Crawshaw93fec602025-05-05 08:40:06 -0700451func (ui *TermUI) AppendSystemMessage(fmtString string, args ...any) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000452 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700453 ui.termLogCh <- fmt.Sprintf(fmtString, args...)
454}
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000455
Josh Bleecher Snyder8fdf7532025-05-06 00:56:12 +0000456// getShortSHA returns the short SHA for the given git reference, falling back to the original SHA on error.
457func getShortSHA(sha string) string {
458 cmd := exec.Command("git", "rev-parse", "--short", sha)
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000459 shortSha, err := cmd.Output()
460 if err == nil {
461 shortStr := strings.TrimSpace(string(shortSha))
462 if shortStr != "" {
463 return shortStr
464 }
465 }
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000466 return sha
467}