From 51ea99dcdfce8ccad31af277025cd3019587c3c2 Mon Sep 17 00:00:00 2001 From: Mike Hilgendorf Date: Wed, 25 Mar 2026 14:38:54 -0500 Subject: [PATCH 1/3] feat(cli): add printout for `tg updated` and force `tg outdated` to use lockfiles --- packages/cli/src/outdated.rs | 165 ++++++---- packages/cli/src/update.rs | 308 +++++++++++++++++- packages/cli/tests/tag/outdated.nu | 1 + packages/cli/tests/update/updated_printout.nu | 92 ++++++ 4 files changed, 493 insertions(+), 73 deletions(-) create mode 100644 packages/cli/tests/update/updated_printout.nu diff --git a/packages/cli/src/outdated.rs b/packages/cli/src/outdated.rs index 05f96864a..e1c4d56d6 100644 --- a/packages/cli/src/outdated.rs +++ b/packages/cli/src/outdated.rs @@ -1,4 +1,11 @@ -use {crate::Cli, std::collections::HashSet, tangram_client::prelude::*}; +use { + crate::Cli, + std::{ + collections::{BTreeSet, HashSet}, + path::PathBuf, + }, + tangram_client::prelude::*, +}; /// Get a package's outdated dependencies. #[derive(Clone, Debug, clap::Args)] @@ -11,95 +18,53 @@ pub struct Args { pub print: crate::print::Options, #[arg(default_value = ".", index = 1)] - pub reference: tg::Reference, + pub path: PathBuf, } impl Cli { pub async fn command_outdated(&mut self, args: Args) -> tg::Result<()> { let handle = self.handle().await?; - let mut visitor = Visitor::default(); - let arg = tg::get::Arg { - checkin: args.checkin.to_options(), - ..Default::default() - }; - let referent = self - .get_reference_with_arg(&args.reference, arg, true) - .await? - .try_map(|item| item.left().ok_or_else(|| tg::error!("expected an object")))?; - tg::object::visit(&handle, &mut visitor, &referent, false) - .await - .map_err(|source| tg::error!(!source, "failed to walk objects"))?; - let output = visitor.entries.into_iter().collect::>(); - self.print_serde(output, args.print).await?; - Ok(()) - } -} -#[derive(Default)] -struct Visitor { - entries: HashSet, -} - -#[derive(serde::Serialize, Debug, PartialEq, Eq, Hash)] -struct Entry { - current: tg::Tag, - compatible: Option, - latest: Option, -} - -impl tg::object::Visitor for Visitor -where - H: tg::Handle, -{ - async fn visit_directory( - &mut self, - _handle: &H, - _directory: tg::Referent<&tg::Directory>, - ) -> tg::Result { - Ok(true) - } + // Find the root. + let root = Self::find_root(args.path.clone()) + .await + .map_err(|source| tg::error!(!source, "failed to find the root"))?; - async fn visit_symlink( - &mut self, - _handle: &H, - _symlink: tg::Referent<&tg::Symlink>, - ) -> tg::Result { - Ok(true) - } + // Deserialize the lock. + let lock = Self::try_read_lock(root) + .await + .map_err(|source| tg::error!(!source, "failed to read lockfile"))? + .ok_or_else(|| tg::error!("missing lockfile"))?; - async fn visit_file(&mut self, handle: &H, file: tg::Referent<&tg::File>) -> tg::Result { - let file = file.item; + // Edge case, do nothing. + if lock.nodes.is_empty() { + return Ok(()); + } - // Get the file's dependencies. - let dependencies = file - .dependencies(handle) - .await - .map_err(|source| tg::error!(!source, "failed to get the file's dependencies"))? - .into_iter() - .filter_map(|(reference, option)| { - let pattern = reference.item().clone().try_unwrap_tag().ok()?; - let tag = option?.0.options.tag.take()?; - Some((pattern, tag)) - }); + // Collect dependencies. + let mut visitor = Visitor::default(); + visitor.walk(0, &lock.nodes); - for (pattern, current) in dependencies { + // Find compatible and latest versions. + let mut output: HashSet = HashSet::default(); + for (pattern, current) in visitor.dependencies { let compatible = handle .list_tags(tg::tag::list::Arg { + pattern: pattern.clone(), cached: false, - length: Some(1), + length: None, local: None, - pattern: pattern.clone(), recursive: false, remotes: None, reverse: true, ttl: None, }) - .await? + .await + .map_err(|source| tg::error!(!source, "failed to list tags"))? .data .into_iter() .map(|output| output.tag) .next(); - let mut components = pattern.components().collect::>(); components.pop(); components.push("*"); @@ -120,16 +85,72 @@ where .into_iter() .map(|output| output.tag) .next(); - - let entry = Entry { + output.insert(Entry { current, compatible, latest, - }; + }); + } + + // Display output. + self.print_serde(output, args.print).await?; + Ok(()) + } +} - self.entries.insert(entry); +#[derive(Default)] +struct Visitor { + dependencies: Vec<(tg::tag::Pattern, tg::Tag)>, + visited: BTreeSet, +} + +#[derive(serde::Serialize, Debug, PartialEq, Eq, Hash)] +struct Entry { + current: tg::Tag, + compatible: Option, + latest: Option, +} + +impl Visitor { + fn walk(&mut self, node: usize, nodes: &[tg::graph::data::Node]) { + if !self.visited.insert(node) { + return; } + let node = &nodes[node]; + match node { + tg::graph::data::Node::Directory(directory) => { + let entries = crate::update::flatten_directory(directory, nodes); + for node in entries.into_values() { + self.walk(node, nodes); + } + }, + tg::graph::data::Node::File(file) => { + for (reference, dependency) in &file.dependencies { + let Some(dependency) = dependency else { + continue; + }; - Ok(true) + let pattern = reference.item().try_unwrap_tag_ref().ok().cloned(); + let tag = dependency.tag().cloned(); + if let (Some(pattern), Some(tag)) = (pattern, tag) { + self.dependencies.push((pattern, tag)); + } + if let Some(dependency) = dependency.0.item().as_ref().and_then(|edge| { + let pointer = edge.try_unwrap_pointer_ref().ok()?; + pointer.graph.is_none().then_some(pointer.index) + }) { + self.walk(dependency, nodes); + } + } + }, + tg::graph::data::Node::Symlink(symlink) => { + if let Some(artifact) = symlink.artifact.as_ref().and_then(|edge| { + let pointer = edge.try_unwrap_pointer_ref().ok()?; + pointer.graph.is_none().then_some(pointer.index) + }) { + self.walk(artifact, nodes); + } + }, + } } } diff --git a/packages/cli/src/update.rs b/packages/cli/src/update.rs index 0a061cbe1..a13e0f88f 100644 --- a/packages/cli/src/update.rs +++ b/packages/cli/src/update.rs @@ -1,4 +1,11 @@ -use {crate::Cli, std::path::PathBuf, tangram_client::prelude::*}; +use { + crate::Cli, + std::{ + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, + }, + tangram_client::prelude::*, +}; /// Update a lock. #[derive(Clone, Debug, clap::Args)] @@ -27,6 +34,18 @@ impl Cli { .await .map_err(|source| tg::error!(!source, "failed to canonicalize the path"))?; + // Get the old lockfile. + let root = if args.checkin.root { + path.clone() + } else { + Self::find_root(path.clone()) + .await + .map_err(|source| tg::error!(!source, "failed to find the root"))? + }; + let old_lock = Self::try_read_lock(root.clone()) + .await + .map_err(|source| tg::error!(!source, "failed to read lockfile"))?; + // Get the updates. let updates = args.updates.unwrap_or_else(|| vec!["*".parse().unwrap()]); @@ -42,6 +61,293 @@ impl Cli { .map_err(|source| tg::error!(!source, path = %path.display(), "failed to check in"))?; self.render_progress_stream(stream).await?; + // Get the new lockfile. + let new_lock = Self::try_read_lock(root) + .await + .map_err(|source| tg::error!(!source, "failed to read lockfile"))?; + + // Print out any changes. + for updated in Self::updates(old_lock.as_ref(), new_lock.as_ref()) { + match (updated.old, updated.new) { + (Some(old), Some(new)) => { + println!("↑ updated {old} to {new}"); + }, + (None, Some(new)) => { + println!("+ added {new}"); + }, + (Some(old), None) => { + println!("- removed {old}"); + }, + (None, None) => (), + } + } Ok(()) } + + pub(crate) async fn find_root(path: PathBuf) -> tg::Result { + let output = tokio::task::spawn_blocking(move || { + let mut output = None; + for ancestor in path.ancestors() { + let metadata = std::fs::symlink_metadata(ancestor).map_err( + |source| tg::error!(!source, path = %path.display(), "failed to get the metadata"), + )?; + if metadata.is_dir() + && tg::module::try_get_root_module_file_name_sync(ancestor)?.is_some() + { + output.replace(ancestor.to_owned()); + } + } + let output = output.unwrap_or(path); + Ok::<_, tg::Error>(output) + }) + .await + .map_err(|source| tg::error!(!source, "the checkin root task panicked"))??; + Ok(output) + } + + pub(crate) async fn try_read_lock(path: PathBuf) -> tg::Result> { + tokio::task::spawn_blocking(move || { + let contents = if path.is_dir() { + let lockfile_path = path.join(tg::module::LOCKFILE_FILE_NAME); + Self::try_read_lockfile(&lockfile_path)? + } else if std::fs::metadata(&path).is_ok_and(|metadata| metadata.is_file()) { + let lockfile_path = path.with_extension("lock"); + let contents = Self::try_read_lockfile(&lockfile_path)?; + if contents.is_some() { + contents + } else { + // Fall back to xattr. + xattr::get(&path, tg::file::LOCKATTR_XATTR_NAME) + .ok() + .flatten() + } + } else { + None + }; + + // Return early if no contents found. + let Some(contents) = contents else { + return Ok(None); + }; + + // Deserialize the lock. + let lock = serde_json::from_slice::(&contents).map_err( + |source| tg::error!(!source, path = %path.display(), "failed to deserialize the lock"), + )?; + + Ok(Some(lock)) + }) + .await + .map_err(|source| tg::error!(!source, "the lockfile task panicked"))? + } + + fn try_read_lockfile(path: &Path) -> tg::Result>> { + match std::fs::read(path) { + Ok(contents) => Ok(Some(contents)), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + Ok(None) + }, + Err(source) => { + Err(tg::error!(!source, path = %path.display(), "failed to read the lockfile")) + }, + } + } + + fn updates(old: Option<&tg::graph::Data>, new: Option<&tg::graph::Data>) -> Vec { + let mut visitor = Visitor::default(); + let old = old.map(|old| old.nodes.as_slice()).unwrap_or_default(); + let new = new.map(|new| new.nodes.as_slice()).unwrap_or_default(); + let old_node = (!old.is_empty()).then_some(0); + let new_node = (!new.is_empty()).then_some(0); + visitor.walk(old_node, new_node, old, new); + visitor.updated + } +} + +#[allow(unused)] +struct Updated { + pattern: tg::tag::Pattern, + old: Option, + new: Option, +} + +#[derive(Default)] +struct Visitor { + updated: Vec, + visited: BTreeSet<(Option, Option)>, +} + +impl Visitor { + fn walk( + &mut self, + old_node_index: Option, + new_node_index: Option, + old: &[tg::graph::data::Node], + new: &[tg::graph::data::Node], + ) { + if !self.visited.insert((old_node_index, new_node_index)) { + return; + } + + match (old_node_index, new_node_index) { + (Some(old_node_index), Some(new_node_index)) => { + match (&old[old_node_index], &new[new_node_index]) { + ( + tg::graph::data::Node::Directory(old_node), + tg::graph::data::Node::Directory(new_node), + ) => { + let old_entries = flatten_directory(old_node, old); + let new_entries = flatten_directory(new_node, new); + let all_entries: BTreeSet<&String> = + old_entries.keys().chain(new_entries.keys()).collect(); + for name in all_entries { + let old_node_index = old_entries.get(name).cloned(); + let new_node_index = new_entries.get(name).cloned(); + self.walk(old_node_index, new_node_index, old, new); + } + }, + ( + tg::graph::data::Node::File(old_node), + tg::graph::data::Node::File(new_node), + ) => { + let old_dependencies: &std::collections::BTreeMap< + tangram_client::Reference, + Option, + > = &old_node.dependencies; + let new_dependencies = &new_node.dependencies; + let all_references = old_dependencies + .keys() + .chain(new_dependencies.keys()) + .collect::>(); + for reference in all_references { + let old_dep = old_dependencies.get(reference).and_then(|d| d.as_ref()); + let new_dep = new_dependencies.get(reference).and_then(|d| d.as_ref()); + let old_tag = old_dep.and_then(|d| d.0.options.tag.clone()); + let new_tag = new_dep.and_then(|d| d.0.options.tag.clone()); + if (old_tag != new_tag) && (old_tag.is_some() || new_tag.is_some()) { + if let Ok(pattern) = reference.item().try_unwrap_tag_ref() { + self.updated.push(Updated { + pattern: pattern.clone(), + old: old_tag, + new: new_tag, + }); + } + } + let old_node_index = old_dep.and_then(|d| { + let edge = d.0.item.as_ref()?; + let pointer = edge.try_unwrap_pointer_ref().ok()?; + if pointer.graph.is_some() { + return None; + } + Some(pointer.index) + }); + let new_node_index = new_dep.and_then(|d| { + let edge = d.0.item.as_ref()?; + let pointer = edge.try_unwrap_pointer_ref().ok()?; + if pointer.graph.is_some() { + return None; + } + Some(pointer.index) + }); + self.walk(old_node_index, new_node_index, old, new); + } + }, + ( + tg::graph::data::Node::Symlink(old_node), + tg::graph::data::Node::Symlink(new_node), + ) => { + let Some(old_artifact) = &old_node.artifact else { + return; + }; + let Some(new_artifact) = &new_node.artifact else { + return; + }; + let old_node_index = old_artifact + .try_unwrap_pointer_ref() + .ok() + .and_then(|pointer| pointer.graph.is_none().then_some(pointer.index)); + let new_node_index = new_artifact + .try_unwrap_pointer_ref() + .ok() + .and_then(|pointer| pointer.graph.is_none().then_some(pointer.index)); + self.walk(old_node_index, new_node_index, old, new); + }, + _ => (), + } + }, + (Some(old_node_index), None) => { + if let tg::graph::data::Node::File(old_file) = &old[old_node_index] { + for (reference, dep) in &old_file.dependencies { + let old_tag = dep.as_ref().and_then(|d| d.0.options.tag.clone()); + if let Some(old_tag) = old_tag { + if let Ok(pattern) = reference.item().try_unwrap_tag_ref() { + self.updated.push(Updated { + pattern: pattern.clone(), + old: Some(old_tag), + new: None, + }); + } + } + } + } + }, + (None, Some(new_node_index)) => { + if let tg::graph::data::Node::File(new_file) = &new[new_node_index] { + for (reference, dep) in &new_file.dependencies { + let new_tag = dep.as_ref().and_then(|d| d.0.options.tag.clone()); + if let Some(new_tag) = new_tag { + if let Ok(pattern) = reference.item().try_unwrap_tag_ref() { + self.updated.push(Updated { + pattern: pattern.clone(), + old: None, + new: Some(new_tag), + }); + } + } + } + } + }, + (None, None) => (), + } + } +} + +pub(crate) fn flatten_directory( + directory: &tg::graph::data::Directory, + nodes: &[tg::graph::data::Node], +) -> BTreeMap { + match directory { + tg::graph::data::Directory::Leaf(leaf) => leaf + .entries + .iter() + .filter_map(|(name, edge)| { + let pointer = edge.try_unwrap_pointer_ref().ok()?; + if pointer.graph.is_some() { + return None; + } + Some((name.clone(), pointer.index)) + }) + .collect(), + tg::graph::data::Directory::Branch(branch) => branch + .children + .iter() + .filter_map(|child| { + let pointer = child.directory.try_unwrap_pointer_ref().ok()?; + if pointer.graph.is_some() { + return None; + } + let node = nodes.get(pointer.index)?; + let child_directory = node.try_unwrap_directory_ref().ok()?; + Some(flatten_directory(child_directory, nodes)) + }) + .fold(BTreeMap::new(), |mut acc, entries| { + acc.extend(entries); + acc + }), + } } diff --git a/packages/cli/tests/tag/outdated.nu b/packages/cli/tests/tag/outdated.nu index 8f05590a8..c1e4d0de2 100644 --- a/packages/cli/tests/tag/outdated.nu +++ b/packages/cli/tests/tag/outdated.nu @@ -20,6 +20,7 @@ let path = artifact { ' } +tg checkin $path let output = tg outdated --pretty $path snapshot $output ' [ diff --git a/packages/cli/tests/update/updated_printout.nu b/packages/cli/tests/update/updated_printout.nu new file mode 100644 index 000000000..5e9b8a8a8 --- /dev/null +++ b/packages/cli/tests/update/updated_printout.nu @@ -0,0 +1,92 @@ +use ../../test.nu * + +let server = spawn + +# Create the transitive dependency (version 1). +let transitive_1_0_0 = artifact { + tangram.ts: ' + export default () => "transitive v1"; + ' +} +tg tag transitive/1.0.0 $transitive_1_0_0 +tg index +# Create the direct dependency (version 1) which depends on transitive. +let dep_1_0_0 = artifact { + tangram.ts: ' + import transitive from "transitive/^1.0"; + export default () => transitive(); + ' +} +tg tag dep/1.0.0 $dep_1_0_0 +tg index +# Create a dependency that will be removed in the next version. +let removed = artifact { + tangram.ts: ' + export default () => "removed"; + ' +} +tg tag removed/1.0.0 $removed +tg index +# Create the initial root package which depends on dep and removed. +let old_root = artifact { + tangram.ts: ' + import dep from "dep/^1.0"; + import removed from "removed/^1"; + export default () => dep(); + ' +} + +# Check in to create the initial lockfile. +tg checkin $old_root + +# Create updated versions. +let transitive_1_1_0 = artifact { + tangram.ts: ' + export default () => "transitive v2"; + ' +} +tg tag transitive/1.1.0 $transitive_1_1_0 +tg index + +# Create a new dependency that will be added. +let added_path = artifact { + tangram.ts: ' + export default () => "added"; + ' +} +tg tag added/1.0.0 $added_path +tg index + +let dep_1_1_0 = artifact { + tangram.ts: ' + import transitive from "transitive/^1.0"; + import added from "added/^1"; + export default () => transitive(); + ' +} +tg tag dep/1.1.0 $dep_1_1_0 +tg index + +# Create the updated root package which drops removed and adds added. +let new_root = artifact { + tangram.ts: ' + import dep from "dep/^1.0"; + import added from "added/^1"; + export default () => dep(); + ' +} + +# Copy the lockfile from the initial root to the new root so the update can diff against it. +cp ($old_root | path join "tangram.lock") ($new_root | path join "tangram.lock") + +# Run update and capture the output. +let output = do { tg update $new_root } | complete +success $output +let stdout = $output.stdout | str trim +snapshot $stdout ' + + added added/1.0.0 + ↑ updated dep/1.0.0 to dep/1.1.0 + + added added/1.0.0 + ↑ updated transitive/1.0.0 to transitive/1.1.0 + - removed removed/1.0.0 +' From 8186d9bf0ee6b45fb1f598e2bdf47164fe996864 Mon Sep 17 00:00:00 2001 From: Mike Hilgendorf Date: Fri, 27 Mar 2026 16:05:48 -0500 Subject: [PATCH 2/3] fix: outdated graph --- packages/cli/src/outdated.rs | 244 ++++++++++++++++++----------- packages/cli/tests/tag/outdated.nu | 8 +- packages/client/src/referent.rs | 36 +++-- 3 files changed, 179 insertions(+), 109 deletions(-) diff --git a/packages/cli/src/outdated.rs b/packages/cli/src/outdated.rs index e1c4d56d6..c05078189 100644 --- a/packages/cli/src/outdated.rs +++ b/packages/cli/src/outdated.rs @@ -1,7 +1,7 @@ use { crate::Cli, std::{ - collections::{BTreeSet, HashSet}, + collections::HashSet, path::PathBuf, }, tangram_client::prelude::*, @@ -31,7 +31,7 @@ impl Cli { .map_err(|source| tg::error!(!source, "failed to find the root"))?; // Deserialize the lock. - let lock = Self::try_read_lock(root) + let lock = Self::try_read_lock(root.clone()) .await .map_err(|source| tg::error!(!source, "failed to read lockfile"))? .ok_or_else(|| tg::error!("missing lockfile"))?; @@ -42,54 +42,70 @@ impl Cli { } // Collect dependencies. - let mut visitor = Visitor::default(); - visitor.walk(0, &lock.nodes); - - // Find compatible and latest versions. - let mut output: HashSet = HashSet::default(); - for (pattern, current) in visitor.dependencies { - let compatible = handle - .list_tags(tg::tag::list::Arg { - pattern: pattern.clone(), - cached: false, - length: None, - local: None, - recursive: false, - remotes: None, - reverse: true, - ttl: None, - }) - .await - .map_err(|source| tg::error!(!source, "failed to list tags"))? - .data - .into_iter() - .map(|output| output.tag) - .next(); - let mut components = pattern.components().collect::>(); - components.pop(); - components.push("*"); - let pattern = tg::tag::Pattern::new(components.join("/")); - let latest = handle - .list_tags(tg::tag::list::Arg { - cached: false, - length: Some(1), - local: None, - pattern: pattern.clone(), - recursive: false, - remotes: None, - reverse: true, - ttl: None, - }) - .await? - .data - .into_iter() - .map(|output| output.tag) - .next(); - output.insert(Entry { - current, - compatible, - latest, - }); + let graph = Graph::new(&lock, root); + let mut output: HashSet = HashSet::new(); + for node in &graph.nodes { + for (pattern, edge) in &node.edges { + let Some(pattern) = pattern else { + continue; + }; + let current = match edge{ + Edge::Node(node) => { + let Some(tag) = graph.nodes[*node].options.tag.clone() else { + continue; + }; + tag + }, + Edge::Tag(tag) => tag.clone() + }; + let compatible = handle + .list_tags(tg::tag::list::Arg { + cached: false, + length: Some(1), + local: None, + pattern: pattern.clone(), + recursive: false, + remotes: None, + reverse: true, + ttl: None, + }) + .await? + .data + .into_iter() + .map(|output| output.tag) + .next(); + + let mut components = pattern.components().collect::>(); + components.pop(); + components.push("*"); + let pattern = tg::tag::Pattern::new(components.join("/")); + let latest = handle + .list_tags(tg::tag::list::Arg { + cached: false, + length: Some(1), + local: None, + pattern: pattern.clone(), + recursive: false, + remotes: None, + reverse: true, + ttl: None, + }) + .await? + .data + .into_iter() + .map(|output| output.tag) + .next(); + + let entry = Entry { + current, + compatible, + latest, + required_by: tg::Referent::new((), node.options.clone()), + }; + + output.insert(entry); + } + // let c } // Display output. @@ -98,59 +114,103 @@ impl Cli { } } -#[derive(Default)] -struct Visitor { - dependencies: Vec<(tg::tag::Pattern, tg::Tag)>, - visited: BTreeSet, +#[derive(Clone, Debug)] +struct Node { + edges: Vec<(Option, Edge)>, + options: tg::referent::Options, + path: Vec, } -#[derive(serde::Serialize, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug)] +enum Edge { + Node(usize), + Tag(tg::Tag), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize)] struct Entry { current: tg::Tag, compatible: Option, latest: Option, + required_by: tg::Referent<()>, } -impl Visitor { - fn walk(&mut self, node: usize, nodes: &[tg::graph::data::Node]) { - if !self.visited.insert(node) { - return; - } - let node = &nodes[node]; - match node { - tg::graph::data::Node::Directory(directory) => { - let entries = crate::update::flatten_directory(directory, nodes); - for node in entries.into_values() { - self.walk(node, nodes); - } - }, - tg::graph::data::Node::File(file) => { - for (reference, dependency) in &file.dependencies { - let Some(dependency) = dependency else { - continue; - }; +#[derive(Debug)] +pub struct Graph { + nodes: Vec, +} - let pattern = reference.item().try_unwrap_tag_ref().ok().cloned(); - let tag = dependency.tag().cloned(); - if let (Some(pattern), Some(tag)) = (pattern, tag) { - self.dependencies.push((pattern, tag)); +impl Graph { + pub fn new(lock: &tg::graph::Data, path: PathBuf) -> Self { + let mut nodes = vec![None; lock.nodes.len()]; + let mut stack = vec![(0, tg::referent::Options::with_path(path), Vec::new())]; + while let Some((index, options, path)) = stack.pop() { + if nodes[index].is_some() { + continue; + } + let mut node = Node { + edges: Vec::new(), + options, + path, + }; + match &lock.nodes[index] { + tg::graph::data::Node::Directory(directory) => { + let entries = crate::update::flatten_directory(directory, &lock.nodes); + for (name, child) in entries { + let mut child_options = tg::referent::Options::with_path(name); + child_options.inherit(&node.options); + let mut path = node.path.clone(); + path.push(index); + node.edges.push((None, Edge::Node(child))); + stack.push((child, child_options, path)); } - if let Some(dependency) = dependency.0.item().as_ref().and_then(|edge| { - let pointer = edge.try_unwrap_pointer_ref().ok()?; - pointer.graph.is_none().then_some(pointer.index) - }) { - self.walk(dependency, nodes); + }, + tg::graph::data::Node::File(file) => { + for (reference, dependency) in &file.dependencies { + let pattern = reference.item().try_unwrap_tag_ref().ok().cloned(); + let Some(dependency) = dependency else { + continue; + }; + match (dependency.item(), dependency.tag()) { + (Some(tg::graph::data::Edge::Pointer(pointer)), tag) => { + if pointer.graph.is_none() { + let reference = + reference.item().try_unwrap_tag_ref().ok().cloned(); + node.edges.push((reference, Edge::Node(pointer.index))); + let mut child_options = dependency.options.clone(); + let mut path = node.path.clone(); + path.push(index); + child_options.inherit(&node.options); + stack.push((pointer.index, child_options, path)); + } else if let Some(tag) = tag { + node.edges.push((pattern, Edge::Tag(tag.clone()))); + } + }, + (_, Some(tag)) => { + node.edges.push((pattern, Edge::Tag(tag.clone()))) + }, + _ => (), + } } - } - }, - tg::graph::data::Node::Symlink(symlink) => { - if let Some(artifact) = symlink.artifact.as_ref().and_then(|edge| { - let pointer = edge.try_unwrap_pointer_ref().ok()?; - pointer.graph.is_none().then_some(pointer.index) - }) { - self.walk(artifact, nodes); - } - }, + }, + tg::graph::data::Node::Symlink(symlink) => { + let Some(tg::graph::data::Edge::Pointer(pointer)) = &symlink.artifact else { + continue; + }; + if pointer.graph.is_some() { + continue; + } + node.edges.push((None, Edge::Node(pointer.index))); + let child_options = tg::referent::Options::default(); + let mut path = node.path.clone(); + path.push(index); + stack.push((pointer.index, child_options, path)); + }, + } + nodes[index].replace(node); } + + let nodes = nodes.into_iter().map(Option::unwrap).collect::>(); + Self { nodes } } } diff --git a/packages/cli/tests/tag/outdated.nu b/packages/cli/tests/tag/outdated.nu index c1e4d0de2..14663137e 100644 --- a/packages/cli/tests/tag/outdated.nu +++ b/packages/cli/tests/tag/outdated.nu @@ -21,13 +21,19 @@ let path = artifact { } tg checkin $path -let output = tg outdated --pretty $path +let output = tg outdated --pretty $path | str replace --all --regex '"/[^"]*"' '"PATH"' snapshot $output ' [ { "compatible": "hello/1.1.0", "current": "hello/1.1.0", "latest": "hello/2.0.0", + "required_by": { + "item": null, + "options": { + "path": "PATH", + }, + }, }, ] ' diff --git a/packages/client/src/referent.rs b/packages/client/src/referent.rs index aeeabb874..bcf44bac7 100644 --- a/packages/client/src/referent.rs +++ b/packages/client/src/referent.rs @@ -128,22 +128,7 @@ impl Referent { } pub fn inherit(&mut self, parent: &tg::Referent) { - if self.id().is_none() && self.tag().is_none() { - self.options.id = parent.options.id.clone(); - self.options.tag = parent.options.tag.clone(); - match (&self.options.path, &parent.options.path) { - (None, Some(parent_path)) => { - let path = parent_path.clone(); - self.options.path = Some(path); - }, - (Some(self_path), Some(parent_path)) => { - let path = parent_path.parent().unwrap().join(self_path); - let path = tangram_util::path::normalize(&path); - self.options.path = Some(path); - }, - _ => (), - } - } + self.options.inherit(&parent.options); } } @@ -254,6 +239,25 @@ impl Options { tag: None, } } + + pub fn inherit(&mut self, parent: &Options) { + if self.id.is_none() && self.tag.is_none() { + self.id = parent.id.clone(); + self.tag = parent.tag.clone(); + match (&self.path, &parent.path) { + (None, Some(parent_path)) => { + let path = parent_path.clone(); + self.path = Some(path); + }, + (Some(self_path), Some(parent_path)) => { + let path = parent_path.parent().unwrap().join(self_path); + let path = tangram_util::path::normalize(&path); + self.path = Some(path); + }, + _ => (), + } + } + } } impl std::fmt::Display for Referent From 5cce3747a3f4a85cbf9e8e90f3f3004687e7c15a Mon Sep 17 00:00:00 2001 From: Mike Hilgendorf Date: Fri, 27 Mar 2026 16:52:17 -0500 Subject: [PATCH 3/3] fix: updated graph --- packages/cli/src/update.rs | 323 +++++++++--------- packages/cli/tests/update/updated_printout.nu | 13 +- 2 files changed, 176 insertions(+), 160 deletions(-) diff --git a/packages/cli/src/update.rs b/packages/cli/src/update.rs index a13e0f88f..e79c39807 100644 --- a/packages/cli/src/update.rs +++ b/packages/cli/src/update.rs @@ -1,7 +1,7 @@ use { crate::Cli, std::{ - collections::{BTreeMap, BTreeSet}, + collections::{BTreeMap, BTreeSet, HashMap}, path::{Path, PathBuf}, }, tangram_client::prelude::*, @@ -14,6 +14,13 @@ pub struct Args { #[command(flatten)] pub checkin: crate::checkin::Options, + /// Whether to output JSON. + #[arg(long)] + pub json: bool, + + #[command(flatten)] + pub print: crate::print::Options, + #[arg(default_value = ".", index = 1)] pub path: PathBuf, @@ -62,23 +69,30 @@ impl Cli { self.render_progress_stream(stream).await?; // Get the new lockfile. - let new_lock = Self::try_read_lock(root) + let new_lock = Self::try_read_lock(root.clone()) .await .map_err(|source| tg::error!(!source, "failed to read lockfile"))?; // Print out any changes. - for updated in Self::updates(old_lock.as_ref(), new_lock.as_ref()) { - match (updated.old, updated.new) { - (Some(old), Some(new)) => { - println!("↑ updated {old} to {new}"); - }, - (None, Some(new)) => { - println!("+ added {new}"); - }, - (Some(old), None) => { - println!("- removed {old}"); - }, - (None, None) => (), + let updates = Self::updates(old_lock.as_ref(), new_lock.as_ref(), root); + if args.json { + self.print_serde(updates, args.print).await?; + } else { + for updated in updates { + let referrer = format_referrer(&updated.pattern); + match (updated.old, updated.new) { + + (Some(old), Some(new)) => { + println!("↑ updated {old} to {new}, required by {referrer}"); + }, + (None, Some(new)) => { + println!("+ added {new}, required by {referrer}"); + }, + (Some(old), None) => { + println!("- removed {old}, required by {referrer}"); + }, + (None, None) => (), + } } } Ok(()) @@ -158,162 +172,147 @@ impl Cli { } } - fn updates(old: Option<&tg::graph::Data>, new: Option<&tg::graph::Data>) -> Vec { - let mut visitor = Visitor::default(); - let old = old.map(|old| old.nodes.as_slice()).unwrap_or_default(); - let new = new.map(|new| new.nodes.as_slice()).unwrap_or_default(); - let old_node = (!old.is_empty()).then_some(0); - let new_node = (!new.is_empty()).then_some(0); - visitor.walk(old_node, new_node, old, new); - visitor.updated + fn updates( + old: Option<&tg::graph::Data>, + new: Option<&tg::graph::Data>, + root: PathBuf, + ) -> Vec { + let old_deps = old + .map(|lock| Graph::new(lock, root.clone()).dependencies()) + .unwrap_or_default(); + let new_deps = new + .map(|lock| Graph::new(lock, root.clone()).dependencies()) + .unwrap_or_default(); + let all_patterns: BTreeSet<_> = old_deps + .keys() + .cloned() + .chain(new_deps.keys().cloned()) + .collect(); + let mut updated = Vec::new(); + for pattern in all_patterns { + let old = old_deps.get(&pattern).cloned(); + let new = new_deps.get(&pattern).cloned(); + if old != new { + updated.push(Updated { pattern, old, new }); + } + } + updated } } -#[allow(unused)] +#[derive(Clone, Debug, serde::Serialize)] struct Updated { - pattern: tg::tag::Pattern, + pattern: tg::Referent, old: Option, new: Option, } -#[derive(Default)] -struct Visitor { - updated: Vec, - visited: BTreeSet<(Option, Option)>, +#[derive(Clone, Debug)] +struct Node { + edges: Vec<(Option, Edge)>, + options: tg::referent::Options, + path: Vec, } -impl Visitor { - fn walk( - &mut self, - old_node_index: Option, - new_node_index: Option, - old: &[tg::graph::data::Node], - new: &[tg::graph::data::Node], - ) { - if !self.visited.insert((old_node_index, new_node_index)) { - return; - } +#[derive(Clone, Debug)] +enum Edge { + Node(usize), + Tag(tg::Tag), +} - match (old_node_index, new_node_index) { - (Some(old_node_index), Some(new_node_index)) => { - match (&old[old_node_index], &new[new_node_index]) { - ( - tg::graph::data::Node::Directory(old_node), - tg::graph::data::Node::Directory(new_node), - ) => { - let old_entries = flatten_directory(old_node, old); - let new_entries = flatten_directory(new_node, new); - let all_entries: BTreeSet<&String> = - old_entries.keys().chain(new_entries.keys()).collect(); - for name in all_entries { - let old_node_index = old_entries.get(name).cloned(); - let new_node_index = new_entries.get(name).cloned(); - self.walk(old_node_index, new_node_index, old, new); - } - }, - ( - tg::graph::data::Node::File(old_node), - tg::graph::data::Node::File(new_node), - ) => { - let old_dependencies: &std::collections::BTreeMap< - tangram_client::Reference, - Option, - > = &old_node.dependencies; - let new_dependencies = &new_node.dependencies; - let all_references = old_dependencies - .keys() - .chain(new_dependencies.keys()) - .collect::>(); - for reference in all_references { - let old_dep = old_dependencies.get(reference).and_then(|d| d.as_ref()); - let new_dep = new_dependencies.get(reference).and_then(|d| d.as_ref()); - let old_tag = old_dep.and_then(|d| d.0.options.tag.clone()); - let new_tag = new_dep.and_then(|d| d.0.options.tag.clone()); - if (old_tag != new_tag) && (old_tag.is_some() || new_tag.is_some()) { - if let Ok(pattern) = reference.item().try_unwrap_tag_ref() { - self.updated.push(Updated { - pattern: pattern.clone(), - old: old_tag, - new: new_tag, - }); - } - } - let old_node_index = old_dep.and_then(|d| { - let edge = d.0.item.as_ref()?; - let pointer = edge.try_unwrap_pointer_ref().ok()?; - if pointer.graph.is_some() { - return None; - } - Some(pointer.index) - }); - let new_node_index = new_dep.and_then(|d| { - let edge = d.0.item.as_ref()?; - let pointer = edge.try_unwrap_pointer_ref().ok()?; - if pointer.graph.is_some() { - return None; - } - Some(pointer.index) - }); - self.walk(old_node_index, new_node_index, old, new); - } - }, - ( - tg::graph::data::Node::Symlink(old_node), - tg::graph::data::Node::Symlink(new_node), - ) => { - let Some(old_artifact) = &old_node.artifact else { - return; - }; - let Some(new_artifact) = &new_node.artifact else { - return; +#[derive(Debug)] +struct Graph { + nodes: Vec, +} + +impl Graph { + fn new(lock: &tg::graph::Data, path: PathBuf) -> Self { + let mut nodes = vec![None; lock.nodes.len()]; + let mut stack = vec![(0, tg::referent::Options::with_path(path), Vec::new())]; + while let Some((index, options, path)) = stack.pop() { + if nodes[index].is_some() { + continue; + } + let mut node = Node { + edges: Vec::new(), + options, + path, + }; + match &lock.nodes[index] { + tg::graph::data::Node::Directory(directory) => { + let entries = flatten_directory(directory, &lock.nodes); + for (name, child) in entries { + let mut child_options = tg::referent::Options::with_path(name); + child_options.inherit(&node.options); + let mut path = node.path.clone(); + path.push(index); + node.edges.push((None, Edge::Node(child))); + stack.push((child, child_options, path)); + } + }, + tg::graph::data::Node::File(file) => { + for (reference, dependency) in &file.dependencies { + let pattern = reference.item().try_unwrap_tag_ref().ok().cloned(); + let Some(dependency) = dependency else { + continue; }; - let old_node_index = old_artifact - .try_unwrap_pointer_ref() - .ok() - .and_then(|pointer| pointer.graph.is_none().then_some(pointer.index)); - let new_node_index = new_artifact - .try_unwrap_pointer_ref() - .ok() - .and_then(|pointer| pointer.graph.is_none().then_some(pointer.index)); - self.walk(old_node_index, new_node_index, old, new); - }, - _ => (), - } - }, - (Some(old_node_index), None) => { - if let tg::graph::data::Node::File(old_file) = &old[old_node_index] { - for (reference, dep) in &old_file.dependencies { - let old_tag = dep.as_ref().and_then(|d| d.0.options.tag.clone()); - if let Some(old_tag) = old_tag { - if let Ok(pattern) = reference.item().try_unwrap_tag_ref() { - self.updated.push(Updated { - pattern: pattern.clone(), - old: Some(old_tag), - new: None, - }); - } + match (dependency.item(), dependency.tag()) { + (Some(tg::graph::data::Edge::Pointer(pointer)), tag) => { + if pointer.graph.is_none() { + let reference = + reference.item().try_unwrap_tag_ref().ok().cloned(); + node.edges.push((reference, Edge::Node(pointer.index))); + let mut child_options = dependency.options.clone(); + let mut path = node.path.clone(); + path.push(index); + child_options.inherit(&node.options); + stack.push((pointer.index, child_options, path)); + } else if let Some(tag) = tag { + node.edges.push((pattern, Edge::Tag(tag.clone()))); + } + }, + (_, Some(tag)) => node.edges.push((pattern, Edge::Tag(tag.clone()))), + _ => (), } } - } - }, - (None, Some(new_node_index)) => { - if let tg::graph::data::Node::File(new_file) = &new[new_node_index] { - for (reference, dep) in &new_file.dependencies { - let new_tag = dep.as_ref().and_then(|d| d.0.options.tag.clone()); - if let Some(new_tag) = new_tag { - if let Ok(pattern) = reference.item().try_unwrap_tag_ref() { - self.updated.push(Updated { - pattern: pattern.clone(), - old: None, - new: Some(new_tag), - }); - } - } + }, + tg::graph::data::Node::Symlink(symlink) => { + let Some(tg::graph::data::Edge::Pointer(pointer)) = &symlink.artifact else { + continue; + }; + if pointer.graph.is_some() { + continue; } - } - }, - (None, None) => (), + node.edges.push((None, Edge::Node(pointer.index))); + let child_options = tg::referent::Options::default(); + let mut path = node.path.clone(); + path.push(index); + stack.push((pointer.index, child_options, path)); + }, + } + nodes[index].replace(node); } + + let nodes = nodes.into_iter().map(Option::unwrap).collect::>(); + Self { nodes } + } + + fn dependencies(&self) -> HashMap, tg::Tag> { + self.nodes + .iter() + .flat_map(|node| { + node.edges.iter().filter_map(|(pattern, edge)| { + let options = node.options.clone(); + let pattern = pattern.clone()?; + let tag = match edge { + Edge::Node(index) => self.nodes[*index].options.tag.clone()?, + Edge::Tag(tag) => tag.clone(), + }; + let key = tg::Referent::new(pattern, options); + Some((key, tag)) + }) + }) + .collect() } } @@ -351,3 +350,19 @@ pub(crate) fn flatten_directory( }), } } + +pub(crate) fn format_referrer(referrer: &tg::Referent) -> String { + let tg::referent::Options { artifact, id, name, path, tag } = referrer.options(); + let mut name = name + .as_ref() + .map(String::clone) + .or_else(|| artifact.as_ref().map(tg::artifact::Id::to_string)) + .or_else(|| tag.as_ref().map(tg::Tag::to_string)) + .unwrap_or_default(); + if let Some(path) = path { + name = PathBuf::from(name).join(path).to_str().unwrap().to_owned(); + } else if let Some(id) = id { + name = id.to_string(); + } + name +} diff --git a/packages/cli/tests/update/updated_printout.nu b/packages/cli/tests/update/updated_printout.nu index 5e9b8a8a8..469a8cc3f 100644 --- a/packages/cli/tests/update/updated_printout.nu +++ b/packages/cli/tests/update/updated_printout.nu @@ -82,11 +82,12 @@ cp ($old_root | path join "tangram.lock") ($new_root | path join "tangram.lock") # Run update and capture the output. let output = do { tg update $new_root } | complete success $output -let stdout = $output.stdout | str trim +let stdout = $output.stdout | str trim | str replace --all --regex '/tmp/[^\s,]+' 'PATH' snapshot $stdout ' - + added added/1.0.0 - ↑ updated dep/1.0.0 to dep/1.1.0 - + added added/1.0.0 - ↑ updated transitive/1.0.0 to transitive/1.1.0 - - removed removed/1.0.0 + + added added/1.0.0, required by PATH + + added added/1.0.0, required by dep/1.1.0/tangram.ts + ↑ updated dep/1.0.0 to dep/1.1.0, required by PATH + - removed removed/1.0.0, required by PATH + - removed transitive/1.0.0, required by dep/1.0.0/tangram.ts + + added transitive/1.1.0, required by dep/1.1.0/tangram.ts '