detached 46b99e8

fix(handlers): fix deferred response close in loop, add bootstrap and partition tests

commit
46b99e83273f7d501efe685cf1a2c3dcc818795b
author
brice · · 2026-03-13 06:00 UTC
parent
7f26589
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +252 -48
M internal/application/handlers/bootstrap_handler.go +15 -2
@@ -76,7 +76,7 @@ func (h *BootstrapHandler) Handle(ctx context.Context) (*dto.BootstrapResult, er
7676 return result, nil
7777 }
7878
79-// cloneRepo clones the archup repository
79+// cloneRepo clones the archup repository at the configured git ref.
8080 func (h *BootstrapHandler) cloneRepo(ctx context.Context) error {
8181 repoDir := config.DefaultInstallRepoDir
8282
@@ -176,11 +176,24 @@ func (h *BootstrapHandler) downloadFiles(_ context.Context) error {
176176 if err != nil {
177177 return fmt.Errorf("failed to download %s: %w", f.urlPath, err)
178178 }
179- defer resp.Close()
179+
180+ if resp.StatusCode() < 200 || resp.StatusCode() > 299 {
181+ if closeErr := resp.Close(); closeErr != nil {
182+ h.logger.Warn("Failed to close HTTP response", "error", closeErr)
183+ }
184+ return fmt.Errorf("unexpected status %d from %s", resp.StatusCode(), url)
185+ }
180186
181187 if err := h.fs.WriteFile(destPath, resp.Body(), 0644); err != nil {
188+ if closeErr := resp.Close(); closeErr != nil {
189+ h.logger.Warn("Failed to close HTTP response", "error", closeErr)
190+ }
182191 return fmt.Errorf("failed to write %s: %w", f.destPath, err)
183192 }
193+
194+ if closeErr := resp.Close(); closeErr != nil {
195+ h.logger.Warn("Failed to close HTTP response", "error", closeErr)
196+ }
184197 }
185198
186199 return nil
@@ -76,7 +76,7 @@ func (h *BootstrapHandler) Handle(ctx context.Context) (*dto.BootstrapResult, er
76 return result, nil76 return result, nil
77 }77 }
78 78
79-// cloneRepo clones the archup repository79+// cloneRepo clones the archup repository at the configured git ref.
80 func (h *BootstrapHandler) cloneRepo(ctx context.Context) error {80 func (h *BootstrapHandler) cloneRepo(ctx context.Context) error {
81 repoDir := config.DefaultInstallRepoDir81 repoDir := config.DefaultInstallRepoDir
82 82
@@ -176,11 +176,24 @@ func (h *BootstrapHandler) downloadFiles(_ context.Context) error {
176 if err != nil {176 if err != nil {
177 return fmt.Errorf("failed to download %s: %w", f.urlPath, err)177 return fmt.Errorf("failed to download %s: %w", f.urlPath, err)
178 }178 }
179- defer resp.Close()179+
180+ if resp.StatusCode() < 200 || resp.StatusCode() > 299 {
181+ if closeErr := resp.Close(); closeErr != nil {
182+ h.logger.Warn("Failed to close HTTP response", "error", closeErr)
183+ }
184+ return fmt.Errorf("unexpected status %d from %s", resp.StatusCode(), url)
185+ }
180 186
181 if err := h.fs.WriteFile(destPath, resp.Body(), 0644); err != nil {187 if err := h.fs.WriteFile(destPath, resp.Body(), 0644); err != nil {
188+ if closeErr := resp.Close(); closeErr != nil {
189+ h.logger.Warn("Failed to close HTTP response", "error", closeErr)
190+ }
182 return fmt.Errorf("failed to write %s: %w", f.destPath, err)191 return fmt.Errorf("failed to write %s: %w", f.destPath, err)
183 }192 }
193+
194+ if closeErr := resp.Close(); closeErr != nil {
195+ h.logger.Warn("Failed to close HTTP response", "error", closeErr)
196+ }
184 }197 }
185 198
186 return nil199 return nil
A internal/application/handlers/bootstrap_handler_test.go +45 -0
@@ -0,0 +1,45 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "net/http"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/archup/internal/domain/ports/mocks"
10+ "go.uber.org/mock/gomock"
11+)
12+
13+func TestBootstrapHandler_DownloadFiles_RejectsHTTPErrors(t *testing.T) {
14+ ctrl := gomock.NewController(t)
15+ defer ctrl.Finish()
16+
17+ mockFS := mocks.NewMockFileSystem(ctrl)
18+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
19+ mockLogger := mocks.NewMockLogger(ctrl)
20+
21+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
22+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
23+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
24+ mockHTTP.EXPECT().
25+ Get("https://raw.githubusercontent.com/bnema/archup/v1.2.3/install/base.packages").
26+ Return(newMockResponse(ctrl, http.StatusNotFound, []byte("not found")), nil)
27+
28+ handler := NewBootstrapHandler(
29+ mockFS,
30+ mockHTTP,
31+ mockLogger,
32+ "https://github.com/bnema/archup",
33+ "https://raw.githubusercontent.com/bnema/archup/v1.2.3",
34+ "v1.2.3",
35+ )
36+
37+ err := handler.downloadFiles(context.Background())
38+ if err == nil {
39+ t.Fatal("expected downloadFiles to fail on non-2xx response")
40+ }
41+
42+ if !strings.Contains(err.Error(), "unexpected status 404") {
43+ t.Fatalf("expected status error, got %v", err)
44+ }
45+}
@@ -0,0 +1,45 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "net/http"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/archup/internal/domain/ports/mocks"
10+ "go.uber.org/mock/gomock"
11+)
12+
13+func TestBootstrapHandler_DownloadFiles_RejectsHTTPErrors(t *testing.T) {
14+ ctrl := gomock.NewController(t)
15+ defer ctrl.Finish()
16+
17+ mockFS := mocks.NewMockFileSystem(ctrl)
18+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
19+ mockLogger := mocks.NewMockLogger(ctrl)
20+
21+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
22+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
23+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
24+ mockHTTP.EXPECT().
25+ Get("https://raw.githubusercontent.com/bnema/archup/v1.2.3/install/base.packages").
26+ Return(newMockResponse(ctrl, http.StatusNotFound, []byte("not found")), nil)
27+
28+ handler := NewBootstrapHandler(
29+ mockFS,
30+ mockHTTP,
31+ mockLogger,
32+ "https://github.com/bnema/archup",
33+ "https://raw.githubusercontent.com/bnema/archup/v1.2.3",
34+ "v1.2.3",
35+ )
36+
37+ err := handler.downloadFiles(context.Background())
38+ if err == nil {
39+ t.Fatal("expected downloadFiles to fail on non-2xx response")
40+ }
41+
42+ if !strings.Contains(err.Error(), "unexpected status 404") {
43+ t.Fatalf("expected status error, got %v", err)
44+ }
45+}
M internal/application/handlers/partition_handler.go +17 -10
@@ -249,21 +249,28 @@ func (h *PartitionHandler) setupLUKSEncryption(ctx context.Context, partitionPat
249249 return "", fmt.Errorf("wipefs root partition failed: %w", err)
250250 }
251251
252- // Create LUKS2 container with Argon2id
253- // Note: Using printf to pipe password securely
254- luksFormatCmd := fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file=- %s",
255- password, partitionPath)
256-
257- if _, err := h.cmdExec.Execute(ctx, "sh", "-c", luksFormatCmd); err != nil {
252+ // Feed the passphrase over stdin so shell metacharacters are treated as data.
253+ if _, err := h.cmdExec.ExecuteWithStdin(ctx, password, "cryptsetup",
254+ "luksFormat",
255+ "--type", "luks2",
256+ "--batch-mode",
257+ "--pbkdf", "argon2id",
258+ "--iter-time", "2000",
259+ "--label", "ARCHUP_LUKS",
260+ "--key-file=-",
261+ partitionPath,
262+ ); err != nil {
258263 return "", fmt.Errorf("luksFormat failed: %w", err)
259264 }
260265
261266 // Open LUKS container
262267 cryptDevice := "/dev/mapper/cryptroot"
263- luksOpenCmd := fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot",
264- password, partitionPath)
265-
266- if _, err := h.cmdExec.Execute(ctx, "sh", "-c", luksOpenCmd); err != nil {
268+ if _, err := h.cmdExec.ExecuteWithStdin(ctx, password, "cryptsetup",
269+ "open",
270+ "--key-file=-",
271+ partitionPath,
272+ "cryptroot",
273+ ); err != nil {
267274 return "", fmt.Errorf("cryptsetup open failed: %w", err)
268275 }
269276
@@ -249,21 +249,28 @@ func (h *PartitionHandler) setupLUKSEncryption(ctx context.Context, partitionPat
249 return "", fmt.Errorf("wipefs root partition failed: %w", err)249 return "", fmt.Errorf("wipefs root partition failed: %w", err)
250 }250 }
251 251
252- // Create LUKS2 container with Argon2id252+ // Feed the passphrase over stdin so shell metacharacters are treated as data.
253- // Note: Using printf to pipe password securely253+ if _, err := h.cmdExec.ExecuteWithStdin(ctx, password, "cryptsetup",
254- luksFormatCmd := fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file=- %s",254+ "luksFormat",
255- password, partitionPath)255+ "--type", "luks2",
256-256+ "--batch-mode",
257- if _, err := h.cmdExec.Execute(ctx, "sh", "-c", luksFormatCmd); err != nil {257+ "--pbkdf", "argon2id",
258+ "--iter-time", "2000",
259+ "--label", "ARCHUP_LUKS",
260+ "--key-file=-",
261+ partitionPath,
262+ ); err != nil {
258 return "", fmt.Errorf("luksFormat failed: %w", err)263 return "", fmt.Errorf("luksFormat failed: %w", err)
259 }264 }
260 265
261 // Open LUKS container266 // Open LUKS container
262 cryptDevice := "/dev/mapper/cryptroot"267 cryptDevice := "/dev/mapper/cryptroot"
263- luksOpenCmd := fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot",268+ if _, err := h.cmdExec.ExecuteWithStdin(ctx, password, "cryptsetup",
264- password, partitionPath)269+ "open",
265-270+ "--key-file=-",
266- if _, err := h.cmdExec.Execute(ctx, "sh", "-c", luksOpenCmd); err != nil {271+ partitionPath,
272+ "cryptroot",
273+ ); err != nil {
267 return "", fmt.Errorf("cryptsetup open failed: %w", err)274 return "", fmt.Errorf("cryptsetup open failed: %w", err)
268 }275 }
269 276
M internal/application/handlers/partition_handler_test.go +46 -0
@@ -147,3 +147,49 @@ func TestPartitionHandler_Handle_RootPartitionTooSmall(t *testing.T) {
147147 t.Errorf("expected success, got error: %s", result.ErrorDetail)
148148 }
149149 }
150+
151+func TestPartitionHandler_SetupLUKSEncryption_UsesStdinForPassword(t *testing.T) {
152+ ctrl := gomock.NewController(t)
153+ defer ctrl.Finish()
154+
155+ mockExec := mocks.NewMockCommandExecutor(ctrl)
156+ mockLogger := mocks.NewMockLogger(ctrl)
157+
158+ password := "pa'ss$word !"
159+
160+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
161+ mockExec.EXPECT().Execute(gomock.Any(), "wipefs", "-af", "/dev/sda2").Return([]byte{}, nil)
162+ mockExec.EXPECT().ExecuteWithStdin(
163+ gomock.Any(),
164+ password,
165+ "cryptsetup",
166+ "luksFormat",
167+ "--type", "luks2",
168+ "--batch-mode",
169+ "--pbkdf", "argon2id",
170+ "--iter-time", "2000",
171+ "--label", "ARCHUP_LUKS",
172+ "--key-file=-",
173+ "/dev/sda2",
174+ ).Return([]byte{}, nil)
175+ mockExec.EXPECT().ExecuteWithStdin(
176+ gomock.Any(),
177+ password,
178+ "cryptsetup",
179+ "open",
180+ "--key-file=-",
181+ "/dev/sda2",
182+ "cryptroot",
183+ ).Return([]byte{}, nil)
184+
185+ handler := NewPartitionHandler(mockExec, mockLogger)
186+
187+ cryptDevice, err := handler.setupLUKSEncryption(context.Background(), "/dev/sda2", password)
188+ if err != nil {
189+ t.Fatalf("expected no error, got %v", err)
190+ }
191+
192+ if cryptDevice != "/dev/mapper/cryptroot" {
193+ t.Fatalf("expected cryptroot mapper path, got %q", cryptDevice)
194+ }
195+}
@@ -147,3 +147,49 @@ func TestPartitionHandler_Handle_RootPartitionTooSmall(t *testing.T) {
147 t.Errorf("expected success, got error: %s", result.ErrorDetail)147 t.Errorf("expected success, got error: %s", result.ErrorDetail)
148 }148 }
149 }149 }
150+
151+func TestPartitionHandler_SetupLUKSEncryption_UsesStdinForPassword(t *testing.T) {
152+ ctrl := gomock.NewController(t)
153+ defer ctrl.Finish()
154+
155+ mockExec := mocks.NewMockCommandExecutor(ctrl)
156+ mockLogger := mocks.NewMockLogger(ctrl)
157+
158+ password := "pa'ss$word !"
159+
160+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
161+ mockExec.EXPECT().Execute(gomock.Any(), "wipefs", "-af", "/dev/sda2").Return([]byte{}, nil)
162+ mockExec.EXPECT().ExecuteWithStdin(
163+ gomock.Any(),
164+ password,
165+ "cryptsetup",
166+ "luksFormat",
167+ "--type", "luks2",
168+ "--batch-mode",
169+ "--pbkdf", "argon2id",
170+ "--iter-time", "2000",
171+ "--label", "ARCHUP_LUKS",
172+ "--key-file=-",
173+ "/dev/sda2",
174+ ).Return([]byte{}, nil)
175+ mockExec.EXPECT().ExecuteWithStdin(
176+ gomock.Any(),
177+ password,
178+ "cryptsetup",
179+ "open",
180+ "--key-file=-",
181+ "/dev/sda2",
182+ "cryptroot",
183+ ).Return([]byte{}, nil)
184+
185+ handler := NewPartitionHandler(mockExec, mockLogger)
186+
187+ cryptDevice, err := handler.setupLUKSEncryption(context.Background(), "/dev/sda2", password)
188+ if err != nil {
189+ t.Fatalf("expected no error, got %v", err)
190+ }
191+
192+ if cryptDevice != "/dev/mapper/cryptroot" {
193+ t.Fatalf("expected cryptroot mapper path, got %q", cryptDevice)
194+ }
195+}
M internal/config/config.go +24 -22
@@ -184,27 +184,11 @@ type Config struct {
184184 RawURL string
185185 }
186186
187-// NewConfig creates a new Config with sensible defaults
188-// version parameter determines which branch to use for downloads (dev builds use dev branch)
189-// ENV=dev environment variable takes precedence over version-based detection
187+// NewConfig creates a new Config with sensible defaults.
188+// version determines which git ref to use for downloads and bootstrap assets.
189+// ENV=dev forces the dev ref regardless of the version string.
190190 func NewConfig(version string) *Config {
191- // Determine which branch to use
192- // ENV=dev takes precedence, then fall back to version-based detection
193- var branch string
194- if os.Getenv("ENV") == "dev" {
195- branch = "dev"
196- } else {
197- switch {
198- case version == "dev" || version == "":
199- branch = "dev"
200- case strings.Contains(version, "-dev"):
201- branch = "dev"
202- case strings.Contains(version, "-next"):
203- branch = "dev"
204- default:
205- branch = "main"
206- }
207- }
191+ ref := bootstrapRef(version)
208192
209193 return &Config{
210194 Hostname: "arch",
@@ -221,11 +205,29 @@ func NewConfig(version string) *Config {
221205 ConfigPath: DefaultConfigPath,
222206 LogPath: DefaultLogPath,
223207 RepoURL: "https://github.com/bnema/archup",
224- RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", branch),
208+ RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", ref),
209+ }
210+}
211+
212+func bootstrapRef(version string) string {
213+ if os.Getenv("ENV") == "dev" {
214+ return "dev"
215+ }
216+
217+ version = strings.TrimSpace(version)
218+ switch {
219+ case version == "" || version == "dev":
220+ return "dev"
221+ case strings.Contains(version, "-dev"):
222+ return "dev"
223+ case strings.Contains(version, "-next"):
224+ return "dev"
225+ default:
226+ return version
225227 }
226228 }
227229
228-// Branch returns the git branch to use for asset downloads and repo cloning.
230+// Branch returns the git ref to use for asset downloads and repo cloning.
229231 func (c *Config) Branch() string {
230232 rawURL := strings.TrimSpace(c.RawURL)
231233 if rawURL == "" {
@@ -184,27 +184,11 @@ type Config struct {
184 RawURL string184 RawURL string
185 }185 }
186 186
187-// NewConfig creates a new Config with sensible defaults187+// NewConfig creates a new Config with sensible defaults.
188-// version parameter determines which branch to use for downloads (dev builds use dev branch)188+// version determines which git ref to use for downloads and bootstrap assets.
189-// ENV=dev environment variable takes precedence over version-based detection189+// ENV=dev forces the dev ref regardless of the version string.
190 func NewConfig(version string) *Config {190 func NewConfig(version string) *Config {
191- // Determine which branch to use191+ ref := bootstrapRef(version)
192- // ENV=dev takes precedence, then fall back to version-based detection
193- var branch string
194- if os.Getenv("ENV") == "dev" {
195- branch = "dev"
196- } else {
197- switch {
198- case version == "dev" || version == "":
199- branch = "dev"
200- case strings.Contains(version, "-dev"):
201- branch = "dev"
202- case strings.Contains(version, "-next"):
203- branch = "dev"
204- default:
205- branch = "main"
206- }
207- }
208 192
209 return &Config{193 return &Config{
210 Hostname: "arch",194 Hostname: "arch",
@@ -221,11 +205,29 @@ func NewConfig(version string) *Config {
221 ConfigPath: DefaultConfigPath,205 ConfigPath: DefaultConfigPath,
222 LogPath: DefaultLogPath,206 LogPath: DefaultLogPath,
223 RepoURL: "https://github.com/bnema/archup",207 RepoURL: "https://github.com/bnema/archup",
224- RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", branch),208+ RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", ref),
209+ }
210+}
211+
212+func bootstrapRef(version string) string {
213+ if os.Getenv("ENV") == "dev" {
214+ return "dev"
215+ }
216+
217+ version = strings.TrimSpace(version)
218+ switch {
219+ case version == "" || version == "dev":
220+ return "dev"
221+ case strings.Contains(version, "-dev"):
222+ return "dev"
223+ case strings.Contains(version, "-next"):
224+ return "dev"
225+ default:
226+ return version
225 }227 }
226 }228 }
227 229
228-// Branch returns the git branch to use for asset downloads and repo cloning.230+// Branch returns the git ref to use for asset downloads and repo cloning.
229 func (c *Config) Branch() string {231 func (c *Config) Branch() string {
230 rawURL := strings.TrimSpace(c.RawURL)232 rawURL := strings.TrimSpace(c.RawURL)
231 if rawURL == "" {233 if rawURL == "" {
A internal/config/config_test.go +41 -0
@@ -0,0 +1,41 @@
1+package config
2+
3+import "testing"
4+
5+func TestNewConfig_UsesReleaseVersionAsBootstrapRef(t *testing.T) {
6+ cfg := NewConfig("v1.2.3")
7+
8+ if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/v1.2.3" {
9+ t.Fatalf("expected release raw URL, got %q", cfg.RawURL)
10+ }
11+
12+ if cfg.Branch() != "v1.2.3" {
13+ t.Fatalf("expected release ref, got %q", cfg.Branch())
14+ }
15+}
16+
17+func TestNewConfig_UsesDevRefForDevelopmentBuilds(t *testing.T) {
18+ cfg := NewConfig("v1.2.3-dev")
19+
20+ if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/dev" {
21+ t.Fatalf("expected dev raw URL, got %q", cfg.RawURL)
22+ }
23+
24+ if cfg.Branch() != "dev" {
25+ t.Fatalf("expected dev ref, got %q", cfg.Branch())
26+ }
27+}
28+
29+func TestNewConfig_ENVDevOverridesReleaseVersion(t *testing.T) {
30+ t.Setenv("ENV", "dev")
31+
32+ cfg := NewConfig("v1.2.3")
33+
34+ if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/dev" {
35+ t.Fatalf("expected dev raw URL override, got %q", cfg.RawURL)
36+ }
37+
38+ if cfg.Branch() != "dev" {
39+ t.Fatalf("expected dev ref override, got %q", cfg.Branch())
40+ }
41+}
@@ -0,0 +1,41 @@
1+package config
2+
3+import "testing"
4+
5+func TestNewConfig_UsesReleaseVersionAsBootstrapRef(t *testing.T) {
6+ cfg := NewConfig("v1.2.3")
7+
8+ if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/v1.2.3" {
9+ t.Fatalf("expected release raw URL, got %q", cfg.RawURL)
10+ }
11+
12+ if cfg.Branch() != "v1.2.3" {
13+ t.Fatalf("expected release ref, got %q", cfg.Branch())
14+ }
15+}
16+
17+func TestNewConfig_UsesDevRefForDevelopmentBuilds(t *testing.T) {
18+ cfg := NewConfig("v1.2.3-dev")
19+
20+ if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/dev" {
21+ t.Fatalf("expected dev raw URL, got %q", cfg.RawURL)
22+ }
23+
24+ if cfg.Branch() != "dev" {
25+ t.Fatalf("expected dev ref, got %q", cfg.Branch())
26+ }
27+}
28+
29+func TestNewConfig_ENVDevOverridesReleaseVersion(t *testing.T) {
30+ t.Setenv("ENV", "dev")
31+
32+ cfg := NewConfig("v1.2.3")
33+
34+ if cfg.RawURL != "https://raw.githubusercontent.com/bnema/archup/dev" {
35+ t.Fatalf("expected dev raw URL override, got %q", cfg.RawURL)
36+ }
37+
38+ if cfg.Branch() != "dev" {
39+ t.Fatalf("expected dev ref override, got %q", cfg.Branch())
40+ }
41+}
M internal/domain/ports/mocks/mock_ports.go +20 -0
@@ -270,6 +270,26 @@ func (mr *MockCommandExecutorMockRecorder) Execute(ctx, command any, args ...any
270270 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...)
271271 }
272272
273+// ExecuteWithStdin mocks base method.
274+func (m *MockCommandExecutor) ExecuteWithStdin(ctx context.Context, stdin, command string, args ...string) ([]byte, error) {
275+ m.ctrl.T.Helper()
276+ varargs := []any{ctx, stdin, command}
277+ for _, a := range args {
278+ varargs = append(varargs, a)
279+ }
280+ ret := m.ctrl.Call(m, "ExecuteWithStdin", varargs...)
281+ ret0, _ := ret[0].([]byte)
282+ ret1, _ := ret[1].(error)
283+ return ret0, ret1
284+}
285+
286+// ExecuteWithStdin indicates an expected call of ExecuteWithStdin.
287+func (mr *MockCommandExecutorMockRecorder) ExecuteWithStdin(ctx, stdin, command any, args ...any) *gomock.Call {
288+ mr.mock.ctrl.T.Helper()
289+ varargs := append([]any{ctx, stdin, command}, args...)
290+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteWithStdin", reflect.TypeOf((*MockCommandExecutor)(nil).ExecuteWithStdin), varargs...)
291+}
292+
273293 // ExecuteWithEnv mocks base method.
274294 func (m *MockCommandExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) {
275295 m.ctrl.T.Helper()
@@ -270,6 +270,26 @@ func (mr *MockCommandExecutorMockRecorder) Execute(ctx, command any, args ...any
270 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...)270 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...)
271 }271 }
272 272
273+// ExecuteWithStdin mocks base method.
274+func (m *MockCommandExecutor) ExecuteWithStdin(ctx context.Context, stdin, command string, args ...string) ([]byte, error) {
275+ m.ctrl.T.Helper()
276+ varargs := []any{ctx, stdin, command}
277+ for _, a := range args {
278+ varargs = append(varargs, a)
279+ }
280+ ret := m.ctrl.Call(m, "ExecuteWithStdin", varargs...)
281+ ret0, _ := ret[0].([]byte)
282+ ret1, _ := ret[1].(error)
283+ return ret0, ret1
284+}
285+
286+// ExecuteWithStdin indicates an expected call of ExecuteWithStdin.
287+func (mr *MockCommandExecutorMockRecorder) ExecuteWithStdin(ctx, stdin, command any, args ...any) *gomock.Call {
288+ mr.mock.ctrl.T.Helper()
289+ varargs := append([]any{ctx, stdin, command}, args...)
290+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteWithStdin", reflect.TypeOf((*MockCommandExecutor)(nil).ExecuteWithStdin), varargs...)
291+}
292+
273 // ExecuteWithEnv mocks base method.293 // ExecuteWithEnv mocks base method.
274 func (m *MockCommandExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) {294 func (m *MockCommandExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) {
275 m.ctrl.T.Helper()295 m.ctrl.T.Helper()
M internal/domain/ports/ports.go +3 -0
@@ -52,6 +52,9 @@ type CommandExecutor interface {
5252 // Execute runs a command and returns output
5353 Execute(ctx context.Context, command string, args ...string) ([]byte, error)
5454
55+ // ExecuteWithStdin runs a command with stdin content and returns output
56+ ExecuteWithStdin(ctx context.Context, stdin string, command string, args ...string) ([]byte, error)
57+
5558 // ExecuteWithEnv runs a command with custom environment variables
5659 ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error)
5760 }
@@ -52,6 +52,9 @@ type CommandExecutor interface {
52 // Execute runs a command and returns output52 // Execute runs a command and returns output
53 Execute(ctx context.Context, command string, args ...string) ([]byte, error)53 Execute(ctx context.Context, command string, args ...string) ([]byte, error)
54 54
55+ // ExecuteWithStdin runs a command with stdin content and returns output
56+ ExecuteWithStdin(ctx context.Context, stdin string, command string, args ...string) ([]byte, error)
57+
55 // ExecuteWithEnv runs a command with custom environment variables58 // ExecuteWithEnv runs a command with custom environment variables
56 ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error)59 ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error)
57 }60 }
M internal/infrastructure/executor/shell.go +19 -14
@@ -24,31 +24,36 @@ func NewShellExecutor(logger ports.Logger) *ShellExecutor {
2424
2525 // Execute runs a command and returns output
2626 func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) {
27- cmd := exec.CommandContext(ctx, command, args...)
28- var stdout, stderr bytes.Buffer
29- cmd.Stdout = &stdout
30- cmd.Stderr = &stderr
31-
32- if err := cmd.Run(); err != nil {
33- return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())
34- }
27+ return se.run(ctx, "", nil, command, args...)
28+}
3529
36- return stdout.Bytes(), nil
30+// ExecuteWithStdin runs a command with stdin content and returns output
31+func (se *ShellExecutor) ExecuteWithStdin(ctx context.Context, stdin string, command string, args ...string) ([]byte, error) {
32+ return se.run(ctx, stdin, nil, command, args...)
3733 }
3834
3935 // ExecuteWithEnv runs a command with custom environment variables
4036 func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) {
37+ return se.run(ctx, "", env, command, args...)
38+}
39+
40+func (se *ShellExecutor) run(ctx context.Context, stdin string, env map[string]string, command string, args ...string) ([]byte, error) {
4141 cmd := exec.CommandContext(ctx, command, args...)
4242 var stdout, stderr bytes.Buffer
4343 cmd.Stdout = &stdout
4444 cmd.Stderr = &stderr
45+ if stdin != "" {
46+ cmd.Stdin = bytes.NewBufferString(stdin)
47+ }
4548
46- // Build environment: start with current environment, then add/override with provided env
47- cmdEnv := os.Environ()
48- for key, value := range env {
49- cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value))
49+ if env != nil {
50+ // Build environment: start with current environment, then add/override with provided env
51+ cmdEnv := os.Environ()
52+ for key, value := range env {
53+ cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value))
54+ }
55+ cmd.Env = cmdEnv
5056 }
51- cmd.Env = cmdEnv
5257
5358 if err := cmd.Run(); err != nil {
5459 return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())
@@ -24,31 +24,36 @@ func NewShellExecutor(logger ports.Logger) *ShellExecutor {
24 24
25 // Execute runs a command and returns output25 // Execute runs a command and returns output
26 func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) {26 func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) {
27- cmd := exec.CommandContext(ctx, command, args...)27+ return se.run(ctx, "", nil, command, args...)
28- var stdout, stderr bytes.Buffer28+}
29- cmd.Stdout = &stdout
30- cmd.Stderr = &stderr
31-
32- if err := cmd.Run(); err != nil {
33- return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())
34- }
35 29
36- return stdout.Bytes(), nil30+// ExecuteWithStdin runs a command with stdin content and returns output
31+func (se *ShellExecutor) ExecuteWithStdin(ctx context.Context, stdin string, command string, args ...string) ([]byte, error) {
32+ return se.run(ctx, stdin, nil, command, args...)
37 }33 }
38 34
39 // ExecuteWithEnv runs a command with custom environment variables35 // ExecuteWithEnv runs a command with custom environment variables
40 func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) {36 func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) {
37+ return se.run(ctx, "", env, command, args...)
38+}
39+
40+func (se *ShellExecutor) run(ctx context.Context, stdin string, env map[string]string, command string, args ...string) ([]byte, error) {
41 cmd := exec.CommandContext(ctx, command, args...)41 cmd := exec.CommandContext(ctx, command, args...)
42 var stdout, stderr bytes.Buffer42 var stdout, stderr bytes.Buffer
43 cmd.Stdout = &stdout43 cmd.Stdout = &stdout
44 cmd.Stderr = &stderr44 cmd.Stderr = &stderr
45+ if stdin != "" {
46+ cmd.Stdin = bytes.NewBufferString(stdin)
47+ }
45 48
46- // Build environment: start with current environment, then add/override with provided env49+ if env != nil {
47- cmdEnv := os.Environ()50+ // Build environment: start with current environment, then add/override with provided env
48- for key, value := range env {51+ cmdEnv := os.Environ()
49- cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value))52+ for key, value := range env {
53+ cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value))
54+ }
55+ cmd.Env = cmdEnv
50 }56 }
51- cmd.Env = cmdEnv
52 57
53 if err := cmd.Run(); err != nil {58 if err := cmd.Run(); err != nil {
54 return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())59 return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())
M internal/infrastructure/executor/shell_test.go +22 -0
@@ -148,6 +148,28 @@ func TestShellExecutor_ExecuteWithEnv_MultipleVars(t *testing.T) {
148148 }
149149 }
150150
151+func TestShellExecutor_ExecuteWithStdin_Success(t *testing.T) {
152+ ctrl := gomock.NewController(t)
153+ defer ctrl.Finish()
154+ mockLogger := mocks.NewMockLogger(ctrl)
155+ mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes()
156+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
157+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
158+ mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
159+
160+ executor := NewShellExecutor(mockLogger)
161+ ctx := context.Background()
162+
163+ output, err := executor.ExecuteWithStdin(ctx, "hello stdin", "cat")
164+ if err != nil {
165+ t.Fatalf("expected no error, got %v", err)
166+ }
167+
168+ if string(output) != "hello stdin" {
169+ t.Errorf("expected stdin to be forwarded, got %q", string(output))
170+ }
171+}
172+
151173 func TestShellExecutor_Execute_ContextCancellation(t *testing.T) {
152174 ctrl := gomock.NewController(t)
153175 defer ctrl.Finish()
@@ -148,6 +148,28 @@ func TestShellExecutor_ExecuteWithEnv_MultipleVars(t *testing.T) {
148 }148 }
149 }149 }
150 150
151+func TestShellExecutor_ExecuteWithStdin_Success(t *testing.T) {
152+ ctrl := gomock.NewController(t)
153+ defer ctrl.Finish()
154+ mockLogger := mocks.NewMockLogger(ctrl)
155+ mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes()
156+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
157+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
158+ mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
159+
160+ executor := NewShellExecutor(mockLogger)
161+ ctx := context.Background()
162+
163+ output, err := executor.ExecuteWithStdin(ctx, "hello stdin", "cat")
164+ if err != nil {
165+ t.Fatalf("expected no error, got %v", err)
166+ }
167+
168+ if string(output) != "hello stdin" {
169+ t.Errorf("expected stdin to be forwarded, got %q", string(output))
170+ }
171+}
172+
151 func TestShellExecutor_Execute_ContextCancellation(t *testing.T) {173 func TestShellExecutor_Execute_ContextCancellation(t *testing.T) {
152 ctrl := gomock.NewController(t)174 ctrl := gomock.NewController(t)
153 defer ctrl.Finish()175 defer ctrl.Finish()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close