From 85e14e74ae3965630d3b053a2fe9401ab98e5e6c Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:33:46 -0700 Subject: [PATCH] feat(cmd): add a status command for scriptable repo state Add a `grut status` subcommand that prints a summary of the working tree: current branch, upstream tracking with ahead/behind counts, and the staged, unstaged, untracked, and conflicted files. Pass --json for a stable machine-readable document that scripts and other tools can parse. The command reuses the existing porcelain v2 parsing. A new StatusWithBranch git client method returns the branch header and file entries from a single git invocation so callers do not run status twice. Closes #314 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cmd/root.go | 1 + cmd/status.go | 209 +++++++++++++++++++++++++++++++++++++++++ cmd/status_test.go | 179 +++++++++++++++++++++++++++++++++++ internal/git/status.go | 16 ++++ 4 files changed, 405 insertions(+) create mode 100644 cmd/status.go create mode 100644 cmd/status_test.go diff --git a/cmd/root.go b/cmd/root.go index 9040d589..5f649bbd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -357,6 +357,7 @@ Environment: rootCmd.AddCommand(newRunCmd()) rootCmd.AddCommand(newReportCmd()) rootCmd.AddCommand(newConfigCmd()) + rootCmd.AddCommand(newStatusCmd()) // cleanup releases profiling resources. It is idempotent — safe to call // multiple times (subsequent calls are no-ops). diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 00000000..da8d4864 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/jongio/grut/internal/git" + "github.com/spf13/cobra" +) + +// statusEntry is a single changed file in the JSON output. +type statusEntry struct { + Path string `json:"path"` + Status string `json:"status"` +} + +// statusCounts summarizes how many entries fall into each category. +type statusCounts struct { + Staged int `json:"staged"` + Unstaged int `json:"unstaged"` + Untracked int `json:"untracked"` + Conflicted int `json:"conflicted"` +} + +// statusReport is the full machine-readable status document. +type statusReport struct { + Branch string `json:"branch"` + Upstream string `json:"upstream"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Detached bool `json:"detached"` + Clean bool `json:"clean"` + Staged []statusEntry `json:"staged"` + Unstaged []statusEntry `json:"unstaged"` + Untracked []string `json:"untracked"` + Conflicted []string `json:"conflicted"` + Counts statusCounts `json:"counts"` +} + +// newStatusCmd creates the status command, which prints a summary of the +// current repository state for humans or, with --json, for scripts. +func newStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Print a summary of the working tree status", + Long: `Print a summary of the current repository: branch, tracking +information (ahead/behind), and the staged, unstaged, untracked, and +conflicted files. + +Use --json for a stable, machine-readable document that scripts and other +tools can parse.`, + Args: cobra.NoArgs, + RunE: runStatus, + } + cmd.Flags().Bool("json", false, "Output the status as JSON") + return cmd +} + +func runStatus(cmd *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + gc, err := git.NewClient(cwd) + if err != nil { + return fmt.Errorf("git client: %w", err) + } + + ctx := context.Background() + isRepo, err := gc.IsRepo(ctx) + if err != nil { + return fmt.Errorf("check repository: %w", err) + } + if !isRepo { + return fmt.Errorf("not a git repository: %s", cwd) + } + + branch, files, err := gc.StatusWithBranch(ctx) + if err != nil { + return fmt.Errorf("read status: %w", err) + } + + report := buildStatusReport(branch, files) + + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return writeStatusJSON(cmd.OutOrStdout(), report) + } + writeStatusText(cmd.OutOrStdout(), report) + return nil +} + +// buildStatusReport classifies file entries into staged, unstaged, untracked, +// and conflicted buckets. A file may be both staged and unstaged (for example +// an index change plus a later worktree edit), in which case it appears in both. +func buildStatusReport(branch git.StatusBranch, files []git.FileStatus) statusReport { + head := branch.Head + detached := head == "" || head == "(detached)" + + report := statusReport{ + Branch: head, + Upstream: branch.Upstream, + Ahead: branch.Ahead, + Behind: branch.Behind, + Detached: detached, + Staged: []statusEntry{}, + Unstaged: []statusEntry{}, + Untracked: []string{}, + Conflicted: []string{}, + } + + for _, f := range files { + switch { + case f.StagedStatus == git.StatusUntracked: + report.Untracked = append(report.Untracked, f.Path) + case f.StagedStatus == git.StatusConflict: + report.Conflicted = append(report.Conflicted, f.Path) + default: + if isChange(f.StagedStatus) { + report.Staged = append(report.Staged, statusEntry{Path: f.Path, Status: f.StagedStatus.String()}) + } + if isChange(f.WorktreeStatus) { + report.Unstaged = append(report.Unstaged, statusEntry{Path: f.Path, Status: f.WorktreeStatus.String()}) + } + } + } + + report.Counts = statusCounts{ + Staged: len(report.Staged), + Unstaged: len(report.Unstaged), + Untracked: len(report.Untracked), + Conflicted: len(report.Conflicted), + } + report.Clean = report.Counts.Staged == 0 && + report.Counts.Unstaged == 0 && + report.Counts.Untracked == 0 && + report.Counts.Conflicted == 0 + + return report +} + +// isChange reports whether a status code represents an actual change to the +// index or worktree (as opposed to unmodified). +func isChange(code git.StatusCode) bool { + return code != git.StatusUnmodified +} + +func writeStatusJSON(w io.Writer, report statusReport) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return fmt.Errorf("encode status: %w", err) + } + return nil +} + +func writeStatusText(w io.Writer, report statusReport) { + if report.Detached { + fmt.Fprintln(w, "HEAD detached") + } else { + fmt.Fprintf(w, "On branch %s\n", report.Branch) + } + + if report.Upstream != "" { + track := "" + switch { + case report.Ahead > 0 && report.Behind > 0: + track = fmt.Sprintf(" [ahead %d, behind %d]", report.Ahead, report.Behind) + case report.Ahead > 0: + track = fmt.Sprintf(" [ahead %d]", report.Ahead) + case report.Behind > 0: + track = fmt.Sprintf(" [behind %d]", report.Behind) + } + fmt.Fprintf(w, "Tracking %s%s\n", report.Upstream, track) + } + + if report.Clean { + fmt.Fprintln(w, "Working tree clean") + return + } + + writeStatusSection(w, "Staged", report.Staged) + writeStatusSection(w, "Unstaged", report.Unstaged) + if len(report.Conflicted) > 0 { + fmt.Fprintf(w, "Conflicted (%d):\n", len(report.Conflicted)) + for _, p := range report.Conflicted { + fmt.Fprintf(w, " U %s\n", p) + } + } + if len(report.Untracked) > 0 { + fmt.Fprintf(w, "Untracked (%d):\n", len(report.Untracked)) + for _, p := range report.Untracked { + fmt.Fprintf(w, " ? %s\n", p) + } + } +} + +func writeStatusSection(w io.Writer, label string, entries []statusEntry) { + if len(entries) == 0 { + return + } + fmt.Fprintf(w, "%s (%d):\n", label, len(entries)) + for _, e := range entries { + fmt.Fprintf(w, " %s %s\n", e.Status, e.Path) + } +} diff --git a/cmd/status_test.go b/cmd/status_test.go new file mode 100644 index 00000000..f6e5868b --- /dev/null +++ b/cmd/status_test.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/jongio/grut/internal/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStatusCmd_Wiring(t *testing.T) { + cmd := newStatusCmd() + assert.Equal(t, "status", cmd.Use) + assert.NotEmpty(t, cmd.Short) + require.NotNil(t, cmd.Flags().Lookup("json"), "--json flag should be registered") +} + +func TestBuildStatusReport_Clean(t *testing.T) { + report := buildStatusReport(git.StatusBranch{Head: "main", Upstream: "origin/main"}, nil) + assert.True(t, report.Clean) + assert.False(t, report.Detached) + assert.Equal(t, "main", report.Branch) + assert.Equal(t, "origin/main", report.Upstream) + assert.Equal(t, 0, report.Counts.Staged) + assert.Equal(t, 0, report.Counts.Unstaged) + // Slices are non-nil so JSON renders [] rather than null. + assert.NotNil(t, report.Staged) + assert.NotNil(t, report.Untracked) +} + +func TestBuildStatusReport_Detached(t *testing.T) { + for _, head := range []string{"", "(detached)"} { + report := buildStatusReport(git.StatusBranch{Head: head}, nil) + assert.True(t, report.Detached, "head %q should be detached", head) + } +} + +func TestBuildStatusReport_Classification(t *testing.T) { + files := []git.FileStatus{ + {Path: "staged.go", StagedStatus: git.StatusModified, WorktreeStatus: git.StatusUnmodified}, + {Path: "unstaged.go", StagedStatus: git.StatusUnmodified, WorktreeStatus: git.StatusModified}, + {Path: "both.go", StagedStatus: git.StatusModified, WorktreeStatus: git.StatusModified}, + {Path: "new.txt", StagedStatus: git.StatusUntracked, WorktreeStatus: git.StatusUntracked}, + {Path: "conflict.txt", StagedStatus: git.StatusConflict, WorktreeStatus: git.StatusConflict}, + } + report := buildStatusReport(git.StatusBranch{Head: "main"}, files) + + assert.False(t, report.Clean) + // staged.go and both.go are staged. + assert.Equal(t, 2, report.Counts.Staged) + // unstaged.go and both.go are unstaged. + assert.Equal(t, 2, report.Counts.Unstaged) + assert.Equal(t, 1, report.Counts.Untracked) + assert.Equal(t, 1, report.Counts.Conflicted) + assert.Equal(t, []string{"new.txt"}, report.Untracked) + assert.Equal(t, []string{"conflict.txt"}, report.Conflicted) + + stagedPaths := entryPaths(report.Staged) + assert.ElementsMatch(t, []string{"staged.go", "both.go"}, stagedPaths) + unstagedPaths := entryPaths(report.Unstaged) + assert.ElementsMatch(t, []string{"unstaged.go", "both.go"}, unstagedPaths) +} + +func entryPaths(entries []statusEntry) []string { + paths := make([]string, len(entries)) + for i, e := range entries { + paths[i] = e.Path + } + return paths +} + +func TestWriteStatusText_Clean(t *testing.T) { + var buf bytes.Buffer + writeStatusText(&buf, buildStatusReport(git.StatusBranch{Head: "main", Upstream: "origin/main", Ahead: 2}, nil)) + out := buf.String() + assert.Contains(t, out, "On branch main") + assert.Contains(t, out, "Tracking origin/main [ahead 2]") + assert.Contains(t, out, "Working tree clean") +} + +func TestWriteStatusText_Sections(t *testing.T) { + files := []git.FileStatus{ + {Path: "a.go", StagedStatus: git.StatusModified, WorktreeStatus: git.StatusUnmodified}, + {Path: "b.txt", StagedStatus: git.StatusUntracked, WorktreeStatus: git.StatusUntracked}, + } + var buf bytes.Buffer + writeStatusText(&buf, buildStatusReport(git.StatusBranch{Head: "main"}, files)) + out := buf.String() + assert.Contains(t, out, "Staged (1):") + assert.Contains(t, out, " M a.go") + assert.Contains(t, out, "Untracked (1):") + assert.Contains(t, out, " ? b.txt") + assert.NotContains(t, out, "Working tree clean") +} + +func TestWriteStatusText_DetachedHead(t *testing.T) { + var buf bytes.Buffer + writeStatusText(&buf, buildStatusReport(git.StatusBranch{Head: "(detached)"}, nil)) + assert.Contains(t, buf.String(), "HEAD detached") +} + +func TestWriteStatusJSON_RoundTrip(t *testing.T) { + files := []git.FileStatus{ + {Path: "a.go", StagedStatus: git.StatusAdded, WorktreeStatus: git.StatusUnmodified}, + } + report := buildStatusReport(git.StatusBranch{Head: "main", Upstream: "origin/main", Behind: 3}, files) + + var buf bytes.Buffer + require.NoError(t, writeStatusJSON(&buf, report)) + + var decoded statusReport + require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded)) + assert.Equal(t, "main", decoded.Branch) + assert.Equal(t, 3, decoded.Behind) + assert.Equal(t, 1, decoded.Counts.Staged) + require.Len(t, decoded.Staged, 1) + assert.Equal(t, "a.go", decoded.Staged[0].Path) + assert.Equal(t, "A", decoded.Staged[0].Status) +} + +// --------------------------------------------------------------------------- +// Integration: runStatus against a real temporary repository. +// --------------------------------------------------------------------------- + +func TestRunStatus_NotARepo(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + cmd := newStatusCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := runStatus(cmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a git repository") +} + +func TestRunStatus_RealRepo_JSON(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + runGit(t, dir, "init", "-b", "main") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("hi"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "loose.txt"), []byte("bye"), 0o644)) + runGit(t, dir, "add", "staged.txt") + + t.Chdir(dir) + cmd := newStatusCmd() + var out bytes.Buffer + cmd.SetOut(&out) + require.NoError(t, cmd.Flags().Set("json", "true")) + require.NoError(t, runStatus(cmd, nil)) + + var report statusReport + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + assert.Equal(t, "main", report.Branch) + assert.False(t, report.Clean) + assert.Equal(t, 1, report.Counts.Staged) + assert.Equal(t, []string{"loose.txt"}, report.Untracked) + assert.Equal(t, "staged.txt", report.Staged[0].Path) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) + } +} diff --git a/internal/git/status.go b/internal/git/status.go index d66495b8..16eed3f6 100644 --- a/internal/git/status.go +++ b/internal/git/status.go @@ -16,6 +16,22 @@ func (c *Client) Status(ctx context.Context) ([]FileStatus, error) { return parseStatusV2(out) } +// StatusWithBranch returns branch tracking metadata together with the working +// tree file statuses from a single git invocation. It is a convenience for +// callers that need both the branch header (name, upstream, ahead, behind) and +// the changed entries without running git status twice. +func (c *Client) StatusWithBranch(ctx context.Context) (StatusBranch, []FileStatus, error) { + out, err := c.run(ctx, "status", "--porcelain=v2", "--branch", "-uall") + if err != nil { + return StatusBranch{}, nil, fmt.Errorf("status: %w", err) + } + files, err := parseStatusV2(out) + if err != nil { + return StatusBranch{}, nil, err + } + return ParseStatusBranch(out), files, nil +} + // parseStatusV2 parses git status --porcelain=v2 output into FileStatus entries. // // Porcelain v2 format: