Skip to content

Commit 1ceb700

Browse files
committed
allowlist flag
1 parent 2502db5 commit 1ceb700

File tree

2 files changed

+205
-8
lines changed

2 files changed

+205
-8
lines changed

crates/turborepo-lib/src/cli/mod.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,13 @@ pub enum Command {
616616
},
617617
/// Check that all dependencies across workspaces are synchronized
618618
#[clap(name = "deps-sync")]
619-
DepsSync,
619+
DepsSync {
620+
/// Generate allowlist configuration for current conflicts instead of
621+
/// reporting them. This helps with incremental adoption by
622+
/// writing configuration that ignores existing conflicts.
623+
#[clap(long)]
624+
allowlist: bool,
625+
},
620626
/// Generate a new app / package
621627
#[clap(aliases = ["g", "gen"])]
622628
Generate {
@@ -1444,13 +1450,13 @@ pub async fn run(
14441450

14451451
Ok(0)
14461452
}
1447-
Command::DepsSync => {
1453+
Command::DepsSync { allowlist } => {
14481454
let event = CommandEventBuilder::new("deps-sync").with_parent(&root_telemetry);
14491455
event.track_call();
14501456
let base = CommandBase::new(cli_args.clone(), repo_root, version, color_config)?;
14511457
event.track_ui_mode(base.opts.run_opts.ui_mode);
14521458

1453-
Ok(deps_sync::run(&base).await?)
1459+
Ok(deps_sync::run(&base, *allowlist).await?)
14541460
}
14551461
Command::Generate {
14561462
tag,

crates/turborepo-lib/src/commands/deps_sync.rs

Lines changed: 196 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ pub struct DepsSyncConfig {
3535
/// Dependencies that should be pinned to a specific version across all
3636
/// packages by default. Packages can be excluded using the `exceptions`
3737
/// field.
38-
#[serde(default)]
38+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
3939
pub pinned_dependencies: HashMap<String, PinnedDependency>,
4040
/// Dependencies that should be ignored in specific packages.
4141
/// The `exceptions` field lists the packages where the dependency should be
4242
/// ignored.
43-
#[serde(default)]
43+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
4444
pub ignored_dependencies: HashMap<String, IgnoredDependency>,
4545
}
4646

@@ -60,7 +60,7 @@ pub struct PinnedDependency {
6060
#[serde(rename_all = "camelCase")]
6161
pub struct IgnoredDependency {
6262
/// Packages where this dependency should be ignored
63-
#[serde(default)]
63+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
6464
pub exceptions: Vec<String>,
6565
}
6666

@@ -113,12 +113,36 @@ struct DependencyConflict {
113113
conflict_reason: Option<String>,
114114
}
115115

116-
pub async fn run(base: &CommandBase) -> Result<i32, Error> {
116+
pub async fn run(base: &CommandBase, allowlist: bool) -> Result<i32, Error> {
117117
let color_config = base.color_config;
118118

119-
println!("🔍 Scanning workspace packages for dependency conflicts...\n");
119+
println!("🔍 Scanning workspace packages for dependency conflicts...");
120120

121121
let config = load_deps_sync_config(&base.repo_root).await?;
122+
123+
// Print ignored dependencies count if any exist
124+
if !config.ignored_dependencies.is_empty() {
125+
let ignored_count = config.ignored_dependencies.len();
126+
let dependency_word = if ignored_count == 1 {
127+
"dependency"
128+
} else {
129+
"dependencies"
130+
};
131+
let message = format!(
132+
"→ {} ignored {} in `turbo.json`",
133+
ignored_count, dependency_word
134+
);
135+
136+
if color_config.should_strip_ansi {
137+
println!("{}", message);
138+
} else {
139+
use turborepo_ui::GREY;
140+
println!("{}", GREY.apply_to(&message));
141+
}
142+
}
143+
144+
println!();
145+
122146
let all_deps = collect_all_dependencies(&base.repo_root).await?;
123147
let conflicts = find_dependency_conflicts(&all_deps, &config);
124148
let pinned_conflicts = find_pinned_version_conflicts(&all_deps, &config);
@@ -128,6 +152,20 @@ pub async fn run(base: &CommandBase) -> Result<i32, Error> {
128152
if all_conflicts.is_empty() {
129153
print_success("✅ All dependencies are in sync!", color_config);
130154
Ok(0)
155+
} else if allowlist {
156+
// Generate allowlist configuration instead of reporting conflicts
157+
let allowlist_config = generate_allowlist_config(&all_conflicts, &config);
158+
write_allowlist_config(&base.repo_root, &allowlist_config).await?;
159+
160+
print_success(
161+
&format!(
162+
"✅ Generated allowlist configuration for {} conflicts in turbo.json. \
163+
Dependencies are now synchronized!",
164+
all_conflicts.len()
165+
),
166+
color_config,
167+
);
168+
Ok(0)
131169
} else {
132170
print_conflicts(&all_conflicts, color_config);
133171
Ok(1)
@@ -489,6 +527,99 @@ fn print_success(message: &str, color_config: ColorConfig) {
489527
}
490528
}
491529

530+
fn generate_allowlist_config(
531+
conflicts: &[DependencyConflict],
532+
current_config: &DepsSyncConfig,
533+
) -> DepsSyncConfig {
534+
let mut new_config = DepsSyncConfig {
535+
pinned_dependencies: HashMap::new(),
536+
ignored_dependencies: HashMap::new(),
537+
};
538+
539+
// Only copy existing pinned dependencies that are being modified
540+
for conflict in conflicts {
541+
if conflict.conflict_reason.is_some() {
542+
// This is a pinned dependency conflict
543+
// Copy the existing pinned dependency and add exceptions
544+
if let Some(existing_pinned_dep) = current_config
545+
.pinned_dependencies
546+
.get(&conflict.dependency_name)
547+
{
548+
let mut pinned_dep = existing_pinned_dep.clone();
549+
for usage in &conflict.conflicting_packages {
550+
if !pinned_dep.exceptions.contains(&usage.package_name) {
551+
pinned_dep.exceptions.push(usage.package_name.clone());
552+
}
553+
}
554+
new_config
555+
.pinned_dependencies
556+
.insert(conflict.dependency_name.clone(), pinned_dep);
557+
}
558+
} else {
559+
// This is a regular version conflict
560+
// Add the dependency to ignored_dependencies with all conflicting packages as
561+
// exceptions
562+
let package_names: Vec<String> = conflict
563+
.conflicting_packages
564+
.iter()
565+
.map(|usage| usage.package_name.clone())
566+
.collect();
567+
568+
new_config.ignored_dependencies.insert(
569+
conflict.dependency_name.clone(),
570+
IgnoredDependency {
571+
exceptions: package_names,
572+
},
573+
);
574+
}
575+
}
576+
577+
// Also copy any existing ignored dependencies
578+
for (dep_name, ignored_dep) in &current_config.ignored_dependencies {
579+
if !new_config.ignored_dependencies.contains_key(dep_name) {
580+
new_config
581+
.ignored_dependencies
582+
.insert(dep_name.clone(), ignored_dep.clone());
583+
}
584+
}
585+
586+
// Copy any existing pinned dependencies that weren't modified
587+
for (dep_name, pinned_dep) in &current_config.pinned_dependencies {
588+
if !new_config.pinned_dependencies.contains_key(dep_name) {
589+
new_config
590+
.pinned_dependencies
591+
.insert(dep_name.clone(), pinned_dep.clone());
592+
}
593+
}
594+
595+
new_config
596+
}
597+
598+
async fn write_allowlist_config(
599+
repo_root: &AbsoluteSystemPath,
600+
config: &DepsSyncConfig,
601+
) -> Result<(), Error> {
602+
let config_opts = crate::config::ConfigurationOptions::default();
603+
let turbo_json_path = config_opts
604+
.root_turbo_json_path(repo_root)
605+
.map_err(|e| Error::Config(e))?;
606+
607+
// Read the current turbo.json file
608+
let mut raw_turbo_json = match RawTurboJson::read(repo_root, &turbo_json_path)? {
609+
Some(turbo_json) => turbo_json,
610+
None => RawTurboJson::default(),
611+
};
612+
613+
// Update the deps_sync configuration
614+
raw_turbo_json.deps_sync = Some(config.clone());
615+
616+
// Write the updated configuration back to the file
617+
let json_content = serde_json::to_string_pretty(&raw_turbo_json)?;
618+
std::fs::write(&turbo_json_path, json_content)?;
619+
620+
Ok(())
621+
}
622+
492623
#[test]
493624
fn test_ignored_dependencies_with_exceptions() {
494625
let dependencies = vec![
@@ -759,4 +890,64 @@ mod tests {
759890
assert_eq!(conflict.conflicting_packages[0].package_name, "app2");
760891
assert_eq!(conflict.conflicting_packages[0].version, "16.0.0");
761892
}
893+
894+
#[test]
895+
fn test_generate_allowlist_config() {
896+
let conflicts = vec![
897+
// Regular version conflict
898+
DependencyConflict {
899+
dependency_name: "lodash".to_string(),
900+
conflicting_packages: vec![
901+
DependencyUsage {
902+
package_name: "app1".to_string(),
903+
version: "4.17.0".to_string(),
904+
package_path: "packages/app1".to_string(),
905+
},
906+
DependencyUsage {
907+
package_name: "app2".to_string(),
908+
version: "4.18.0".to_string(),
909+
package_path: "packages/app2".to_string(),
910+
},
911+
],
912+
conflict_reason: None,
913+
},
914+
// Pinned dependency conflict
915+
DependencyConflict {
916+
dependency_name: "react".to_string(),
917+
conflicting_packages: vec![DependencyUsage {
918+
package_name: "app3".to_string(),
919+
version: "17.0.0".to_string(),
920+
package_path: "packages/app3".to_string(),
921+
}],
922+
conflict_reason: Some("pinned to 18.0.0".to_string()),
923+
},
924+
];
925+
926+
let current_config = DepsSyncConfig {
927+
pinned_dependencies: HashMap::from([(
928+
"react".to_string(),
929+
PinnedDependency {
930+
version: "18.0.0".to_string(),
931+
exceptions: vec![],
932+
},
933+
)]),
934+
ignored_dependencies: HashMap::new(),
935+
};
936+
937+
let allowlist_config = generate_allowlist_config(&conflicts, &current_config);
938+
939+
// lodash should be added to ignored_dependencies with all conflicting packages
940+
// as exceptions
941+
assert!(allowlist_config.ignored_dependencies.contains_key("lodash"));
942+
let lodash_exceptions = &allowlist_config.ignored_dependencies["lodash"].exceptions;
943+
assert_eq!(lodash_exceptions.len(), 2);
944+
assert!(lodash_exceptions.contains(&"app1".to_string()));
945+
assert!(lodash_exceptions.contains(&"app2".to_string()));
946+
947+
// app3 should be added to react's exceptions
948+
assert!(allowlist_config.pinned_dependencies.contains_key("react"));
949+
assert!(allowlist_config.pinned_dependencies["react"]
950+
.exceptions
951+
.contains(&"app3".to_string()));
952+
}
762953
}

0 commit comments

Comments
 (0)