Skip to main content

Minimal apt install

· 4 min read
Ubuntu logo

I was recently working on a bootstrap script that, among other things, needed to install a fairly large number of apt packages. The list of packages was pre-existing, and had grown organically over several years. As most apt users would know, many packages require a significant number of dependenecies, and so it's pretty likely that any significant list of apt packages would include at least some transitive redundancy. So, mostly as a mental exercise, I started pondering how I could go about reducing any set of required apt packages to tha minimal set that would still include all of the same dependencies.

It actually proved to be pretty easy in the end. But first, I needed to find a reliable way to fetch the list of dependencies for any given package. The apt-cache utility does the job, but I had to sort through some misinformation online re the command's output format. You read about that in the previous Pipes in apt-cache Output post.

Once I knew how to determine the dependencies for a package, it was pretty easy to wrap it up in a Bash script:

minimise.sh
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2024 Paul Colby <git@colby.id.au>
# SPDX-License-Identifier: MIT

set -o errexit -o noclobber -o nounset -o pipefail
shopt -s inherit_errexit

declare -A seen

function getDeps {
echo "Getting deps for $1" >&2
local -a deps
deps=$(apt-cache depends "$1" | gawk -- '/^ *[|]?Depends:/ {if (substr(prev,0,1)!="|")print $2;prev=$1}')
for dep in ${deps}; do
[[ ! "${dep}" =~ ^\<([^:]+)(:[^:]+)?\>$ ]] || dep=${BASH_REMATCH[1]}
if [[ -v seen[${dep}] ]]; then (( seen[${dep}]++ ))
else
echo "Found dep: ${dep}" >&2
seen[${dep}]=1
getDeps "${dep}"
fi
done
}

for pkg in "$@"; do
getDeps "${pkg}"
done

echo -n 'apt install' >&2
for pkg in "$@"; do
[[ -v seen["${pkg}"] ]] || echo "${pkg}"
done | sort | tr '\n' ' '
echo

The script works by defining a getDeps() function (lines 10 to 23) which recursively finds all dependencies for a given package, adding those dependencies to a global seen associative array. Note, if a dependency has already been seen (line 16), then its dependency are not fetched again, since that would take more time, and not yield anything new.

With getDeps() available, the script first runs getDeps() for all packages (lines 25 to 28) specified on command line:

for pkg in "$@"; do
getDeps "${pkg}"
done

After that, the seen associative array will contain entries for every package that is a dependant (directly, or indirectly) of any of the packages provided on the command line. Note, however, that the packages from the command line themselves will not have been added to seen unless they are themselve dependencies.

Put another way, getDeps packageX will add all of packageX's dependencies to seen, but not packageX itself. Thus, the only way packageX itself will appear in seen, is if packageX was a dependancy for at least one of the other packages passed to another call to getDeps.

So finally, all we need to do is loop through all of the packages provided on the command line (lines 29 to 33), outputting just the ones that are not set in seen:

for pkg in "$@"; do
[[ -v seen["${pkg}"] ]] || echo "${pkg}"
done | sort | tr '\n' ' '

The script sorts the package list for aesthetics only.

Finally, we can run the script like:

./minimise.sh <package1> ... <packageN>

For example:

$ ./minimise.sh cmake cpp gcc gdb g++ lcov perl qmake6 qt6-base-dev qt6-base-dev-tools qt6-l10n-tools
Getting deps for cmake
Found dep: libarchive13t64
Getting deps for libarchive13t64
Found dep: libacl1
Getting deps for libacl1
Found dep: libc6
Getting deps for libc6
...
Getting deps for libllvm15t64
Found dep: libqt6qml6
Getting deps for libqt6qml6
apt install cmake g++ gdb lcov qt6-base-dev qt6-l10n-tools

So the list:

cmake cpp gcc gdb g++ lcov perl qmake6 qt6-base-dev qt6-base-dev-tools qt6-l10n-tools

Has been reduced to:

cmake g++ gdb lcov qt6-base-dev qt6-l10n-tools

Note, the script's diagnotic output is sent to stderr, so you can, for example redirect stderr to /dev/null (or a file) for cleaner output. Likewise, you could redirect (or tee) stdout to a file have the final apt install command written to a file. Some examples:

$ # Hide all the diagnostic logging.
$ ./minimise.sh <lots-of-packages> 2> /dev/null
<minimal-packages>
$ # Redirect just the minimal packages list to a text file.
$ ./minimise.sh <lots-of-packages> > min.txt
Getting ...
...
$ cat min.txt
<minimal-packages>
$ # Tee the minimal packages list to a text file, and stdout.
$ ./minimise.sh <lots-of-packages> | tee min2.txt
Getting ...
...
apt install <minimal-packages>
$ cat min2.txt
<minimal-packages>