From 58f70860ee63f98fe39ba36a4e54ed7df63b218f Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:00:05 +0000 Subject: [PATCH] feat(cli): implement exec and exec_all commands --- internal/homesick/cli/cli.go | 13 +++-- internal/homesick/cli/cli_test.go | 11 ++++ internal/homesick/core/core.go | 69 +++++++++++++++++++++++++ internal/homesick/core/exec_test.go | 79 +++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 internal/homesick/core/exec_test.go diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 9df13d2..0b5d0df 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -180,9 +180,14 @@ type openCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } -type execCmd struct{} +type execCmd struct { + Castle string `arg:"" name:"CASTLE" help:"Castle name."` + Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in the castle root."` +} -type execAllCmd struct{} +type execAllCmd struct { + Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in each castle root."` +} type rcCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` @@ -200,8 +205,8 @@ func (c *commitCmd) Run(app *core.App) error { func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) } func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) } func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.Castle)) } -func (c *execCmd) Run() error { return notImplemented("exec") } -func (c *execAllCmd) Run() error { return notImplemented("exec_all") } +func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) } +func (c *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) } func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) } diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index 20df130..9f633d1 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -68,6 +68,17 @@ func (s *CLISuite) TestRun_Cd_ExplicitCastle() { require.Empty(s.T(), s.stderr.String()) } +func (s *CLISuite) TestRun_Exec_RunsCommandInCastleRoot() { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + + exitCode := cli.Run([]string{"exec", "dotfiles", "pwd"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), castleRoot) + require.Empty(s.T(), s.stderr.String()) +} + func (s *CLISuite) TestRun_CloneSubcommandHelp() { exitCode := cli.Run([]string{"clone", "--help"}, s.stdout, s.stderr) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index b09d441..22b4124 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -219,6 +219,75 @@ func (a *App) Open(castle string) error { return nil } +func (a *App) Exec(castle string, command []string) error { + commandString := strings.TrimSpace(strings.Join(command, " ")) + if commandString == "" { + return errors.New("exec requires COMMAND") + } + + castleRoot := filepath.Join(a.ReposDir, castle) + if _, err := os.Stat(castleRoot); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("castle %q not found", castle) + } + return err + } + + cmd := exec.Command("sh", "-c", commandString) + cmd.Dir = castleRoot + cmd.Stdout = a.Stdout + cmd.Stderr = a.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("exec failed: %w", err) + } + + return nil +} + +func (a *App) ExecAll(command []string) error { + commandString := strings.TrimSpace(strings.Join(command, " ")) + if commandString == "" { + return errors.New("exec_all requires COMMAND") + } + + if _, err := os.Stat(a.ReposDir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + var castles []string + err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !d.IsDir() || d.Name() != ".git" { + return nil + } + + castleRoot := filepath.Dir(path) + rel, err := filepath.Rel(a.ReposDir, castleRoot) + if err != nil { + return err + } + castles = append(castles, rel) + return filepath.SkipDir + }) + if err != nil { + return err + } + + sort.Strings(castles) + for _, castle := range castles { + if err := a.Exec(castle, []string{commandString}); err != nil { + return fmt.Errorf("exec_all failed for %q: %w", castle, err) + } + } + + return nil +} + func (a *App) Generate(castlePath string) error { trimmed := strings.TrimSpace(castlePath) if trimmed == "" { diff --git a/internal/homesick/core/exec_test.go b/internal/homesick/core/exec_test.go new file mode 100644 index 0000000..301ec07 --- /dev/null +++ b/internal/homesick/core/exec_test.go @@ -0,0 +1,79 @@ +package core_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ExecSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + stdout *bytes.Buffer + stderr *bytes.Buffer + app *core.App +} + +func TestExecSuite(t *testing.T) { + suite.Run(t, new(ExecSuite)) +} + +func (s *ExecSuite) SetupTest() { + s.tmpDir = s.T().TempDir() + s.homeDir = filepath.Join(s.tmpDir, "home") + s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos") + require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755)) + + s.stdout = &bytes.Buffer{} + s.stderr = &bytes.Buffer{} + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: s.stdout, + Stderr: s.stderr, + } +} + +func (s *ExecSuite) createCastle(name string) string { + castleRoot := filepath.Join(s.reposDir, name) + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + return castleRoot +} + +func (s *ExecSuite) TestExec_UnknownCastleReturnsError() { + err := s.app.Exec("nonexistent", []string{"pwd"}) + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "not found") +} + +func (s *ExecSuite) TestExec_RunsCommandInCastleRoot() { + castleRoot := s.createCastle("dotfiles") + + require.NoError(s.T(), s.app.Exec("dotfiles", []string{"pwd"})) + require.Contains(s.T(), s.stdout.String(), castleRoot) +} + +func (s *ExecSuite) TestExec_ForwardsStdoutAndStderr() { + s.createCastle("dotfiles") + + require.NoError(s.T(), s.app.Exec("dotfiles", []string{"echo out && echo err >&2"})) + require.Contains(s.T(), s.stdout.String(), "out") + require.Contains(s.T(), s.stderr.String(), "err") +} + +func (s *ExecSuite) TestExecAll_RunsCommandForEachCastle() { + zeta := s.createCastle("zeta") + alpha := s.createCastle("alpha") + require.NoError(s.T(), os.MkdirAll(filepath.Join(zeta, ".git"), 0o755)) + require.NoError(s.T(), os.MkdirAll(filepath.Join(alpha, ".git"), 0o755)) + + require.NoError(s.T(), s.app.ExecAll([]string{"basename \"$PWD\""})) + require.Equal(s.T(), "alpha\nzeta\n", s.stdout.String()) +}