From 8db27e298c7a06a704dd41c222aa6fb4d2fa8b1f Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Tue, 31 Mar 2026 23:51:49 +0300 Subject: [PATCH] feat: add repo_update control modes --- README.md | 4 + appconfig/appconfig.go | 26 ++++++ appconfig/appconfig_test.go | 47 +++++++++++ docs/configuration-reference.md | 19 +++++ docs/installer-configuration.md | 6 ++ installer/apt_installer.go | 23 +++-- installer/brew_installer.go | 37 +++++--- installer/repo_update_test.go | 144 ++++++++++++++++++++++++++++++++ 8 files changed, 290 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bbe6f90..da9af7c 100755 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ example configuration to demonstrate most of its options. ```yaml debug: true # Global debug mode (optional). check_updates: true # Enable update checking (optional). +repo_update: # Control repo index updates per type (optional). + brew: once # Run brew update once per run (default). + apt: once # Run apt update once per run (default). defaults: # Define default behaviors for installer types. type: brew: @@ -172,6 +175,7 @@ For a full breakdown with all the supported options, see | ------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `debug` | Boolean | Enable or disable debug mode. Default: `false`. | | `check_updates` | Boolean | Enable or disable checking for updates before running operations. Default: `false`. | +| `repo_update` | Object | Controls repo index updates per installer type (e.g. `apt update`, `brew update`). Values: `once` (default), `always`, `never`. Supported types: `brew`, `apt`, `apk`. | | `summary` | Boolean | Enable or disable the installation summary at the end. Default: `true`. | | `category_display` | String | Controls how category headers are rendered. Values: `border` (default), `border-compact`, `minimal`. | | `defaults` | Object | Defaults to apply to all installer types, such as specifying supported platforms or commonly used flags. | diff --git a/appconfig/appconfig.go b/appconfig/appconfig.go index 57f4188..0c9819d 100755 --- a/appconfig/appconfig.go +++ b/appconfig/appconfig.go @@ -25,6 +25,18 @@ const ( CategoryDisplayMinimal CategoryDisplayMode = "minimal" ) +// RepoUpdateMode controls how repository index updates are handled for a package manager. +type RepoUpdateMode string + +const ( + // RepoUpdateOnce runs the repository update at most once per sofmani run (default). + RepoUpdateOnce RepoUpdateMode = "once" + // RepoUpdateAlways runs the repository update before every install/update check. + RepoUpdateAlways RepoUpdateMode = "always" + // RepoUpdateNever skips the repository update entirely. + RepoUpdateNever RepoUpdateMode = "never" +) + // AppConfig represents the main application configuration. type AppConfig struct { // Debug enables or disables debug mode. @@ -35,6 +47,9 @@ type AppConfig struct { Summary *bool `json:"summary" yaml:"summary"` // CategoryDisplay controls how category headers are rendered. CategoryDisplay *CategoryDisplayMode `json:"category_display" yaml:"category_display"` + // RepoUpdate controls repository index update behavior per installer type. + // Supported types: brew, apt, apk. Values: "once" (default), "always", "never". + RepoUpdate *map[InstallerType]RepoUpdateMode `json:"repo_update" yaml:"repo_update"` // Install is a list of installers to run. Install []InstallerData `json:"install" yaml:"install"` // Defaults provides default configurations for installer types. @@ -49,6 +64,17 @@ type AppConfig struct { Filter []string } +// GetRepoUpdateMode returns the repo update mode for the given installer type, +// defaulting to RepoUpdateOnce. +func (c *AppConfig) GetRepoUpdateMode(t InstallerType) RepoUpdateMode { + if c.RepoUpdate != nil { + if mode, ok := (*c.RepoUpdate)[t]; ok { + return mode + } + } + return RepoUpdateOnce +} + // AppCliConfig represents the command-line interface configuration. type AppCliConfig struct { // ConfigFile is the path to the configuration file. diff --git a/appconfig/appconfig_test.go b/appconfig/appconfig_test.go index bae1540..e9692a3 100755 --- a/appconfig/appconfig_test.go +++ b/appconfig/appconfig_test.go @@ -125,6 +125,53 @@ install: assert.False(t, *config.CheckUpdates) } +func TestGetRepoUpdateMode(t *testing.T) { + t.Run("defaults to once when not configured", func(t *testing.T) { + config := AppConfig{} + assert.Equal(t, RepoUpdateOnce, config.GetRepoUpdateMode(InstallerTypeBrew)) + assert.Equal(t, RepoUpdateOnce, config.GetRepoUpdateMode(InstallerTypeApt)) + }) + + t.Run("returns configured mode", func(t *testing.T) { + repoUpdate := map[InstallerType]RepoUpdateMode{ + InstallerTypeBrew: RepoUpdateNever, + InstallerTypeApt: RepoUpdateAlways, + } + config := AppConfig{RepoUpdate: &repoUpdate} + assert.Equal(t, RepoUpdateNever, config.GetRepoUpdateMode(InstallerTypeBrew)) + assert.Equal(t, RepoUpdateAlways, config.GetRepoUpdateMode(InstallerTypeApt)) + }) + + t.Run("defaults to once for unconfigured type", func(t *testing.T) { + repoUpdate := map[InstallerType]RepoUpdateMode{ + InstallerTypeBrew: RepoUpdateNever, + } + config := AppConfig{RepoUpdate: &repoUpdate} + assert.Equal(t, RepoUpdateOnce, config.GetRepoUpdateMode(InstallerTypeApt)) + }) + + t.Run("parses from yaml", func(t *testing.T) { + file, err := os.CreateTemp("", "config.*.yaml") + assert.NoError(t, err) + defer func() { assert.NoError(t, os.Remove(file.Name())) }() + + _, err = file.WriteString(` +repo_update: + brew: never + apt: always + apk: once +`) + assert.NoError(t, err) + assert.NoError(t, file.Close()) + + config, err := ParseConfigFrom(file.Name()) + assert.NoError(t, err) + assert.Equal(t, RepoUpdateNever, config.GetRepoUpdateMode(InstallerTypeBrew)) + assert.Equal(t, RepoUpdateAlways, config.GetRepoUpdateMode(InstallerTypeApt)) + assert.Equal(t, RepoUpdateOnce, config.GetRepoUpdateMode(InstallerTypeApk)) + }) +} + func TestFindConfigFile(t *testing.T) { // Create a temporary config file dir := t.TempDir() diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 833ec8a..8f0a819 100755 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -23,6 +23,22 @@ Here is a breakdown of all configuration options: - Enable or disable checking for updates before running operations. - Default: `false`. +- **`repo_update`** (Object) + - Controls how repository index updates (e.g. `apt update`, `brew update`) are handled per + installer type. Keys are installer types, values are one of: + - `once` — Run the repo update at most once per sofmani run (default). + - `always` — Run the repo update before every install/update operation. + - `never` — Skip the repo update entirely. + - Supported types: `brew`, `apt`, `apk`. + - Default: `once` for all supported types. + - Example: + ```yaml + repo_update: + brew: once + apt: always + apk: never + ``` + - **`summary`** (Boolean) - Enable or disable the installation summary at the end. - The summary shows newly installed and upgraded software in a hierarchical format. @@ -73,6 +89,9 @@ debug: false check_updates: true summary: true category_display: border +repo_update: + brew: once + apt: once defaults: type: brew: diff --git a/docs/installer-configuration.md b/docs/installer-configuration.md index cc2a9db..35758d0 100755 --- a/docs/installer-configuration.md +++ b/docs/installer-configuration.md @@ -493,6 +493,9 @@ install: - **`brew`** - **Description**: Installs packages using Homebrew. + - **Repo update**: Brew auto-updates its index on each command. By default, sofmani lets the first + brew command auto-update normally and suppresses it for subsequent ones (`once` mode). Configure + via the top-level [`repo_update`](./configuration-reference.md#global-options) option. - **Options**: - `opts.tap`: Name of the tap to install the package from. @@ -513,6 +516,9 @@ install: - **`apt`/`apk`** - **Description**: Installs packages using apt install or apt add. - Use `type: apt` for `apt install`, and `type: apk` for `apk add`. + - **Repo update**: Runs `apt update` or `apk update` before installing. By default, the update + runs at most once per sofmani run (`once` mode). Configure via the top-level + [`repo_update`](./configuration-reference.md#global-options) option. - **Options**: - `opts.flags`: Additional flags to pass to commands (fallback for install/update). - `opts.install_flags`: Additional flags to pass only during install. diff --git a/installer/apt_installer.go b/installer/apt_installer.go index d68cffb..03e9bb7 100755 --- a/installer/apt_installer.go +++ b/installer/apt_installer.go @@ -43,13 +43,26 @@ func (i *AptInstaller) Validate() []ValidationError { return errors } +// runRepoUpdate runs the package manager's repo update according to the configured mode. +func (i *AptInstaller) runRepoUpdate() error { + mode := i.Config.GetRepoUpdateMode(i.Info.Type) + switch mode { + case appconfig.RepoUpdateNever: + return nil + case appconfig.RepoUpdateAlways: + return i.RunCmdPassThrough(string(i.PackageManager), "update") + default: // once + return RunRepoUpdateOnce(string(i.PackageManager)+"-update", func() error { + return i.RunCmdPassThrough(string(i.PackageManager), "update") + }) + } +} + // Install implements IInstaller. func (i *AptInstaller) Install() error { name := *i.Info.Name opts := i.GetOpts() - err := RunRepoUpdateOnce(string(i.PackageManager)+"-update", func() error { - return i.RunCmdPassThrough(string(i.PackageManager), "update") - }) + err := i.runRepoUpdate() if err != nil { return err } @@ -111,9 +124,7 @@ func (i *AptInstaller) CheckNeedsUpdate() (bool, error) { if i.HasCustomUpdateCheck() { return i.RunCustomUpdateCheck() } - err := RunRepoUpdateOnce(string(i.PackageManager)+"-update", func() error { - return i.RunCmdPassThrough(string(i.PackageManager), "update") - }) + err := i.runRepoUpdate() if err != nil { return false, err } diff --git a/installer/brew_installer.go b/installer/brew_installer.go index e8e135b..3c7b716 100755 --- a/installer/brew_installer.go +++ b/installer/brew_installer.go @@ -55,7 +55,7 @@ func (i *BrewInstaller) Validate() []ValidationError { func (i *BrewInstaller) Install() error { name := i.GetFullName() opts := i.GetOpts() - i.suppressBrewAutoUpdate() + i.handleBrewRepoUpdate() cmd := "brew install" if i.IsVerbose() { cmd += " --verbose" @@ -69,7 +69,7 @@ func (i *BrewInstaller) Install() error { cmd += " " + *opts.Flags } err := i.RunCmdAsFile(fmt.Sprintf("%s %s", cmd, name)) - MarkRepoUpdated("brew") + i.markBrewRepoUpdated() return err } @@ -77,7 +77,7 @@ func (i *BrewInstaller) Install() error { func (i *BrewInstaller) Update() error { name := i.GetFullName() opts := i.GetOpts() - i.suppressBrewAutoUpdate() + i.handleBrewRepoUpdate() cmd := "brew upgrade" if i.IsVerbose() { cmd += " --verbose" @@ -91,7 +91,7 @@ func (i *BrewInstaller) Update() error { cmd += " " + *opts.Flags } err := i.RunCmdAsFile(fmt.Sprintf("%s %s", cmd, name)) - MarkRepoUpdated("brew") + i.markBrewRepoUpdated() return err } @@ -104,11 +104,28 @@ func (i *BrewInstaller) GetFullName() string { return name } -// suppressBrewAutoUpdate sets HOMEBREW_NO_AUTO_UPDATE=1 if brew has already -// auto-updated during this run, preventing redundant repo syncs. -func (i *BrewInstaller) suppressBrewAutoUpdate() { - if IsRepoUpdated("brew") { +// handleBrewRepoUpdate manages brew's auto-update behavior according to the configured mode. +// For "once" (default): lets the first brew command auto-update, then suppresses subsequent ones. +// For "always": does nothing (brew auto-updates every time). +// For "never": always suppresses auto-update. +func (i *BrewInstaller) handleBrewRepoUpdate() { + mode := i.Config.GetRepoUpdateMode(appconfig.InstallerTypeBrew) + switch mode { + case appconfig.RepoUpdateNever: _ = os.Setenv("HOMEBREW_NO_AUTO_UPDATE", "1") + case appconfig.RepoUpdateAlways: + // Let brew auto-update every time + default: // once + if IsRepoUpdated("brew") { + _ = os.Setenv("HOMEBREW_NO_AUTO_UPDATE", "1") + } + } +} + +// markBrewRepoUpdated marks brew's repo as updated (for "once" mode tracking). +func (i *BrewInstaller) markBrewRepoUpdated() { + if i.Config.GetRepoUpdateMode(appconfig.InstallerTypeBrew) == appconfig.RepoUpdateOnce { + MarkRepoUpdated("brew") } } @@ -118,7 +135,7 @@ func (i *BrewInstaller) CheckNeedsUpdate() (bool, error) { return i.RunCustomUpdateCheck() } - i.suppressBrewAutoUpdate() + i.handleBrewRepoUpdate() name := i.GetFullName() cmd := exec.Command("brew", "outdated", "--json", name) @@ -159,7 +176,7 @@ func (i *BrewInstaller) CheckNeedsUpdate() (bool, error) { return false, fmt.Errorf("failed to parse brew output: %w", parseErr) } - MarkRepoUpdated("brew") + i.markBrewRepoUpdated() return updateNeeded, nil } diff --git a/installer/repo_update_test.go b/installer/repo_update_test.go index d28b204..d647da4 100644 --- a/installer/repo_update_test.go +++ b/installer/repo_update_test.go @@ -2,8 +2,12 @@ package installer import ( "errors" + "os" "testing" + "github.com/chenasraf/sofmani/appconfig" + "github.com/chenasraf/sofmani/logger" + "github.com/samber/lo" "github.com/stretchr/testify/assert" ) @@ -82,3 +86,143 @@ func TestResetRepoUpdateTracker(t *testing.T) { ResetRepoUpdateTracker() assert.False(t, IsRepoUpdated("key")) } + +func newAptInstallerWithMode(mode appconfig.RepoUpdateMode) *AptInstaller { + repoUpdate := map[appconfig.InstallerType]appconfig.RepoUpdateMode{ + appconfig.InstallerTypeApt: mode, + } + return &AptInstaller{ + InstallerBase: InstallerBase{Data: &appconfig.InstallerData{Name: lo.ToPtr("test-pkg"), Type: appconfig.InstallerTypeApt}}, + Config: &appconfig.AppConfig{RepoUpdate: &repoUpdate}, + Info: &appconfig.InstallerData{Name: lo.ToPtr("test-pkg"), Type: appconfig.InstallerTypeApt}, + PackageManager: AptPackageManager("true"), // "true" command always succeeds + } +} + +func TestAptRepoUpdateMode(t *testing.T) { + logger.InitLogger(false) + + t.Run("never mode skips repo update", func(t *testing.T) { + ResetRepoUpdateTracker() + inst := newAptInstallerWithMode(appconfig.RepoUpdateNever) + + err := inst.runRepoUpdate() + assert.NoError(t, err) + assert.False(t, IsRepoUpdated("true-update"), "tracker should not be set in never mode") + }) + + t.Run("once mode runs repo update only once", func(t *testing.T) { + ResetRepoUpdateTracker() + callCount := 0 + origFn := RunRepoUpdateOnce + + inst := newAptInstallerWithMode(appconfig.RepoUpdateOnce) + + // First call should run + err := inst.runRepoUpdate() + assert.NoError(t, err) + assert.True(t, IsRepoUpdated("true-update"), "tracker should be set after first call") + + // Second call should be skipped by RunRepoUpdateOnce + // We verify by checking the tracker was already set + _ = RunRepoUpdateOnce("true-update", func() error { + callCount++ + return nil + }) + assert.Equal(t, 0, callCount, "function should not run again for same key") + _ = origFn + }) + + t.Run("always mode runs repo update every time", func(t *testing.T) { + ResetRepoUpdateTracker() + inst := newAptInstallerWithMode(appconfig.RepoUpdateAlways) + + // Should succeed and NOT use the tracker + err := inst.runRepoUpdate() + assert.NoError(t, err) + assert.False(t, IsRepoUpdated("true-update"), "tracker should not be set in always mode") + + // Second call should also succeed (not blocked by tracker) + err = inst.runRepoUpdate() + assert.NoError(t, err) + }) +} + +func newBrewInstallerWithMode(mode appconfig.RepoUpdateMode) *BrewInstaller { + repoUpdate := map[appconfig.InstallerType]appconfig.RepoUpdateMode{ + appconfig.InstallerTypeBrew: mode, + } + return &BrewInstaller{ + InstallerBase: InstallerBase{Data: &appconfig.InstallerData{Name: lo.ToPtr("test-pkg"), Type: appconfig.InstallerTypeBrew}}, + Config: &appconfig.AppConfig{RepoUpdate: &repoUpdate}, + Info: &appconfig.InstallerData{Name: lo.ToPtr("test-pkg"), Type: appconfig.InstallerTypeBrew}, + } +} + +func TestBrewRepoUpdateMode(t *testing.T) { + logger.InitLogger(false) + + t.Run("never mode always suppresses auto-update", func(t *testing.T) { + ResetRepoUpdateTracker() + _ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE") + + inst := newBrewInstallerWithMode(appconfig.RepoUpdateNever) + inst.handleBrewRepoUpdate() + + assert.Equal(t, "1", os.Getenv("HOMEBREW_NO_AUTO_UPDATE")) + + // Cleanup + _ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE") + }) + + t.Run("always mode never suppresses auto-update", func(t *testing.T) { + ResetRepoUpdateTracker() + _ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE") + + inst := newBrewInstallerWithMode(appconfig.RepoUpdateAlways) + + // Even after marking as updated, always mode should not suppress + MarkRepoUpdated("brew") + inst.handleBrewRepoUpdate() + + assert.Empty(t, os.Getenv("HOMEBREW_NO_AUTO_UPDATE")) + }) + + t.Run("once mode lets first through then suppresses", func(t *testing.T) { + ResetRepoUpdateTracker() + _ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE") + + inst := newBrewInstallerWithMode(appconfig.RepoUpdateOnce) + + // First call: brew not yet updated, should NOT suppress + inst.handleBrewRepoUpdate() + assert.Empty(t, os.Getenv("HOMEBREW_NO_AUTO_UPDATE"), "first call should not suppress") + + // Simulate first brew command completing + inst.markBrewRepoUpdated() + assert.True(t, IsRepoUpdated("brew"), "tracker should be set after mark") + + // Second call: brew already updated, should suppress + inst.handleBrewRepoUpdate() + assert.Equal(t, "1", os.Getenv("HOMEBREW_NO_AUTO_UPDATE"), "second call should suppress") + + // Cleanup + _ = os.Unsetenv("HOMEBREW_NO_AUTO_UPDATE") + }) + + t.Run("markBrewRepoUpdated only marks in once mode", func(t *testing.T) { + ResetRepoUpdateTracker() + + instAlways := newBrewInstallerWithMode(appconfig.RepoUpdateAlways) + instAlways.markBrewRepoUpdated() + assert.False(t, IsRepoUpdated("brew"), "should not mark in always mode") + + instNever := newBrewInstallerWithMode(appconfig.RepoUpdateNever) + instNever.markBrewRepoUpdated() + assert.False(t, IsRepoUpdated("brew"), "should not mark in never mode") + + instOnce := newBrewInstallerWithMode(appconfig.RepoUpdateOnce) + instOnce.markBrewRepoUpdated() + assert.True(t, IsRepoUpdated("brew"), "should mark in once mode") + }) +}