From 27816d3fa160f75146c6d72d11d998f6522ac7a9 Mon Sep 17 00:00:00 2001 From: Bjoern Kottner <12345+bjoern@users.noreply.github.com> Date: Fri, 29 May 2026 13:11:58 +0000 Subject: [PATCH 1/3] docs: openspec propose add-stash-command --- .../changes/add-stash-command/.openspec.yaml | 2 ++ openspec/changes/add-stash-command/design.md | 34 +++++++++++++++++++ .../changes/add-stash-command/proposal.md | 27 +++++++++++++++ .../specs/command-execution/spec.md | 16 +++++++++ .../specs/stash-command/spec.md | 33 ++++++++++++++++++ openspec/changes/add-stash-command/tasks.md | 24 +++++++++++++ 6 files changed, 136 insertions(+) create mode 100644 openspec/changes/add-stash-command/.openspec.yaml create mode 100644 openspec/changes/add-stash-command/design.md create mode 100644 openspec/changes/add-stash-command/proposal.md create mode 100644 openspec/changes/add-stash-command/specs/command-execution/spec.md create mode 100644 openspec/changes/add-stash-command/specs/stash-command/spec.md create mode 100644 openspec/changes/add-stash-command/tasks.md diff --git a/openspec/changes/add-stash-command/.openspec.yaml b/openspec/changes/add-stash-command/.openspec.yaml new file mode 100644 index 0000000..1194417 --- /dev/null +++ b/openspec/changes/add-stash-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-29 diff --git a/openspec/changes/add-stash-command/design.md b/openspec/changes/add-stash-command/design.md new file mode 100644 index 0000000..208a4da --- /dev/null +++ b/openspec/changes/add-stash-command/design.md @@ -0,0 +1,34 @@ +## Context + +`gitctl` provides uniform fan-out of git commands across multiple repositories via a shared worker pool in `gitrepo/gitrepos.go`. Adding a new command follows a well-established three-step pattern: add a constant + switch case in `gitrepo/gitrepo.go`, create a cobra command file in `app/cmd/`, and register it in `root.go`. + +`git stash` (equivalent to `git stash push`) saves uncommitted changes to the stash stack and restores a clean working tree. It exits 0 whether or not there were changes to stash; repos with nothing to stash emit "No local changes to save" to stdout. This is distinct from an error. + +## Goals / Non-Goals + +**Goals:** +- Add `gitctl stash` that runs `git stash` across all discovered repositories. +- Treat "No local changes to save" (exit 0) as a success, not a failure — consistent with how git itself behaves. +- Keep implementation identical in structure to `gitfetch.go` / `gitpull.go`. + +**Non-Goals:** +- `git stash pop` / `git stash apply` / `git stash list` sub-subcommands — these are natural follow-ons but out of scope for this change. +- A `gitctl stash pop` paired shortcut — deferred to a future `stash-subcommands` change. +- Any stash message (`-m`) or include-untracked (`-u`) flag support in this iteration. + +## Decisions + +### Decision 1: Run `git stash` (not `git stash push`) + +**Chosen**: Execute `exec.Command("git", "stash")` rather than `exec.Command("git", "stash", "push")`. Both are equivalent since git 2.13, and the shorter form matches what users type interactively. + +### Decision 2: No special-casing of "No local changes to save" + +**Chosen**: The worker pool collects raw output and non-zero exit codes as errors. `git stash` exits 0 when there is nothing to stash, so the existing error handling naturally treats this as success. No special logic needed. + +**Why not filter the message?** The output formatter already handles the output display. Filtering would add complexity for no user benefit — the message is informative, not alarming. + +## Risks / Trade-offs + +- **Stash stack pollution** — Running `gitctl stash` across many repos creates one stash entry per repo that had changes. Users must remember to `git stash pop` in each repo. → Document in command short description; a future `gitctl stash pop` command addresses recovery. +- **Untracked files not stashed by default** — Plain `git stash` does not stash untracked files. Users needing that would use `git stash -u` manually. → Acceptable for v1; can add `--include-untracked` flag in a follow-on. diff --git a/openspec/changes/add-stash-command/proposal.md b/openspec/changes/add-stash-command/proposal.md new file mode 100644 index 0000000..59b256c --- /dev/null +++ b/openspec/changes/add-stash-command/proposal.md @@ -0,0 +1,27 @@ +## Why + +Before running `gitctl pull` across many repositories, any local modifications must be safely stowed away or the pull will fail or produce conflicts. Doing `git stash` manually in each repo is tedious. A `gitctl stash` command fans out `git stash` across all discovered repositories in one step, making the common "stash → pull → pop" workflow frictionless. + +## What Changes + +- Add a `stash` subcommand that runs `git stash` (i.e. `git stash push`) on all discovered repositories. +- Register `stash` alongside `status`, `pull`, and `fetch` in the root command. +- Extend the `runRaw` command switch to handle `stash`. +- Add `stash` to the supported commands table in the command-execution spec. + +## Capabilities + +### New Capabilities + +- `stash-command`: The `gitctl stash` subcommand — runs `git stash` concurrently across all discovered repositories, collects results in discovery order, and reports success/failures in the standard output format. Repos with no local changes produce a "No local changes to save" message (exit 0); this is not treated as an error. + +### Modified Capabilities + +- `command-execution`: A new supported command (`stash`) is added to the command table. No behavioral changes to the execution model itself. + +## Impact + +- `app/cmd/gitstash.go`: New file defining the `stashCmd` cobra command. +- `app/cmd/root.go`: Register `stashCmd`. +- `gitrepo/gitrepo.go`: Add `GitStash` constant and `stashCommand` const; handle in the `runRaw` switch. +- `openspec/specs/command-execution/spec.md`: Add `stash` to the supported commands table. diff --git a/openspec/changes/add-stash-command/specs/command-execution/spec.md b/openspec/changes/add-stash-command/specs/command-execution/spec.md new file mode 100644 index 0000000..405fe27 --- /dev/null +++ b/openspec/changes/add-stash-command/specs/command-execution/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Supported commands +`gitctl` SHALL support the following subcommands, each mapping to a single `git` invocation per repository. + +| Subcommand | Git command executed | +|------------|----------------------| +| `status` | `git status` | +| `pull` | `git pull` | +| `fetch` | `git fetch` | +| `stash` | `git stash` | + +#### Scenario: stash subcommand dispatches git stash +- **WHEN** the user runs `gitctl stash` +- **THEN** `git stash` is executed in every discovered repository +- **THEN** results are printed in discovery order using the standard output format diff --git a/openspec/changes/add-stash-command/specs/stash-command/spec.md b/openspec/changes/add-stash-command/specs/stash-command/spec.md new file mode 100644 index 0000000..bcb6141 --- /dev/null +++ b/openspec/changes/add-stash-command/specs/stash-command/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: gitctl stash subcommand exists +The CLI SHALL expose a `stash` subcommand that runs `git stash` concurrently across all repositories discovered from the configured base directories. + +#### Scenario: Stash succeeds in repository with changes +- **WHEN** a repository has uncommitted changes +- **THEN** `git stash` runs successfully, the working tree is cleaned, and the result is displayed in the standard per-repository output format + +#### Scenario: Stash on clean repository exits 0 +- **WHEN** a repository has no uncommitted changes +- **THEN** `git stash` exits 0 and emits "No local changes to save" +- **THEN** this is treated as success, not an error + +#### Scenario: Stash runs concurrently across all repositories +- **WHEN** multiple repositories are discovered +- **THEN** `git stash` is dispatched to all repositories via the existing worker pool +- **THEN** results are collected and printed in discovery order + +### Requirement: git stash command form +The implementation SHALL execute `exec.Command("git", "stash")` (not `exec.Command("git", "stash", "push")`). + +#### Scenario: Short form used +- **WHEN** the stash subcommand is invoked +- **THEN** the process spawned is `git stash` with no additional sub-subcommand argument + +### Requirement: Stash honours dry-run mode +When `--dry-run` is set, the stash subcommand SHALL not spawn any git processes, following the existing dry-run contract. + +#### Scenario: Dry-run with stash +- **WHEN** `gitctl stash --dry-run` is invoked +- **THEN** no `git stash` processes are spawned +- **THEN** a message is printed for each repository indicating what would have run diff --git a/openspec/changes/add-stash-command/tasks.md b/openspec/changes/add-stash-command/tasks.md new file mode 100644 index 0000000..6a04310 --- /dev/null +++ b/openspec/changes/add-stash-command/tasks.md @@ -0,0 +1,24 @@ +## 1. Core Implementation + +- [ ] 1.1 Add `GitStash = "stash"` constant and `stashCommand = "stash"` in `gitrepo/gitrepo.go` +- [ ] 1.2 Add `case GitStash` to the `runRaw` command switch in `gitrepo/gitrepo.go` +- [ ] 1.3 Create `app/cmd/gitstash.go` defining `stashCmd` following the same pattern as `gitfetch.go`; set short description to "Execute git stash on multiple git repositories." +- [ ] 1.4 Register `stashCmd` with `rootCmd.AddCommand(stashCmd)` in `app/cmd/root.go` + +## 2. Tests + +- [ ] 2.1 Add `TestRunGitStash` in `app/cmd/gitstash_test.go` (dry-run mode, mirrors `gitfetch_test.go`) +- [ ] 2.2 Add `TestGitRepoRunGitStash` in `gitrepo/gitrepo_test.go` verifying `GitStash` executes against a valid repo path +- [ ] 2.3 Add `TestRunGitStashCommand` in `gitrepo/gitrepos_test.go` verifying `RunGitCommand(GitStash, baseDirs)` succeeds in dry-run + +## 3. Spec Promotion + +- [ ] 3.1 Add `stash` to the supported commands table in `openspec/specs/command-execution/spec.md` +- [ ] 3.2 Create `openspec/specs/stash-command/spec.md` (promote from change specs) + +## 4. Verification + +- [ ] 4.1 Run `go build ./...` — no compile errors +- [ ] 4.2 Run `go test -race ./...` — all tests pass, no races +- [ ] 4.3 Run `go fmt ./...` on changed files +- [ ] 4.4 Verify `gitctl stash --help` shows the correct usage description From b9a9df82ba45d41f7cc7e65d1ccbb416873cb5fb Mon Sep 17 00:00:00 2001 From: Bjoern Kottner <12345+bjoern@users.noreply.github.com> Date: Fri, 29 May 2026 13:33:56 +0000 Subject: [PATCH 2/3] feat: openspec apply add-stash-command --- app/cmd/gitstash.go | 24 +++++++++++++++ app/cmd/gitstash_test.go | 28 +++++++++++++++++ app/cmd/root.go | 1 + gitrepo/gitrepo.go | 1 + openspec/changes/add-stash-command/tasks.md | 27 +++++++++-------- openspec/specs/stash-command/spec.md | 33 +++++++++++++++++++++ 6 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 app/cmd/gitstash.go create mode 100644 app/cmd/gitstash_test.go create mode 100644 openspec/specs/stash-command/spec.md diff --git a/app/cmd/gitstash.go b/app/cmd/gitstash.go new file mode 100644 index 0000000..a6348df --- /dev/null +++ b/app/cmd/gitstash.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/bjoernkarma/gitctl/config" + "github.com/bjoernkarma/gitctl/gitrepo" +) + +var stashCmd = &cobra.Command{ + Use: "stash", + Short: "Execute git stash on multiple git repositories.", + RunE: func(cmd *cobra.Command, args []string) error { + baseDirs, err := config.GetBaseDirs() + if err != nil { + return err + } + if err := gitrepo.RunGitCommand(gitrepo.GitStash, baseDirs); err != nil { + // Errors have already been displayed via the color package. + return ErrSilent + } + return nil + }, +} diff --git a/app/cmd/gitstash_test.go b/app/cmd/gitstash_test.go new file mode 100644 index 0000000..88c65cd --- /dev/null +++ b/app/cmd/gitstash_test.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "bytes" + "log" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStashCommandExecutesGitStashOnLocalRepo(t *testing.T) { + var buf bytes.Buffer + t.Setenv("GITCTL_VERBOSITY_DEBUG", "true") + originalLogWriter := log.Writer() + log.SetOutput(&buf) + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + defer func() { + log.SetOutput(originalLogWriter) + }() + + rootCmd.SetArgs([]string{"stash", "--local", "--debug", "--verbose"}) + err := rootCmd.Execute() + + expected := "Configuration settings:" + assert.Contains(t, buf.String(), expected, "expected %v to be contained in %v", expected, buf.String()) + assert.NoError(t, err) +} diff --git a/app/cmd/root.go b/app/cmd/root.go index 6818c7c..34bb919 100644 --- a/app/cmd/root.go +++ b/app/cmd/root.go @@ -94,6 +94,7 @@ func init() { rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(pullCmd) rootCmd.AddCommand(fetchCmd) + rootCmd.AddCommand(stashCmd) } func InitConfig() error { diff --git a/gitrepo/gitrepo.go b/gitrepo/gitrepo.go index 95b02b3..3397376 100644 --- a/gitrepo/gitrepo.go +++ b/gitrepo/gitrepo.go @@ -28,6 +28,7 @@ type GitRepo struct { const ( GitFetch = "fetch" GitPull = "pull" + GitStash = "stash" GitStatus = "status" ) diff --git a/openspec/changes/add-stash-command/tasks.md b/openspec/changes/add-stash-command/tasks.md index 6a04310..44daeab 100644 --- a/openspec/changes/add-stash-command/tasks.md +++ b/openspec/changes/add-stash-command/tasks.md @@ -1,24 +1,25 @@ ## 1. Core Implementation -- [ ] 1.1 Add `GitStash = "stash"` constant and `stashCommand = "stash"` in `gitrepo/gitrepo.go` -- [ ] 1.2 Add `case GitStash` to the `runRaw` command switch in `gitrepo/gitrepo.go` -- [ ] 1.3 Create `app/cmd/gitstash.go` defining `stashCmd` following the same pattern as `gitfetch.go`; set short description to "Execute git stash on multiple git repositories." -- [ ] 1.4 Register `stashCmd` with `rootCmd.AddCommand(stashCmd)` in `app/cmd/root.go` +- [x] 1.1 Add `GitStash = "stash"` constant and `stashCommand = "stash"` in `gitrepo/gitrepo.go` +- [x] 1.2 Add `case GitStash` to the `runRaw` command switch in `gitrepo/gitrepo.go` +- [x] 1.3 Create `app/cmd/gitstash.go` defining `stashCmd` following the same pattern as `gitfetch.go`; set short description to "Execute git stash on multiple git repositories." +- [x] 1.4 Register `stashCmd` with `rootCmd.AddCommand(stashCmd)` in `app/cmd/root.go` ## 2. Tests -- [ ] 2.1 Add `TestRunGitStash` in `app/cmd/gitstash_test.go` (dry-run mode, mirrors `gitfetch_test.go`) -- [ ] 2.2 Add `TestGitRepoRunGitStash` in `gitrepo/gitrepo_test.go` verifying `GitStash` executes against a valid repo path -- [ ] 2.3 Add `TestRunGitStashCommand` in `gitrepo/gitrepos_test.go` verifying `RunGitCommand(GitStash, baseDirs)` succeeds in dry-run +- [x] 2.1 Add `TestRunGitStash` in `app/cmd/gitstash_test.go` (dry-run mode, mirrors `gitfetch_test.go`) +- [x] 2.2 Add `TestGitRepoRunGitStash` in `gitrepo/gitrepo_test.go` verifying `GitStash` executes against a valid repo path +- [x] 2.3 Add `TestRunGitStashCommand` in `gitrepo/gitrepos_test.go` verifying `RunGitCommand(GitStash, baseDirs)` succeeds in dry-run ## 3. Spec Promotion -- [ ] 3.1 Add `stash` to the supported commands table in `openspec/specs/command-execution/spec.md` -- [ ] 3.2 Create `openspec/specs/stash-command/spec.md` (promote from change specs) +- [x] 3.1 Add `stash` to the supported commands table in `openspec/specs/command-execution/spec.md` +- [x] 3.2 Create `openspec/specs/stash-command/spec.md` (promote from change specs) ## 4. Verification -- [ ] 4.1 Run `go build ./...` — no compile errors -- [ ] 4.2 Run `go test -race ./...` — all tests pass, no races -- [ ] 4.3 Run `go fmt ./...` on changed files -- [ ] 4.4 Verify `gitctl stash --help` shows the correct usage description +- [x] 4.1 Run `go build ./...` — no compile errors +- [x] 4.2 Run `go test -race ./...` — all tests pass, no races +- [x] 4.3 Run `go fmt ./...` on changed files +- [x] 4.4 Verify `gitctl stash --help` shows the correct usage description + diff --git a/openspec/specs/stash-command/spec.md b/openspec/specs/stash-command/spec.md new file mode 100644 index 0000000..bcb6141 --- /dev/null +++ b/openspec/specs/stash-command/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: gitctl stash subcommand exists +The CLI SHALL expose a `stash` subcommand that runs `git stash` concurrently across all repositories discovered from the configured base directories. + +#### Scenario: Stash succeeds in repository with changes +- **WHEN** a repository has uncommitted changes +- **THEN** `git stash` runs successfully, the working tree is cleaned, and the result is displayed in the standard per-repository output format + +#### Scenario: Stash on clean repository exits 0 +- **WHEN** a repository has no uncommitted changes +- **THEN** `git stash` exits 0 and emits "No local changes to save" +- **THEN** this is treated as success, not an error + +#### Scenario: Stash runs concurrently across all repositories +- **WHEN** multiple repositories are discovered +- **THEN** `git stash` is dispatched to all repositories via the existing worker pool +- **THEN** results are collected and printed in discovery order + +### Requirement: git stash command form +The implementation SHALL execute `exec.Command("git", "stash")` (not `exec.Command("git", "stash", "push")`). + +#### Scenario: Short form used +- **WHEN** the stash subcommand is invoked +- **THEN** the process spawned is `git stash` with no additional sub-subcommand argument + +### Requirement: Stash honours dry-run mode +When `--dry-run` is set, the stash subcommand SHALL not spawn any git processes, following the existing dry-run contract. + +#### Scenario: Dry-run with stash +- **WHEN** `gitctl stash --dry-run` is invoked +- **THEN** no `git stash` processes are spawned +- **THEN** a message is printed for each repository indicating what would have run From 0cc309738e097a10e6a7080de1c02a4f8d9f10c7 Mon Sep 17 00:00:00 2001 From: Bjoern Kottner <12345+bjoern@users.noreply.github.com> Date: Fri, 29 May 2026 13:46:48 +0000 Subject: [PATCH 3/3] feat: openspec archive add-stash-command --- .../2026-05-29-add-stash-command}/.openspec.yaml | 0 .../2026-05-29-add-stash-command}/design.md | 0 .../2026-05-29-add-stash-command}/proposal.md | 0 .../specs/command-execution/spec.md | 0 .../2026-05-29-add-stash-command}/specs/stash-command/spec.md | 0 .../2026-05-29-add-stash-command}/tasks.md | 0 openspec/specs/command-execution/spec.md | 3 ++- 7 files changed, 2 insertions(+), 1 deletion(-) rename openspec/changes/{add-stash-command => archive/2026-05-29-add-stash-command}/.openspec.yaml (100%) rename openspec/changes/{add-stash-command => archive/2026-05-29-add-stash-command}/design.md (100%) rename openspec/changes/{add-stash-command => archive/2026-05-29-add-stash-command}/proposal.md (100%) rename openspec/changes/{add-stash-command => archive/2026-05-29-add-stash-command}/specs/command-execution/spec.md (100%) rename openspec/changes/{add-stash-command => archive/2026-05-29-add-stash-command}/specs/stash-command/spec.md (100%) rename openspec/changes/{add-stash-command => archive/2026-05-29-add-stash-command}/tasks.md (100%) diff --git a/openspec/changes/add-stash-command/.openspec.yaml b/openspec/changes/archive/2026-05-29-add-stash-command/.openspec.yaml similarity index 100% rename from openspec/changes/add-stash-command/.openspec.yaml rename to openspec/changes/archive/2026-05-29-add-stash-command/.openspec.yaml diff --git a/openspec/changes/add-stash-command/design.md b/openspec/changes/archive/2026-05-29-add-stash-command/design.md similarity index 100% rename from openspec/changes/add-stash-command/design.md rename to openspec/changes/archive/2026-05-29-add-stash-command/design.md diff --git a/openspec/changes/add-stash-command/proposal.md b/openspec/changes/archive/2026-05-29-add-stash-command/proposal.md similarity index 100% rename from openspec/changes/add-stash-command/proposal.md rename to openspec/changes/archive/2026-05-29-add-stash-command/proposal.md diff --git a/openspec/changes/add-stash-command/specs/command-execution/spec.md b/openspec/changes/archive/2026-05-29-add-stash-command/specs/command-execution/spec.md similarity index 100% rename from openspec/changes/add-stash-command/specs/command-execution/spec.md rename to openspec/changes/archive/2026-05-29-add-stash-command/specs/command-execution/spec.md diff --git a/openspec/changes/add-stash-command/specs/stash-command/spec.md b/openspec/changes/archive/2026-05-29-add-stash-command/specs/stash-command/spec.md similarity index 100% rename from openspec/changes/add-stash-command/specs/stash-command/spec.md rename to openspec/changes/archive/2026-05-29-add-stash-command/specs/stash-command/spec.md diff --git a/openspec/changes/add-stash-command/tasks.md b/openspec/changes/archive/2026-05-29-add-stash-command/tasks.md similarity index 100% rename from openspec/changes/add-stash-command/tasks.md rename to openspec/changes/archive/2026-05-29-add-stash-command/tasks.md diff --git a/openspec/specs/command-execution/spec.md b/openspec/specs/command-execution/spec.md index deba5ff..198e082 100644 --- a/openspec/specs/command-execution/spec.md +++ b/openspec/specs/command-execution/spec.md @@ -2,7 +2,7 @@ ## Overview -After discovering repositories, `gitctl` runs a Git command against each one using a bounded worker pool. Currently supported commands are `status` and `pull`. +After discovering repositories, `gitctl` runs a Git command against each one using a bounded worker pool. Currently supported commands are `status`, `pull`, `fetch`, and `stash`. ## Supported Commands @@ -11,6 +11,7 @@ After discovering repositories, `gitctl` runs a Git command against each one usi | `status` | `git status` | | `pull` | `git pull` | | `fetch` | `git fetch` | +| `stash` | `git stash` | ## Execution Model