From e854644083a2f7fff4c61299a5e9bdd8237212a4 Mon Sep 17 00:00:00 2001 From: Maximilian Lang Date: Sat, 2 Aug 2025 12:40:49 +0200 Subject: [PATCH 1/3] refactor exection of scripts --- internal/cli/init.go | 21 +---- internal/{script/script.go => cmd/cmd.go} | 6 +- internal/config/config.go | 4 +- internal/config/repo.go | 12 +-- internal/config/script.go | 95 +++++++++++++++++++++++ 5 files changed, 108 insertions(+), 30 deletions(-) rename internal/{script/script.go => cmd/cmd.go} (73%) create mode 100644 internal/config/script.go diff --git a/internal/cli/init.go b/internal/cli/init.go index afe69e8..eac21b3 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,14 +1,12 @@ package cli import ( - "path/filepath" "slices" "github.com/mxlang/dotx/internal/config" "github.com/mxlang/dotx/internal/fs" "github.com/mxlang/dotx/internal/git" "github.com/mxlang/dotx/internal/logger" - "github.com/mxlang/dotx/internal/script" "github.com/mxlang/dotx/internal/tui" "github.com/spf13/cobra" ) @@ -67,7 +65,7 @@ func runInit(cfg *config.Config, opts initOptions, url string) { logger.Info("successfully cloned remote dotfiles") } - runInitScripts(cfg) + cfg.Repo.ExecuteScripts(config.OnInit) if opts.deploy { logger.Debug("automatic deploy is active") @@ -107,20 +105,3 @@ func shouldCloneDotfiles(dir fs.Path, url string) bool { return false } - -func runInitScripts(cfg *config.Config) { - for _, scriptPath := range cfg.Repo.Scripts.Init { - fullPath := fs.NewPath(filepath.Join(cfg.RepoPath, scriptPath)) - if !fullPath.Exists() { - logger.Warn("script does not exist", "script", fullPath.AbsPath()) - continue - } - - logger.Info("execute script", "script", fullPath.AbsPath()) - if err := script.Run(fullPath.AbsPath()); err != nil { - logger.Warn(err) - } else { - logger.Debug("successfully executed script", "script", fullPath.AbsPath()) - } - } -} diff --git a/internal/script/script.go b/internal/cmd/cmd.go similarity index 73% rename from internal/script/script.go rename to internal/cmd/cmd.go index 8b2229d..bb60e2d 100644 --- a/internal/script/script.go +++ b/internal/cmd/cmd.go @@ -1,12 +1,12 @@ -package script +package cmd import ( "os" "os/exec" ) -func Run(scriptPath string) error { - cmd := exec.Command("bash", scriptPath) +func Run(command string) error { + cmd := exec.Command(command) cmd.Stdin = os.Stdin // pass input from terminal/user cmd.Stdout = os.Stdout // print output to terminal diff --git a/internal/config/config.go b/internal/config/config.go index 6c71eb0..8b3d0e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,14 +76,14 @@ func loadRepoConfig() repoConfig { content, err := os.ReadFile(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { - logger.Warn("error while reading dotfiles config", "error", err) + logger.Error("error while reading dotfiles config", "error", err) } return config } if err := yaml.Unmarshal(content, &config); err != nil { - logger.Warn("invalid dotfiles config", "error", err) + logger.Error("invalid dotfiles config", "error", err) } return config diff --git a/internal/config/repo.go b/internal/config/repo.go index a459afb..7558c36 100644 --- a/internal/config/repo.go +++ b/internal/config/repo.go @@ -14,13 +14,9 @@ type dotfile struct { Destination string `yaml:"destination"` } -type scripts struct { - Init []string `yaml:"init"` -} - type repoConfig struct { Dotfiles []dotfile `yaml:"dotfiles"` - Scripts scripts `yaml:"scripts,omitempty"` + Scripts []script `yaml:"scripts"` } func (r *repoConfig) HasDotfile(source fs.Path) bool { @@ -57,3 +53,9 @@ func (r *repoConfig) AddDotfile(source fs.Path, dest fs.Path) error { return nil } + +func (r *repoConfig) ExecuteScripts(event Event) { + for _, script := range r.Scripts { + script.execute(event) + } +} diff --git a/internal/config/script.go b/internal/config/script.go new file mode 100644 index 0000000..63ca240 --- /dev/null +++ b/internal/config/script.go @@ -0,0 +1,95 @@ +package config + +import ( + "fmt" + "path/filepath" + + "github.com/mxlang/dotx/internal/cmd" + "github.com/mxlang/dotx/internal/fs" + "github.com/mxlang/dotx/internal/logger" +) + +type Event string + +const ( + OnInit Event = "init" + OnPull Event = "pull" + OnDeploy Event = "deploy" +) + +func (e *Event) UnmarshalYAML(unmarshal func(any) error) error { + var value string + if err := unmarshal(&value); err != nil { + return err + } + + switch Event(value) { + case OnInit, OnPull, OnDeploy: + *e = Event(value) + return nil + default: + return fmt.Errorf("invalid on value: %s. Must be one of: %s, %s, %s", value, OnInit, OnPull, OnDeploy) + } +} + +type runCondition string + +const ( + runAlways runCondition = "always" + runOnce runCondition = "once" + runChanged runCondition = "changed" +) + +func (r *runCondition) UnmarshalYAML(unmarshal func(any) error) error { + var value string + if err := unmarshal(&value); err != nil { + return err + } + + switch runCondition(value) { + case runAlways, runOnce, runChanged: + *r = runCondition(value) + return nil + default: + return fmt.Errorf("invalid run value: %s. Must be one of: %s, %s, %s", value, runAlways, runOnce, runChanged) + } +} + +type script struct { + Path string `yaml:"path"` + Event Event `yaml:"on"` + RunCondition runCondition `yaml:"run,omitempty"` +} + +type scripts []script + +func (s *scripts) filter(event Event) { + // TODO filter scripts by event +} + +func (s *script) execute(event Event) { + if s.Event != event { + return + } + + path := fs.NewPath(filepath.Join(repoDirPath(), s.Path)) + if !path.Exists() { + logger.Warn("script does not exist", "script", path.AbsPath()) + return + } + + switch s.RunCondition { + case runOnce: + fmt.Println("run condition is once") + // TODO check file already executed + case runChanged: + fmt.Println("run condition is changed") + // TODO check file changed + } + + if err := cmd.Run(path.AbsPath()); err != nil { + logger.Warn(err) + } else { + logger.Debug("successfully executed script", "script", path.AbsPath()) + } +} From 88950a4eee10d2439337e3e987d7a77827f6bc90 Mon Sep 17 00:00:00 2001 From: Maximilian Lang Date: Wed, 6 Aug 2025 17:03:45 +0200 Subject: [PATCH 2/3] Execute user-defined scripts post pull and deploy actions, improve logging consistency. --- internal/cli/deploy.go | 2 ++ internal/cli/pull.go | 2 ++ internal/config/script.go | 7 ++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/cli/deploy.go b/internal/cli/deploy.go index 72226f3..e9214ee 100644 --- a/internal/cli/deploy.go +++ b/internal/cli/deploy.go @@ -85,4 +85,6 @@ func runDeploy(cfg *config.Config, force bool) { logger.Info("successfully deployed", "dotfile", source.Filename()) } + + cfg.Repo.ExecuteScripts(config.OnDeploy) } diff --git a/internal/cli/pull.go b/internal/cli/pull.go index 436708e..1f135c5 100644 --- a/internal/cli/pull.go +++ b/internal/cli/pull.go @@ -44,6 +44,8 @@ func runPull(cfg *config.Config, opts pullOptions) { logger.Info("successfully pulled from remote dotfiles") + cfg.Repo.ExecuteScripts(config.OnPull) + if opts.deploy { logger.Debug("automatic deploy is active") runDeploy(cfg, opts.force) diff --git a/internal/config/script.go b/internal/config/script.go index 63ca240..4689e82 100644 --- a/internal/config/script.go +++ b/internal/config/script.go @@ -74,7 +74,7 @@ func (s *script) execute(event Event) { path := fs.NewPath(filepath.Join(repoDirPath(), s.Path)) if !path.Exists() { - logger.Warn("script does not exist", "script", path.AbsPath()) + logger.Warn("not found", "script", path.AbsPath()) return } @@ -87,9 +87,10 @@ func (s *script) execute(event Event) { // TODO check file changed } + logger.Info("execute", "script", path.AbsPath()) if err := cmd.Run(path.AbsPath()); err != nil { - logger.Warn(err) + logger.Warn("failed to execute", "script", path.AbsPath(), "error", err) } else { - logger.Debug("successfully executed script", "script", path.AbsPath()) + logger.Debug("successfully executed", "script", path.AbsPath()) } } From 4fe7af59f6c4daed375a045d329f0d9c356c11fe Mon Sep 17 00:00:00 2001 From: Maximilian Lang Date: Wed, 10 Sep 2025 21:52:58 +0200 Subject: [PATCH 3/3] Add `dataConfig` implementation and integrate script execution tracking Implemented a new `dataConfig` for managing executed scripts with added functionalities to track, add, and update script execution state based on hash changes. Refactored script execution logic to leverage `dataConfig` for conditional execution, ensuring accurate handling of `runOnce` and `runChanged` conditions. Enhanced error handling and logging for improved visibility. --- README.md | 2 +- internal/config/app.go | 42 ++++++++++++ internal/config/config.go | 70 -------------------- internal/config/data.go | 130 ++++++++++++++++++++++++++++++++++++++ internal/config/repo.go | 34 ++++++++-- internal/config/script.go | 57 +++++++++++------ internal/config/util.go | 8 ++- 7 files changed, 246 insertions(+), 97 deletions(-) create mode 100644 internal/config/data.go diff --git a/README.md b/README.md index 955b6bd..da20c35 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ cd dotx go install ./cmd/dotx ``` -#### Setup your shell to use dotx +#### Set up your shell to use dotx ```bash eval "$(dotx init )" diff --git a/internal/config/app.go b/internal/config/app.go index 3017a6f..287e0ff 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -1,8 +1,50 @@ package config +import ( + "errors" + "os" + + "github.com/goccy/go-yaml" + "github.com/mxlang/dotx/internal/fs" + "github.com/mxlang/dotx/internal/logger" +) + type appConfig struct { Verbose bool `yaml:"verbose"` CommitMessage string `yaml:"commitMessage"` DeployOnInit bool `yaml:"deployOnInit"` DeployOnPull bool `yaml:"deployOnPull"` } + +func defaultAppConfig() appConfig { + return appConfig{ + Verbose: false, + CommitMessage: "update dotfiles", + DeployOnPull: false, + DeployOnInit: false, + } +} + +func loadAppConfig() appConfig { + // Ensure the config directory exists + appDir := fs.NewPath(appDirPath()) + if err := fs.Mkdir(appDir); err != nil { + logger.Error("error while creating dotx config directory", "error", err) + } + + config := defaultAppConfig() + content, err := os.ReadFile(appConfigFilePath()) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Error("error while reading dotx config", "error", err) + } + + return config + } + + if err := yaml.Unmarshal(content, &config); err != nil { + logger.Error("invalid dotx config", "error", err) + } + + return config +} diff --git a/internal/config/config.go b/internal/config/config.go index 8b3d0e4..05799e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,14 +1,5 @@ package config -import ( - "errors" - "os" - - "github.com/goccy/go-yaml" - "github.com/mxlang/dotx/internal/fs" - "github.com/mxlang/dotx/internal/logger" -) - type Config struct { RepoPath string // TODO change type to fs.Path @@ -27,64 +18,3 @@ func Load() *Config { Repo: repo, } } - -func loadAppConfig() appConfig { - // Ensure the config directory exists - appDir := fs.NewPath(appDirPath()) - if err := fs.Mkdir(appDir); err != nil { - logger.Error("error while creating dotx config directory", "error", err) - } - - config := defaultAppConfig() - path := appConfigFilePath() - - content, err := os.ReadFile(path) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - logger.Warn("error while reading dotx config", "error", err) - } - - return config - } - - if err := yaml.Unmarshal(content, &config); err != nil { - logger.Warn("invalid dotx config", "error", err) - } - - return config -} - -func defaultAppConfig() appConfig { - return appConfig{ - Verbose: false, - CommitMessage: "update dotfiles", - DeployOnPull: false, - DeployOnInit: false, - } -} - -func loadRepoConfig() repoConfig { - // Ensure the dotfiles directory exists - repoDir := fs.NewPath(repoDirPath()) - if err := fs.Mkdir(repoDir); err != nil { - logger.Error("error while creating dotfiles directory", "error", err) - } - - config := repoConfig{} - path := repoConfigFilePath() - - content, err := os.ReadFile(path) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - logger.Error("error while reading dotfiles config", "error", err) - } - - return config - } - - if err := yaml.Unmarshal(content, &config); err != nil { - logger.Error("invalid dotfiles config", "error", err) - } - - return config -} diff --git a/internal/config/data.go b/internal/config/data.go new file mode 100644 index 0000000..547e90d --- /dev/null +++ b/internal/config/data.go @@ -0,0 +1,130 @@ +package config + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/goccy/go-yaml" + "github.com/mxlang/dotx/internal/logger" +) + +type executed struct { + Path string `yaml:"path"` // TODO change type to fs.Path + Hash string `yaml:"hash"` +} + +type dataConfig struct { + Scripts []executed `yaml:"scripts"` +} + +func (d *dataConfig) alreadyExecuted(s script) bool { + for _, exec := range d.Scripts { + if exec.Path == s.Path { + return true + } + } + + return false +} + +func (d *dataConfig) hashChanged(s script) bool { + for _, exec := range d.Scripts { + if exec.Path == s.Path { + hash, err := getHash(s) + if err != nil { + // TODO + } + + if exec.Hash != hash { + return true + } + } + } + + return false +} + +func (d *dataConfig) addScript(s script) error { + hash, err := getHash(s) + if err != nil { + return fmt.Errorf("unable to create hash: %w", err) + } + + exec := executed{ + Path: s.Path, + Hash: hash, + } + + d.Scripts = append(d.Scripts, exec) + + config, err := yaml.Marshal(d) + if err != nil { + return fmt.Errorf("unable to marshal data config: %w", err) + } + + if err := os.WriteFile(dataConfigFilePath(), config, 0644); err != nil { + return fmt.Errorf("unable to write data config: %w", err) + } + + return nil +} + +func (d *dataConfig) updateScript(s script) error { + hash, err := getHash(s) + if err != nil { + return fmt.Errorf("unable to create hash: %w", err) + } + + for i, exec := range d.Scripts { + if exec.Path == s.Path { + d.Scripts[i].Hash = hash + } + } + + config, err := yaml.Marshal(d) + if err != nil { + return fmt.Errorf("unable to marshal data config: %w", err) + } + + if err := os.WriteFile(dataConfigFilePath(), config, 0644); err != nil { + return fmt.Errorf("unable to write data config: %w", err) + } + + return nil +} + +func getHash(s script) (string, error) { + file, err := os.Open(filepath.Join(repoDirPath(), s.Path)) + if err != nil { + return "", err + } + defer file.Close() + + sha := sha256.New() + if _, err := io.Copy(sha, file); err != nil { + return "", err + } + + return fmt.Sprintf("%x", sha.Sum(nil)), nil +} + +func loadDataConfig() dataConfig { + content, err := os.ReadFile(dataConfigFilePath()) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Warn("error while reading data config", "error", err) + } + } + + config := dataConfig{} + + if err := yaml.Unmarshal(content, &config); err != nil { + logger.Error("invalid data config", "error", err) + } + + return config +} diff --git a/internal/config/repo.go b/internal/config/repo.go index 7558c36..57ccbf9 100644 --- a/internal/config/repo.go +++ b/internal/config/repo.go @@ -1,17 +1,19 @@ package config import ( + "errors" "fmt" "os" "strings" "github.com/goccy/go-yaml" "github.com/mxlang/dotx/internal/fs" + "github.com/mxlang/dotx/internal/logger" ) type dotfile struct { - Source string `yaml:"source"` - Destination string `yaml:"destination"` + Source string `yaml:"source"` // TODO change type to fs.Path + Destination string `yaml:"destination"` // TODO change type to fs.Path } type repoConfig struct { @@ -30,7 +32,7 @@ func (r *repoConfig) HasDotfile(source fs.Path) bool { } func (r *repoConfig) AddDotfile(source fs.Path, dest fs.Path) error { - // normalize paths + // Normalize paths home, _ := os.UserHomeDir() sourcePath := strings.Replace(source.AbsPath(), home, "$HOME", 1) destinationPath := strings.Replace(dest.AbsPath(), repoDirPath(), "", 1) @@ -54,8 +56,32 @@ func (r *repoConfig) AddDotfile(source fs.Path, dest fs.Path) error { return nil } -func (r *repoConfig) ExecuteScripts(event Event) { +func (r *repoConfig) ExecuteScripts(event event) { for _, script := range r.Scripts { script.execute(event) } } + +func loadRepoConfig() repoConfig { + // Ensure the dotfiles directory exists + repoDir := fs.NewPath(repoDirPath()) + if err := fs.Mkdir(repoDir); err != nil { + logger.Error("error while creating dotfiles directory", "error", err) + } + + config := repoConfig{} + content, err := os.ReadFile(repoConfigFilePath()) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Error("error while reading dotfiles config", "error", err) + } + + return config + } + + if err := yaml.Unmarshal(content, &config); err != nil { + logger.Error("invalid dotfiles config", "error", err) + } + + return config +} diff --git a/internal/config/script.go b/internal/config/script.go index 4689e82..14d05b2 100644 --- a/internal/config/script.go +++ b/internal/config/script.go @@ -9,23 +9,23 @@ import ( "github.com/mxlang/dotx/internal/logger" ) -type Event string +type event string const ( - OnInit Event = "init" - OnPull Event = "pull" - OnDeploy Event = "deploy" + OnInit event = "init" + OnPull event = "pull" + OnDeploy event = "deploy" ) -func (e *Event) UnmarshalYAML(unmarshal func(any) error) error { +func (e *event) UnmarshalYAML(unmarshal func(any) error) error { var value string if err := unmarshal(&value); err != nil { return err } - switch Event(value) { + switch event(value) { case OnInit, OnPull, OnDeploy: - *e = Event(value) + *e = event(value) return nil default: return fmt.Errorf("invalid on value: %s. Must be one of: %s, %s, %s", value, OnInit, OnPull, OnDeploy) @@ -56,18 +56,12 @@ func (r *runCondition) UnmarshalYAML(unmarshal func(any) error) error { } type script struct { - Path string `yaml:"path"` - Event Event `yaml:"on"` + Path string `yaml:"path"` // TODO change type to fs.Path + Event event `yaml:"on"` RunCondition runCondition `yaml:"run,omitempty"` } -type scripts []script - -func (s *scripts) filter(event Event) { - // TODO filter scripts by event -} - -func (s *script) execute(event Event) { +func (s *script) execute(event event) { if s.Event != event { return } @@ -78,16 +72,37 @@ func (s *script) execute(event Event) { return } + data := loadDataConfig() + switch s.RunCondition { case runOnce: - fmt.Println("run condition is once") - // TODO check file already executed + if data.alreadyExecuted(*s) { + logger.Debug("already executed", "script", path.AbsPath()) + return + } + + if err := data.addScript(*s); err != nil { + logger.Error("failed to write data config", "error", err) + } case runChanged: - fmt.Println("run condition is changed") - // TODO check file changed + if data.alreadyExecuted(*s) { + if data.hashChanged(*s) { + logger.Debug("hash changed", "script", path.AbsPath()) + if err := data.updateScript(*s); err != nil { + logger.Error("failed to write data config", "error", err) + } + } else { + logger.Debug("hash not changed", "script", path.AbsPath()) + return + } + } else { + if err := data.addScript(*s); err != nil { + logger.Error("failed to write data config", "error", err) + } + } } - logger.Info("execute", "script", path.AbsPath()) + logger.Info("execute", "script", path.AbsPath(), "on", s.Event, "run", s.RunCondition) if err := cmd.Run(path.AbsPath()); err != nil { logger.Warn("failed to execute", "script", path.AbsPath(), "error", err) } else { diff --git a/internal/config/util.go b/internal/config/util.go index 9c8e6d8..2fde7fc 100644 --- a/internal/config/util.go +++ b/internal/config/util.go @@ -1,8 +1,9 @@ package config import ( - "github.com/adrg/xdg" "path/filepath" + + "github.com/adrg/xdg" ) const ( @@ -10,6 +11,7 @@ const ( appConfigFile = "config.yaml" repoDir = "dotfiles" repoConfigFile = "dotx.yaml" + dataConfigFile = "data.yaml" ) func appDirPath() string { @@ -27,3 +29,7 @@ func repoDirPath() string { func repoConfigFilePath() string { return filepath.Join(repoDirPath(), repoConfigFile) } + +func dataConfigFilePath() string { + return filepath.Join(xdg.DataHome, baseDir, dataConfigFile) +}