Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ env:
CARGO_TERM_COLOR: always

jobs:
test:
name: Test
runs-on: ubuntu-latest
quality:
name: Quality / Linux amd64
runs-on: ubuntu-24.04
steps:
- name: Check out repository
uses: actions/checkout@v4
Expand All @@ -26,11 +26,80 @@ jobs:
- name: Check formatting
run: cargo fmt --check

- name: Run Clippy
run: cargo clippy --locked --all-targets -- -D warnings

test:
name: Test / ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux amd64
os: ubuntu-24.04
- name: Linux arm64
os: ubuntu-24.04-arm
- name: Windows x64
os: windows-2025
- name: macOS x64
os: macos-15-intel
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Install Rust toolchain
run: |
rustup toolchain install stable --profile minimal
rustup default stable

- name: Run unit and integration tests
if: runner.os != 'Windows'
run: cargo test --locked

- name: Run Clippy
run: cargo clippy --locked --all-targets -- -D warnings
- name: Run unit tests
if: runner.os == 'Windows'
run: cargo test --locked --bin vex

- name: Build
run: cargo build --locked

macos-arm64:
name: Build / macOS arm64
runs-on: macos-15-intel
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Install Rust toolchain
run: |
rustup toolchain install stable --profile minimal --target aarch64-apple-darwin
rustup default stable

- name: Cross-build
run: cargo build --locked --target aarch64-apple-darwin

linux-riscv64:
name: Build / Linux riscv64
runs-on: ubuntu-24.04
env:
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Install RISC-V system tools
run: |
sudo apt-get update
sudo apt-get install --yes gcc-riscv64-linux-gnu qemu-user

- name: Install Rust toolchain
run: |
rustup toolchain install stable --profile minimal --target riscv64gc-unknown-linux-gnu
rustup default stable

- name: Cross-build
run: cargo build --locked --target riscv64gc-unknown-linux-gnu

- name: Run RISC-V smoke test with QEMU
run: qemu-riscv64 -L /usr/riscv64-linux-gnu target/riscv64gc-unknown-linux-gnu/debug/vex --version
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ vex build [--target <triple>] [--release] [--dry-run] [--locked] [--offline]
vex run [--target <triple>] [--release] [--dry-run] [--locked] [--offline] [-- <args...>]
vex check [--target <triple>] [--release] [--dry-run] [--locked] [--offline]
vex fetch [--locked] [--offline]
vex update
vex update [<package>...]
vex info
vex setup wavec [--version <version>]
vex --version
Expand Down Expand Up @@ -86,7 +86,17 @@ A dependency entry must use exactly one of `path` or `git`. Git dependencies may

Fetched Git dependencies are stored under `.vex/deps/<name>`. Every fetched dependency must contain a `vex.ws` file at its root. Dependency manifests are resolved recursively, and a package name must identify one source and version requirement across the graph.

On the first `vex fetch`, build, run, or check, Vex resolves each Git selector to an exact commit and records the complete transitive graph in `vex.lock`. Later commands reuse those commits without updating branches or tags. Run `vex update` explicitly to refresh Git refs and rewrite the lockfile.
On the first `vex fetch`, build, run, or check, Vex resolves each Git selector to an exact commit and records the complete transitive graph in `vex.lock`. Later commands reuse those commits without updating branches or tags. Run `vex update` explicitly to refresh every Git dependency and rewrite the lockfile.

Pass one or more package names to update only those packages, including transitive dependencies. Unrelated packages keep their exact locked commits and are not fetched. If an updated package changes its dependencies, Vex recalculates that part of the graph while preserving unrelated locked packages.

```sh
# Refresh the complete Git dependency graph.
vex update

# Refresh only alpha and the transitive package shared_core.
vex update alpha shared_core
```

Commit `vex.lock` so the same manifest and lockfile select the same dependency graph. A dry run never fetches or rewrites dependencies; use `vex fetch` first when the locked checkout is not available locally.

Expand Down
4 changes: 2 additions & 2 deletions src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use std::time::Instant;

use crate::manifest::Manifest;
use crate::resolver::{resolve, ResolveOptions};
use crate::resolver::{resolve, ResolveOptions, UpdatePolicy};
use crate::ui;
use crate::validate::{collect_inputs, validate_build_invocation, BuildValidationRequest};
use crate::wavec::run_build_with_dry_run;
Expand Down Expand Up @@ -75,7 +75,7 @@ fn run_build(mode: BuildMode, args: &[String]) -> Result<(), String> {
&manifest,
ResolveOptions {
dry_run: options.dry_run,
update: false,
update: UpdatePolicy::ReuseLocked,
locked: options.locked,
offline: options.offline,
},
Expand Down
46 changes: 42 additions & 4 deletions src/commands/deps.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
use std::collections::BTreeSet;
use std::time::Instant;

use crate::manifest::Manifest;
use crate::resolver::{resolve, ResolveOptions};
use crate::resolver::{resolve, ResolveOptions, UpdatePolicy};
use crate::ui;

#[derive(Debug, Default)]
struct DependencyOptions {
locked: bool,
offline: bool,
packages: BTreeSet<String>,
}

pub fn fetch(update: bool, args: &[String]) {
Expand All @@ -33,11 +35,34 @@ fn run_fetch(update: bool, args: &[String]) -> Result<(), String> {
}
let started = Instant::now();
let manifest = Manifest::load()?;
let update_policy = if update {
if options.packages.is_empty() {
ui::status("Updating", "all Git dependencies");
UpdatePolicy::UpdateAll
} else {
ui::status(
"Updating",
format!(
"Git package{} {}",
if options.packages.len() == 1 { "" } else { "s" },
options
.packages
.iter()
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ")
),
);
UpdatePolicy::UpdateSelected(options.packages)
}
} else {
UpdatePolicy::ReuseLocked
};
let resolution = resolve(
&manifest,
ResolveOptions {
dry_run: false,
update,
update: update_policy,
locked: options.locked,
offline: options.offline,
},
Expand Down Expand Up @@ -66,16 +91,19 @@ fn parse_options(update: bool, args: &[String]) -> Result<DependencyOptions, Str
"--offline" => options.offline = true,
"-h" | "--help" => {
return Err(if update {
"usage: vex update".to_string()
"usage: vex update [<package>...]".to_string()
} else {
"usage: vex fetch [--locked] [--offline]".to_string()
});
}
_ if update && !argument.starts_with('-') => {
options.packages.insert(argument.clone());
}
_ => {
return Err(format!(
"unknown Vex option `{argument}`\nusage: vex {}",
if update {
"update"
"update [<package>...]"
} else {
"fetch [--locked] [--offline]"
}
Expand All @@ -101,4 +129,14 @@ mod tests {
assert!(options.locked);
assert!(options.offline);
}

#[test]
fn parses_and_deduplicates_targeted_update_packages() {
let options = parse_options(true, &strings(&["beta", "alpha", "beta"]))
.expect("targeted update packages must parse");
assert_eq!(
options.packages.into_iter().collect::<Vec<_>>(),
["alpha", "beta"]
);
}
}
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ fn print_help() {
);
println!(" vex check [--target <triple>] [--release] [--dry-run] [--locked] [--offline]");
println!(" vex fetch [--locked] [--offline]");
println!(" vex update");
println!(" vex update [<package>...]");
println!(" vex info");
println!(" vex setup wavec [--version <version>]");
println!(" vex --version");
Expand Down
82 changes: 74 additions & 8 deletions src/resolver.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
Expand All @@ -10,14 +10,36 @@ use crate::lockfile::{
use crate::manifest::{Dependency, DependencySource, Manifest, MANIFEST_FILE};
use crate::ui;

#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Debug, Default)]
pub struct ResolveOptions {
pub dry_run: bool,
pub update: bool,
pub update: UpdatePolicy,
pub locked: bool,
pub offline: bool,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub enum UpdatePolicy {
#[default]
ReuseLocked,
UpdateAll,
UpdateSelected(BTreeSet<String>),
}

impl UpdatePolicy {
fn is_update(&self) -> bool {
!matches!(self, Self::ReuseLocked)
}

fn updates(&self, package: &str) -> bool {
match self {
Self::ReuseLocked => false,
Self::UpdateAll => true,
Self::UpdateSelected(packages) => packages.contains(package),
}
}
}

#[derive(Debug)]
pub struct Resolution {
packages: Vec<LockedPackage>,
Expand Down Expand Up @@ -50,10 +72,10 @@ pub fn resolve(manifest: &Manifest, options: ResolveOptions) -> Result<Resolutio
);
}

if options.update && options.locked {
if options.update.is_update() && options.locked {
return Err("`--locked` cannot be used while updating dependencies".to_string());
}
if options.update && options.offline {
if options.update.is_update() && options.offline {
return Err("`--offline` cannot be used while updating Git dependencies".to_string());
}

Expand All @@ -74,6 +96,8 @@ pub fn resolve(manifest: &Manifest, options: ResolveOptions) -> Result<Resolutio
let existing = existing.unwrap_or_else(Lockfile::empty);
let root = env_root()?;
let dep_root = root.join(".vex/deps");
let locked = options.locked;
let dry_run = options.dry_run;
if !options.dry_run {
fs::create_dir_all(&dep_root)
.map_err(|e| format!("failed to create `{}`: {e}", dep_root.display()))?;
Expand All @@ -89,6 +113,7 @@ pub fn resolve(manifest: &Manifest, options: ResolveOptions) -> Result<Resolutio
visiting: Vec::new(),
};
resolver.resolve_manifest_dependencies(manifest)?;
resolver.validate_selected_packages()?;

let resolved = Lockfile {
version: LOCKFILE_VERSION,
Expand All @@ -97,12 +122,12 @@ pub fn resolve(manifest: &Manifest, options: ResolveOptions) -> Result<Resolutio
.normalized();

if resolved != existing {
if options.locked {
if locked {
return Err(format!(
"`{LOCKFILE_NAME}` needs to be updated, but `--locked` prevents changes\nhelp: run `vex fetch` and commit the updated `{LOCKFILE_NAME}`"
));
}
if options.dry_run {
if dry_run {
return Err(format!(
"dependency graph differs from `{LOCKFILE_NAME}`\nhelp: run `vex fetch` to resolve and lock dependencies"
));
Expand Down Expand Up @@ -153,6 +178,47 @@ enum RequestKey {
}

impl Resolver<'_> {
fn validate_selected_packages(&self) -> Result<(), String> {
let UpdatePolicy::UpdateSelected(selected) = &self.options.update else {
return Ok(());
};

let available = self
.packages
.values()
.filter(|package| matches!(package.source, LockedSource::Git { .. }))
.map(|package| package.name.as_str())
.collect::<BTreeSet<_>>();
let unavailable = selected
.iter()
.filter(|name| !available.contains(name.as_str()))
.cloned()
.collect::<Vec<_>>();

if unavailable.is_empty() {
return Ok(());
}

let requested = unavailable
.iter()
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ");
let package_label = if unavailable.len() == 1 {
"package"
} else {
"packages"
};
let available = if available.is_empty() {
"<none>".to_string()
} else {
available.into_iter().collect::<Vec<_>>().join(", ")
};
Err(format!(
"cannot update {package_label} {requested}: one or more requested names are not Git dependencies in the current graph\nhelp: available Git packages: {available}\nhelp: run `vex update <package>...` using one or more available package names"
))
}

fn resolve_manifest_dependencies(
&mut self,
manifest: &Manifest,
Expand Down Expand Up @@ -309,7 +375,7 @@ impl Resolver<'_> {
return Err("internal error: expected Git dependency".to_string());
};

let locked = if self.options.update {
let locked = if self.options.update.updates(&dependency.name) {
None
} else {
self.existing
Expand Down
Loading
Loading