Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
209 changes: 209 additions & 0 deletions cmd/status.go
Original file line number Diff line number Diff line change
@@ -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)"

Check failure on line 101 in cmd/status.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

string `(detached)` has 3 occurrences, make it a constant (goconst)

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 {

Check failure on line 116 in cmd/status.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

QF1002: could use tagged switch on f.StagedStatus (staticcheck)
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)
}
}
179 changes: 179 additions & 0 deletions cmd/status_test.go
Original file line number Diff line number Diff line change
@@ -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", "[email protected]")
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)
}
}
Loading
Loading