瀏覽代碼

fix(new_engine): Improve partial upgrade protection and pinned deps (#1945)

* fix dep graph, existing in graph

* do not change from same dep reason

* roll up layer installs in case of fail

* re-use pacman exclude mechanism

should finish the reimplementation of the missing guards from the legacy
engine.

* include update in debug log

* test rollups
Jo 2 年之前
父節點
當前提交
8b8d6001a4
共有 12 個文件被更改,包括 785 次插入86 次删除
  1. 45 10
      aur_install.go
  2. 57 24
      aur_install_test.go
  3. 12 12
      cmd.go
  4. 5 5
      install.go
  5. 1 1
      local_install.go
  6. 4 0
      pkg/db/mock/executor.go
  7. 5 4
      pkg/dep/dep_graph.go
  8. 5 3
      pkg/topo/dep.go
  9. 12 11
      pkg/upgrade/service.go
  10. 10 1
      pkg/upgrade/service_test.go
  11. 17 15
      sync.go
  12. 612 0
      sync_test.go

+ 45 - 10
aur_install.go

@@ -28,12 +28,13 @@ type (
 		vcsStore         vcs.Store
 		targetMode       parser.TargetMode
 		downloadOnly     bool
+		log              *text.Logger
 	}
 )
 
 func NewInstaller(dbExecutor db.Executor,
 	exeCmd exe.ICmdBuilder, vcsStore vcs.Store, targetMode parser.TargetMode,
-	downloadOnly bool,
+	downloadOnly bool, logger *text.Logger,
 ) *Installer {
 	return &Installer{
 		dbExecutor:       dbExecutor,
@@ -43,6 +44,7 @@ func NewInstaller(dbExecutor db.Executor,
 		vcsStore:         vcsStore,
 		targetMode:       targetMode,
 		downloadOnly:     downloadOnly,
+		log:              logger,
 	}
 }
 
@@ -80,17 +82,39 @@ func (installer *Installer) Install(ctx context.Context,
 	cmdArgs *parser.Arguments,
 	targets []map[string]*dep.InstallInfo,
 	pkgBuildDirs map[string]string,
+	excluded []string,
 ) error {
 	// Reorganize targets into layers of dependencies
+	var errMulti multierror.MultiError
 	for i := len(targets) - 1; i >= 0; i-- {
-		err := installer.handleLayer(ctx, cmdArgs, targets[i], pkgBuildDirs, i == 0)
-		if err != nil {
-			// rollback
-			return err
+		lastLayer := i == 0
+		errI := installer.handleLayer(ctx, cmdArgs, targets[i], pkgBuildDirs, lastLayer, excluded)
+		if errI == nil && lastLayer {
+			// success after rollups
+			return nil
+		}
+
+		if errI != nil {
+			errMulti.Add(errI)
+			if lastLayer {
+				break
+			}
+
+			// rollup
+			installer.log.Warnln(gotext.Get("Failed to install layer, rolling up to next layer."), "error:", errI)
+			targets[i-1] = mergeLayers(targets[i-1], targets[i])
 		}
 	}
 
-	return nil
+	return errMulti.Return()
+}
+
+func mergeLayers(layer1, layer2 map[string]*dep.InstallInfo) map[string]*dep.InstallInfo {
+	for name, info := range layer2 {
+		layer1[name] = info
+	}
+
+	return layer1
 }
 
 func (installer *Installer) handleLayer(ctx context.Context,
@@ -98,12 +122,14 @@ func (installer *Installer) handleLayer(ctx context.Context,
 	layer map[string]*dep.InstallInfo,
 	pkgBuildDirs map[string]string,
 	lastLayer bool,
+	excluded []string,
 ) error {
 	// Install layer
 	nameToBaseMap := make(map[string]string, 0)
 	syncDeps, syncExp := mapset.NewThreadUnsafeSet[string](), mapset.NewThreadUnsafeSet[string]()
 	aurDeps, aurExp := mapset.NewThreadUnsafeSet[string](), mapset.NewThreadUnsafeSet[string]()
 
+	upgradeSync := false
 	for name, info := range layer {
 		switch info.Source {
 		case dep.AUR, dep.SrcInfo:
@@ -120,6 +146,10 @@ func (installer *Installer) handleLayer(ctx context.Context,
 				aurDeps.Add(name)
 			}
 		case dep.Sync:
+			if info.Upgrade {
+				upgradeSync = true
+				continue // do not add to targets, let pacman handle it
+			}
 			compositePkgName := fmt.Sprintf("%s/%s", *info.SyncDBName, name)
 
 			switch info.Reason {
@@ -135,9 +165,10 @@ func (installer *Installer) handleLayer(ctx context.Context,
 		}
 	}
 
-	text.Debugln("syncDeps", syncDeps, "SyncExp", syncExp, "aurDeps", aurDeps, "aurExp", aurExp)
+	text.Debugln("syncDeps", syncDeps, "SyncExp", syncExp,
+		"aurDeps", aurDeps, "aurExp", aurExp, "upgrade", upgradeSync)
 
-	errShow := installer.installSyncPackages(ctx, cmdArgs, syncDeps, syncExp)
+	errShow := installer.installSyncPackages(ctx, cmdArgs, syncDeps, syncExp, excluded, upgradeSync)
 	if errShow != nil {
 		return ErrInstallRepoPkgs
 	}
@@ -326,9 +357,11 @@ func (installer *Installer) getNewTargets(pkgdests map[string]string, name strin
 func (installer *Installer) installSyncPackages(ctx context.Context, cmdArgs *parser.Arguments,
 	syncDeps, // repo targets that are deps
 	syncExp mapset.Set[string], // repo targets that are exp
+	excluded []string,
+	upgrade bool, // run even without targets
 ) error {
 	repoTargets := syncDeps.Union(syncExp).ToSlice()
-	if len(repoTargets) == 0 {
+	if len(repoTargets) == 0 && !upgrade {
 		return nil
 	}
 
@@ -336,10 +369,12 @@ func (installer *Installer) installSyncPackages(ctx context.Context, cmdArgs *pa
 	arguments.DelArg("asdeps", "asdep")
 	arguments.DelArg("asexplicit", "asexp")
 	arguments.DelArg("i", "install")
-	arguments.DelArg("u", "upgrade")
 	arguments.Op = "S"
 	arguments.ClearTargets()
 	arguments.AddTarget(repoTargets...)
+	if len(excluded) > 0 {
+		arguments.CreateOrAppendOption("ignore", excluded...)
+	}
 
 	errShow := installer.exeCmd.Show(installer.exeCmd.BuildPacmanCmd(ctx,
 		arguments, installer.targetMode, settings.NoConfirm))

+ 57 - 24
aur_install_test.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"os"
 	"os/exec"
 	"strings"
@@ -16,9 +17,12 @@ import (
 	"github.com/Jguer/yay/v11/pkg/dep"
 	"github.com/Jguer/yay/v11/pkg/settings/exe"
 	"github.com/Jguer/yay/v11/pkg/settings/parser"
+	"github.com/Jguer/yay/v11/pkg/text"
 	"github.com/Jguer/yay/v11/pkg/vcs"
 )
 
+var testLogger = text.NewLogger(io.Discard, strings.NewReader(""), true, "test")
+
 func ptrString(s string) *string {
 	return &s
 }
@@ -127,7 +131,7 @@ func TestInstaller_InstallNeeded(t *testing.T) {
 
 			cmdBuilder.Runner = mockRunner
 
-			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false)
+			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false, testLogger)
 
 			cmdArgs := parser.MakeArguments()
 			cmdArgs.AddArg("needed")
@@ -149,7 +153,7 @@ func TestInstaller_InstallNeeded(t *testing.T) {
 				},
 			}
 
-			errI := installer.Install(context.Background(), cmdArgs, targets, pkgBuildDirs)
+			errI := installer.Install(context.Background(), cmdArgs, targets, pkgBuildDirs, []string{})
 			require.NoError(td, errI)
 
 			require.Len(td, mockRunner.ShowCalls, len(tc.wantShow))
@@ -401,7 +405,7 @@ func TestInstaller_InstallMixedSourcesAndLayers(t *testing.T) {
 
 			cmdBuilder.Runner = mockRunner
 
-			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false)
+			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false, testLogger)
 
 			cmdArgs := parser.MakeArguments()
 			cmdArgs.AddTarget("yay")
@@ -411,7 +415,7 @@ func TestInstaller_InstallMixedSourcesAndLayers(t *testing.T) {
 				"jellyfin": tmpDirJfin,
 			}
 
-			errI := installer.Install(context.Background(), cmdArgs, tc.targets, pkgBuildDirs)
+			errI := installer.Install(context.Background(), cmdArgs, tc.targets, pkgBuildDirs, []string{})
 			require.NoError(td, errI)
 
 			require.Len(td, mockRunner.ShowCalls, len(tc.wantShow))
@@ -454,7 +458,7 @@ func TestInstaller_RunPostHooks(t *testing.T) {
 
 	cmdBuilder.Runner = mockRunner
 
-	installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false)
+	installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false, testLogger)
 
 	called := false
 	hook := func(ctx context.Context) error {
@@ -482,17 +486,41 @@ func TestInstaller_CompileFailed(t *testing.T) {
 	require.NoError(t, f.Close())
 
 	type testCase struct {
-		desc      string
-		targets   []map[string]*dep.InstallInfo
-		lastLayer bool
+		desc           string
+		targets        []map[string]*dep.InstallInfo
+		wantErrInstall bool
+		wantErrCompile bool
+		failBuild      bool
+		failPkgInstall bool
 	}
 
 	tmpDir := t.TempDir()
 
 	testCases := []testCase{
 		{
-			desc:      "last layer",
-			lastLayer: true,
+			desc:           "one layer",
+			wantErrInstall: false,
+			wantErrCompile: true,
+			failBuild:      true,
+			failPkgInstall: false,
+			targets: []map[string]*dep.InstallInfo{
+				{
+					"yay": {
+						Source:      dep.AUR,
+						Reason:      dep.Explicit,
+						Version:     "91.0.0-1",
+						SrcinfoPath: ptrString(tmpDir + "/.SRCINFO"),
+						AURBase:     ptrString("yay"),
+					},
+				},
+			},
+		},
+		{
+			desc:           "one layer -- fail install",
+			wantErrInstall: true,
+			wantErrCompile: false,
+			failBuild:      false,
+			failPkgInstall: true,
 			targets: []map[string]*dep.InstallInfo{
 				{
 					"yay": {
@@ -506,10 +534,15 @@ func TestInstaller_CompileFailed(t *testing.T) {
 			},
 		},
 		{
-			desc:      "not last layer",
-			lastLayer: false,
+			desc:           "two layers",
+			wantErrInstall: false,
+			wantErrCompile: true,
+			failBuild:      true,
+			failPkgInstall: false,
 			targets: []map[string]*dep.InstallInfo{
-				{"bob": {}},
+				{"bob": {
+					AURBase: ptrString("yay"),
+				}},
 				{
 					"yay": {
 						Source:      dep.AUR,
@@ -533,7 +566,7 @@ func TestInstaller_CompileFailed(t *testing.T) {
 			}
 
 			showOverride := func(cmd *exec.Cmd) error {
-				if strings.Contains(cmd.String(), "makepkg -cf --noconfirm") && cmd.Dir == tmpDir {
+				if tc.failBuild && strings.Contains(cmd.String(), "makepkg -cf --noconfirm") && cmd.Dir == tmpDir {
 					return errors.New("makepkg failed")
 				}
 				return nil
@@ -555,7 +588,7 @@ func TestInstaller_CompileFailed(t *testing.T) {
 
 			cmdBuilder.Runner = mockRunner
 
-			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false)
+			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false, testLogger)
 
 			cmdArgs := parser.MakeArguments()
 			cmdArgs.AddArg("needed")
@@ -565,14 +598,14 @@ func TestInstaller_CompileFailed(t *testing.T) {
 				"yay": tmpDir,
 			}
 
-			errI := installer.Install(context.Background(), cmdArgs, tc.targets, pkgBuildDirs)
-			if tc.lastLayer {
-				require.NoError(td, errI) // last layer error
-			} else {
+			errI := installer.Install(context.Background(), cmdArgs, tc.targets, pkgBuildDirs, []string{})
+			if tc.wantErrInstall {
 				require.Error(td, errI)
+			} else {
+				require.NoError(td, errI)
 			}
 			err := installer.CompileFailedAndIgnored()
-			if tc.lastLayer {
+			if tc.wantErrCompile {
 				require.Error(td, err)
 				assert.ErrorContains(td, err, "yay")
 			} else {
@@ -713,7 +746,7 @@ func TestInstaller_InstallSplitPackage(t *testing.T) {
 
 			cmdBuilder.Runner = mockRunner
 
-			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false)
+			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, false, testLogger)
 
 			cmdArgs := parser.MakeArguments()
 			cmdArgs.AddTarget("jellyfin")
@@ -722,7 +755,7 @@ func TestInstaller_InstallSplitPackage(t *testing.T) {
 				"jellyfin": tmpDir,
 			}
 
-			errI := installer.Install(context.Background(), cmdArgs, tc.targets, pkgBuildDirs)
+			errI := installer.Install(context.Background(), cmdArgs, tc.targets, pkgBuildDirs, []string{})
 			require.NoError(td, errI)
 
 			require.Len(td, mockRunner.ShowCalls, len(tc.wantShow))
@@ -851,7 +884,7 @@ func TestInstaller_InstallDownloadOnly(t *testing.T) {
 
 			cmdBuilder.Runner = mockRunner
 
-			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, true)
+			installer := NewInstaller(mockDB, cmdBuilder, &vcs.Mock{}, parser.ModeAny, true, testLogger)
 
 			cmdArgs := parser.MakeArguments()
 			cmdArgs.AddTarget("yay")
@@ -872,7 +905,7 @@ func TestInstaller_InstallDownloadOnly(t *testing.T) {
 				},
 			}
 
-			errI := installer.Install(context.Background(), cmdArgs, targets, pkgBuildDirs)
+			errI := installer.Install(context.Background(), cmdArgs, targets, pkgBuildDirs, []string{})
 			require.NoError(td, errI)
 
 			require.Len(td, mockRunner.ShowCalls, len(tc.wantShow))

+ 12 - 12
cmd.go

@@ -174,7 +174,7 @@ func handleCmd(ctx context.Context, cfg *settings.Configuration, cmdArgs *parser
 	case "R", "remove":
 		return handleRemove(ctx, cmdArgs, cfg.Runtime.VCSStore)
 	case "S", "sync":
-		return handleSync(ctx, cmdArgs, dbExecutor)
+		return handleSync(ctx, cfg, cmdArgs, dbExecutor)
 	case "T", "deptest":
 		return cfg.Runtime.CmdBuilder.Show(cfg.Runtime.CmdBuilder.BuildPacmanCmd(ctx,
 			cmdArgs, cfg.Runtime.Mode, settings.NoConfirm))
@@ -349,33 +349,33 @@ func handleBuild(ctx context.Context,
 	return nil
 }
 
-func handleSync(ctx context.Context, cmdArgs *parser.Arguments, dbExecutor db.Executor) error {
+func handleSync(ctx context.Context, cfg *settings.Configuration, cmdArgs *parser.Arguments, dbExecutor db.Executor) error {
 	targets := cmdArgs.Targets
 
 	switch {
 	case cmdArgs.ExistsArg("s", "search"):
-		return syncSearch(ctx, targets, dbExecutor, config.Runtime.QueryBuilder, !cmdArgs.ExistsArg("q", "quiet"))
+		return syncSearch(ctx, targets, dbExecutor, cfg.Runtime.QueryBuilder, !cmdArgs.ExistsArg("q", "quiet"))
 	case cmdArgs.ExistsArg("p", "print", "print-format"):
-		return config.Runtime.CmdBuilder.Show(config.Runtime.CmdBuilder.BuildPacmanCmd(ctx,
-			cmdArgs, config.Runtime.Mode, settings.NoConfirm))
+		return cfg.Runtime.CmdBuilder.Show(cfg.Runtime.CmdBuilder.BuildPacmanCmd(ctx,
+			cmdArgs, cfg.Runtime.Mode, settings.NoConfirm))
 	case cmdArgs.ExistsArg("c", "clean"):
 		return syncClean(ctx, cmdArgs, dbExecutor)
 	case cmdArgs.ExistsArg("l", "list"):
-		return syncList(ctx, config.Runtime.HTTPClient, cmdArgs, dbExecutor)
+		return syncList(ctx, cfg.Runtime.HTTPClient, cmdArgs, dbExecutor)
 	case cmdArgs.ExistsArg("g", "groups"):
-		return config.Runtime.CmdBuilder.Show(config.Runtime.CmdBuilder.BuildPacmanCmd(ctx,
-			cmdArgs, config.Runtime.Mode, settings.NoConfirm))
+		return cfg.Runtime.CmdBuilder.Show(cfg.Runtime.CmdBuilder.BuildPacmanCmd(ctx,
+			cmdArgs, cfg.Runtime.Mode, settings.NoConfirm))
 	case cmdArgs.ExistsArg("i", "info"):
 		return syncInfo(ctx, cmdArgs, targets, dbExecutor)
 	case cmdArgs.ExistsArg("u", "sysupgrade") || len(cmdArgs.Targets) > 0:
-		if config.NewInstallEngine {
-			return syncInstall(ctx, config, cmdArgs, dbExecutor)
+		if cfg.NewInstallEngine {
+			return syncInstall(ctx, cfg, cmdArgs, dbExecutor)
 		}
 
 		return install(ctx, cmdArgs, dbExecutor, false)
 	case cmdArgs.ExistsArg("y", "refresh"):
-		return config.Runtime.CmdBuilder.Show(config.Runtime.CmdBuilder.BuildPacmanCmd(ctx,
-			cmdArgs, config.Runtime.Mode, settings.NoConfirm))
+		return cfg.Runtime.CmdBuilder.Show(cfg.Runtime.CmdBuilder.BuildPacmanCmd(ctx,
+			cmdArgs, cfg.Runtime.Mode, settings.NoConfirm))
 	}
 
 	return nil

+ 5 - 5
install.go

@@ -104,9 +104,10 @@ func install(ctx context.Context, cmdArgs *parser.Arguments, dbExecutor db.Execu
 	if config.Runtime.Mode.AtLeastRepo() {
 		if config.CombinedUpgrade {
 			if refreshArg {
-				if errR := earlyRefresh(ctx, cmdArgs); errR != nil {
+				if errR := earlyRefresh(ctx, config, config.Runtime.CmdBuilder, cmdArgs); errR != nil {
 					return fmt.Errorf("%s - %w", gotext.Get("error refreshing databases"), errR)
 				}
+				cmdArgs.DelArg("y", "refresh")
 			}
 		} else if refreshArg || sysupgradeArg || len(cmdArgs.Targets) > 0 {
 			if errP := earlyPacmanCall(ctx, cmdArgs, dbExecutor); errP != nil {
@@ -451,17 +452,16 @@ func earlyPacmanCall(ctx context.Context, cmdArgs *parser.Arguments, dbExecutor
 	return nil
 }
 
-func earlyRefresh(ctx context.Context, cmdArgs *parser.Arguments) error {
+func earlyRefresh(ctx context.Context, cfg *settings.Configuration, cmdBuilder exe.ICmdBuilder, cmdArgs *parser.Arguments) error {
 	arguments := cmdArgs.Copy()
-	cmdArgs.DelArg("y", "refresh")
 	arguments.DelArg("u", "sysupgrade")
 	arguments.DelArg("s", "search")
 	arguments.DelArg("i", "info")
 	arguments.DelArg("l", "list")
 	arguments.ClearTargets()
 
-	return config.Runtime.CmdBuilder.Show(config.Runtime.CmdBuilder.BuildPacmanCmd(ctx,
-		arguments, config.Runtime.Mode, settings.NoConfirm))
+	return cmdBuilder.Show(cmdBuilder.BuildPacmanCmd(ctx,
+		arguments, cfg.Runtime.Mode, settings.NoConfirm))
 }
 
 func confirmIncompatibleInstall(srcinfos map[string]*gosrc.Srcinfo, dbExecutor db.Executor) error {

+ 1 - 1
local_install.go

@@ -101,5 +101,5 @@ func installLocalPKGBUILD(
 	if err := multiErr.Return(); err != nil {
 		return err
 	}
-	return opService.Run(ctx, cmdArgs, targets)
+	return opService.Run(ctx, cmdArgs, targets, []string{})
 }

+ 4 - 0
pkg/db/mock/executor.go

@@ -26,6 +26,7 @@ type DBExecutor struct {
 	InstalledRemotePackageNamesFn func() []string
 	InstalledRemotePackagesFn     func() map[string]IPackage
 	SyncUpgradesFn                func(bool) (map[string]db.SyncUpgrade, error)
+	RefreshHandleFn               func() error
 	ReposFn                       func() []string
 }
 
@@ -117,6 +118,9 @@ func (t *DBExecutor) PackagesFromGroup(s string) []IPackage {
 }
 
 func (t *DBExecutor) RefreshHandle() error {
+	if t.RefreshHandleFn != nil {
+		return t.RefreshHandleFn()
+	}
 	panic("implement me")
 }
 

+ 5 - 4
pkg/dep/dep_graph.go

@@ -488,8 +488,8 @@ func (g *Grapher) ValidateAndSetNodeInfo(graph *topo.Graph[string, *InstallInfo]
 ) {
 	info := graph.GetNodeInfo(node)
 	if info != nil && info.Value != nil {
-		if info.Value.Reason < nodeInfo.Value.Reason {
-			return // refuse to downgrade reason from explicit to dep
+		if info.Value.Reason <= nodeInfo.Value.Reason {
+			return // refuse to downgrade reason
 		}
 	}
 
@@ -506,11 +506,12 @@ func (g *Grapher) addNodes(
 	targetsToFind := mapset.NewThreadUnsafeSet(deps...)
 	// Check if in graph already
 	for _, depString := range targetsToFind.ToSlice() {
-		if !graph.Exists(depString) {
+		depName, _, _ := splitDep(depString)
+		if !graph.Exists(depName) {
 			continue
 		}
 
-		if err := graph.DependOn(depString, parentPkgName); err != nil {
+		if err := graph.DependOn(depName, parentPkgName); err != nil {
 			g.logger.Warnln(depString, parentPkgName, err)
 		}
 

+ 5 - 3
pkg/topo/dep.go

@@ -204,13 +204,14 @@ func (dm DepMap[T]) removeFromDepmap(key, node T) bool {
 // Prune removes the node,
 // its dependencies if there are no other dependents
 // and its dependents
-func (g *Graph[T, V]) Prune(node T) {
+func (g *Graph[T, V]) Prune(node T) []T {
+	pruned := []T{node}
 	// Remove edges from things that depend on `node`.
 	for dependent := range g.dependents[node] {
 		last := g.dependencies.removeFromDepmap(dependent, node)
 		text.Debugln("pruning dependent", dependent, last)
 		if last {
-			g.Prune(dependent)
+			pruned = append(pruned, g.Prune(dependent)...)
 		}
 	}
 
@@ -221,7 +222,7 @@ func (g *Graph[T, V]) Prune(node T) {
 		last := g.dependents.removeFromDepmap(dependency, node)
 		text.Debugln("pruning dependency", dependency, last)
 		if last {
-			g.Prune(dependency)
+			pruned = append(pruned, g.Prune(dependency)...)
 		}
 	}
 
@@ -229,6 +230,7 @@ func (g *Graph[T, V]) Prune(node T) {
 
 	// Finally, remove the node itself.
 	delete(g.nodes, node)
+	return pruned
 }
 
 func (g *Graph[T, V]) remove(node T) {

+ 12 - 11
pkg/upgrade/service.go

@@ -220,7 +220,7 @@ func (u *UpgradeService) graphToUpSlice(graph *topo.Graph[string, *dep.InstallIn
 func (u *UpgradeService) GraphUpgrades(ctx context.Context,
 	graph *topo.Graph[string, *dep.InstallInfo],
 	enableDowngrade bool,
-) (*topo.Graph[string, *dep.InstallInfo], error) {
+) ([]string, *topo.Graph[string, *dep.InstallInfo], error) {
 	if graph == nil {
 		graph = topo.New[string, *dep.InstallInfo]()
 	}
@@ -228,20 +228,20 @@ func (u *UpgradeService) GraphUpgrades(ctx context.Context,
 	err := u.upGraph(ctx, graph, enableDowngrade,
 		func(*Upgrade) bool { return true })
 	if err != nil {
-		return graph, err
+		return []string{}, graph, err
 	}
 
 	if graph.Len() == 0 {
-		return graph, nil
+		return []string{}, graph, nil
 	}
 
-	errUp := u.userExcludeUpgrades(graph)
-	return graph, errUp
+	excluded, errUp := u.userExcludeUpgrades(graph)
+	return excluded, graph, errUp
 }
 
 // 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]) error {
+func (u *UpgradeService) userExcludeUpgrades(graph *topo.Graph[string, *dep.InstallInfo]) ([]string, error) {
 	allUpLen := graph.Len()
 	aurUp, repoUp := u.graphToUpSlice(graph)
 
@@ -258,7 +258,7 @@ func (u *UpgradeService) userExcludeUpgrades(graph *topo.Graph[string, *dep.Inst
 
 	numbers, err := u.log.GetInput(u.cfg.AnswerUpgrade, settings.NoConfirm)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	// upgrade menu asks you which packages to NOT upgrade so in this case
@@ -266,25 +266,26 @@ func (u *UpgradeService) userExcludeUpgrades(graph *topo.Graph[string, *dep.Inst
 	exclude, include, otherExclude, otherInclude := intrange.ParseNumberMenu(numbers)
 	isInclude := len(include) == 0 && len(otherInclude) == 0
 
+	excluded := make([]string, 0)
 	for i := range allUp.Up {
 		up := &allUp.Up[i]
 		if isInclude && otherExclude.Get(up.Repository) {
 			u.log.Debugln("pruning", up.Name)
-			graph.Prune(up.Name)
+			excluded = append(excluded, graph.Prune(up.Name)...)
 		}
 
 		if isInclude && exclude.Get(allUpLen-i) {
 			u.log.Debugln("pruning", up.Name)
-			graph.Prune(up.Name)
+			excluded = append(excluded, graph.Prune(up.Name)...)
 			continue
 		}
 
 		if !isInclude && !(include.Get(allUpLen-i) || otherInclude.Get(up.Repository)) {
 			u.log.Debugln("pruning", up.Name)
-			graph.Prune(up.Name)
+			excluded = append(excluded, graph.Prune(up.Name)...)
 			continue
 		}
 	}
 
-	return nil
+	return excluded, nil
 }

+ 10 - 1
pkg/upgrade/service_test.go

@@ -136,6 +136,7 @@ func TestUpgradeService_GraphUpgrades(t *testing.T) {
 		args         args
 		mustExist    map[string]*dep.InstallInfo
 		mustNotExist map[string]bool
+		wantExclude  []string
 		wantErr      bool
 	}{
 		{
@@ -156,6 +157,7 @@ func TestUpgradeService_GraphUpgrades(t *testing.T) {
 			},
 			mustNotExist: map[string]bool{},
 			wantErr:      false,
+			wantExclude:  []string{},
 		},
 		{
 			name: "no input devel",
@@ -176,6 +178,7 @@ func TestUpgradeService_GraphUpgrades(t *testing.T) {
 			},
 			mustNotExist: map[string]bool{},
 			wantErr:      false,
+			wantExclude:  []string{},
 		},
 		{
 			name: "exclude yay",
@@ -194,6 +197,7 @@ func TestUpgradeService_GraphUpgrades(t *testing.T) {
 			},
 			mustNotExist: map[string]bool{"yay": true},
 			wantErr:      false,
+			wantExclude:  []string{"yay"},
 		},
 		{
 			name: "exclude linux",
@@ -212,6 +216,7 @@ func TestUpgradeService_GraphUpgrades(t *testing.T) {
 			},
 			mustNotExist: map[string]bool{"linux": true},
 			wantErr:      false,
+			wantExclude:  []string{"linux"},
 		},
 		{
 			name: "only linux",
@@ -229,6 +234,7 @@ func TestUpgradeService_GraphUpgrades(t *testing.T) {
 			},
 			mustNotExist: map[string]bool{"yay": true, "example-git": true},
 			wantErr:      false,
+			wantExclude:  []string{"yay", "example-git"},
 		},
 		{
 			name: "exclude all",
@@ -244,6 +250,7 @@ func TestUpgradeService_GraphUpgrades(t *testing.T) {
 			mustExist:    map[string]*dep.InstallInfo{},
 			mustNotExist: map[string]bool{"yay": true, "example-git": true, "linux": true},
 			wantErr:      false,
+			wantExclude:  []string{"yay", "example-git", "linux"},
 		},
 	}
 	for _, tt := range tests {
@@ -269,7 +276,7 @@ func TestUpgradeService_GraphUpgrades(t *testing.T) {
 				noConfirm:  tt.fields.noConfirm,
 			}
 
-			got, err := u.GraphUpgrades(context.Background(), tt.args.graph, tt.args.enableDowngrade)
+			excluded, got, err := u.GraphUpgrades(context.Background(), tt.args.graph, tt.args.enableDowngrade)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("UpgradeService.GraphUpgrades() error = %v, wantErr %v", err, tt.wantErr)
 				return
@@ -283,6 +290,8 @@ func TestUpgradeService_GraphUpgrades(t *testing.T) {
 			for node := range tt.mustNotExist {
 				assert.False(t, got.Exists(node), node)
 			}
+
+			assert.ElementsMatch(t, tt.wantExclude, excluded)
 		})
 	}
 }

+ 17 - 15
sync.go

@@ -20,20 +20,20 @@ import (
 )
 
 func syncInstall(ctx context.Context,
-	config *settings.Configuration,
+	cfg *settings.Configuration,
 	cmdArgs *parser.Arguments,
 	dbExecutor db.Executor,
 ) error {
-	aurCache := config.Runtime.AURCache
+	aurCache := cfg.Runtime.AURCache
 	refreshArg := cmdArgs.ExistsArg("y", "refresh")
 	noDeps := cmdArgs.ExistsArg("d", "nodeps")
-	noCheck := strings.Contains(config.MFlags, "--nocheck")
+	noCheck := strings.Contains(cfg.MFlags, "--nocheck")
 	if noDeps {
-		config.Runtime.CmdBuilder.AddMakepkgFlag("-d")
+		cfg.Runtime.CmdBuilder.AddMakepkgFlag("-d")
 	}
 
-	if refreshArg && config.Runtime.Mode.AtLeastRepo() {
-		if errR := earlyRefresh(ctx, cmdArgs); errR != nil {
+	if refreshArg && cfg.Runtime.Mode.AtLeastRepo() {
+		if errR := earlyRefresh(ctx, cfg, cfg.Runtime.CmdBuilder, cmdArgs); errR != nil {
 			return fmt.Errorf("%s - %w", gotext.Get("error refreshing databases"), errR)
 		}
 
@@ -45,27 +45,28 @@ func syncInstall(ctx context.Context,
 	}
 
 	grapher := dep.NewGrapher(dbExecutor, aurCache, false, settings.NoConfirm,
-		noDeps, noCheck, cmdArgs.ExistsArg("needed"), config.Runtime.Logger.Child("grapher"))
+		noDeps, noCheck, cmdArgs.ExistsArg("needed"), cfg.Runtime.Logger.Child("grapher"))
 
 	graph, err := grapher.GraphFromTargets(ctx, nil, cmdArgs.Targets)
 	if err != nil {
 		return err
 	}
 
+	excluded := []string{}
 	if cmdArgs.ExistsArg("u", "sysupgrade") {
 		var errSysUp error
 
 		upService := upgrade.NewUpgradeService(
-			grapher, aurCache, dbExecutor, config.Runtime.VCSStore,
-			config.Runtime, config, settings.NoConfirm, config.Runtime.Logger.Child("upgrade"))
+			grapher, aurCache, dbExecutor, cfg.Runtime.VCSStore,
+			cfg.Runtime, cfg, settings.NoConfirm, cfg.Runtime.Logger.Child("upgrade"))
 
-		graph, errSysUp = upService.GraphUpgrades(ctx, graph, cmdArgs.ExistsDouble("u", "sysupgrade"))
+		excluded, graph, errSysUp = upService.GraphUpgrades(ctx, graph, cmdArgs.ExistsDouble("u", "sysupgrade"))
 		if errSysUp != nil {
 			return errSysUp
 		}
 	}
 
-	opService := NewOperationService(ctx, config, dbExecutor)
+	opService := NewOperationService(ctx, cfg, dbExecutor)
 	multiErr := &multierror.MultiError{}
 	targets := graph.TopoSortedLayerMap(func(s string, ii *dep.InstallInfo) error {
 		if ii.Source == dep.Missing {
@@ -77,7 +78,8 @@ func syncInstall(ctx context.Context,
 	if err := multiErr.Return(); err != nil {
 		return err
 	}
-	return opService.Run(ctx, cmdArgs, targets)
+
+	return opService.Run(ctx, cmdArgs, targets, excluded)
 }
 
 type OperationService struct {
@@ -96,7 +98,7 @@ func NewOperationService(ctx context.Context, cfg *settings.Configuration, dbExe
 
 func (o *OperationService) Run(ctx context.Context,
 	cmdArgs *parser.Arguments,
-	targets []map[string]*dep.InstallInfo,
+	targets []map[string]*dep.InstallInfo, excluded []string,
 ) error {
 	if len(targets) == 0 {
 		fmt.Fprintln(os.Stdout, "", gotext.Get("there is nothing to do"))
@@ -105,7 +107,7 @@ func (o *OperationService) Run(ctx context.Context,
 	preparer := NewPreparer(o.dbExecutor, o.cfg.Runtime.CmdBuilder, o.cfg)
 	installer := NewInstaller(o.dbExecutor, o.cfg.Runtime.CmdBuilder,
 		o.cfg.Runtime.VCSStore, o.cfg.Runtime.Mode,
-		cmdArgs.ExistsArg("w", "downloadonly"))
+		cmdArgs.ExistsArg("w", "downloadonly"), o.cfg.Runtime.Logger.Child("installer"))
 
 	pkgBuildDirs, errInstall := preparer.Run(ctx, os.Stdout, targets)
 	if errInstall != nil {
@@ -147,7 +149,7 @@ func (o *OperationService) Run(ctx context.Context,
 		return errPGP
 	}
 
-	if errInstall := installer.Install(ctx, cmdArgs, targets, pkgBuildDirs); errInstall != nil {
+	if errInstall := installer.Install(ctx, cmdArgs, targets, pkgBuildDirs, excluded); errInstall != nil {
 		return errInstall
 	}
 

+ 612 - 0
sync_test.go

@@ -0,0 +1,612 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/Jguer/aur"
+	alpm "github.com/Jguer/go-alpm/v2"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/Jguer/yay/v11/pkg/db"
+	"github.com/Jguer/yay/v11/pkg/db/mock"
+	mockaur "github.com/Jguer/yay/v11/pkg/dep/mock"
+	"github.com/Jguer/yay/v11/pkg/settings"
+	"github.com/Jguer/yay/v11/pkg/settings/exe"
+	"github.com/Jguer/yay/v11/pkg/settings/parser"
+	"github.com/Jguer/yay/v11/pkg/text"
+	"github.com/Jguer/yay/v11/pkg/vcs"
+)
+
+func TestSyncUpgrade(t *testing.T) {
+	t.Parallel()
+	makepkgBin := t.TempDir() + "/makepkg"
+	pacmanBin := t.TempDir() + "/pacman"
+	gitBin := t.TempDir() + "/git"
+	f, err := os.OpenFile(makepkgBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	f, err = os.OpenFile(pacmanBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	f, err = os.OpenFile(gitBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	captureOverride := func(cmd *exec.Cmd) (stdout string, stderr string, err error) {
+		return "", "", nil
+	}
+
+	showOverride := func(cmd *exec.Cmd) error {
+		return nil
+	}
+
+	mockRunner := &exe.MockRunner{CaptureFn: captureOverride, ShowFn: showOverride}
+	cmdBuilder := &exe.CmdBuilder{
+		MakepkgBin:       makepkgBin,
+		SudoBin:          "su",
+		PacmanBin:        pacmanBin,
+		PacmanConfigPath: "/etc/pacman.conf",
+		GitBin:           "git",
+		Runner:           mockRunner,
+		SudoLoopEnabled:  false,
+	}
+
+	cmdArgs := parser.MakeArguments()
+	cmdArgs.AddArg("S")
+	cmdArgs.AddArg("y")
+	cmdArgs.AddArg("u")
+
+	dbName := mock.NewDB("core")
+
+	db := &mock.DBExecutor{
+		AlpmArchitecturesFn: func() ([]string, error) {
+			return []string{"x86_64"}, nil
+		},
+		RefreshHandleFn: func() error {
+			return nil
+		},
+		ReposFn: func() []string {
+			return []string{"core"}
+		},
+		InstalledRemotePackagesFn: func() map[string]alpm.IPackage {
+			return map[string]alpm.IPackage{}
+		},
+		InstalledRemotePackageNamesFn: func() []string {
+			return []string{}
+		},
+		SyncUpgradesFn: func(
+			bool,
+		) (map[string]db.SyncUpgrade, error) {
+			return map[string]db.SyncUpgrade{
+				"linux": {
+					Package: &mock.Package{
+						PName:    "linux",
+						PVersion: "5.10.0",
+						PDB:      dbName,
+					},
+					LocalVersion: "4.3.0",
+					Reason:       alpm.PkgReasonExplicit,
+				},
+			}, nil
+		},
+	}
+
+	cfg := &settings.Configuration{
+		NewInstallEngine: true,
+		RemoveMake:       "no",
+		Runtime: &settings.Runtime{
+			Logger:     text.NewLogger(io.Discard, strings.NewReader("\n"), true, "test"),
+			CmdBuilder: cmdBuilder,
+			VCSStore:   &vcs.Mock{},
+			AURCache: &mockaur.MockAUR{
+				GetFn: func(ctx context.Context, query *aur.Query) ([]aur.Pkg, error) {
+					return []aur.Pkg{}, nil
+				},
+			},
+		},
+	}
+
+	err = handleCmd(context.Background(), cfg, cmdArgs, db)
+	require.NoError(t, err)
+
+	wantCapture := []string{}
+	wantShow := []string{
+		"pacman -S -y --config /etc/pacman.conf --",
+		"pacman -S -y -u --config /etc/pacman.conf --",
+	}
+
+	require.Len(t, mockRunner.ShowCalls, len(wantShow))
+	require.Len(t, mockRunner.CaptureCalls, len(wantCapture))
+
+	for i, call := range mockRunner.ShowCalls {
+		show := call.Args[0].(*exec.Cmd).String()
+		show = strings.ReplaceAll(show, makepkgBin, "makepkg")
+		show = strings.ReplaceAll(show, pacmanBin, "pacman")
+		show = strings.ReplaceAll(show, gitBin, "pacman")
+
+		// options are in a different order on different systems and on CI root user is used
+		assert.Subset(t, strings.Split(show, " "), strings.Split(wantShow[i], " "), fmt.Sprintf("%d - %s", i, show))
+	}
+}
+
+func TestSyncUpgrade_IgnoreAll(t *testing.T) {
+	t.Parallel()
+	makepkgBin := t.TempDir() + "/makepkg"
+	pacmanBin := t.TempDir() + "/pacman"
+	gitBin := t.TempDir() + "/git"
+	f, err := os.OpenFile(makepkgBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	f, err = os.OpenFile(pacmanBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	f, err = os.OpenFile(gitBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	captureOverride := func(cmd *exec.Cmd) (stdout string, stderr string, err error) {
+		return "", "", nil
+	}
+
+	showOverride := func(cmd *exec.Cmd) error {
+		return nil
+	}
+
+	mockRunner := &exe.MockRunner{CaptureFn: captureOverride, ShowFn: showOverride}
+	cmdBuilder := &exe.CmdBuilder{
+		MakepkgBin:       makepkgBin,
+		SudoBin:          "su",
+		PacmanBin:        pacmanBin,
+		PacmanConfigPath: "/etc/pacman.conf",
+		GitBin:           "git",
+		Runner:           mockRunner,
+		SudoLoopEnabled:  false,
+	}
+
+	cmdArgs := parser.MakeArguments()
+	cmdArgs.AddArg("S")
+	cmdArgs.AddArg("y")
+	cmdArgs.AddArg("u")
+
+	dbName := mock.NewDB("core")
+
+	db := &mock.DBExecutor{
+		AlpmArchitecturesFn: func() ([]string, error) {
+			return []string{"x86_64"}, nil
+		},
+		RefreshHandleFn: func() error {
+			return nil
+		},
+		ReposFn: func() []string {
+			return []string{"core"}
+		},
+		InstalledRemotePackagesFn: func() map[string]alpm.IPackage {
+			return map[string]alpm.IPackage{}
+		},
+		InstalledRemotePackageNamesFn: func() []string {
+			return []string{}
+		},
+		SyncUpgradesFn: func(
+			bool,
+		) (map[string]db.SyncUpgrade, error) {
+			return map[string]db.SyncUpgrade{
+				"linux": {
+					Package: &mock.Package{
+						PName:    "linux",
+						PVersion: "5.10.0",
+						PDB:      dbName,
+					},
+					LocalVersion: "4.3.0",
+					Reason:       alpm.PkgReasonExplicit,
+				},
+			}, nil
+		},
+	}
+
+	cfg := &settings.Configuration{
+		NewInstallEngine: true,
+		RemoveMake:       "no",
+		Runtime: &settings.Runtime{
+			Logger:     text.NewLogger(io.Discard, strings.NewReader("1\n"), true, "test"),
+			CmdBuilder: cmdBuilder,
+			VCSStore:   &vcs.Mock{},
+			AURCache: &mockaur.MockAUR{
+				GetFn: func(ctx context.Context, query *aur.Query) ([]aur.Pkg, error) {
+					return []aur.Pkg{}, nil
+				},
+			},
+		},
+	}
+
+	err = handleCmd(context.Background(), cfg, cmdArgs, db)
+	require.NoError(t, err)
+
+	wantCapture := []string{}
+	wantShow := []string{
+		"pacman -S -y --config /etc/pacman.conf --",
+	}
+
+	require.Len(t, mockRunner.ShowCalls, len(wantShow))
+	require.Len(t, mockRunner.CaptureCalls, len(wantCapture))
+
+	for i, call := range mockRunner.ShowCalls {
+		show := call.Args[0].(*exec.Cmd).String()
+		show = strings.ReplaceAll(show, makepkgBin, "makepkg")
+		show = strings.ReplaceAll(show, pacmanBin, "pacman")
+		show = strings.ReplaceAll(show, gitBin, "pacman")
+
+		// options are in a different order on different systems and on CI root user is used
+		assert.Subset(t, strings.Split(show, " "), strings.Split(wantShow[i], " "), fmt.Sprintf("%d - %s", i, show))
+	}
+}
+
+func TestSyncUpgrade_IgnoreOne(t *testing.T) {
+	t.Parallel()
+	makepkgBin := t.TempDir() + "/makepkg"
+	pacmanBin := t.TempDir() + "/pacman"
+	gitBin := t.TempDir() + "/git"
+	f, err := os.OpenFile(makepkgBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	f, err = os.OpenFile(pacmanBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	f, err = os.OpenFile(gitBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	captureOverride := func(cmd *exec.Cmd) (stdout string, stderr string, err error) {
+		return "", "", nil
+	}
+
+	showOverride := func(cmd *exec.Cmd) error {
+		return nil
+	}
+
+	mockRunner := &exe.MockRunner{CaptureFn: captureOverride, ShowFn: showOverride}
+	cmdBuilder := &exe.CmdBuilder{
+		MakepkgBin:       makepkgBin,
+		SudoBin:          "su",
+		PacmanBin:        pacmanBin,
+		PacmanConfigPath: "/etc/pacman.conf",
+		GitBin:           "git",
+		Runner:           mockRunner,
+		SudoLoopEnabled:  false,
+	}
+
+	cmdArgs := parser.MakeArguments()
+	cmdArgs.AddArg("S")
+	cmdArgs.AddArg("y")
+	cmdArgs.AddArg("u")
+
+	dbName := mock.NewDB("core")
+
+	db := &mock.DBExecutor{
+		AlpmArchitecturesFn: func() ([]string, error) {
+			return []string{"x86_64"}, nil
+		},
+		RefreshHandleFn: func() error {
+			return nil
+		},
+		ReposFn: func() []string {
+			return []string{"core"}
+		},
+		InstalledRemotePackagesFn: func() map[string]alpm.IPackage {
+			return map[string]alpm.IPackage{}
+		},
+		InstalledRemotePackageNamesFn: func() []string {
+			return []string{}
+		},
+		SyncUpgradesFn: func(
+			bool,
+		) (map[string]db.SyncUpgrade, error) {
+			return map[string]db.SyncUpgrade{
+				"gcc": {
+					Package: &mock.Package{
+						PName:    "gcc",
+						PVersion: "6.0.0",
+						PDB:      dbName,
+					},
+					LocalVersion: "5.0.0",
+					Reason:       alpm.PkgReasonExplicit,
+				},
+				"linux": {
+					Package: &mock.Package{
+						PName:    "linux",
+						PVersion: "5.10.0",
+						PDB:      dbName,
+					},
+					LocalVersion: "4.3.0",
+					Reason:       alpm.PkgReasonExplicit,
+				},
+				"linux-headers": {
+					Package: &mock.Package{
+						PName:    "linux-headers",
+						PVersion: "5.10.0",
+						PDB:      dbName,
+					},
+					LocalVersion: "4.3.0",
+					Reason:       alpm.PkgReasonDepend,
+				},
+			}, nil
+		},
+	}
+
+	cfg := &settings.Configuration{
+		NewInstallEngine: true,
+		RemoveMake:       "no",
+		Runtime: &settings.Runtime{
+			Logger:     text.NewLogger(io.Discard, strings.NewReader("1\n"), true, "test"),
+			CmdBuilder: cmdBuilder,
+			VCSStore:   &vcs.Mock{},
+			AURCache: &mockaur.MockAUR{
+				GetFn: func(ctx context.Context, query *aur.Query) ([]aur.Pkg, error) {
+					return []aur.Pkg{}, nil
+				},
+			},
+		},
+	}
+
+	err = handleCmd(context.Background(), cfg, cmdArgs, db)
+	require.NoError(t, err)
+
+	wantCapture := []string{}
+	wantShow := []string{
+		"pacman -S -y --config /etc/pacman.conf --",
+		"pacman -S -y -u --config /etc/pacman.conf --ignore linux-headers --",
+	}
+
+	require.Len(t, mockRunner.ShowCalls, len(wantShow))
+	require.Len(t, mockRunner.CaptureCalls, len(wantCapture))
+
+	for i, call := range mockRunner.ShowCalls {
+		show := call.Args[0].(*exec.Cmd).String()
+		show = strings.ReplaceAll(show, makepkgBin, "makepkg")
+		show = strings.ReplaceAll(show, pacmanBin, "pacman")
+		show = strings.ReplaceAll(show, gitBin, "pacman")
+
+		// options are in a different order on different systems and on CI root user is used
+		assert.Subset(t, strings.Split(show, " "), strings.Split(wantShow[i], " "), fmt.Sprintf("%d - %s", i, show))
+	}
+}
+
+// Pinned deps with rollup
+func TestSyncUpgradeAURPinnedSplitPackage(t *testing.T) {
+	t.Parallel()
+	makepkgBin := t.TempDir() + "/makepkg"
+	pacmanBin := t.TempDir() + "/pacman"
+	tmpDir := t.TempDir()
+	gitBin := t.TempDir() + "/git"
+	f, err := os.OpenFile(makepkgBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	f, err = os.OpenFile(pacmanBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	f, err = os.OpenFile(gitBin, os.O_RDONLY|os.O_CREATE, 0o755)
+	require.NoError(t, err)
+	require.NoError(t, f.Close())
+
+	pkgBuildDir := tmpDir + "/vosk-api"
+	os.Mkdir(pkgBuildDir, 0o755)
+	fSource, err := os.OpenFile(pkgBuildDir+"/.SRCINFO", os.O_RDWR|os.O_CREATE, 0o666)
+	require.NoError(t, err)
+	n, errF := fSource.WriteString(`pkgbase = vosk-api
+	pkgdesc = Offline speech recognition toolkit
+	pkgver = 0.3.45
+	pkgrel = 1
+	url = https://alphacephei.com/vosk/
+	arch = x86_64
+	license = Apache
+
+pkgname = vosk-api
+	pkgdesc = vosk-api
+
+pkgname = python-vosk
+	pkgdesc = Python module for vosk-api
+	depends = vosk-api=0.3.45`)
+	require.NoError(t, errF)
+	require.Greater(t, n, 0)
+	require.NoError(t, fSource.Close())
+
+	tars := []string{
+		tmpDir + "/vosk-api-0.3.45-1-x86_64.pkg.tar.zst",
+		tmpDir + "/python-vosk-0.3.45-1-x86_64.pkg.tar.zst",
+	}
+
+	captureOverride := func(cmd *exec.Cmd) (stdout string, stderr string, err error) {
+		return strings.Join(tars, "\n"), "", nil
+	}
+
+	once := sync.Once{}
+
+	showOverride := func(cmd *exec.Cmd) error {
+		once.Do(func() {
+			for _, tar := range tars {
+				f, err := os.OpenFile(tar, os.O_RDONLY|os.O_CREATE, 0o666)
+				require.NoError(t, err)
+				require.NoError(t, f.Close())
+			}
+		})
+		if sanitizeCall(cmd.String(), tmpDir, makepkgBin,
+			pacmanBin, gitBin) == "pacman -U --config /etc/pacman.conf -- /testdir/vosk-api-0.3.45-1-x86_64.pkg.tar.zst" {
+			return errors.New("Unsatisfied dependency")
+		}
+		return nil
+	}
+
+	mockRunner := &exe.MockRunner{CaptureFn: captureOverride, ShowFn: showOverride}
+	cmdBuilder := &exe.CmdBuilder{
+		MakepkgBin:       makepkgBin,
+		SudoBin:          "su",
+		PacmanBin:        pacmanBin,
+		PacmanConfigPath: "/etc/pacman.conf",
+		GitBin:           "git",
+		Runner:           mockRunner,
+		SudoLoopEnabled:  false,
+	}
+
+	cmdArgs := parser.MakeArguments()
+	cmdArgs.AddArg("S")
+	cmdArgs.AddArg("y")
+	cmdArgs.AddArg("u")
+
+	db := &mock.DBExecutor{
+		AlpmArchitecturesFn: func() ([]string, error) {
+			return []string{"x86_64"}, nil
+		},
+		RefreshHandleFn: func() error {
+			return nil
+		},
+		ReposFn: func() []string {
+			return []string{"core"}
+		},
+		SyncSatisfierFn: func(s string) mock.IPackage {
+			return nil
+		},
+		InstalledRemotePackagesFn: func() map[string]alpm.IPackage {
+			return map[string]alpm.IPackage{
+				"vosk-api": &mock.Package{
+					PName:    "vosk-api",
+					PVersion: "0.3.43-1",
+					PBase:    "vosk-api",
+					PReason:  alpm.PkgReasonDepend,
+				},
+				"python-vosk": &mock.Package{
+					PName:    "python-vosk",
+					PVersion: "0.3.43-1",
+					PBase:    "python-vosk",
+					PReason:  alpm.PkgReasonExplicit,
+					// TODO: fix mock Depends
+				},
+			}
+		},
+		InstalledRemotePackageNamesFn: func() []string {
+			return []string{"vosk-api", "python-vosk"}
+		},
+		LocalSatisfierExistsFn: func(s string) bool {
+			return false
+		},
+		SyncUpgradesFn: func(
+			bool,
+		) (map[string]db.SyncUpgrade, error) {
+			return map[string]db.SyncUpgrade{}, nil
+		},
+	}
+
+	cfg := &settings.Configuration{
+		NewInstallEngine: true,
+		RemoveMake:       "no",
+		BuildDir:         tmpDir,
+		Runtime: &settings.Runtime{
+			Logger:     text.NewLogger(io.Discard, strings.NewReader("\n\n\n\n"), true, "test"),
+			CmdBuilder: cmdBuilder,
+			VCSStore:   &vcs.Mock{},
+			AURCache: &mockaur.MockAUR{
+				GetFn: func(ctx context.Context, query *aur.Query) ([]aur.Pkg, error) {
+					return []aur.Pkg{
+						{
+							Name:        "vosk-api",
+							PackageBase: "vosk-api",
+							Version:     "0.3.45-1",
+						},
+						{
+							Name:        "python-vosk",
+							PackageBase: "vosk-api",
+							Version:     "0.3.45-1",
+							Depends: []string{
+								"vosk-api=0.3.45",
+							},
+						},
+					}, nil
+				},
+			},
+		},
+	}
+
+	err = handleCmd(context.Background(), cfg, cmdArgs, db)
+	require.NoError(t, err)
+
+	wantCapture := []string{
+		"/usr/bin/git -C /testdir/vosk-api reset --hard HEAD",
+		"/usr/bin/git -C /testdir/vosk-api merge --no-edit --ff",
+		"makepkg --packagelist", "makepkg --packagelist",
+		"makepkg --packagelist",
+	}
+	wantShow := []string{
+		"pacman -S -y --config /etc/pacman.conf --",
+		"makepkg --verifysource -Ccf", "makepkg --nobuild -fC --ignorearch",
+		"makepkg -c --nobuild --noextract --ignorearch",
+		"pacman -U --config /etc/pacman.conf -- /testdir/vosk-api-0.3.45-1-x86_64.pkg.tar.zst",
+		"makepkg --nobuild -fC --ignorearch", "makepkg -c --nobuild --noextract --ignorearch",
+		"makepkg --nobuild -fC --ignorearch", "makepkg -c --nobuild --noextract --ignorearch",
+		"pacman -U --config /etc/pacman.conf -- /testdir/vosk-api-0.3.45-1-x86_64.pkg.tar.zst /testdir/python-vosk-0.3.45-1-x86_64.pkg.tar.zst",
+		"pacman -D -q --asdeps --config /etc/pacman.conf -- vosk-api",
+		"pacman -D -q --asexplicit --config /etc/pacman.conf -- python-vosk",
+	}
+
+	require.Len(t, mockRunner.ShowCalls, len(wantShow),
+		fmt.Sprintf("%#v", sanitizeCalls(mockRunner.ShowCalls, tmpDir, makepkgBin, pacmanBin, gitBin)))
+	require.Len(t, mockRunner.CaptureCalls, len(wantCapture),
+		fmt.Sprintf("%#v", sanitizeCalls(mockRunner.CaptureCalls, tmpDir, makepkgBin, pacmanBin, gitBin)))
+
+	for i, call := range mockRunner.ShowCalls {
+		show := call.Args[0].(*exec.Cmd).String()
+		show = strings.ReplaceAll(show, tmpDir, "/testdir") // replace the temp dir with a static path
+		show = strings.ReplaceAll(show, makepkgBin, "makepkg")
+		show = strings.ReplaceAll(show, pacmanBin, "pacman")
+		show = strings.ReplaceAll(show, gitBin, "pacman")
+
+		// options are in a different order on different systems and on CI root user is used
+		assert.Subset(t, strings.Split(show, " "), strings.Split(wantShow[i], " "), fmt.Sprintf("%d - %s", i, show))
+	}
+}
+
+func sanitizeCalls(calls []exe.Call, tmpDir, makepkg, pacman, git string) []string {
+	san := make([]string, 0, len(calls))
+	for _, c := range calls {
+		s := c.Args[0].(*exec.Cmd).String()
+		san = append(san, sanitizeCall(s, tmpDir, makepkg, pacman, git))
+	}
+
+	return san
+}
+
+func sanitizeCall(s, tmpDir, makepkg, pacman, git string) string {
+	_, after, found := strings.Cut(s, makepkg)
+	if found {
+		s = "makepkg" + after
+	}
+
+	_, after, found = strings.Cut(s, pacman)
+	if found {
+		s = "pacman" + after
+	}
+
+	_, after, found = strings.Cut(s, git)
+	if found {
+		s = "git" + after
+	}
+
+	s = strings.ReplaceAll(s, tmpDir, "/testdir")
+
+	return s
+}