Skip to content

Commit e656226

Browse files
committed
feat: show git command output and add program lifecycle tests
Add RunTo/RunInTo functions that stream git stdout+stderr to an io.Writer. Add SpinWithOutput that prints a status message, runs the command with output visible on stderr, then prints the result. Migrate all Spin+RunWithOutput call sites to SpinWithOutput+RunTo so users see actual git output (clone progress, fetch stats, etc.) instead of just spinner messages. Add tea.Program lifecycle tests that run bubbletea models with a timeout context to catch hangs in CI.
1 parent 441d093 commit e656226

File tree

9 files changed

+221
-51
lines changed

9 files changed

+221
-51
lines changed

internal/cmd/add.go

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"strings"
78

@@ -59,9 +60,8 @@ func runAdd(cmd *cobra.Command, args []string) error {
5960
remote := worktree.DefaultRemote()
6061

6162
if remote != "" {
62-
if err := ui.Spin(fmt.Sprintf("Fetching from %s", remote), func() error {
63-
_, err := git.RunWithOutput("fetch", remote, "--prune")
64-
return err
63+
if err := ui.SpinWithOutput(fmt.Sprintf("Fetching from %s", remote), func(w io.Writer) error {
64+
return git.RunTo(w, "fetch", remote, "--prune")
6565
}); err != nil {
6666
return err
6767
}
@@ -139,17 +139,15 @@ func runAddInteractive(remote string) error {
139139

140140
// Create worktree from selected remote branch
141141
branch := selected.Value
142-
if err := ui.Spin(fmt.Sprintf("Creating worktree for %s", ui.Accent(branch)), func() error {
143-
_, err := git.RunWithOutput("worktree", "add", "-b", branch, branch, remote+"/"+branch)
144-
return err
142+
if err := ui.SpinWithOutput(fmt.Sprintf("Creating worktree for %s", ui.Accent(branch)), func(w io.Writer) error {
143+
return git.RunTo(w, "worktree", "add", "-b", branch, branch, remote+"/"+branch)
145144
}); err != nil {
146145
return err
147146
}
148147

149148
// Set upstream tracking
150-
if err := ui.Spin(fmt.Sprintf("Setting upstream to %s", ui.Accent(remote+"/"+branch)), func() error {
151-
_, err := git.RunWithOutput("branch", "--set-upstream-to="+remote+"/"+branch, branch)
152-
return err
149+
if err := ui.SpinWithOutput(fmt.Sprintf("Setting upstream to %s", ui.Accent(remote+"/"+branch)), func(w io.Writer) error {
150+
return git.RunTo(w, "branch", "--set-upstream-to="+remote+"/"+branch, branch)
153151
}); err != nil {
154152
return err
155153
}
@@ -167,9 +165,8 @@ func createNewBranch() error {
167165

168166
wtPath := branchName
169167

170-
return ui.Spin(fmt.Sprintf("Creating worktree for %s", ui.Accent(branchName)), func() error {
171-
_, err := git.RunWithOutput("worktree", "add", "-b", branchName, wtPath)
172-
return err
168+
return ui.SpinWithOutput(fmt.Sprintf("Creating worktree for %s", ui.Accent(branchName)), func(w io.Writer) error {
169+
return git.RunTo(w, "worktree", "add", "-b", branchName, wtPath)
173170
})
174171
}
175172

@@ -199,9 +196,8 @@ func runAddDirect(cmd *cobra.Command, args []string, remote string) error {
199196

200197
// Create the worktree
201198
fullArgs := append([]string{"worktree", "add"}, gitArgs...)
202-
if err := ui.Spin("Creating worktree", func() error {
203-
_, err := git.RunWithOutput(fullArgs...)
204-
return err
199+
if err := ui.SpinWithOutput("Creating worktree", func(w io.Writer) error {
200+
return git.RunTo(w, fullArgs...)
205201
}); err != nil {
206202
return err
207203
}
@@ -213,9 +209,8 @@ func runAddDirect(cmd *cobra.Command, args []string, remote string) error {
213209
}
214210
if trackBranch != "" && remote != "" {
215211
if _, err := git.Query("rev-parse", "--verify", remote+"/"+trackBranch); err == nil {
216-
if err := ui.Spin(fmt.Sprintf("Setting upstream to %s", ui.Accent(remote+"/"+trackBranch)), func() error {
217-
_, err := git.RunWithOutput("branch", "--set-upstream-to="+remote+"/"+trackBranch, trackBranch)
218-
return err
212+
if err := ui.SpinWithOutput(fmt.Sprintf("Setting upstream to %s", ui.Accent(remote+"/"+trackBranch)), func(w io.Writer) error {
213+
return git.RunTo(w, "branch", "--set-upstream-to="+remote+"/"+trackBranch, trackBranch)
219214
}); err != nil {
220215
return err
221216
}

internal/cmd/clone.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"path/filepath"
78
"strings"
@@ -52,9 +53,8 @@ func runClone(cmd *cobra.Command, args []string) error {
5253
}
5354

5455
// Clone with cleanup on failure
55-
if err := ui.Spin("Cloning repository", func() error {
56-
_, err := git.RunWithOutput("clone", "--bare", repoURL, ".bare")
57-
return err
56+
if err := ui.SpinWithOutput("Cloning repository", func(w io.Writer) error {
57+
return git.RunTo(w, "clone", "--bare", repoURL, ".bare")
5858
}); err != nil {
5959
ui.Error("Failed to clone repository")
6060
os.Chdir("..")
@@ -72,9 +72,8 @@ func runClone(cmd *cobra.Command, args []string) error {
7272
return err
7373
}
7474

75-
if err := ui.Spin("Fetching all branches", func() error {
76-
_, err := git.RunWithOutput("fetch", "--all")
77-
return err
75+
if err := ui.SpinWithOutput("Fetching all branches", func(w io.Writer) error {
76+
return git.RunTo(w, "fetch", "--all")
7877
}); err != nil {
7978
ui.Warn("Failed to fetch all branches")
8079
}
@@ -99,9 +98,8 @@ func runClone(cmd *cobra.Command, args []string) error {
9998
}
10099

101100
if defaultBranch != "" {
102-
if err := ui.Spin(fmt.Sprintf("Creating worktree for %s", ui.Accent(defaultBranch)), func() error {
103-
_, err := git.RunWithOutput("worktree", "add", "-B", defaultBranch, defaultBranch, "origin/"+defaultBranch)
104-
return err
101+
if err := ui.SpinWithOutput(fmt.Sprintf("Creating worktree for %s", ui.Accent(defaultBranch)), func(w io.Writer) error {
102+
return git.RunTo(w, "worktree", "add", "-B", defaultBranch, defaultBranch, "origin/"+defaultBranch)
105103
}); err != nil {
106104
ui.Warn("Failed to create worktree for default branch")
107105
}

internal/cmd/migrate.go

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"os/signal"
78
"path/filepath"
@@ -140,9 +141,8 @@ func runMigrate(cmd *cobra.Command, args []string) error {
140141
}
141142

142143
// Clone existing repo as bare into .bare
143-
if err := ui.Spin("Converting to bare repository", func() error {
144-
_, err := git.RunWithOutput("clone", "--bare", repoRoot, filepath.Join(newStructure, ".bare"))
145-
return err
144+
if err := ui.SpinWithOutput("Converting to bare repository", func(w io.Writer) error {
145+
return git.RunTo(w, "clone", "--bare", repoRoot, filepath.Join(newStructure, ".bare"))
146146
}); err != nil {
147147
return err
148148
}
@@ -159,9 +159,8 @@ func runMigrate(cmd *cobra.Command, args []string) error {
159159

160160
// Fetch from remote if available
161161
if remoteURL, _ := git.QueryIn(newStructure, "remote", "get-url", "origin"); remoteURL != "" {
162-
if err := ui.Spin("Fetching all branches from remote", func() error {
163-
_, err := git.RunInWithOutput(newStructure, "fetch", "--all")
164-
return err
162+
if err := ui.SpinWithOutput("Fetching all branches from remote", func(w io.Writer) error {
163+
return git.RunInTo(newStructure, w, "fetch", "--all")
165164
}); err != nil {
166165
ui.Warn("Could not fetch from remote (remote may be unreachable) - continuing with local data")
167166
}
@@ -206,24 +205,21 @@ func runMigrate(cmd *cobra.Command, args []string) error {
206205

207206
// Create worktrees
208207
if defaultBranch != "" && defaultBranch == currentBranch {
209-
if err := ui.Spin(fmt.Sprintf("Creating worktree for %s", ui.Accent(currentBranch)), func() error {
210-
_, err := git.RunInWithOutput(newStructure, "worktree", "add", currentBranch, currentBranch)
211-
return err
208+
if err := ui.SpinWithOutput(fmt.Sprintf("Creating worktree for %s", ui.Accent(currentBranch)), func(w io.Writer) error {
209+
return git.RunInTo(newStructure, w, "worktree", "add", currentBranch, currentBranch)
212210
}); err != nil {
213211
return err
214212
}
215213
} else {
216214
if defaultBranch != "" {
217-
if err := ui.Spin(fmt.Sprintf("Creating worktree for %s", ui.Accent(defaultBranch)), func() error {
218-
_, err := git.RunInWithOutput(newStructure, "worktree", "add", defaultBranch, defaultBranch)
219-
return err
215+
if err := ui.SpinWithOutput(fmt.Sprintf("Creating worktree for %s", ui.Accent(defaultBranch)), func(w io.Writer) error {
216+
return git.RunInTo(newStructure, w, "worktree", "add", defaultBranch, defaultBranch)
220217
}); err != nil {
221218
return err
222219
}
223220
}
224-
if err := ui.Spin(fmt.Sprintf("Creating worktree for %s", ui.Accent(currentBranch)), func() error {
225-
_, err := git.RunInWithOutput(newStructure, "worktree", "add", currentBranch, currentBranch)
226-
return err
221+
if err := ui.SpinWithOutput(fmt.Sprintf("Creating worktree for %s", ui.Accent(currentBranch)), func(w io.Writer) error {
222+
return git.RunInTo(newStructure, w, "worktree", "add", currentBranch, currentBranch)
227223
}); err != nil {
228224
return err
229225
}

internal/cmd/remove.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"path/filepath"
78
"strings"
@@ -261,9 +262,8 @@ func removeSingleWorktree(wtPath, branch, mode, remote string) error {
261262
name := filepath.Base(wtPath)
262263

263264
// Remove the worktree
264-
if err := ui.Spin(fmt.Sprintf("Removing worktree %s", ui.Accent(name)), func() error {
265-
_, err := git.RunWithOutput("worktree", "remove", "-f", wtPath)
266-
return err
265+
if err := ui.SpinWithOutput(fmt.Sprintf("Removing worktree %s", ui.Accent(name)), func(w io.Writer) error {
266+
return git.RunTo(w, "worktree", "remove", "-f", wtPath)
267267
}); err != nil {
268268
return err
269269
}
@@ -294,9 +294,8 @@ func deleteRemoteBranch(branch, remote string) {
294294
}
295295

296296
// Delete remote branch (network operation, needs spinner)
297-
if err := ui.Spin(fmt.Sprintf("Deleting remote branch %s", ui.Accent(remoteBranch)), func() error {
298-
_, err := git.RunWithOutput("push", remote, "--delete", branch)
299-
return err
297+
if err := ui.SpinWithOutput(fmt.Sprintf("Deleting remote branch %s", ui.Accent(remoteBranch)), func(w io.Writer) error {
298+
return git.RunTo(w, "push", remote, "--delete", branch)
300299
}); err != nil {
301300
ui.Warnf("Failed to delete remote branch %s: %s", remoteBranch, err)
302301
}

internal/cmd/update.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67

78
"github.com/ahmedelgabri/git-wt/internal/git"
@@ -21,9 +22,8 @@ in its worktree.`,
2122
SilenceUsage: true,
2223
SilenceErrors: true,
2324
RunE: func(cmd *cobra.Command, args []string) error {
24-
if err := ui.Spin("Fetching from all remotes", func() error {
25-
_, err := git.RunWithOutput("fetch", "--all", "--prune", "--prune-tags")
26-
return err
25+
if err := ui.SpinWithOutput("Fetching from all remotes", func(w io.Writer) error {
26+
return git.RunTo(w, "fetch", "--all", "--prune", "--prune-tags")
2727
}); err != nil {
2828
return err
2929
}

internal/git/git.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package git
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"os/exec"
78
"strings"
@@ -63,6 +64,32 @@ func RunInWithOutput(dir string, args ...string) (string, error) {
6364
return strings.TrimSpace(string(out)), err
6465
}
6566

67+
// RunTo executes a git mutation command, streaming stdout and stderr to w.
68+
func RunTo(w io.Writer, args ...string) error {
69+
if debug() {
70+
fmt.Fprintln(w, "git "+strings.Join(args, " "))
71+
return nil
72+
}
73+
cmd := exec.Command("git", args...)
74+
cmd.Stdout = w
75+
cmd.Stderr = w
76+
return cmd.Run()
77+
}
78+
79+
// RunInTo executes a git mutation command in the specified directory,
80+
// streaming stdout and stderr to w.
81+
func RunInTo(dir string, w io.Writer, args ...string) error {
82+
if debug() {
83+
fmt.Fprintf(w, "[in %s] git %s\n", dir, strings.Join(args, " "))
84+
return nil
85+
}
86+
cmd := exec.Command("git", args...)
87+
cmd.Dir = dir
88+
cmd.Stdout = w
89+
cmd.Stderr = w
90+
return cmd.Run()
91+
}
92+
6693
// Query executes a read-only git command (always runs, even in DEBUG mode).
6794
func Query(args ...string) (string, error) {
6895
cmd := exec.Command("git", args...)

internal/git/git_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package git
22

33
import (
4+
"bytes"
45
"os/exec"
56
"strings"
67
"testing"
@@ -183,3 +184,57 @@ func TestDebugDefaultOff(t *testing.T) {
183184
t.Error("debug() should be false when DEBUG env is empty")
184185
}
185186
}
187+
188+
func TestRunToDebug(t *testing.T) {
189+
t.Setenv("DEBUG", "1")
190+
191+
var buf bytes.Buffer
192+
if err := RunTo(&buf, "status"); err != nil {
193+
t.Errorf("RunTo() in debug mode error: %v", err)
194+
}
195+
if got := buf.String(); !strings.Contains(got, "git status") {
196+
t.Errorf("RunTo() debug output = %q, want to contain 'git status'", got)
197+
}
198+
}
199+
200+
func TestRunToNonDebug(t *testing.T) {
201+
t.Setenv("DEBUG", "")
202+
203+
var buf bytes.Buffer
204+
if err := RunTo(&buf, "--version"); err != nil {
205+
t.Errorf("RunTo(--version) error: %v", err)
206+
}
207+
if got := buf.String(); !strings.Contains(got, "git version") {
208+
t.Errorf("RunTo(--version) = %q, want to contain 'git version'", got)
209+
}
210+
}
211+
212+
func TestRunInToDebug(t *testing.T) {
213+
t.Setenv("DEBUG", "1")
214+
215+
dir := t.TempDir()
216+
var buf bytes.Buffer
217+
if err := RunInTo(dir, &buf, "status"); err != nil {
218+
t.Errorf("RunInTo() in debug mode error: %v", err)
219+
}
220+
got := buf.String()
221+
if !strings.Contains(got, "git status") {
222+
t.Errorf("RunInTo() debug output = %q, want to contain 'git status'", got)
223+
}
224+
if !strings.Contains(got, dir) {
225+
t.Errorf("RunInTo() debug output = %q, want to contain dir %q", got, dir)
226+
}
227+
}
228+
229+
func TestRunInToNonDebug(t *testing.T) {
230+
t.Setenv("DEBUG", "")
231+
232+
repo := initGitRepo(t)
233+
var buf bytes.Buffer
234+
if err := RunInTo(repo, &buf, "status"); err != nil {
235+
t.Errorf("RunInTo(%s, status) error: %v", repo, err)
236+
}
237+
if buf.Len() == 0 {
238+
t.Error("RunInTo() returned empty output")
239+
}
240+
}

internal/ui/ui.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ui
33
import (
44
"bufio"
55
"fmt"
6+
"io"
67
"os"
78
"strings"
89

@@ -210,3 +211,17 @@ func Spin(msg string, fn func() error) error {
210211
}
211212
return result.(spinnerModel).err
212213
}
214+
215+
// SpinWithOutput prints a status message, runs fn with an io.Writer
216+
// connected to stderr so subprocess output is visible, then prints
217+
// the result. Stderr is used so that stdout remains clean for
218+
// structured output and BATS test assertions.
219+
func SpinWithOutput(msg string, fn func(w io.Writer) error) error {
220+
fmt.Printf("%s %s\n", Accent("●"), msg)
221+
if err := fn(os.Stderr); err != nil {
222+
fmt.Printf("%s %s\n", Red("●"), msg)
223+
return err
224+
}
225+
fmt.Printf("%s %s\n", Green("●"), msg)
226+
return nil
227+
}

0 commit comments

Comments
 (0)