diff --git a/build/src/JewelMavenArtifactsBuildTarget.kt b/build/src/JewelMavenArtifactsBuildTarget.kt index 5f29496c96b9..d707b6974c50 100644 --- a/build/src/JewelMavenArtifactsBuildTarget.kt +++ b/build/src/JewelMavenArtifactsBuildTarget.kt @@ -33,4 +33,4 @@ internal object JewelMavenArtifactsBuildTarget { context.notifyArtifactBuilt(outputDir) } } -} \ No newline at end of file +} diff --git a/platform/jewel/scripts/compare-branches.main.kts b/platform/jewel/scripts/compare-branches.main.kts index ca665852ef0b..b9f0ce742db0 100755 --- a/platform/jewel/scripts/compare-branches.main.kts +++ b/platform/jewel/scripts/compare-branches.main.kts @@ -33,7 +33,7 @@ private data class CommitInfo( val subject: String, ) -class CompareBranchesCommand : SuspendingCliktCommand() { +private class CompareBranchesCommand : SuspendingCliktCommand() { private val verbose: Boolean by option("--verbose", help = "Prints the normalized commit subjects being compared.").flag(default = false) private val jewelOnly: Boolean by @@ -207,12 +207,6 @@ class CompareBranchesCommand : SuspendingCliktCommand() { } } - private suspend fun isDirectoryGitRepo(directory: File): Boolean = - runCommand("git rev-parse --is-inside-work-tree", directory).isSuccess - - private suspend fun branchExists(branch: String, directory: File): Boolean = - runCommand("git rev-parse --verify $branch", directory).isSuccess - private fun normalizeSubject(subject: String): String = subject .replaceFirst(Regex("""^(cherry picked from commit [0-9a-f]+):? """, RegexOption.IGNORE_CASE), "") diff --git a/platform/jewel/scripts/extract-release-notes.main.kts b/platform/jewel/scripts/extract-release-notes.main.kts index 6faf57344e14..b8e71647b9b3 100755 --- a/platform/jewel/scripts/extract-release-notes.main.kts +++ b/platform/jewel/scripts/extract-release-notes.main.kts @@ -36,7 +36,7 @@ private object Config { const val RELEASE_NOTES_FILE = "RELEASE NOTES.md" } -class ExtractReleaseNotesCommand : CliktCommand() { +private class ExtractReleaseNotesCommand : CliktCommand() { private val startDate: String by option( "--start-date", @@ -105,7 +105,7 @@ class ExtractReleaseNotesCommand : CliktCommand() { val elapsed = mark.elapsedNow() - println(" DONE") + printlnSuccess(" DONE") println(" ℹ️ Found ${allCommitHashes.size} commits in $elapsed") @@ -138,7 +138,7 @@ class ExtractReleaseNotesCommand : CliktCommand() { val uniquePrCommits = prCommits.distinctBy { it.prId }.sortedBy { it.issueId } - println(" DONE") + printlnSuccess(" DONE") println( " ℹ️ Found ${uniquePrCommits.size} unique PRs to process. " + diff --git a/platform/jewel/scripts/metalava-signatures.main.kts b/platform/jewel/scripts/metalava-signatures.main.kts index 60749e554a32..a7d855d58c06 100755 --- a/platform/jewel/scripts/metalava-signatures.main.kts +++ b/platform/jewel/scripts/metalava-signatures.main.kts @@ -4,9 +4,10 @@ @file:Import("utils.main.kts") @file:Suppress("RAW_RUN_BLOCKING") -import kotlinx.coroutines.runBlocking +import com.github.pgreze.process.Redirect import kotlin.system.exitProcess import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.runBlocking private fun printHelp() { println( @@ -145,7 +146,7 @@ private val commands = buildList { println("⏳ Executing: ${commands.joinToString(" ")}") private val result = runBlocking { - runCommand(commands.joinToString(" "), jewelDir, inheritIO = true, timeoutAmount = 60.minutes) + runCommand(commands.joinToString(" "), jewelDir, timeoutAmount = 60.minutes, outputRedirect = Redirect.PRINT) } if (result.isSuccess) { diff --git a/platform/jewel/scripts/utils.main.kts b/platform/jewel/scripts/utils.main.kts index 3cfcaab99726..99aa7a34437a 100755 --- a/platform/jewel/scripts/utils.main.kts +++ b/platform/jewel/scripts/utils.main.kts @@ -1,10 +1,11 @@ @file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") +@file:DependsOn("com.github.ajalt.clikt:clikt-jvm:5.0.3") @file:DependsOn("com.github.pgreze:kotlin-process:1.5.1") @file:Suppress("RAW_RUN_BLOCKING", "VariableNaming") +import com.github.ajalt.clikt.core.PrintMessage import com.github.pgreze.process.Redirect import com.github.pgreze.process.process -import kotlin.system.exitProcess import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.runBlocking @@ -17,6 +18,7 @@ private val COMMUNITY_ROOT_MARKER_FILE_NAME = ".community.root.marker" private val COMMUNITY_IML_FILE_NAME = "intellij.idea.community.main.iml" private val CONTROL_CODE = '\u001b' +private val CODE_SUCCESS = "$CONTROL_CODE[0;32m" private val CODE_ERR = "$CONTROL_CODE[0;31m" private val CODE_WARN = "$CONTROL_CODE[0;33m" private val CODE_BOLD = "$CONTROL_CODE[1m" @@ -51,8 +53,7 @@ fun getPrNumber() = checkNotNull(System.getenv("PR_NUMBER")?.trim()) { "PR numbe fun requireGhTool() { if (checkGhTool()) return - printlnErr("ERROR: the GitHub CLI tool must be present on the PATH.") - exitProcess(1) + throw PrintMessage("ERROR: the GitHub CLI tool must be present on the PATH.", printError = true) } /** @@ -102,8 +103,7 @@ private fun findDir(base: File, isDesiredDir: (File) -> Boolean): File? { fun requirePrNumber() { if (checkPrNumber()) return - printlnErr("ERROR: PR_NUMBER environment variable not set.") - exitProcess(2) + throw PrintMessage("ERROR: PR_NUMBER environment variable not set.", printError = true) } /** @@ -124,6 +124,15 @@ fun printlnWarn(message: String) { System.err.println(message.asWarning()) } +/** + * Prints a success message to the standard output stream with success (green) formatting. + * + * @param message The success message to print. + */ +fun printlnSuccess(message: String) { + println(message.asSuccess()) +} + /** * Formats a string as a warning message with ANSI escape codes. * @@ -145,6 +154,13 @@ fun String.asError() = if (stylingSupported) "$CODE_ERR$this$CODE_CLEAR" else th */ fun String.asBold(): String = if (stylingSupported) "$CODE_BOLD$this$CODE_CLEAR" else this +/** + * Formats a string as a success (green) text with ANSI escape codes if the terminal supports it. + * + * @return The formatted text, or the text itself if the terminal does not support styling. + */ +fun String.asSuccess(): String = if (stylingSupported) "$CODE_SUCCESS$this$CODE_CLEAR" else this + /** * Detects if the current terminal likely supports OSC 8 hyperlinks by checking for known environment variables. * @@ -187,7 +203,7 @@ private val stylingSupported = doesTerminalSupportStyling() * @return `true` if a compatible terminal is detected, `false` otherwise. */ private fun doesTerminalSupportStyling(): Boolean = - System.getenv("TERM")?.startsWith("xterm") ?: (System.getenv("COLORTERM") != null) + System.getenv("TERM")?.contains("color") == true || System.getenv("COLORTERM") == "truecolor" /** * Formats a string as a hyperlink using OSC 8 escape codes if the terminal supports it. @@ -262,14 +278,14 @@ suspend fun runCommand( workingDir: File?, timeoutAmount: Duration = 60.seconds, exitOnError: Boolean = true, - inheritIO: Boolean = false, + outputRedirect: Redirect = Redirect.CAPTURE, ): CmdResult { val result = withTimeout(timeoutAmount) { process( command = command.split(" ").toTypedArray(), - stdout = if (inheritIO) Redirect.PRINT else Redirect.CAPTURE, - stderr = if (inheritIO) Redirect.PRINT else Redirect.CAPTURE, + stdout = outputRedirect, + stderr = outputRedirect, directory = workingDir, ) } @@ -279,8 +295,19 @@ suspend fun runCommand( CmdResult.Success(output) } else { if (exitOnError) { - printlnErr("Command '$command' failed with exit code ${result.resultCode}:\n$output") - exitProcess(result.resultCode) + throw PrintMessage( + buildString { + appendLine() + append("Command '$command' failed with exit code ${result.resultCode}".asError()) + if (output.isNotBlank()) { + appendLine(":".asError()) + appendLine(output.asError()) + } else { + appendLine() + } + }, + printError = true, + ) } CmdResult.Failure(output) } @@ -345,3 +372,29 @@ fun getLatestReleaseDate(baseDir: File = File(".").canonicalFile): String? { printlnWarn("⚠️ Could not find any release date in RELEASE NOTES.md.") return null } + +/** + * Checks whether the given directory is (part of) a git repository. + * + * @return true if it is, false otherwise. + */ +suspend fun isDirectoryGitRepo(directory: File): Boolean = + runCommand("git rev-parse --is-inside-work-tree", directory, exitOnError = false).isSuccess + +/** + * Checks whether the given branch exists in the current git repository. + * + * @param branch The name of the branch to check. + * @return true if the branch exists, false otherwise. + */ +suspend fun branchExists(branch: String, directory: File): Boolean = + runCommand("git rev-parse --verify $branch", directory).isSuccess + +val isWindows = "windows" in System.getProperty("os.name").lowercase() + +fun exitWithError(message: String): Nothing { + throw PrintMessage(message.asError(), printError = true) +} + +suspend fun getCurrentBranchName(directory: File): String = + runCommand("git rev-parse --abbrev-ref HEAD", directory, exitOnError = true).output.trim() diff --git a/platform/jewel/scripts/validate-maven-artifacts.main.kts b/platform/jewel/scripts/validate-maven-artifacts.main.kts new file mode 100755 index 000000000000..7abc4ef5f930 --- /dev/null +++ b/platform/jewel/scripts/validate-maven-artifacts.main.kts @@ -0,0 +1,624 @@ +#!/usr/bin/env kotlin +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +@file:Suppress("RAW_RUN_BLOCKING", "TooGenericExceptionCaught") +@file:DependsOn("com.github.ajalt.clikt:clikt-jvm:5.0.3") +@file:Import("utils.main.kts") + +import com.github.ajalt.clikt.command.SuspendingCliktCommand +import com.github.ajalt.clikt.command.main +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.terminal +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.multiple +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.file +import com.github.ajalt.mordant.terminal.YesNoPrompt +import com.github.pgreze.process.Redirect +import java.io.File +import java.nio.file.Files +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.io.path.ExperimentalPathApi +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.runBlocking +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.w3c.dom.NodeList + +@OptIn(ExperimentalPathApi::class) +private class ValidateMavenArtifactsCommand : SuspendingCliktCommand() { + private val branches: List by + argument(help = "Branches to build and compare artifacts from (at least two).").multiple(required = true) + + private val forcePull: Boolean by + option("--force-pull", help = "Force pull untracked branches before building.").flag(default = false) + + private val verbose: Boolean by option("--verbose", "-v", help = "Enable verbose output.").flag(default = false) + + private val artifactsDir: File? by + option( + "--artifacts-dir", + "-a", + "--out-dir", + "-o", + help = + "Directory to store the built artifacts. " + + "If not specified, it will create a temp dir in the system temp dir.", + ) + .file(mustExist = false, canBeFile = false) + + private val preserveTemp: Boolean by + option( + "--preserve-temp", + "-p", + help = + "Do not clean up the temporary directory after execution. Implicit when --no-build is specified.", + ) + .flag(default = false) + + private val noBuild: Boolean by + option( + "--no-build", + "-n", + help = + "Skip building artifacts, only validate the artifacts in --artifacts-dir. " + + "Requires passing --artifacts-dir, implies --preserve-temp.", + ) + .flag(default = false) + + private val mavenArtifactsOutputDirName = "maven-artifacts" // Matches JewelMavenArtifactsBuildTarget + + override fun help(context: Context): String = "Validates Jewel Maven artifacts across specified branches." + + override suspend fun run() { + if (branches.size < 2) { + exitWithError("Please specify at least two branches to compare.") + } + + print("⏳ Locating IntelliJ Community root...") + val communityRoot = findCommunityRoot() + + if (communityRoot == null || !communityRoot.isDirectory) { + println() + exitWithError( + "Could not find the IntelliJ Community root directory. " + + "Please make sure you're running the script from somewhere inside it." + ) + } + println(" DONE: ${communityRoot.canonicalPath}") + + print("⏳ Checking git status...") + if (!isDirectoryGitRepo(communityRoot)) { + println() + exitWithError("Not a git repository at ${communityRoot.canonicalPath}.") + } + + // Check if the repo has pending changes, which can cause issues when switching branches. + // If --no-build is specified, we don't switch branches to build, so this is not needed. + if (!noBuild) { + val gitStatus = runCommand("git status --porcelain", communityRoot, exitOnError = true).output.trim() + if (gitStatus.isNotEmpty()) { + println() + exitWithError( + "Local changes detected in the git repository at ${communityRoot.canonicalPath}. " + + "Please commit or stash them." + ) + } + } + + // Store the original branch to return to later + val originalBranch = getCurrentBranchName(communityRoot) + printlnSuccess(" DONE") + + if (verbose) println(" Original branch: $originalBranch") + + val mavenArtifactsDir = communityRoot.resolve("out/idea-ce/artifacts/$mavenArtifactsOutputDirName") + val artifactsDirectory = artifactsDir ?: Files.createTempDirectory("jewel-artifacts").toFile() + + if (noBuild) { + if (artifactsDirectory == null || !artifactsDirectory.exists() || !artifactsDirectory.isDirectory) { + exitWithError( + "When --no-build is specified, --artifacts-dir must be " + + "provided and point to an existing directory." + ) + } + + println("ℹ️ Skipping build and validating from ${artifactsDirectory.canonicalPath}") + + val hasDiscrepancies = validateMavenArtifacts(artifactsDirectory) + if (!hasDiscrepancies) { + println("✅ No discrepancies found in artifact presence or dependencies.") + } else { + exitWithError("❌ Discrepancies found. Please review the output.") + } + } else { + validateAndBuildArtifacts(mavenArtifactsDir, artifactsDirectory, communityRoot, originalBranch) + + if (!preserveTemp && artifactsDirectory.exists() && artifactsDir == null) { + println("🧹 Cleaning temporary artifacts directory: ${artifactsDirectory.canonicalPath}") + artifactsDirectory.deleteRecursively() + } + } + } + + private suspend fun validateAndBuildArtifacts( + mavenArtifactsDir: File, + artifactsRoot: File, + communityRoot: File, + originalBranch: String, + ) { + try { + checkJewelMavenArtifactsFileConsistency(communityRoot) + + print("🧹 Cleaning output directory: $mavenArtifactsDir...") + if (mavenArtifactsDir.exists()) { + mavenArtifactsDir.deleteRecursively() + } + mavenArtifactsDir.mkdirs() + printlnSuccess(" DONE") + + if (artifactsRoot.exists()) { + print("🧹 Cleaning artifacts directory: $artifactsRoot...") + artifactsRoot.deleteRecursively() + printlnSuccess(" DONE") + } + artifactsRoot.mkdirs() + + for (branch in branches) { + println("\n--- Building artifacts for branch ${branch.asBold()} ---") + checkoutBranch(branch, communityRoot) + + val buildTargetFile = communityRoot.resolve("build/src/JewelMavenArtifactsBuildTarget.kt") + if (!buildTargetFile.isFile) { + exitWithError("Build target file not found at ${buildTargetFile.canonicalPath}") + } + + println("🩹 Patching ${buildTargetFile.relativeTo(communityRoot).toString().asBold()}") + val patchedBuildTargetContent = + buildTargetFile + .readText() + .replace( + "projectHome = ULTIMATE_HOME", + "projectHome = COMMUNITY_ROOT.communityRoot", + ) + buildTargetFile.writeText(patchedBuildTargetContent) + + pullBranchIfNeeded(branch, communityRoot) + + buildArtifactsOnBranch(communityRoot, branch) + + // Copy artifacts to the temp directory + println("📦 Copying artifacts for branch ${branch.asBold()} to ${artifactsRoot.canonicalPath}") + mavenArtifactsDir.copyRecursively(artifactsRoot, true) + } + + println() + + val hasDiscrepancies = validateMavenArtifacts(artifactsRoot) + if (!hasDiscrepancies) { + println("✅ No discrepancies found in artifact presence or dependencies.") + } else { + exitWithError("❌ Discrepancies found. Please review the output.") + } + } finally { + cleanup(communityRoot, originalBranch, artifactsRoot) + } + } + + private suspend fun checkJewelMavenArtifactsFileConsistency(communityRoot: File) { + println("\n🔍 Verifying JewelMavenArtifacts.kt consistency across branches...") + + val filePath = "build/src/org/jetbrains/intellij/build/JewelMavenArtifacts.kt" + val contentsByBranch = + branches.associateWith { branch -> + println(" Checking branch $branch...") + runCommand("git show $branch:$filePath", communityRoot, exitOnError = false) + } + + val failedBranches = contentsByBranch.filterValues { it.isFailure }.map { it.key } + if (failedBranches.isNotEmpty()) { + exitWithError("Could not read $filePath from branches: ${failedBranches.joinToString()}.") + } + + val contents = contentsByBranch.mapValues { it.value.getOrThrow() } + val referenceBranch = branches.first() + val referenceContent = contents[referenceBranch] + + val differingBranches = branches.drop(1).filter { contents[it] != referenceContent } + + if (differingBranches.isEmpty()) { + printlnSuccess("✅ JewelMavenArtifacts.kt is consistent across all branches.") + return + } + + printlnWarn( + "Found differences in build/src/org/jetbrains/intellij/build/JewelMavenArtifacts.kt between branches." + ) + printlnWarn("This is likely to produce inconsistent build results.") + + for (branch in differingBranches) { + println("\n--- Diff between ${referenceBranch.asBold()} and ${branch.asBold()} ---") + val diffResult = + runCommand("git diff $referenceBranch $branch -- $filePath", communityRoot, exitOnError = false) + if (diffResult.isSuccess && diffResult.output.isNotBlank()) { + println(diffResult.output) + } else { + printlnErr("Could not compute diff for $filePath between branches '$referenceBranch' and '$branch'.") + } + } + + if (YesNoPrompt("Do you want to proceed with the build anyway?", terminal, default = true).ask() == false) { + exitWithError("Build cancelled by user due to file inconsistencies.") + } + } + + private suspend fun checkoutBranch(branch: String, communityRoot: File) { + print("Checking out branch '$branch'...") + val checkoutResult = runCommand("git checkout $branch", communityRoot) + if (checkoutResult.isFailure) { + // Attempt to stash and retry checkout + printlnWarn("Checkout failed, attempting to stash changes and retry...") + runCommand("git stash", communityRoot, exitOnError = false) + val retryCheckoutResult = runCommand("git checkout $branch", communityRoot) + if (retryCheckoutResult.isFailure) { + println() + exitWithError("Failed to checkout branch '$branch' even after stashing. Aborting.") + } else { + println(" Checkout successful after stashing.") + } + } else { + printlnSuccess(" DONE") + } + } + + private suspend fun pullBranchIfNeeded(branch: String, communityRoot: File) { + val remoteInfo = + runCommand( + command = "git for-each-ref --format='%(upstream:remotename)' refs/heads/$branch", + workingDir = communityRoot, + exitOnError = false, + ) + .output + .trim() + + val trackingBranch = + runCommand( + command = "git for-each-ref --format='%(upstream:short)' refs/heads/$branch", + workingDir = communityRoot, + exitOnError = false, + ) + .output + .trim() + + if (remoteInfo.isNotEmpty() && trackingBranch.isNotEmpty()) { + val pullNeeded = + runCommand("git status -uno", communityRoot, exitOnError = false).output.let { + it.contains("Your branch is behind") || it.contains("Your branch and") // Covers divergence + } + + if (pullNeeded || forcePull) { + pullBranch(branch, trackingBranch, communityRoot) + } else if (verbose) { + println("Branch '$branch' is up-to-date or doesn't need pulling.") + } + } else if (verbose) { + println("Branch '$branch' does not track a remote branch.") + } + } + + private suspend fun pullBranch(branch: String, trackingBranch: String, communityRoot: File) { + val shouldPull = + if (forcePull) { + true + } else { + print("Branch '$branch' tracks '$trackingBranch'. Do you want to pull? (y/n): ") + readlnOrNull()?.trim()?.lowercase() == "y" + } + + if (shouldPull) { + println("Pulling from '$trackingBranch'...") + val pullResult = + runCommand( + "git pull --rebase", + communityRoot, + exitOnError = false, + ) + + if (pullResult.isFailure) { + val statusAfterPull = runCommand("git status", communityRoot).output + if ( + statusAfterPull.contains("Unmerged paths") || statusAfterPull.contains("You have unstaged changes") + ) { + printlnErr("Conflicts detected after pull. Please resolve manually and re-run the script.") + } + exitWithError("Failed to pull branch '$branch'. Aborting...") + } + println("Pull successful.") + } else { + println("Skipping pull for branch '$branch'.") + } + } + + private suspend fun buildArtifactsOnBranch(communityRoot: File, branch: String) { + println("🚀 Building artifacts...") + + val buildCommand = buildString { + append("platform/jps-bootstrap/jps-bootstrap") + append(if (isWindows) ".cmd" else ".sh") + append(" ") + append(communityRoot.absolutePath) + append(" intellij.idea.community.build JewelMavenArtifactsBuildTarget") + } + + if (verbose) println("Build command: $buildCommand") + + val buildResult = + runCommand( + buildCommand, + communityRoot, + timeoutAmount = 30.minutes, // Allow more time for build + outputRedirect = if (verbose) Redirect.PRINT else Redirect.CAPTURE, + ) + + if (buildResult.isFailure) { + println(buildResult.output) + exitWithError("Build failed for branch '$branch'. Aborting...") + } + println("Build successful for branch ${branch.asBold()}.") + } + + private fun validateMavenArtifacts(artifactsDir: File): Boolean { + println("\n--- Comparing built artifacts ---") + + val pomFiles = + artifactsDir + .walkTopDown() + .onFail { file, ioException -> printlnErr("Error accessing $file: $ioException") } + .filter { it.extension.lowercase() == "pom" } + .toList() + + if (verbose) { + println("Found ${pomFiles.size} POM files. Computing distinct groupId:artifactId coordinates...") + } + + if (pomFiles.isEmpty()) { + exitWithError("No POM files found in the temporary output directory.") + } + + val artifactNames = pomFiles.map { getArtifactNameFromPom(it) }.toSortedSet() + + println("Found ${artifactNames.size} unique artifacts.") + + if (verbose) { + println("Artifact coordinates:") + artifactNames.sorted().forEach { println(" - $it") } + } + + return checkForDiscrepancies(artifactNames, pomFiles) + } + + private fun checkForDiscrepancies( + allFoundArtifactNames: MutableSet, + pomFiles: List, + ): Boolean { + println("\nVerifying artifact presence across branches...") + + var hasDiscrepancies = false + + // First, check that we have all versions for each artifact + for (artifactName in allFoundArtifactNames) { + val pomsForArtifact = pomFiles.filter { pomFile -> getArtifactNameFromPom(pomFile) == artifactName } + + if (pomsForArtifact.size != branches.size) { + printlnErr( + buildString { + append(" * Artifact ") + append(artifactName.asBold()) + append(" is missing in one or more branches.".asError()) + append(" (") + append(branches.size) + append(" expected, ") + append(pomsForArtifact.size) + append(" found).") + } + ) + printlnErr(" Found versions:") + pomsForArtifact.forEach { println(" - " + getVersionFromPom(it).asBold()) } + hasDiscrepancies = true + } + } + + // Then, compare dependencies for each artifact across versions + return checkForDependencyDiscrepancies( + allFoundArtifactNames, + pomFiles, + ) || hasDiscrepancies + } + + private fun checkForDependencyDiscrepancies( + artifactNames: MutableSet, + pomFiles: List, + ): Boolean { + println("\nVerifying dependencies across branches...") + + var hasDiscrepancies = false + for (artifactName in artifactNames) { + val pomsForArtifact = pomFiles.filter { pomFile -> getArtifactNameFromPom(pomFile) == artifactName } + + if (pomsForArtifact.size < 2) { + printlnWarn( + "Skipping dependency comparison for ${artifactName.asBold()}: " + + "only ${pomsForArtifact.size} POMs found." + ) + continue + } + + val dependencySets = pomsForArtifact.associateWith { pomFile -> getDependenciesFromPom(pomFile) } + + hasDiscrepancies = + crossValidateDependencies(pomsForArtifact, dependencySets, artifactName) || hasDiscrepancies + } + return hasDiscrepancies + } + + private fun getArtifactNameFromPom(pomFile: File): String = + try { + val factory = DocumentBuilderFactory.newInstance() + val builder = factory.newDocumentBuilder() + val doc = builder.parse(pomFile) + doc.documentElement.normalize() + + val groupId = + checkNotNull(doc.getElementsByTagName("groupId").item(0).textContent) { "groupId not found" }.trim() + val artifactId = + checkNotNull(doc.getElementsByTagName("artifactId").item(0).textContent) { "artifactId not found" } + .trim() + + "$groupId:$artifactId" + } catch (e: Exception) { + exitWithError("Error parsing POM file ${pomFile.name}: ${e.message}") + } + + private fun getDependenciesFromPom(pomFile: File): Set { + return try { + val factory = DocumentBuilderFactory.newInstance() + val builder = factory.newDocumentBuilder() + val doc = builder.parse(pomFile) + doc.documentElement.normalize() + + val dependencies = mutableSetOf() + val dependencyNodes: NodeList = doc.getElementsByTagName("dependency") + + for (i in 0 until dependencyNodes.length) { + val dependencyNode: Node = dependencyNodes.item(i) + val coordinates = processDependencyNode(dependencyNode, pomFile.name, i) + if (coordinates != null) dependencies.add(coordinates) + } + dependencies + } catch (e: Exception) { + printlnErr("Error parsing dependencies in POM file ${pomFile.name}: ${e.message}") + emptySet() + } + } + + private fun processDependencyNode(dependencyNode: Node, fileName: String, position: Int): String? { + if (dependencyNode.nodeType != Node.ELEMENT_NODE) return null + val element = dependencyNode as Element + val groupId = element.getElementsByTagName("groupId").item(0)?.textContent?.trim() + val artifactId = element.getElementsByTagName("artifactId").item(0)?.textContent?.trim() + + return if (groupId != null && artifactId != null) { + "$groupId:$artifactId" + } else { + printlnWarn("Skipping incomplete dependency in $fileName at position #$position") + null + } + } + + private fun crossValidateDependencies( + poms: List, + dependencySets: Map>, + artifactName: String, + ): Boolean { + var hasDiscrepancies = false + + for (i in 0 until poms.size) { + for (j in i + 1 until poms.size) { + val file1 = poms[i] + val file2 = poms[j] + val deps1 = dependencySets[file1] ?: emptySet() + val deps2 = dependencySets[file2] ?: emptySet() + + hasDiscrepancies = + crossValidateDependencies(artifactName, file1, file2, deps1, deps2) || hasDiscrepancies + } + } + + println() + + return hasDiscrepancies + } + + private fun crossValidateDependencies( + artifactName: String, + file1: File, + file2: File, + deps1: Set, + deps2: Set, + ): Boolean { + var hasDiscrepancies = false + val discrepancies = (deps1 union deps2) - (deps1 intersect deps2) + + if (discrepancies.isNotEmpty()) { + printlnErr( + buildString { + append(" * Dependencies DIFFER for ") + append(artifactName.asBold()) + append(" between ") + append(file1.name.asBold().asLink(file1.absolutePath)) + append(" and ") + append(file2.name.asBold().asLink(file2.absolutePath)) + append(".") + } + ) + + println(" Unique dependencies:") + discrepancies.sorted().forEach { println(" - ${it.asBold()}") } + hasDiscrepancies = true + } else { + printlnSuccess( + buildString { + append(" * Dependencies MATCH for ") + append(artifactName.asBold()) + append(" between ") + append(file1.name.asBold().asLink(file1.absolutePath)) + append(" and ") + append(file2.name.asBold().asLink(file2.absolutePath)) + append(".") + } + ) + } + + return hasDiscrepancies + } + + private suspend fun cleanup( + communityRoot: File, + originalBranch: String, + artifactsRoot: File, + ) { + println("\n--- Cleaning up ---") + + print("🔄 Reverting local changes...") + runCommand("git reset --hard", communityRoot) + printlnSuccess(" DONE") + + print("🔄 Returning to original branch '$originalBranch'...") + runCommand("git checkout $originalBranch", communityRoot) + printlnSuccess(" DONE") + + if (!preserveTemp) { + println("🧹 Cleaning artifacts directory: ${artifactsRoot.canonicalPath}") + if (artifactsRoot.exists()) { + artifactsRoot.deleteRecursively() + } + } else { + println("ℹ️ Skipping cleanup of artifacts directory: ${artifactsRoot.canonicalPath}") + } + } + + private fun getVersionFromPom(pomFile: File): String = + try { + val factory = DocumentBuilderFactory.newInstance() + val builder = factory.newDocumentBuilder() + val doc = builder.parse(pomFile) + doc.documentElement.normalize() + + checkNotNull(doc.getElementsByTagName("version").item(0)?.textContent?.trim()) { "version not found" } + } catch (e: Exception) { + exitWithError("Error parsing POM file ${pomFile.name}: ${e.message}") + } +} + +runBlocking { ValidateMavenArtifactsCommand().main(args) } diff --git a/platform/jps-bootstrap/README.md b/platform/jps-bootstrap/README.md index d8cae430db49..790ebe479907 100644 --- a/platform/jps-bootstrap/README.md +++ b/platform/jps-bootstrap/README.md @@ -20,11 +20,19 @@ On buildserver it's not easy to output something correctly, the output is interc ## Running build scripts via jps-bootstrap ### Locally - * just run CLASS_NAME from IDEA - * run any main class (written in Java/Kotlin) from intellij project via\ -`./jps-bootstrap.cmd PROJECT_HOME MODULE_NAME CLASS_NAME ARGS` - -Example: `./community/platform/jps-bootstrap/jps-bootstrap.sh ./community intellij.idea.ultimate.build DownloadLibrariesBuildTarget` + * Run the CLASS_NAME from IDEA from the gutter ▶️ icon, or + * Run any main class (written in Java/Kotlin) from the IntelliJ project from CLI via: + ```shell + ./jps-bootstrap.cmd PROJECT_HOME MODULE_NAME CLASS_NAME ARGS + ``` + Where `PROJECT_HOME` is the path where the `.idea` folder is (either the ultimate or community root), the `MODULE_NAME` is the name of + the JPS module that contains the class, and `CLASS_NAME` is the FQN of the class that contains the `main()` to run. + + Example: + + ```shell + ./community/platform/jps-bootstrap/jps-bootstrap.sh . intellij.idea.ultimate.build DownloadLibrariesBuildTarget + ``` Special wrappers could be written to make scripts easier, see e.g. `build/downloadLibraries.cmd`