diff --git a/packages/cli/src/outdated.rs b/packages/cli/src/outdated.rs index 05f96864a..c05078189 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::HashSet, + path::PathBuf, + }, + tangram_client::prelude::*, +}; /// Get a package's outdated dependencies. #[derive(Clone, Debug, clap::Args)] @@ -11,125 +18,199 @@ 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) + + // Find the root. + let root = Self::find_root(args.path.clone()) + .await + .map_err(|source| tg::error!(!source, "failed to find the root"))?; + + // Deserialize the lock. + let lock = Self::try_read_lock(root.clone()) .await - .map_err(|source| tg::error!(!source, "failed to walk objects"))?; - let output = visitor.entries.into_iter().collect::>(); + .map_err(|source| tg::error!(!source, "failed to read lockfile"))? + .ok_or_else(|| tg::error!("missing lockfile"))?; + + // Edge case, do nothing. + if lock.nodes.is_empty() { + return Ok(()); + } + + // Collect dependencies. + 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. self.print_serde(output, args.print).await?; Ok(()) } } -#[derive(Default)] -struct Visitor { - entries: HashSet, +#[derive(Clone, Debug)] +struct Node { + edges: Vec<(Option, Edge)>, + options: tg::referent::Options, + path: Vec, +} + +#[derive(Clone, Debug)] +enum Edge { + Node(usize), + Tag(tg::Tag), } -#[derive(serde::Serialize, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize)] struct Entry { current: tg::Tag, compatible: Option, latest: Option, + required_by: tg::Referent<()>, } -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) - } - - async fn visit_symlink( - &mut self, - _handle: &H, - _symlink: tg::Referent<&tg::Symlink>, - ) -> tg::Result { - Ok(true) - } - - async fn visit_file(&mut self, handle: &H, file: tg::Referent<&tg::File>) -> tg::Result { - let file = file.item; +#[derive(Debug)] +pub struct Graph { + nodes: Vec, +} - // 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)) - }); - - for (pattern, current) in dependencies { - 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, +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, }; - - self.entries.insert(entry); + 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)); + } + }, + 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) => { + 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); } - Ok(true) + let nodes = nodes.into_iter().map(Option::unwrap).collect::>(); + Self { nodes } } } diff --git a/packages/cli/src/update.rs b/packages/cli/src/update.rs index 0a061cbe1..e79c39807 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, HashMap}, + path::{Path, PathBuf}, + }, + tangram_client::prelude::*, +}; /// Update a lock. #[derive(Clone, Debug, clap::Args)] @@ -7,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, @@ -27,6 +41,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 +68,301 @@ 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.clone()) + .await + .map_err(|source| tg::error!(!source, "failed to read lockfile"))?; + + // Print out any changes. + 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(()) } + + 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>, + 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 + } +} + +#[derive(Clone, Debug, serde::Serialize)] +struct Updated { + pattern: tg::Referent, + old: Option, + new: Option, +} + +#[derive(Clone, Debug)] +struct Node { + edges: Vec<(Option, Edge)>, + options: tg::referent::Options, + path: Vec, +} + +#[derive(Clone, Debug)] +enum Edge { + Node(usize), + Tag(tg::Tag), +} + +#[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; + }; + 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) => { + 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 } + } + + 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() + } +} + +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 + }), + } +} + +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/tag/outdated.nu b/packages/cli/tests/tag/outdated.nu index 8f05590a8..14663137e 100644 --- a/packages/cli/tests/tag/outdated.nu +++ b/packages/cli/tests/tag/outdated.nu @@ -20,13 +20,20 @@ let path = artifact { ' } -let output = tg outdated --pretty $path +tg checkin $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/cli/tests/update/updated_printout.nu b/packages/cli/tests/update/updated_printout.nu new file mode 100644 index 000000000..469a8cc3f --- /dev/null +++ b/packages/cli/tests/update/updated_printout.nu @@ -0,0 +1,93 @@ +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 | str replace --all --regex '/tmp/[^\s,]+' 'PATH' +snapshot $stdout ' + + 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 +' 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