Skip to content

Commit d2e67f7

Browse files
authored
feat(pm): add dedupe command for dependency optimization (#256)
### TL;DR Added a new `vite dedupe` command (with `ddp` alias) that automatically adapts to the detected package manager to optimize dependency trees by removing duplicate packages. ### What changed? - Added a new `dedupe` command to the CLI that works with pnpm, npm, and yarn@2+ - Implemented package manager-specific adaptations for the dedupe command: - Maps to `pnpm dedupe`, `npm dedupe`, or `yarn dedupe` based on detected package manager - Supports `--check` flag that maps to the appropriate dry-run flag for each package manager - Handles pass-through arguments for advanced use cases - Created new modules: - `crates/vite_package_manager/src/commands/dedupe.rs` - `packages/cli/binding/src/commands/dedupe.rs` - Added comprehensive test coverage for the new command - Included RFC documentation explaining the design decisions and implementation details ### How to test? 1. Basic deduplication: ```bash vite dedupe vite ddp # alias ``` 2. Check mode (preview changes without modifying): ```bash vite dedupe --check ``` 3. With pass-through arguments: ```bash vite dedupe -- --some-flag --another-flag ``` ### Why make this change? This change simplifies dependency optimization by providing a unified interface across all supported package managers. Previously, developers had to manually use package manager-specific commands with different syntaxes: - `pnpm dedupe --check` - `npm dedupe --dry-run` - `yarn dedupe --check` (yarn@2+ only) The new command automatically detects the package manager and uses the appropriate syntax, reducing friction in dependency management workflows and improving project efficiency by making it easier to remove redundant packages.
1 parent 9095de6 commit d2e67f7

20 files changed

Lines changed: 1523 additions & 2 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
use std::{collections::HashMap, process::ExitStatus};
2+
3+
use vite_error::Error;
4+
use vite_path::AbsolutePath;
5+
6+
use crate::package_manager::{
7+
PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,
8+
};
9+
10+
/// Options for the dedupe command.
11+
#[derive(Debug, Default)]
12+
pub struct DedupeCommandOptions<'a> {
13+
pub check: bool,
14+
pub pass_through_args: Option<&'a [String]>,
15+
}
16+
17+
impl PackageManager {
18+
/// Run the dedupe command with the package manager.
19+
/// Return the exit status of the command.
20+
#[must_use]
21+
pub async fn run_dedupe_command(
22+
&self,
23+
options: &DedupeCommandOptions<'_>,
24+
cwd: impl AsRef<AbsolutePath>,
25+
) -> Result<ExitStatus, Error> {
26+
let resolve_command = self.resolve_dedupe_command(options);
27+
run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)
28+
.await
29+
}
30+
31+
/// Resolve the dedupe command.
32+
#[must_use]
33+
pub fn resolve_dedupe_command(&self, options: &DedupeCommandOptions) -> ResolveCommandResult {
34+
let bin_name: String;
35+
let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]);
36+
let mut args: Vec<String> = Vec::new();
37+
38+
match self.client {
39+
PackageManagerType::Pnpm => {
40+
bin_name = "pnpm".into();
41+
args.push("dedupe".into());
42+
43+
// pnpm uses --check for dry-run
44+
if options.check {
45+
args.push("--check".into());
46+
}
47+
}
48+
PackageManagerType::Yarn => {
49+
bin_name = "yarn".into();
50+
args.push("dedupe".into());
51+
52+
// yarn@2+ supports --check
53+
if options.check {
54+
args.push("--check".into());
55+
}
56+
}
57+
PackageManagerType::Npm => {
58+
bin_name = "npm".into();
59+
args.push("dedupe".into());
60+
61+
if options.check {
62+
args.push("--dry-run".into());
63+
}
64+
}
65+
}
66+
67+
// Add pass-through args
68+
if let Some(pass_through_args) = options.pass_through_args {
69+
args.extend_from_slice(pass_through_args);
70+
}
71+
72+
ResolveCommandResult { bin_path: bin_name, args, envs }
73+
}
74+
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
use tempfile::{TempDir, tempdir};
79+
use vite_path::AbsolutePathBuf;
80+
use vite_str::Str;
81+
82+
use super::*;
83+
84+
fn create_temp_dir() -> TempDir {
85+
tempdir().expect("Failed to create temp directory")
86+
}
87+
88+
fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {
89+
let temp_dir = create_temp_dir();
90+
let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
91+
let install_dir = temp_dir_path.join("install");
92+
93+
PackageManager {
94+
client: pm_type,
95+
package_name: pm_type.to_string().into(),
96+
version: Str::from(version),
97+
hash: None,
98+
bin_name: pm_type.to_string().into(),
99+
workspace_root: temp_dir_path.clone(),
100+
install_dir,
101+
}
102+
}
103+
104+
#[test]
105+
fn test_pnpm_dedupe_basic() {
106+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
107+
let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() });
108+
assert_eq!(result.bin_path, "pnpm");
109+
assert_eq!(result.args, vec!["dedupe"]);
110+
}
111+
112+
#[test]
113+
fn test_pnpm_dedupe_check() {
114+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
115+
let result =
116+
pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() });
117+
assert_eq!(result.bin_path, "pnpm");
118+
assert_eq!(result.args, vec!["dedupe", "--check"]);
119+
}
120+
121+
#[test]
122+
fn test_npm_dedupe_basic() {
123+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
124+
let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() });
125+
assert_eq!(result.args, vec!["dedupe"]);
126+
assert_eq!(result.bin_path, "npm");
127+
}
128+
129+
#[test]
130+
fn test_npm_dedupe_check() {
131+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
132+
let result =
133+
pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() });
134+
assert_eq!(result.args, vec!["dedupe", "--dry-run"]);
135+
assert_eq!(result.bin_path, "npm");
136+
}
137+
138+
#[test]
139+
fn test_yarn_dedupe_basic() {
140+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
141+
let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() });
142+
assert_eq!(result.args, vec!["dedupe"]);
143+
assert_eq!(result.bin_path, "yarn");
144+
}
145+
146+
#[test]
147+
fn test_yarn_dedupe_check() {
148+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
149+
let result =
150+
pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() });
151+
assert_eq!(result.args, vec!["dedupe", "--check"]);
152+
assert_eq!(result.bin_path, "yarn");
153+
}
154+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod add;
2+
pub mod dedupe;
23
mod install;
34
pub mod remove;
45
pub mod update;

packages/cli/binding/src/cli.rs

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use vite_task::{
1919

2020
use crate::commands::{
2121
add::AddCommand,
22+
dedupe::DedupeCommand,
2223
doc::doc as doc_cmd,
2324
fmt::{FmtConfig, fmt},
2425
install::InstallCommand,
@@ -272,6 +273,17 @@ pub enum Commands {
272273
/// Packages to update (optional - updates all if omitted)
273274
packages: Vec<String>,
274275

276+
/// Additional arguments to pass through to the package manager
277+
#[arg(last = true, allow_hyphen_values = true)]
278+
pass_through_args: Option<Vec<String>>,
279+
},
280+
/// Deduplicate dependencies by removing older versions
281+
#[command(alias = "ddp")]
282+
Dedupe {
283+
/// Check if deduplication would make changes
284+
#[arg(long)]
285+
check: bool,
286+
275287
/// Additional arguments to pass through to the package manager
276288
#[arg(last = true, allow_hyphen_values = true)]
277289
pass_through_args: Option<Vec<String>>,
@@ -281,7 +293,13 @@ pub enum Commands {
281293
impl Commands {
282294
/// Check if this command is a package manager command that should skip auto-install
283295
pub fn is_package_manager_command(&self) -> bool {
284-
matches!(self, Commands::Install { .. } | Commands::Add { .. } | Commands::Remove { .. })
296+
matches!(
297+
self,
298+
Commands::Install { .. }
299+
| Commands::Add { .. }
300+
| Commands::Remove { .. }
301+
| Commands::Dedupe { .. }
302+
)
285303
}
286304
}
287305

@@ -703,6 +721,11 @@ pub async fn main<
703721
.await?;
704722
return Ok(exit_status);
705723
}
724+
Commands::Dedupe { check, pass_through_args } => {
725+
let exit_status =
726+
DedupeCommand::new(cwd).execute(*check, pass_through_args.as_deref()).await?;
727+
return Ok(exit_status);
728+
}
706729
};
707730

708731
let execution_summary_dir = EXECUTION_SUMMARY_DIR.as_path();
@@ -2320,4 +2343,67 @@ mod tests {
23202343
}
23212344
}
23222345
}
2346+
2347+
mod dedupe_command_tests {
2348+
use super::*;
2349+
2350+
#[test]
2351+
fn test_args_dedupe_command_basic() {
2352+
let args = Args::try_parse_from(&["vite-plus", "dedupe"]).unwrap();
2353+
if let Commands::Dedupe { check, .. } = &args.commands {
2354+
assert!(!check);
2355+
} else {
2356+
panic!("Expected Dedupe command");
2357+
}
2358+
}
2359+
2360+
#[test]
2361+
fn test_args_dedupe_command_with_alias() {
2362+
let args = Args::try_parse_from(&["vite-plus", "ddp"]).unwrap();
2363+
assert!(matches!(args.commands, Commands::Dedupe { .. }));
2364+
}
2365+
2366+
#[test]
2367+
fn test_args_dedupe_command_with_check() {
2368+
let args = Args::try_parse_from(&["vite-plus", "dedupe", "--check"]).unwrap();
2369+
if let Commands::Dedupe { check, .. } = &args.commands {
2370+
assert!(check);
2371+
} else {
2372+
panic!("Expected Dedupe command");
2373+
}
2374+
}
2375+
2376+
#[test]
2377+
fn test_args_dedupe_command_with_pass_through_args() {
2378+
let args = Args::try_parse_from(&[
2379+
"vite-plus",
2380+
"dedupe",
2381+
"--",
2382+
"--some-flag",
2383+
"--another-flag",
2384+
])
2385+
.unwrap();
2386+
if let Commands::Dedupe { pass_through_args, .. } = &args.commands {
2387+
assert_eq!(
2388+
pass_through_args,
2389+
&Some(vec!["--some-flag".to_string(), "--another-flag".to_string()])
2390+
);
2391+
} else {
2392+
panic!("Expected Dedupe command");
2393+
}
2394+
}
2395+
2396+
#[test]
2397+
fn test_args_dedupe_command_with_check_and_pass_through() {
2398+
let args =
2399+
Args::try_parse_from(&["vite-plus", "dedupe", "--check", "--", "--custom-flag"])
2400+
.unwrap();
2401+
if let Commands::Dedupe { check, pass_through_args, .. } = &args.commands {
2402+
assert!(check);
2403+
assert_eq!(pass_through_args, &Some(vec!["--custom-flag".to_string()]));
2404+
} else {
2405+
panic!("Expected Dedupe command");
2406+
}
2407+
}
2408+
}
23232409
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
use std::process::ExitStatus;
2+
3+
use vite_install::{commands::dedupe::DedupeCommandOptions, package_manager::PackageManager};
4+
use vite_path::AbsolutePathBuf;
5+
6+
use crate::Error;
7+
8+
/// Dedupe command for deduplicating dependencies by removing older versions.
9+
///
10+
/// This command automatically detects the package manager and translates
11+
/// the dedupe command to the appropriate package manager-specific syntax.
12+
pub struct DedupeCommand {
13+
cwd: AbsolutePathBuf,
14+
}
15+
16+
impl DedupeCommand {
17+
pub fn new(cwd: AbsolutePathBuf) -> Self {
18+
Self { cwd }
19+
}
20+
21+
pub async fn execute(
22+
self,
23+
check: bool,
24+
pass_through_args: Option<&[String]>,
25+
) -> Result<ExitStatus, Error> {
26+
// Detect package manager
27+
let package_manager = PackageManager::builder(&self.cwd).build().await?;
28+
29+
let dedupe_command_options = DedupeCommandOptions { check, pass_through_args };
30+
package_manager.run_dedupe_command(&dedupe_command_options, &self.cwd).await
31+
}
32+
}
33+
34+
#[cfg(test)]
35+
mod tests {
36+
use super::*;
37+
38+
#[test]
39+
fn test_dedupe_command_new() {
40+
let workspace_root = if cfg!(windows) {
41+
AbsolutePathBuf::new("C:\\test".into()).unwrap()
42+
} else {
43+
AbsolutePathBuf::new("/test".into()).unwrap()
44+
};
45+
46+
let cmd = DedupeCommand::new(workspace_root.clone());
47+
assert_eq!(cmd.cwd, workspace_root);
48+
}
49+
}

packages/cli/binding/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub(crate) mod add;
2+
pub(crate) mod dedupe;
23
pub(crate) mod doc;
34
pub(crate) mod fmt;
45
pub(crate) mod install;

packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[2]> vite command-not-exists # should exit with non-zero code
22
error: 'vite' requires a subcommand but one was not provided
3-
[subcommands: run, lint, fmt, build, test, lib, dev, doc, cache, install, i, add, remove, rm, un, uninstall, update, up, help]
3+
[subcommands: run, lint, fmt, build, test, lib, dev, doc, cache, install, i, add, remove, rm, un, uninstall, update, up, dedupe, ddp, help]
44

55
Usage: vite [OPTIONS] [TASK] [-- <TASK_ARGS>...] <COMMAND>
66

packages/global/snap-tests/cli-helper-message/snap.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Commands:
1515
add Add packages to dependencies
1616
remove Remove packages from dependencies
1717
update Update packages to their latest versions
18+
dedupe Deduplicate dependencies by removing older versions
1819
help Print this message or the help of the given subcommand(s)
1920

2021
Arguments:
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "command-dedupe-npm10",
3+
"version": "1.0.0",
4+
"packageManager": "npm@10.9.4",
5+
"dependencies": {
6+
"testnpm2": "1.0.1"
7+
},
8+
"devDependencies": {
9+
"test-vite-plus-package": "1.0.0"
10+
},
11+
"optionalDependencies": {
12+
"test-vite-plus-package-optional": "1.0.0"
13+
}
14+
}

0 commit comments

Comments
 (0)