feat: add repo_update control modes

This commit is contained in:
2026-03-31 23:51:49 +03:00
parent dcd46a8499
commit 8db27e298c
8 changed files with 290 additions and 16 deletions

View File

@@ -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. |

View File

@@ -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.

View File

@@ -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()

View File

@@ -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:

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")
})
}