feat: Add UpgradeSelect hook, logging and security example (#2855)

* add upgrade hook structure

* remove extra field

* feat: Add UpgradeSelect hook logging and security example

Add yay.log API to UpgradeSelect hooks for informational and warning messages.

- Add yay.log.info() and yay.log.warn() for hook logging
- Add doc/examples/recently_modified.lua: practical example to pre-exclude
  recently-modified AUR packages (supply chain attack mitigation)
- Update doc/init.lua with improved UpgradeSelect example
- Update doc/lua.md with logging API documentation
- Refactor autocmd and load modules for cleaner log integration
- Add comprehensive unit tests for logging functionality

* lint
This commit is contained in:
Jo
2026-06-15 15:25:40 +02:00
committed by GitHub
parent f1f21f1ff8
commit 421b65bf43
13 changed files with 876 additions and 12 deletions

View File

@@ -0,0 +1,16 @@
yay.create_autocmd("UpgradeSelect", {
desc = "skip recently modified AUR upgrades",
callback = function(event)
yay.log.info("pre-excluding AUR packages modified in the last 3 days")
local exclude = {}
local recent_cutoff = os.time() - (3 * 24 * 60 * 60)
for _, pkg in ipairs(event.data.upgrades) do
if pkg.repository == "aur" and pkg.last_modified >= recent_cutoff then
yay.log.warn("pre-excluding recently modified AUR package: ", pkg.name)
table.insert(exclude, pkg.name)
end
end
return { exclude = exclude, skip_menu = false }
end,
})

View File

@@ -52,7 +52,31 @@ yay.opt.debug = false -- Enable debug logging and local init.lua lookup convenie
yay.opt.rpc = true -- Use AUR RPC for dependency/query operations.
yay.opt.double_confirm = true -- Ask for confirmation before and after builds during upgrades.
-- Logging
-- yay.log.info("loaded yay init.lua")
-- yay.log.debug("build dir:", yay.opt.build_dir)
-- Hooks
-- Run Lua before yay prints the upgrade exclusion menu. Return package names
-- from event.data.upgrades to pre-exclude them. Set skip_menu = false, or omit
-- it, to show the native menu after these exclusions are applied.
--
-- yay.create_autocmd("UpgradeSelect", {
-- desc = "skip recently modified AUR upgrades",
-- callback = function(event)
-- local exclude = {}
-- local recent_cutoff = os.time() - (3 * 24 * 60 * 60)
-- for _, pkg in ipairs(event.data.upgrades) do
-- if pkg.repository == "aur" and pkg.last_modified >= recent_cutoff then
-- yay.log.warn("pre-excluding recently modified AUR package:", pkg.name)
-- table.insert(exclude, pkg.name)
-- end
-- end
--
-- return { exclude = exclude, skip_menu = true }
-- end,
-- })
--
-- Run Lua after AUR PKGBUILD repos are downloaded/merged and before the
-- clean/diff/edit menus or source downloads.
--
@@ -60,6 +84,7 @@ yay.opt.double_confirm = true -- Ask for confirmation before and after builds du
-- desc = "inspect or modify AUR package files",
-- callback = function(event)
-- if event.data.pkgbuild:match("forbidden.example") then
-- yay.log.warn(event.match .. ": forbidden source URL")
-- yay.abort(event.match .. ": forbidden source URL")
-- end
--

View File

@@ -48,6 +48,97 @@ startup and reports the offending keys/values so misconfigurations fail fast.
A ready-to-copy example
lives at [`doc/init.lua`](init.lua).
## Logging with `yay.log`
Lua config and hooks can write through yay's normal logger:
```lua
yay.log.debug("build dir:", yay.opt.build_dir)
yay.log.info("loaded init.lua")
yay.log.warn("skipping", "pkgname")
yay.log.error("policy check failed")
```
`debug` only prints when debug logging is enabled. `error` logs an error-level
message and does not stop execution; use `yay.abort("message")` for controlled
hook stops.
## Upgrade selection hooks
`UpgradeSelect` runs during `yay -Syu` after yay has built and sorted the
upgrade graph, and before the native "Packages to exclude" menu is printed.
The hook can return package names to exclude. By default, yay still shows the
native menu after applying hook exclusions.
```lua
yay.create_autocmd("UpgradeSelect", {
desc = "skip recently modified AUR upgrades",
callback = function(event)
local exclude = {}
local recent_cutoff = os.time() - (3 * 24 * 60 * 60)
for _, pkg in ipairs(event.data.upgrades) do
if pkg.repository == "aur" and pkg.last_modified >= recent_cutoff then
yay.log.debug("pre-excluding recently modified AUR package:", pkg.name)
table.insert(exclude, pkg.name)
end
end
return { exclude = exclude, skip_menu = true }
end,
})
```
Multiple `UpgradeSelect` hooks run in registration order. Their `exclude`
lists are unioned. If any hook returns `skip_menu = true`, yay applies all hook
exclusions and skips the native menu. With `skip_menu = false` or no return
value, hook exclusions are applied first and then the native menu is shown.
Returned exclusions must name packages from `event.data.upgrades`. Unknown
names are treated as hook errors so typos do not silently upgrade the wrong
package. Pulled dependencies are visible in `event.data.pulled_dependencies`,
but they are removed only when pruning an excluded upgrade candidate requires
it.
### UpgradeSelect event
The callback receives this table:
```lua
{
event = "UpgradeSelect",
data = {
upgrades = {
{
id = 3,
name = "pkgname",
base = "pkgbase",
repository = "aur",
local_version = "1.2.3-3",
remote_version = "1.2.3-4",
reason = "explicit",
last_modified = 1700000000,
},
},
pulled_dependencies = {
{
id = 0,
name = "depname",
base = "",
repository = "core",
local_version = "",
remote_version = "1.0-1",
reason = "dependency",
last_modified = 0,
},
},
},
}
```
For selectable `data.upgrades` entries, `id` matches the number shown in the
native menu. `pulled_dependencies` entries are shown separately by yay and use
`id = 0` because they are not directly selectable.
## AUR pre-install hooks
`init.lua` can register hooks with a small autocmd API:
@@ -134,6 +225,7 @@ yay.create_autocmd("AURPreInstall", {
desc = "block forbidden sources and patch a PKGBUILD",
callback = function(event)
if event.data.pkgbuild:match("forbidden.example") then
yay.log.warn(event.match .. ": forbidden source URL")
yay.abort(event.match .. ": forbidden source URL")
end

View File

@@ -128,6 +128,10 @@ func main() {
return
}
if luaEngine != nil {
luaEngine.SetLogger(run.Logger.Child("lua"))
}
run.Lua = luaEngine
dbExecutor, err := ialpm.NewExecutor(run.PacmanConf, run.Logger.Child("db"))

View File

@@ -3,10 +3,14 @@ package lua
import (
"fmt"
mapset "github.com/deckarep/golang-set/v2"
glua "github.com/yuin/gopher-lua"
)
const EventAURPreInstall = "AURPreInstall"
const (
EventAURPreInstall = "AURPreInstall"
EventUpgradeSelect = "UpgradeSelect"
)
type Autocmd struct {
Event string
@@ -55,9 +59,30 @@ type AURPreInstallSRCINFO struct {
Replaces []string
}
type UpgradeSelectEvent struct {
Upgrades []UpgradeSelectPackage
PulledDependencies []UpgradeSelectPackage
}
type UpgradeSelectPackage struct {
ID int
Name string
Base string
Repository string
LocalVersion string
RemoteVersion string
Reason string
LastModified int64
}
type UpgradeSelectResult struct {
Exclude []string
SkipMenu bool
}
func (e *Engine) createAutocmd(state *glua.LState) int {
event := state.CheckString(1)
if event != EventAURPreInstall {
if event != EventAURPreInstall && event != EventUpgradeSelect {
state.ArgError(1, fmt.Sprintf("unsupported event %q", event))
return 0
}
@@ -116,6 +141,55 @@ func (e *Engine) RunAURPreInstall(event *AURPreInstallEvent) error {
return nil
}
func (e *Engine) RunUpgradeSelect(event *UpgradeSelectEvent) (UpgradeSelectResult, error) {
var result UpgradeSelectResult
if !e.HasAutocmd(EventUpgradeSelect) {
return result, nil
}
validExcludes := mapset.NewThreadUnsafeSetWithSize[string](len(event.Upgrades))
for _, pkg := range event.Upgrades {
validExcludes.Add(pkg.Name)
}
seenExcludes := mapset.NewThreadUnsafeSet[string]()
for _, autocmd := range e.autocmds[EventUpgradeSelect] {
if err := e.L.CallByParam(glua.P{
Fn: autocmd.callback,
NRet: 1,
Protect: true,
}, e.upgradeSelectTable(event)); err != nil {
wrapped := err
if abortErr, ok := luaAbortError(err); ok {
wrapped = abortErr
}
return result, fmt.Errorf("%s: %w", EventUpgradeSelect, wrapped)
}
value := e.L.Get(-1)
e.L.Pop(1)
hookResult, err := e.parseUpgradeSelectResult(value, validExcludes)
if err != nil {
return result, fmt.Errorf("%s: %w", EventUpgradeSelect, err)
}
for _, name := range hookResult.Exclude {
if !seenExcludes.Add(name) {
continue
}
result.Exclude = append(result.Exclude, name)
}
if hookResult.SkipMenu {
result.SkipMenu = true
}
}
return result, nil
}
func (e *Engine) aurPreInstallTable(event *AURPreInstallEvent) *glua.LTable {
state := e.L
eventTable := state.NewTable()
@@ -139,6 +213,20 @@ func (e *Engine) aurPreInstallTable(event *AURPreInstallEvent) *glua.LTable {
return eventTable
}
func (e *Engine) upgradeSelectTable(event *UpgradeSelectEvent) *glua.LTable {
state := e.L
eventTable := state.NewTable()
data := state.NewTable()
eventTable.RawSetString("event", glua.LString(EventUpgradeSelect))
eventTable.RawSetString("data", data)
data.RawSetString("upgrades", e.upgradeSelectPackagesTable(event.Upgrades))
data.RawSetString("pulled_dependencies", e.upgradeSelectPackagesTable(event.PulledDependencies))
return eventTable
}
func (e *Engine) packagesTable(packages []AURPreInstallPackage) *glua.LTable {
state := e.L
tbl := state.NewTable()
@@ -157,6 +245,26 @@ func (e *Engine) packagesTable(packages []AURPreInstallPackage) *glua.LTable {
return tbl
}
func (e *Engine) upgradeSelectPackagesTable(packages []UpgradeSelectPackage) *glua.LTable {
state := e.L
tbl := state.NewTable()
for _, pkg := range packages {
pkgTbl := state.NewTable()
pkgTbl.RawSetString("id", glua.LNumber(pkg.ID))
pkgTbl.RawSetString("name", glua.LString(pkg.Name))
pkgTbl.RawSetString("base", glua.LString(pkg.Base))
pkgTbl.RawSetString("repository", glua.LString(pkg.Repository))
pkgTbl.RawSetString("local_version", glua.LString(pkg.LocalVersion))
pkgTbl.RawSetString("remote_version", glua.LString(pkg.RemoteVersion))
pkgTbl.RawSetString("reason", glua.LString(pkg.Reason))
pkgTbl.RawSetString("last_modified", glua.LNumber(pkg.LastModified))
tbl.Append(pkgTbl)
}
return tbl
}
func (e *Engine) srcinfoTable(srcinfo *AURPreInstallSRCINFO) *glua.LTable {
state := e.L
tbl := state.NewTable()
@@ -181,6 +289,59 @@ func (e *Engine) srcinfoTable(srcinfo *AURPreInstallSRCINFO) *glua.LTable {
return tbl
}
func (e *Engine) parseUpgradeSelectResult(value glua.LValue, validExcludes mapset.Set[string]) (UpgradeSelectResult, error) {
var result UpgradeSelectResult
if value == glua.LNil {
return result, nil
}
tbl, ok := value.(*glua.LTable)
if !ok {
return result, fmt.Errorf("callback must return nil or table, got %s", value.Type())
}
if excludeValue := tbl.RawGetString("exclude"); excludeValue != glua.LNil {
excludeTbl, ok := excludeValue.(*glua.LTable)
if !ok {
return result, fmt.Errorf("exclude must be a table")
}
var parseErr error
excludeTbl.ForEach(func(_ glua.LValue, val glua.LValue) {
if parseErr != nil {
return
}
name, ok := val.(glua.LString)
if !ok {
parseErr = fmt.Errorf("exclude entries must be strings")
return
}
if !validExcludes.Contains(string(name)) {
parseErr = fmt.Errorf("unknown upgrade exclusion %q", string(name))
return
}
result.Exclude = append(result.Exclude, string(name))
})
if parseErr != nil {
return result, parseErr
}
}
if skipMenuValue := tbl.RawGetString("skip_menu"); skipMenuValue != glua.LNil {
skipMenu, ok := skipMenuValue.(glua.LBool)
if !ok {
return result, fmt.Errorf("skip_menu must be a boolean")
}
result.SkipMenu = bool(skipMenu)
}
return result, nil
}
func (e *Engine) stringArray(values []string) *glua.LTable {
tbl := e.L.NewTable()
for _, value := range values {

View File

@@ -48,6 +48,23 @@ func TestCreateAutocmdRegistersAndRunsInOrder(t *testing.T) {
require.Equal(t, []string{"first:demo-base:demo:demo-base", "second"}, order)
}
func TestCreateAutocmdRegistersUpgradeSelect(t *testing.T) {
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("UpgradeSelect", {
desc = "filter upgrades",
callback = function() end,
})
`))
autocmds := e.autocmds[EventUpgradeSelect]
require.Len(t, autocmds, 1)
require.Equal(t, "filter upgrades", autocmds[0].Desc)
require.True(t, e.HasAutocmd(EventUpgradeSelect))
}
func TestCreateAutocmdRejectsInvalidEvent(t *testing.T) {
e := New()
defer e.Close()
@@ -107,3 +124,131 @@ func TestRunAURPreInstallReturnsAbortWithoutTraceback(t *testing.T) {
err := e.RunAURPreInstall(&AURPreInstallEvent{Base: "demo-base"})
require.EqualError(t, err, "AURPreInstall demo-base: blocked by policy")
}
func TestRunUpgradeSelectEventTableShapeAndReturn(t *testing.T) {
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("UpgradeSelect", {
callback = function(event)
if event.event ~= "UpgradeSelect" then error("bad event") end
if event.data.upgrades[1].id ~= 2 then error("bad upgrade id") end
if event.data.upgrades[1].name ~= "linux" then error("bad upgrade name") end
if event.data.upgrades[1].base ~= "linux" then error("bad upgrade base") end
if event.data.upgrades[1].repository ~= "core" then error("bad upgrade repo") end
if event.data.upgrades[1].local_version ~= "1.0" then error("bad local version") end
if event.data.upgrades[1].remote_version ~= "2.0" then error("bad remote version") end
if event.data.upgrades[1].reason ~= "explicit" then error("bad reason") end
if event.data.upgrades[1].last_modified ~= 123 then error("bad last modified") end
if event.data.upgrades[2].id ~= 1 then error("bad second upgrade id") end
if event.data.pulled_dependencies[1].id ~= 0 then error("bad dependency id") end
if event.data.pulled_dependencies[1].name ~= "new-dep" then error("bad dependency name") end
return { exclude = { "linux" }, skip_menu = true }
end,
})
`))
result, err := e.RunUpgradeSelect(&UpgradeSelectEvent{
Upgrades: []UpgradeSelectPackage{
{
ID: 2,
Name: "linux",
Base: "linux",
Repository: "core",
LocalVersion: "1.0",
RemoteVersion: "2.0",
Reason: "explicit",
LastModified: 123,
},
{ID: 1, Name: "yay", Base: "yay", Repository: "aur"},
},
PulledDependencies: []UpgradeSelectPackage{
{ID: 0, Name: "new-dep", Repository: "core", Reason: "dependency"},
},
})
require.NoError(t, err)
require.Equal(t, UpgradeSelectResult{Exclude: []string{"linux"}, SkipMenu: true}, result)
}
func TestRunUpgradeSelectNilReturnMeansNoExclusionsAndNoSkip(t *testing.T) {
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("UpgradeSelect", {
callback = function() end,
})
`))
result, err := e.RunUpgradeSelect(&UpgradeSelectEvent{
Upgrades: []UpgradeSelectPackage{{Name: "linux"}},
})
require.NoError(t, err)
require.Empty(t, result.Exclude)
require.False(t, result.SkipMenu)
}
func TestRunUpgradeSelectMultipleHooksUnionExclusionsAndSkip(t *testing.T) {
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("UpgradeSelect", {
callback = function()
return { exclude = { "pkg-a" } }
end,
})
yay.create_autocmd("UpgradeSelect", {
callback = function()
return { exclude = { "pkg-b", "pkg-a" }, skip_menu = true }
end,
})
`))
result, err := e.RunUpgradeSelect(&UpgradeSelectEvent{
Upgrades: []UpgradeSelectPackage{{Name: "pkg-a"}, {Name: "pkg-b"}},
})
require.NoError(t, err)
require.Equal(t, []string{"pkg-a", "pkg-b"}, result.Exclude)
require.True(t, result.SkipMenu)
}
func TestRunUpgradeSelectRejectsUnknownExcludedPackage(t *testing.T) {
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("UpgradeSelect", {
callback = function()
return { exclude = { "typo" } }
end,
})
`))
_, err := e.RunUpgradeSelect(&UpgradeSelectEvent{
Upgrades: []UpgradeSelectPackage{{Name: "pkg-a"}},
})
require.Error(t, err)
require.Contains(t, err.Error(), "UpgradeSelect")
require.Contains(t, err.Error(), `unknown upgrade exclusion "typo"`)
}
func TestRunUpgradeSelectReturnsAbortWithoutTraceback(t *testing.T) {
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("UpgradeSelect", {
callback = function()
yay.abort("blocked by policy")
end,
})
`))
_, err := e.RunUpgradeSelect(&UpgradeSelectEvent{
Upgrades: []UpgradeSelectPackage{{Name: "pkg-a"}},
})
require.EqualError(t, err, "UpgradeSelect: blocked by policy")
}

View File

@@ -8,8 +8,13 @@ import (
)
// Load loads path, applies its yay.opt values onto cfg, and returns the live engine.
func Load(_ *text.Logger, path string, cfg any) (*Engine, error) {
engine := New()
func Load(logger *text.Logger, path string, cfg any) (*Engine, error) {
var luaLogger *text.Logger
if logger != nil {
luaLogger = logger.Child("lua")
}
engine := NewWithLogger(luaLogger)
if err := engine.L.DoFile(path); err != nil {
engine.Close()

37
pkg/settings/lua/log.go Normal file
View File

@@ -0,0 +1,37 @@
package lua
import (
"github.com/Jguer/yay/v12/pkg/text"
glua "github.com/yuin/gopher-lua"
)
const logTableName = "log"
func (e *Engine) registerLog(yayTbl *glua.LTable) {
logTbl := e.L.NewTable()
e.L.SetField(logTbl, "debug", e.L.NewFunction(e.newLogFn((*text.Logger).Debugln)))
e.L.SetField(logTbl, "info", e.L.NewFunction(e.newLogFn((*text.Logger).Infoln)))
e.L.SetField(logTbl, "warn", e.L.NewFunction(e.newLogFn((*text.Logger).Warnln)))
e.L.SetField(logTbl, "error", e.L.NewFunction(e.newLogFn((*text.Logger).Errorln)))
e.L.SetField(yayTbl, logTableName, logTbl)
}
func (e *Engine) newLogFn(method func(*text.Logger, ...any)) glua.LGFunction {
return func(state *glua.LState) int {
if e.logger != nil {
method(e.logger, logArgs(state)...)
}
return 0
}
}
func logArgs(state *glua.LState) []any {
top := state.GetTop()
args := make([]any, top)
for i := 1; i <= top; i++ {
args[i-1] = state.ToStringMeta(state.Get(i)).String()
}
return args
}

View File

@@ -0,0 +1,102 @@
package lua
import (
"bytes"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/Jguer/yay/v12/pkg/text"
)
func withColorDisabled(t *testing.T) {
t.Helper()
original := text.UseColor
text.UseColor = false
t.Cleanup(func() { text.UseColor = original })
}
func newLogTestEngine(t *testing.T, debug bool) (*Engine, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
withColorDisabled(t)
var stdout, stderr bytes.Buffer
logger := text.NewLogger(&stdout, &stderr, strings.NewReader(""), debug, "lua")
engine := NewWithLogger(logger)
t.Cleanup(engine.Close)
return engine, &stdout, &stderr
}
func TestLogInfoWarnAndErrorUseLoggerStreams(t *testing.T) {
engine, stdout, stderr := newLogTestEngine(t, false)
require.NoError(t, engine.L.DoString(`
yay.log.info("info message")
yay.log.warn("warn message")
yay.log.error("error message")
`))
require.Equal(t, "==> info message\n -> warn message\n", stdout.String())
require.Equal(t, " -> error message\n", stderr.String())
}
func TestLogDebugUsesLoggerDebugGate(t *testing.T) {
engine, stdout, stderr := newLogTestEngine(t, false)
require.NoError(t, engine.L.DoString(`yay.log.debug("hidden")`))
require.Empty(t, stdout.String())
require.Empty(t, stderr.String())
debugLogger := text.NewLogger(stdout, stderr, strings.NewReader(""), true, "lua")
engine.SetLogger(debugLogger)
require.NoError(t, engine.L.DoString(`yay.log.debug("visible")`))
require.Equal(t, "[DEBUG:lua]visible\n", stdout.String())
require.Empty(t, stderr.String())
}
func TestLogStringifiesLuaArgumentsInOrder(t *testing.T) {
engine, stdout, stderr := newLogTestEngine(t, false)
require.NoError(t, engine.L.DoString(`
local value = setmetatable({}, {
__tostring = function()
return "custom"
end,
})
yay.log.info("pkg", 12, true, nil, value)
`))
require.Equal(t, "==> pkg 12 true nil custom\n", stdout.String())
require.Empty(t, stderr.String())
}
func TestLogWithoutLoggerDoesNotPanic(t *testing.T) {
engine := New()
t.Cleanup(engine.Close)
require.NoError(t, engine.L.DoString(`
yay.log.debug("debug")
yay.log.info("info")
yay.log.warn("warn")
yay.log.error("error")
`))
}
func TestLoadUsesLuaChildLogger(t *testing.T) {
withColorDisabled(t)
path := writeLuaFile(t, `yay.log.debug("loaded")`)
var stdout, stderr bytes.Buffer
logger := text.NewLogger(&stdout, &stderr, strings.NewReader(""), true, "fallback")
engine, err := Load(logger, path, &testConfig{})
require.NoError(t, err)
t.Cleanup(engine.Close)
require.Equal(t, "[DEBUG:lua]loaded\n", stdout.String())
require.Empty(t, stderr.String())
}

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"reflect"
"github.com/Jguer/yay/v12/pkg/text"
lua "github.com/yuin/gopher-lua"
)
@@ -16,13 +18,19 @@ const (
type Engine struct {
L *lua.LState
autocmds map[string][]Autocmd
logger *text.Logger
}
func New() *Engine {
return NewWithLogger(nil)
}
func NewWithLogger(logger *text.Logger) *Engine {
state := lua.NewState()
engine := &Engine{
L: state,
autocmds: make(map[string][]Autocmd),
logger: logger,
}
yayTbl := state.NewTable()
@@ -30,10 +38,15 @@ func New() *Engine {
state.SetField(yayTbl, optTableName, state.NewTable())
state.SetField(yayTbl, "abort", state.NewFunction(abort))
state.SetField(yayTbl, "create_autocmd", state.NewFunction(engine.createAutocmd))
engine.registerLog(yayTbl)
return engine
}
func (e *Engine) SetLogger(logger *text.Logger) {
e.logger = logger
}
func (e *Engine) Close() {
e.L.Close()
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/Jguer/yay/v12/pkg/multierror"
"github.com/Jguer/yay/v12/pkg/query"
"github.com/Jguer/yay/v12/pkg/settings"
settingslua "github.com/Jguer/yay/v12/pkg/settings/lua"
"github.com/Jguer/yay/v12/pkg/text"
"github.com/Jguer/yay/v12/pkg/vcs"
)
@@ -31,6 +32,7 @@ type UpgradeService struct {
vcsStore vcs.Store
cfg *settings.Configuration
log *text.Logger
lua *settingslua.Engine
noConfirm bool
AURWarnings *query.AURWarnings
@@ -52,6 +54,10 @@ func NewUpgradeService(grapher *dep.Grapher, aurCache aur.QueryClient,
}
}
func (u *UpgradeService) SetLua(engine *settingslua.Engine) {
u.lua = engine
}
// upGraph adds packages to upgrade to the graph.
func (u *UpgradeService) upGraph(ctx context.Context, graph *topo.Graph[string, *dep.InstallInfo],
enableDowngrade bool,
@@ -252,12 +258,7 @@ func (u *UpgradeService) GraphUpgrades(ctx context.Context,
return graph, nil
}
// userExcludeUpgrades asks the user which packages to exclude from the upgrade and
// removes them from the graph
func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.InstallInfo]) ([]string, error) {
if graph.Len() == 0 {
return []string{}, nil
}
func (u *UpgradeService) upgradeSelection(graph *topo.Graph[string, *dep.InstallInfo]) UpSlice {
aurUp, repoUp := u.graphToUpSlice(graph)
sort.Sort(repoUp)
@@ -280,6 +281,42 @@ func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.Inst
}
}
return allUp
}
func (u *UpgradeService) runUpgradeSelectHook(graph *topo.Graph[string, *dep.InstallInfo], allUp UpSlice) (
excluded []string, skipMenu bool, err error,
) {
if u.lua == nil || !u.lua.HasAutocmd(settingslua.EventUpgradeSelect) {
return []string{}, false, nil
}
var result settingslua.UpgradeSelectResult
result, err = u.lua.RunUpgradeSelect(upgradeSelectEvent(allUp))
if err != nil {
return nil, false, err
}
excluded = u.pruneUpgradeNames(graph, result.Exclude)
return excluded, result.SkipMenu, nil
}
func (u *UpgradeService) pruneUpgradeNames(graph *topo.Graph[string, *dep.InstallInfo], names []string) []string {
excluded := make([]string, 0, len(names))
for _, name := range names {
if !graph.Exists(name) {
continue
}
u.log.Debugln("pruning", name)
excluded = append(excluded, graph.Prune(name)...)
}
return excluded
}
func (u *UpgradeService) printUpgradeSelection(allUp UpSlice) {
if len(allUp.PulledDeps) > 0 {
u.log.Printf("%s"+text.Bold(" %d ")+"%s\n", text.Bold(text.Cyan("::")),
len(allUp.PulledDeps), text.Bold(gotext.Get("%s will also be installed for this operation.",
@@ -290,6 +327,33 @@ func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.Inst
u.log.Printf("%s"+text.Bold(" %d ")+"%s\n", text.Bold(text.Cyan("::")),
len(allUp.Up), text.Bold(gotext.Get("%s to upgrade/install.", gotext.GetN("package", "packages", len(allUp.Up)))))
allUp.Print(u.log)
}
// userExcludeUpgrades asks the user which packages to exclude from the upgrade and
// removes them from the graph
func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.InstallInfo]) ([]string, error) {
if graph.Len() == 0 {
return []string{}, nil
}
allUp := u.upgradeSelection(graph)
excluded, skipMenu, err := u.runUpgradeSelectHook(graph, allUp)
if err != nil {
return nil, err
}
if skipMenu {
return excluded, nil
}
if len(excluded) > 0 {
// The hook pruned packages; refresh the selection to reflect the
// smaller graph and skip the menu if nothing is left to upgrade.
allUp = u.upgradeSelection(graph)
if len(allUp.Up) == 0 {
return excluded, nil
}
}
u.printUpgradeSelection(allUp)
u.log.Infoln(gotext.Get("Packages to exclude: (eg: \"1 2 3\", \"1-3\", \"^4\" or repo name)"))
u.log.Warnln(gotext.Get("Excluding packages may cause partial upgrades and break systems"))
@@ -308,10 +372,9 @@ func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.Inst
// No exclusions or inclusions specified, return early
if noIncludes && len(exclude) == 0 && otherExclude.Cardinality() == 0 {
return []string{}, nil
return excluded, nil
}
excluded := make([]string, 0)
for i := range allUp.Up {
up := &allUp.Up[i]
upgradeID := len(allUp.Up) - i
@@ -338,3 +401,45 @@ func (u *UpgradeService) UserExcludeUpgrades(graph *topo.Graph[string, *dep.Inst
return excluded, nil
}
func upgradeSelectEvent(allUp UpSlice) *settingslua.UpgradeSelectEvent {
return &settingslua.UpgradeSelectEvent{
Upgrades: upgradeSelectPackages(allUp.Up, true),
PulledDependencies: upgradeSelectPackages(allUp.PulledDeps, false),
}
}
func upgradeSelectPackages(upgrades []Upgrade, selectable bool) []settingslua.UpgradeSelectPackage {
packages := make([]settingslua.UpgradeSelectPackage, 0, len(upgrades))
for i := range upgrades {
up := &upgrades[i]
id := 0
if selectable {
id = len(upgrades) - i
}
packages = append(packages, settingslua.UpgradeSelectPackage{
ID: id,
Name: up.Name,
Base: up.Base,
Repository: up.Repository,
LocalVersion: up.LocalVersion,
RemoteVersion: up.RemoteVersion,
Reason: upgradeSelectReason(up.Reason),
LastModified: up.LastModified,
})
}
return packages
}
func upgradeSelectReason(reason alpm.PkgReason) string {
switch reason {
case alpm.PkgReasonExplicit:
return "explicit"
case alpm.PkgReasonDepend:
return "dependency"
default:
return "unknown"
}
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/Jguer/yay/v12/pkg/dep/topo"
"github.com/Jguer/yay/v12/pkg/query"
"github.com/Jguer/yay/v12/pkg/settings"
settingslua "github.com/Jguer/yay/v12/pkg/settings/lua"
"github.com/Jguer/yay/v12/pkg/settings/parser"
"github.com/Jguer/yay/v12/pkg/text"
"github.com/Jguer/yay/v12/pkg/vcs"
@@ -32,6 +33,77 @@ func ptrString(s string) *string {
return &s
}
func newUpgradeSelectTestService(input io.Reader, luaEngine *settingslua.Engine) *UpgradeService {
logger := text.NewLogger(io.Discard, io.Discard, input, true, "test")
u := &UpgradeService{
log: logger,
dbExecutor: &mock.DBExecutor{
ReposFn: func() []string { return []string{"core"} },
},
cfg: &settings.Configuration{Mode: parser.ModeAny},
AURWarnings: query.NewWarnings(logger),
}
u.SetLua(luaEngine)
return u
}
func newUpgradeSelectTestGraph(t *testing.T) *topo.Graph[string, *dep.InstallInfo] {
t.Helper()
graph := dep.NewGraph()
graph.AddNode("linux")
graph.SetNodeInfo("linux", &topo.NodeInfo[*dep.InstallInfo]{
Value: &dep.InstallInfo{
Reason: dep.Explicit,
Source: dep.Sync,
SyncDBName: ptrString("core"),
LocalVersion: "1.0",
Version: "2.0",
Upgrade: true,
},
})
graph.AddNode("yay")
graph.SetNodeInfo("yay", &topo.NodeInfo[*dep.InstallInfo]{
Value: &dep.InstallInfo{
Reason: dep.Explicit,
Source: dep.AUR,
AURBase: ptrString("yay"),
LocalVersion: "1.0",
Version: "2.0",
Upgrade: true,
LastModified: 123,
},
})
graph.AddNode("example-git")
graph.AddNode("new-dep")
require.NoError(t, graph.DependOn("example-git", "new-dep"))
graph.SetNodeInfo("example-git", &topo.NodeInfo[*dep.InstallInfo]{
Value: &dep.InstallInfo{
Reason: dep.Explicit,
Source: dep.AUR,
AURBase: ptrString("example"),
LocalVersion: "1.0",
Version: "2.0",
Upgrade: true,
LastModified: 456,
},
})
graph.SetNodeInfo("new-dep", &topo.NodeInfo[*dep.InstallInfo]{
Value: &dep.InstallInfo{
Reason: dep.Dep,
Source: dep.Sync,
SyncDBName: ptrString("core"),
Version: "1.0",
Upgrade: true,
},
})
return graph
}
func TestUpgradeService_GraphUpgrades(t *testing.T) {
t.Parallel()
linuxDepInfo := &dep.InstallInfo{
@@ -686,6 +758,92 @@ func TestUpgradeService_GraphUpgradesNoUpdates(t *testing.T) {
}
}
func TestUpgradeService_UserExcludeUpgradesWithoutLuaHookUsesNativeMenu(t *testing.T) {
graph := newUpgradeSelectTestGraph(t)
u := newUpgradeSelectTestService(strings.NewReader("2\n"), nil)
excluded, err := u.UserExcludeUpgrades(graph)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"example-git", "new-dep"}, excluded)
assert.True(t, graph.Exists("linux"))
assert.True(t, graph.Exists("yay"))
assert.False(t, graph.Exists("example-git"))
assert.False(t, graph.Exists("new-dep"))
}
func TestUpgradeService_UserExcludeUpgradesLuaHookPrunesGraph(t *testing.T) {
engine := settingslua.New()
defer engine.Close()
require.NoError(t, engine.L.DoString(`
yay.create_autocmd("UpgradeSelect", {
callback = function()
return { exclude = { "example-git" } }
end,
})
`))
graph := newUpgradeSelectTestGraph(t)
u := newUpgradeSelectTestService(strings.NewReader("\n"), engine)
excluded, err := u.UserExcludeUpgrades(graph)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"example-git", "new-dep"}, excluded)
assert.True(t, graph.Exists("linux"))
assert.True(t, graph.Exists("yay"))
assert.False(t, graph.Exists("example-git"))
assert.False(t, graph.Exists("new-dep"))
}
func TestUpgradeService_UserExcludeUpgradesLuaHookSkipMenuAvoidsInput(t *testing.T) {
engine := settingslua.New()
defer engine.Close()
require.NoError(t, engine.L.DoString(`
yay.create_autocmd("UpgradeSelect", {
callback = function()
return { exclude = { "yay" }, skip_menu = true }
end,
})
`))
graph := newUpgradeSelectTestGraph(t)
u := newUpgradeSelectTestService(strings.NewReader(""), engine)
excluded, err := u.UserExcludeUpgrades(graph)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"yay"}, excluded)
assert.True(t, graph.Exists("linux"))
assert.True(t, graph.Exists("example-git"))
assert.True(t, graph.Exists("new-dep"))
assert.False(t, graph.Exists("yay"))
}
func TestUpgradeService_UserExcludeUpgradesLuaHookFallsThroughToNativeMenu(t *testing.T) {
engine := settingslua.New()
defer engine.Close()
require.NoError(t, engine.L.DoString(`
yay.create_autocmd("UpgradeSelect", {
callback = function()
return { exclude = { "example-git" }, skip_menu = false }
end,
})
`))
graph := newUpgradeSelectTestGraph(t)
u := newUpgradeSelectTestService(strings.NewReader("1\n"), engine)
excluded, err := u.UserExcludeUpgrades(graph)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"example-git", "new-dep", "yay"}, excluded)
assert.True(t, graph.Exists("linux"))
assert.False(t, graph.Exists("yay"))
assert.False(t, graph.Exists("example-git"))
assert.False(t, graph.Exists("new-dep"))
}
func TestUpgradeService_Warnings(t *testing.T) {
t.Parallel()
dbExe := &mock.DBExecutor{

View File

@@ -58,6 +58,7 @@ func syncInstall(ctx context.Context,
upService := upgrade.NewUpgradeService(
grapher, aurCache, dbExecutor, run.VCSStore,
run.Cfg, settings.NoConfirm, run.Logger.Child("upgrade"))
upService.SetLua(run.Lua)
graph, errSysUp = upService.GraphUpgrades(ctx,
graph, cmdArgs.ExistsDouble("u", "sysupgrade"),