Chapter 7: The install Command¶
Okay now let's get to the meat of it: the install command. It reads the manifest,
checks the lock file, resolves if needed, and installs packages into a local
prefix. The lock file (produced by Chapter 6's shot lock) is
our source of truth: if it's fresh, shot install replays it without touching
the network or the solver.
Design¶
shot install runs the full pipeline: check lock, resolve if stale, install.
$ shot install
⠋ Fetching repodata
1523 repodata records loaded
⠋ Solving
Solved 5 packages in 0.3s
✔ Wrote moonshot.lock (5 packages)
Downloading and extracting packages...
✔ Environment updated in 2.1s
Activate with: eval $(shot shell-hook)
When the lock is fresh, the resolve step is skipped entirely:
You can pass an optional --prefix flag to override the install
location. By default, packages go into .env/ relative to the
project root.
Configuration¶
The prefix directory¶
By convention, moonshot puts the environment at .env/ relative to the
project root, alongside moonshot.toml. This keeps the environment close to the
project and out of the user's global namespace. The user can override it with
--prefix /path/to/env. The prefix_dir helper and command module declarations
are defined in Chapter 2.
Concepts: Installation¶
Here is the full pipeline that shot install runs:
graph TD
Start["shot install"] --> CheckLock{Lock fresh?}
CheckLock -->|yes| ReadLock["Read lock file"]
CheckLock -->|no| FetchRepo["Fetch repodata"]
FetchRepo --> Solve["Run solver"]
Solve --> WriteLock["Write lock file"]
WriteLock --> ReadLock
ReadLock --> Download["Download packages\nto cache"]
Download --> Link["Link into prefix"]
The package cache¶
Every package is first extracted into a central cache shared across all environments on your machine. The exact cache location is platform-dependent:
- Linux:
~/.cache/rattler/cache - macOS:
~/Library/Caches/rattler/cache - Windows:
%LOCALAPPDATA%\rattler\cache
These paths come from rattler::default_cache_dir(). The cache key is the
package's content hash, so lua-5.4.7 is stored exactly once regardless of how
many environments use it. Content-addressed keys (rather than name-plus-version)
prevent collisions when the same version is rebuilt with a different build
string. Two builds of lua-5.4.7 with different compiler flags get different
hashes and coexist safely in the cache.
Hard links and reflinks¶
From the cache, files are linked into the target prefix. If possible on the system, rattler uses reflinks (copy-on-write clones) when the filesystem supports them (APFS on macOS, Btrfs and XFS on Linux). A reflink shares the underlying data blocks without sharing the inode, so writing to one copy doesn't affect the other. On filesystems without reflink support, rattler falls back to hard links, which are a second directory entry pointing to the same inode. If hard links are also unavailable (some network filesystems, Windows cross-volume), it copies the file.
This means:
- An environment takes almost no disk space for packages that are already cached.
- Creating a new environment is fast (linking is cheap).
Transactions¶
The Installer computes a transaction, a diff between the currently-installed state and the desired state, and applies only the changes:
- Install packages not currently present
- Remove packages no longer needed
- Update packages whose version changed
This makes shot install idempotent: running it twice with the same manifest
is a no-op.
Implementation¶
Session install methods¶
Now that we have the resolve pipeline in Session, we can add methods that
handle the installation side. These live in src/session.rs alongside the
resolve logic:
<<session-install-packages>> [1, 2]
<<session-resolve-and-install>>
The install_packages method takes a solved set of packages and links them into a prefix.
It scans the prefix for already-installed packages (to compute a minimal
transaction) and runs the Installer with progress bars.
The Installer is configured with a builder chain. Each method sets one aspect of the install:
.with_download_clientsets the HTTP client for fetching packages..with_target_platformselects which platform we are installing for..with_installed_packagestells the installer what is already in the prefix (so it can skip re-installing)..with_execute_link_scriptscontrols whether to run post-link scripts after installation..with_requested_specsrecords which packages the user directly asked for (vs. transitive dependencies)..with_reporterattaches a progress reporting callback.
impl Session {
/// Install a set of solved packages into the given prefix.
pub async fn install_packages(
&self,
prefix: &std::path::Path,
solution: Vec<RepoDataRecord>,
platform: Platform,
) -> miette::Result<()> {
let specs = self.project.manifest.match_specs()?;
let installed_packages =
PrefixRecord::collect_from_prefix::<PrefixRecord>(prefix).into_diagnostic()?;
let start_install = Instant::now();
let result = Installer::new()
.with_download_client(self.client.clone())
.with_target_platform(platform)
.with_installed_packages(installed_packages)
.with_execute_link_scripts(true)
.with_requested_specs(specs)
.with_reporter(IndicatifReporter::builder().finish())
.install(prefix, solution)
.await
.into_diagnostic()
.context("installing packages")?;
After the installer finishes, we check whether it actually changed anything. An empty transaction means the prefix already matches the solution, so we skip the "updated" message.
if result.transaction.operations.is_empty() {
println!(
"{} Environment already up to date",
console::style("✔").green()
);
} else {
println!(
"{} Environment updated in {:.1}s",
console::style("✔").green(),
start_install.elapsed().as_secs_f64()
);
println!(" Activate with: eval $(shot shell-hook)");
}
Ok(())
}
The installer needs to know which packages you directly requested (as opposed
to transitive dependencies) via with_requested_specs. It records this in the
conda-meta/*.json files so that future updates can correctly distinguish
"you asked for this" from "installed because something else needed it".
IndicatifReporter is a rattler-provided reporter backed by indicatif that shows per-package
progress bars during download and extraction. If you want custom progress
display, you can implement your own; it's a trait, not a concrete type.
Setting with_execute_link_scripts(true) tells the installer to run conda's
link scripts after installation. These are scripts in
<prefix>/etc/conda/activate.d/ that some packages use to set up post-install
configuration (updating PKG_CONFIG_PATH, for example).
The resolve_and_install method resolves and installs in one step, without writing a lock file.
We'll reuse it in the build command (Chapter 10) to install
build-time dependencies into a temporary prefix.
/// Resolve and install in one step, without writing a lock file.
pub async fn resolve_and_install(
&self,
prefix: std::path::PathBuf,
) -> miette::Result<Vec<RepoDataRecord>> {
let platform = Platform::current();
let (solution, _channels, platform) = self.resolve(platform, vec![]).await?;
let result = solution.clone();
self.install_packages(&prefix, solution, platform).await?;
Ok(result)
}
}
src/commands/install.rs¶
Here is the full file skeleton, with each section defined as we encounter it:
<<install-imports>>
<<install-args>>
<<install-execute>> [1, 2]
The imports are light because the install command delegates to Session for both
resolving and installing:
use clap::Parser;
use fs_err as fs;
use miette::{Context, IntoDiagnostic};
use crate::lock::LOCK_FILENAME;
use crate::project::Project;
use crate::session::{ResolveStatus, Session};
An optional --prefix flag overrides the default install location:
#[derive(Debug, Parser)]
pub struct Args {
/// Override the target prefix (where packages are installed).
///
/// Defaults to `.env/` relative to the project root.
#[clap(long)]
pub prefix: Option<std::path::PathBuf>,
}
The execute function is our entry point for shot install. It uses
Session::ensure_resolved to check the lock file and resolve if needed,
then calls install_packages to link everything into the prefix.
pub async fn execute(args: Args) -> miette::Result<()> {
let project = Project::discover()?;
let session = Session::new(project)?;
let prefix = args
.prefix
.unwrap_or_else(|| session.project.default_prefix());
fs::create_dir_all(&prefix)
.into_diagnostic()
.context("creating prefix directory")?;
let prefix = std::path::absolute(prefix).into_diagnostic()?;
With the prefix ready, we check the lock file and resolve if needed. The
ResolveStatus tells us whether the solver ran so we can print the right
message.
let status = session.ensure_resolved(false).await?;
match &status {
ResolveStatus::AlreadyFresh(_) => {}
ResolveStatus::Resolved { solution, .. } => {
println!(
"{} Wrote {} ({} packages)",
console::style("✔").green(),
LOCK_FILENAME,
console::style(solution.len()).cyan()
);
}
}
let platform = rattler_conda_types::Platform::current();
session
.install_packages(&prefix, status.into_solution(), platform)
.await
}
If the lock is fresh, ensure_resolved returns AlreadyFresh and the solver
is never invoked. If the lock is stale or missing, it resolves, writes the new
lock, and returns the solution. Either way, install_packages links the
packages into the prefix.
Running shot install¶
$ shot install
⠋ Fetching repodata
1523 repodata records loaded
⠋ Solving
Solved 5 packages in 0.3s
✔ Wrote moonshot.lock (5 packages)
Downloading and extracting packages...
✔ Environment updated in 2.1s
Activate with: eval $(shot shell-hook)
Try it right away:
The Lua interpreter was fetched from conda-forge, unpacked, cached, and linked
into .env/bin/. We can run it without activating the shell because shot run
sets up the environment automatically (we'll build that in Chapter 9).
What gets installed where¶
After shot install, the prefix looks like this:
- .env/
- bin/
- lua the Lua interpreter
- luarocks LuaRocks (if installed)
- lib/
- liblua.so.5.4
- …
- share/
- lua/5.4/
- … pure-Lua libraries
- lua/5.4/
- conda-meta/
- lua-5.4.7-h5eee18b_0.json
- … one file per installed package
- bin/
The conda-meta/ directory is rattler's installation database. Each JSON
file records the package name, version, build, all installed files, and their
hashes. You can inspect these to see exactly what's in your environment.
Exercises¶
List Installed Packages
Add a shot list command that reads the installed prefix and lists all packages. Use PrefixRecord::collect_from_prefix to discover installed packages, then display each one's name, version, and build string.
- Acceptance criteria
-
shot listprints all installed packages sorted alphabetically- If
.env/does not exist, prints "No environment found. Runshot installfirst." - Total count printed at the end
Dry-Run Installation
Add a --dry-run flag to shot install that resolves dependencies and shows what would be installed without actually downloading or linking anything. Compare the resolved packages against what is already in the prefix (via PrefixRecord::collect_from_prefix) and report what would be added, updated, or unchanged.
- Acceptance criteria
-
shot install --dry-runshows packages that would be installed with their versions and sizes- Already-installed packages are listed as "unchanged" or "update from X to Y"
- No files are downloaded or written to the prefix
- Exit code 0 on success
Reinstall Command
Implement shot reinstall that removes the existing environment prefix and re-installs everything from the lock file. This forces a clean install, useful when the prefix is corrupted or when switching platforms. Read the lock file, remove the prefix directory, then run the full install pipeline. Add a --relock flag that also re-resolves before installing.
- Acceptance criteria
-
shot reinstallremoves.env/, reads the lock file, and installs all locked packages fresh- If no lock file exists, it resolves first then installs
shot reinstall --relockforces re-resolution before installing- Progress output shows the full install (downloading + linking)
- After reinstall,
shot listshows the same packages as before
Summary¶
- The install command checks the lock file before doing any work.
- If the lock is fresh, packages are installed directly from it (no solver, no network).
- If the lock is stale or missing,
Session::ensure_resolvedruns the full pipeline and the result is written tomoonshot.lockbefore installation. - The
Installercomputes a transaction (diff) and applies only the changes. - Files are linked from the central cache into the prefix, using reflinks where available and falling back to hard links or copies.
Session::resolve_and_installprovides a one-call interface for the build command.
In the next chapter we set up shell hooks, which generate activation scripts so you can use the installed packages.