From fca80dce1d61e49b65868d9e3ac347ef077a8fc3 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sat, 8 Aug 2026 14:35:11 +0900 Subject: [PATCH 1/3] Add targeted dependency updates --- README.md | 14 +- src/commands/build.rs | 4 +- src/commands/deps.rs | 46 +++++- src/main.rs | 2 +- src/resolver.rs | 82 +++++++++- tests/git_targeted_update.rs | 291 +++++++++++++++++++++++++++++++++++ 6 files changed, 422 insertions(+), 17 deletions(-) create mode 100644 tests/git_targeted_update.rs diff --git a/README.md b/README.md index 38fefb0..9c9055d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ vex build [--target ] [--release] [--dry-run] [--locked] [--offline] vex run [--target ] [--release] [--dry-run] [--locked] [--offline] [-- ] vex check [--target ] [--release] [--dry-run] [--locked] [--offline] vex fetch [--locked] [--offline] -vex update +vex update [...] vex info vex setup wavec [--version ] vex --version @@ -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/`. 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. diff --git a/src/commands/build.rs b/src/commands/build.rs index ecf0a11..a4d89d2 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -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; @@ -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, }, diff --git a/src/commands/deps.rs b/src/commands/deps.rs index 23ee92a..a87e268 100644 --- a/src/commands/deps.rs +++ b/src/commands/deps.rs @@ -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, } pub fn fetch(update: bool, args: &[String]) { @@ -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::>() + .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, }, @@ -66,16 +91,19 @@ fn parse_options(update: bool, args: &[String]) -> Result options.offline = true, "-h" | "--help" => { return Err(if update { - "usage: vex update".to_string() + "usage: vex update [...]".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 [...]" } else { "fetch [--locked] [--offline]" } @@ -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::>(), + ["alpha", "beta"] + ); + } } diff --git a/src/main.rs b/src/main.rs index 44c1df9..a2b4997 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,7 +87,7 @@ fn print_help() { ); println!(" vex check [--target ] [--release] [--dry-run] [--locked] [--offline]"); println!(" vex fetch [--locked] [--offline]"); - println!(" vex update"); + println!(" vex update [...]"); println!(" vex info"); println!(" vex setup wavec [--version ]"); println!(" vex --version"); diff --git a/src/resolver.rs b/src/resolver.rs index 0036d79..d0d08ec 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -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}; @@ -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), +} + +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, @@ -50,10 +72,10 @@ pub fn resolve(manifest: &Manifest, options: ResolveOptions) -> Result Result Result Result { + 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::>(); + let unavailable = selected + .iter() + .filter(|name| !available.contains(name.as_str())) + .cloned() + .collect::>(); + + if unavailable.is_empty() { + return Ok(()); + } + + let requested = unavailable + .iter() + .map(|name| format!("`{name}`")) + .collect::>() + .join(", "); + let package_label = if unavailable.len() == 1 { + "package" + } else { + "packages" + }; + let available = if available.is_empty() { + "".to_string() + } else { + available.into_iter().collect::>().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 ...` using one or more available package names" + )) + } + fn resolve_manifest_dependencies( &mut self, manifest: &Manifest, @@ -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 diff --git a/tests/git_targeted_update.rs b/tests/git_targeted_update.rs new file mode 100644 index 0000000..0efdf24 --- /dev/null +++ b/tests/git_targeted_update.rs @@ -0,0 +1,291 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new() -> Self { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "vex-targeted-update-test-{}-{id}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory must be created"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn targeted_update_preserves_unrelated_commits_and_fetches() { + let fixture = TestDir::new(); + let alpha = fixture.path().join("alpha"); + let beta = fixture.path().join("beta"); + let app = fixture.path().join("app"); + + create_package(&alpha, "alpha", &[]); + init_git(&alpha); + let alpha_initial = commit_all(&alpha, "initial alpha"); + + create_package(&beta, "beta", &[]); + init_git(&beta); + let beta_initial = commit_all(&beta, "initial beta"); + + create_package( + &app, + "app", + &[ + ("alpha", git_url(&alpha), Some("master")), + ("beta", git_url(&beta), Some("master")), + ], + ); + + assert_success(&vex(&app, &["fetch"]), "initial vex fetch"); + assert_eq!(locked_commit(&app, "alpha"), alpha_initial); + assert_eq!(locked_commit(&app, "beta"), beta_initial); + + let locked_update = vex(&app, &["update", "alpha", "--locked"]); + assert_failure(&locked_update, "locked targeted update"); + assert!(String::from_utf8_lossy(&locked_update.stderr) + .contains("`--locked` cannot be used with `vex update`")); + let offline_update = vex(&app, &["update", "alpha", "--offline"]); + assert_failure(&offline_update, "offline targeted update"); + assert!(String::from_utf8_lossy(&offline_update.stderr) + .contains("`--offline` cannot be used with `vex update`")); + + fs::write(alpha.join("CHANGELOG.md"), "new alpha revision\n") + .expect("alpha update must be written"); + let alpha_updated = commit_all(&alpha, "update alpha"); + fs::write(beta.join("CHANGELOG.md"), "new beta revision\n") + .expect("beta update must be written"); + let beta_updated = commit_all(&beta, "update beta"); + + let update_alpha = vex(&app, &["update", "alpha"]); + assert_success(&update_alpha, "targeted alpha update"); + let update_stderr = String::from_utf8_lossy(&update_alpha.stderr); + assert!(update_stderr.contains("Updating"), "{update_stderr}"); + assert!(update_stderr.contains("`alpha`"), "{update_stderr}"); + assert_eq!(locked_commit(&app, "alpha"), alpha_updated); + assert_eq!(locked_commit(&app, "beta"), beta_initial); + assert_eq!( + git_stdout( + &app.join(".vex/deps/beta"), + &["rev-parse", "refs/remotes/origin/master"] + ), + beta_initial, + "the unrelated beta checkout must not fetch its updated branch" + ); + + let lock_before_unknown = read_lock(&app); + let unknown = vex(&app, &["update", "missing"]); + assert_failure(&unknown, "unknown targeted package update"); + let unknown_stderr = String::from_utf8_lossy(&unknown.stderr); + assert!( + unknown_stderr.contains("package `missing`"), + "{unknown_stderr}" + ); + assert!( + unknown_stderr.contains("available Git packages: alpha, beta"), + "{unknown_stderr}" + ); + assert!( + unknown_stderr.contains("vex update ..."), + "{unknown_stderr}" + ); + assert_eq!(read_lock(&app), lock_before_unknown); + + let update_all = vex(&app, &["update"]); + assert_success(&update_all, "complete dependency update"); + assert_eq!(locked_commit(&app, "alpha"), alpha_updated); + assert_eq!(locked_commit(&app, "beta"), beta_updated); +} + +#[test] +fn targeted_update_accepts_transitive_packages_and_recalculates_their_graph() { + let fixture = TestDir::new(); + let leaf = fixture.path().join("leaf"); + let added = fixture.path().join("added"); + let middle = fixture.path().join("middle"); + let app = fixture.path().join("app"); + + create_package(&leaf, "leaf", &[]); + init_git(&leaf); + let leaf_initial = commit_all(&leaf, "initial leaf"); + + create_package(&added, "added", &[]); + init_git(&added); + let added_commit = commit_all(&added, "initial added"); + + create_package( + &middle, + "middle", + &[("leaf", git_url(&leaf), Some("master"))], + ); + init_git(&middle); + let middle_initial = commit_all(&middle, "initial middle"); + + create_package(&app, "app", &[("middle", git_url(&middle), Some("master"))]); + assert_success(&vex(&app, &["fetch"]), "initial transitive fetch"); + + fs::write(leaf.join("CHANGELOG.md"), "new leaf revision\n") + .expect("leaf update must be written"); + let leaf_updated = commit_all(&leaf, "update leaf"); + + let update_leaf = vex(&app, &["update", "leaf"]); + assert_success(&update_leaf, "targeted transitive update"); + assert_eq!(locked_commit(&app, "leaf"), leaf_updated); + assert_eq!(locked_commit(&app, "middle"), middle_initial); + assert_ne!(leaf_initial, leaf_updated); + + create_package( + &middle, + "middle", + &[ + ("leaf", git_url(&leaf), Some("master")), + ("added", git_url(&added), Some("master")), + ], + ); + let middle_updated = commit_all(&middle, "add transitive dependency"); + + let update_middle = vex(&app, &["update", "middle"]); + assert_success(&update_middle, "targeted graph-changing update"); + assert_eq!(locked_commit(&app, "middle"), middle_updated); + assert_eq!(locked_commit(&app, "leaf"), leaf_updated); + assert_eq!(locked_commit(&app, "added"), added_commit); + assert!(read_lock(&app).contains("dependencies = [\"added\", \"leaf\"]")); +} + +fn create_package(path: &Path, name: &str, dependencies: &[(&str, String, Option<&str>)]) { + fs::create_dir_all(path).expect("package directory must be created"); + let dependency_entries = dependencies + .iter() + .map(|(dependency, url, branch)| match branch { + Some(branch) => format!( + " {{ name = \"{dependency}\", git = \"{url}\", branch = \"{branch}\" }}" + ), + None => format!(" {{ name = \"{dependency}\", git = \"{url}\" }}"), + }) + .collect::>(); + let dependencies = if dependency_entries.is_empty() { + "[]".to_string() + } else { + format!("[\n{}\n ]", dependency_entries.join(",\n")) + }; + let manifest = format!( + "{{\n name = \"{name}\",\n version = 0.1.0,\n lib = true,\n dependencies = {dependencies}\n}}\n" + ); + fs::write(path.join("vex.ws"), manifest).expect("manifest must be written"); +} + +fn init_git(path: &Path) { + let output = Command::new("git") + .args(["init", "-q", "-b", "master"]) + .current_dir(path) + .output() + .expect("git init must start"); + assert_success(&output, "git init"); +} + +fn commit_all(path: &Path, message: &str) -> String { + let add = Command::new("git") + .args(["add", "."]) + .current_dir(path) + .output() + .expect("git add must start"); + assert_success(&add, "git add"); + + let commit = Command::new("git") + .args([ + "-c", + "user.name=Vex Test", + "-c", + "user.email=vex@example.invalid", + "commit", + "-q", + "-m", + message, + ]) + .current_dir(path) + .output() + .expect("git commit must start"); + assert_success(&commit, "git commit"); + git_stdout(path, &["rev-parse", "HEAD"]) +} + +fn git_stdout(path: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(path) + .output() + .expect("git command must start"); + assert_success(&output, "git command"); + String::from_utf8(output.stdout) + .expect("git output must be UTF-8") + .trim() + .to_string() +} + +fn git_url(path: &Path) -> String { + format!("file://{}", path.to_string_lossy()) +} + +fn vex(path: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_vex")) + .args(args) + .current_dir(path) + .output() + .expect("vex command must start") +} + +fn read_lock(app: &Path) -> String { + fs::read_to_string(app.join("vex.lock")).expect("vex.lock must exist") +} + +fn locked_commit(app: &Path, package: &str) -> String { + let lock = read_lock(app); + let marker = format!("name = \"{package}\""); + let package_entry = lock + .split_once(&marker) + .unwrap_or_else(|| panic!("package `{package}` must be present in lockfile")) + .1; + package_entry + .lines() + .find_map(|line| { + line.trim() + .strip_prefix("commit = \"") + .and_then(|value| value.strip_suffix("\",")) + .map(str::to_string) + }) + .unwrap_or_else(|| panic!("package `{package}` must have a locked commit")) +} + +fn assert_success(output: &Output, action: &str) { + assert!( + output.status.success(), + "{action} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_failure(output: &Output, action: &str) { + assert!( + !output.status.success(), + "{action} unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} From 010347b22d2a40ffd6c665dbf765ef87f4210ca6 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sat, 8 Aug 2026 15:04:19 +0900 Subject: [PATCH 2/3] Expand cross-platform CI coverage --- .github/workflows/ci.yml | 76 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 754fe11..8c12e0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -26,11 +26,75 @@ jobs: - name: Check formatting run: cargo fmt --check - - name: Run unit and integration tests - run: cargo test --locked - - 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 + run: cargo test --locked + - 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 From df7b2cb752ff1178bfd375e4a7a6730dcf7db35c Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sat, 8 Aug 2026 16:39:23 +0900 Subject: [PATCH 3/3] Limit Windows CI to unit tests --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c12e0a..28098b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,8 +54,13 @@ jobs: rustup default stable - name: Run unit and integration tests + if: runner.os != 'Windows' run: cargo test --locked + - name: Run unit tests + if: runner.os == 'Windows' + run: cargo test --locked --bin vex + - name: Build run: cargo build --locked