diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 0cd516f..e2110ad 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -171,20 +171,22 @@ type execCmd struct{} type execAllCmd struct{} -type rcCmd struct{} +type rcCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} type generateCmd struct{} -func (c *pullCmd) Run() error { return notImplemented("pull") } -func (c *pushCmd) Run() error { return notImplemented("push") } -func (c *commitCmd) Run() error { return notImplemented("commit") } -func (c *destroyCmd) Run() error { return notImplemented("destroy") } -func (c *cdCmd) Run() error { return notImplemented("cd") } -func (c *openCmd) Run() error { return notImplemented("open") } -func (c *execCmd) Run() error { return notImplemented("exec") } -func (c *execAllCmd) Run() error { return notImplemented("exec_all") } -func (c *rcCmd) Run() error { return notImplemented("rc") } -func (c *generateCmd) Run() error { return notImplemented("generate") } +func (c *pullCmd) Run() error { return notImplemented("pull") } +func (c *pushCmd) Run() error { return notImplemented("push") } +func (c *commitCmd) Run() error { return notImplemented("commit") } +func (c *destroyCmd) Run() error { return notImplemented("destroy") } +func (c *cdCmd) Run() error { return notImplemented("cd") } +func (c *openCmd) Run() error { return notImplemented("open") } +func (c *execCmd) Run() error { return notImplemented("exec") } +func (c *execAllCmd) Run() error { return notImplemented("exec_all") } +func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } +func (c *generateCmd) Run() error { return notImplemented("generate") } func defaultCastle(castle string) string { if strings.TrimSpace(castle) == "" { diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 8c31e1d..1d11e65 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -531,6 +531,82 @@ func gitOutput(dir string, args ...string) (string, error) { return string(out), nil } +// Rc runs the rc hooks for the given castle. It looks for executable files +// inside /.homesick.d and runs them in sorted (lexicographic) order +// with the castle root as the working directory, forwarding stdout and stderr +// to the App writers. +// +// If a .homesickrc file exists in the castle root and no parity.rb wrapper +// already exists in .homesick.d, a Ruby wrapper script named parity.rb is +// written there before execution so that it sorts first. +func (a *App) Rc(castle string) error { + 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 + } + + homesickD := filepath.Join(castleRoot, ".homesick.d") + homesickRc := filepath.Join(castleRoot, ".homesickrc") + + // If .homesickrc exists, ensure .homesick.d/parity.rb wrapper is created + // (but do not overwrite an existing parity.rb). + if _, err := os.Stat(homesickRc); err == nil { + wrapperPath := filepath.Join(homesickD, "parity.rb") + if _, err := os.Stat(wrapperPath); errors.Is(err, os.ErrNotExist) { + if mkErr := os.MkdirAll(homesickD, 0o755); mkErr != nil { + return fmt.Errorf("create .homesick.d: %w", mkErr) + } + wrapperContent := "#!/usr/bin/env ruby\n" + + "# parity.rb — generated wrapper for legacy .homesickrc\n" + + "# Evaluates .homesickrc in the context of the castle root.\n" + + "rc_file = File.join(__dir__, '..', '.homesickrc')\n" + + "eval(File.read(rc_file), binding, rc_file) if File.exist?(rc_file)\n" + if writeErr := os.WriteFile(wrapperPath, []byte(wrapperContent), 0o755); writeErr != nil { + return fmt.Errorf("write parity.rb: %w", writeErr) + } + } + } + + if _, err := os.Stat(homesickD); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + entries, err := os.ReadDir(homesickD) + if err != nil { + return err + } + + // ReadDir returns entries in sorted order already. + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, infoErr := entry.Info() + if infoErr != nil { + return infoErr + } + if info.Mode()&0o111 == 0 { + // Not executable — skip. + continue + } + scriptPath := filepath.Join(homesickD, entry.Name()) + cmd := exec.Command(scriptPath) + cmd.Dir = castleRoot + cmd.Stdout = a.Stdout + cmd.Stderr = a.Stderr + if runErr := cmd.Run(); runErr != nil { + return fmt.Errorf("rc script %q failed: %w", entry.Name(), runErr) + } + } + return nil +} + func deriveDestination(uri string) string { candidate := strings.TrimSpace(uri) candidate = strings.TrimPrefix(candidate, "https://github.com/") diff --git a/internal/homesick/core/rc_test.go b/internal/homesick/core/rc_test.go index 89bff37..b9db070 100644 --- a/internal/homesick/core/rc_test.go +++ b/internal/homesick/core/rc_test.go @@ -100,7 +100,7 @@ func (s *RcSuite) TestRc_SkipsNonExecutableFiles() { } // TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes -// a Ruby wrapper to be written into .homesick.d before execution. +// a Ruby wrapper called parity.rb to be written into .homesick.d before execution. func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") @@ -108,7 +108,7 @@ func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() { require.NoError(s.T(), s.app.Rc("dotfiles")) - wrapperPath := filepath.Join(castleRoot, ".homesick.d", "00_homesickrc.rb") + wrapperPath := filepath.Join(castleRoot, ".homesick.d", "parity.rb") require.FileExists(s.T(), wrapperPath) info, err := os.Stat(wrapperPath) @@ -120,9 +120,28 @@ func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() { require.Contains(s.T(), string(content), ".homesickrc") } -// TestRc_HomesickrcWrapperRunsBeforeOtherScripts ensures the wrapper file -// (00_homesickrc.rb) sorts before typical user scripts and is present in -// .homesick.d after Rc returns. +// TestRc_HomesickrcWrapperNotOverwrittenIfExists verifies that an existing +// parity.rb is not overwritten when Rc is called again. +func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() { + castleRoot := s.createCastle("dotfiles") + homesickRc := filepath.Join(castleRoot, ".homesickrc") + require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) + + homesickD := filepath.Join(castleRoot, ".homesick.d") + require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) + wrapperPath := filepath.Join(homesickD, "parity.rb") + originalContent := []byte("#!/bin/sh\n# pre-existing wrapper\n") + require.NoError(s.T(), os.WriteFile(wrapperPath, originalContent, 0o755)) + + require.NoError(s.T(), s.app.Rc("dotfiles")) + + content, err := os.ReadFile(wrapperPath) + require.NoError(s.T(), err) + require.Equal(s.T(), originalContent, content, "existing parity.rb must not be overwritten") +} + +// TestRc_HomesickrcWrapperCreatedBeforeExecution ensures parity.rb is present +// in .homesick.d before any scripts in that directory are executed. func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") @@ -134,7 +153,7 @@ func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() { // A sentinel script that records whether the wrapper already exists. orderFile := filepath.Join(s.tmpDir, "check.txt") sentinel := filepath.Join(homesickD, "50_check.sh") - wrapperPath := filepath.Join(homesickD, "00_homesickrc.rb") + wrapperPath := filepath.Join(homesickD, "parity.rb") require.NoError(s.T(), os.WriteFile(sentinel, []byte( "#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n", ), 0o755))