1// Package gitx shells out to git for the small amount of repository context2// the viewer shows: the current branch, uncommitted openspec files, and diffs.3package gitx45import (6 "bytes"7 "context"8 "errors"9 "fmt"10 "os"11 "os/exec"12 "path/filepath"13 "strings"14 "time"15)1617const timeout = 3 * time.Second1819// Status is the repository context shown in the status bar.20type Status struct {21 IsRepo bool22 Branch string23 // Dirty maps absolute paths under the openspec dir to their two-letter24 // porcelain status (e.g. " M", "??", "A ").25 Dirty map[string]string26}2728func run(dir string, args ...string) (string, error) {29 ctx, cancel := context.WithTimeout(context.Background(), timeout)30 defer cancel()31 cmd := exec.CommandContext(ctx, "git", args...)32 cmd.Dir = dir33 // Never take .git/index.lock: status/diff would otherwise refresh the34 // index and make an agent's concurrent `git add`/`git commit` fail.35 cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0")36 var out, errOut bytes.Buffer37 cmd.Stdout = &out38 cmd.Stderr = &errOut39 err := cmd.Run()40 if err != nil && errOut.Len() > 0 {41 err = fmt.Errorf("%w: %s", err, strings.TrimSpace(errOut.String()))42 }43 return out.String(), err44}4546// Read collects the branch and uncommitted files under openspecDir.47func Read(root, openspecDir string) Status {48 top, err := run(root, "rev-parse", "--show-toplevel")49 if err != nil {50 return Status{}51 }52 top = strings.TrimSpace(top)53 s := Status{IsRepo: true, Dirty: map[string]string{}}5455 if b, err := run(root, "branch", "--show-current"); err == nil {56 s.Branch = strings.TrimSpace(b)57 }58 if s.Branch == "" {59 if h, err := run(root, "rev-parse", "--short", "HEAD"); err == nil {60 s.Branch = "@" + strings.TrimSpace(h)61 }62 }6364 out, err := run(root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", openspecDir)65 if err != nil {66 return s67 }68 entries := strings.Split(out, "\x00")69 for i := 0; i < len(entries); i++ {70 e := entries[i]71 if len(e) < 4 {72 continue73 }74 code, path := e[:2], e[3:]75 if strings.ContainsAny(code, "RC") {76 i++ // -z puts the rename/copy source in the next field77 }78 s.Dirty[filepath.Join(top, filepath.FromSlash(path))] = code79 }80 return s81}8283// Diff returns the working-tree diff of path against HEAD. Untracked files84// are diffed against /dev/null so new artifacts show up in full.85func Diff(root, path string, untracked bool) (string, error) {86 // Relative paths keep the diff header short (a/openspec/...).87 if rel, err := filepath.Rel(root, path); err == nil && !strings.HasPrefix(rel, "..") {88 path = rel89 }90 if !untracked {91 if _, err := run(root, "rev-parse", "--verify", "--quiet", "HEAD"); err != nil {92 untracked = true // no commits yet: show the whole file as added93 }94 }95 if untracked {96 out, err := run(root, "diff", "--no-color", "--no-ext-diff", "--no-index", "--", "/dev/null", path)97 // --no-index exits 1 when files differ.98 var exitErr *exec.ExitError99 if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {100 err = nil101 }102 return out, err103 }104 return run(root, "diff", "--no-color", "--no-ext-diff", "HEAD", "--", path)105}