From 20b884c71ad1553fc7362247c8a9de469b52feeb Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:23:26 -0700 Subject: [PATCH] status: add dirty worktree check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 340b4dd3-b4b3-4f1a-9163-66b16d96fa81 --- cmd/status.go | 16 ++++++++++++++-- cmd/status_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/cmd/status.go b/cmd/status.go index 4c358d79..4c1c1dca 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -56,6 +57,7 @@ tools can parse.`, RunE: runStatus, } cmd.Flags().Bool("json", false, "Output the status as JSON") + cmd.Flags().Bool("check", false, "Exit with an error when the working tree is not clean") return cmd } @@ -87,12 +89,22 @@ func runStatus(cmd *cobra.Command, _ []string) error { asJSON, _ := cmd.Flags().GetBool("json") if asJSON { - return writeStatusJSON(cmd.OutOrStdout(), report) + if err := writeStatusJSON(cmd.OutOrStdout(), report); err != nil { + return err + } + } else { + writeStatusText(cmd.OutOrStdout(), report) + } + + check, _ := cmd.Flags().GetBool("check") + if check && !report.Clean { + return errStatusDirty } - writeStatusText(cmd.OutOrStdout(), report) return nil } +var errStatusDirty = errors.New("working tree has changes") + // 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. diff --git a/cmd/status_test.go b/cmd/status_test.go index 007a3b0e..774d6261 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -19,6 +19,7 @@ func TestNewStatusCmd_Wiring(t *testing.T) { assert.Equal(t, "status", cmd.Use) assert.NotEmpty(t, cmd.Short) require.NotNil(t, cmd.Flags().Lookup("json"), "--json flag should be registered") + require.NotNil(t, cmd.Flags().Lookup("check"), "--check flag should be registered") } func TestBuildStatusReport_Clean(t *testing.T) { @@ -124,6 +125,47 @@ func TestWriteStatusJSON_RoundTrip(t *testing.T) { assert.Equal(t, "A", decoded.Staged[0].Status) } +func TestRunStatus_CheckCleanRepo(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, "tracked.txt"), []byte("hi"), 0o644)) + runGit(t, dir, "add", "tracked.txt") + runGit(t, dir, "commit", "-m", "initial") + + t.Chdir(dir) + cmd := newStatusCmd() + var out bytes.Buffer + cmd.SetOut(&out) + require.NoError(t, cmd.Flags().Set("check", "true")) + + require.NoError(t, runStatus(cmd, nil)) + assert.Contains(t, out.String(), "Working tree clean") +} + +func TestRunStatus_CheckDirtyRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + runGit(t, dir, "init", "-b", "main") + require.NoError(t, os.WriteFile(filepath.Join(dir, "loose.txt"), []byte("hi"), 0o644)) + + t.Chdir(dir) + cmd := newStatusCmd() + var out bytes.Buffer + cmd.SetOut(&out) + require.NoError(t, cmd.Flags().Set("check", "true")) + + err := runStatus(cmd, nil) + require.ErrorIs(t, err, errStatusDirty) + assert.Contains(t, out.String(), "Untracked (1):") +} + // --------------------------------------------------------------------------- // Integration: runStatus against a real temporary repository. // ---------------------------------------------------------------------------