Multi-repository workspace management for Gradle.
Work across multiple repos as if they were one project. Define your repositories in JSON, select which ones to work with today in the DSL, and let the plugin wire composite builds with dependency substitution. Changes to one repo are immediately visible in all others -- no publishing, no version bumps, no waiting.
When your codebase spans multiple repositories, development friction multiplies. You change a library, publish it, bump the version in the consuming app, wait for resolution, discover the change broke something, go back, fix it, publish again. wrkx eliminates this loop.
Think of each workspace as an epic. You're working on a feature that touches three repos -- the UI, a shared model library, and the host app. Create a workspace, list the three repos, enable them, and you have a single Gradle build where changes flow instantly between all three. Done with the epic? Disable the repos or switch to a different set. The JSON stays as your catalog; the DSL is your daily driver.
workspace = epic
repos = the modules that matter for this epic
branches = all repos on the same working branch
A wrkx workspace needs three files:
pluginManagement {
repositories {
mavenLocal()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("zone.clanker.gradle.wrkx") version "latest"
}
rootProject.name = "my-workspace"
wrkx {
workingBranch = "feature/checkout-flow"
enableAll()
}[
{
"name": "checkoutUi",
"path": "git@github.com:MyOrg/checkout-ui.git",
"baseBranch": "main",
"category": "ui",
"substitute": true,
"substitutions": ["com.myorg:checkout-ui,:"]
},
{
"name": "sharedModels",
"path": "git@github.com:MyOrg/shared-models.git",
"baseBranch": "main",
"category": "core",
"substitute": true,
"substitutions": ["com.myorg:shared-models,:"]
},
{
"name": "hostApp",
"path": "git@github.com:MyOrg/host-app.git",
"baseBranch": "develop",
"category": "apps"
}
]plugins {
base
}./gradlew wrkx-clone # clone all repos
./gradlew wrkx-checkout # checkout feature/checkout-flow in all repos
./gradlew build # host-app uses local checkoutUi and sharedModelsChanges to sharedModels are instantly visible in checkoutUi and hostApp. No publishing. No version bumps. Just code.
| Field | Required | Default | Description |
|---|---|---|---|
name |
yes | -- | Unique identifier. Must be a valid Kotlin identifier (camelCase). Used in DSL, tasks, and logs. |
path |
yes | -- | Git URL or local path. Any format git clone accepts (SSH, HTTPS, local). |
baseBranch |
no | "main" |
The repo's default branch. Where wrkx-pull syncs from. |
category |
no | "" |
Grouping label for wrkx-status output. |
substitute |
no | false |
Enable dependency substitution from local source. |
substitutions |
no | [] |
Maven artifacts this repo provides: "group:artifact,project". |
"com.myorg:shared-models,:"
───────────────────────── ─
Maven coordinate Gradle project path (: = root project)
When a repo has substitute: true, Gradle resolves its artifacts from local source instead of Maven. Substitution is global -- all builds in the composite see it. Flip substitute to false to pull from Maven without editing the list.
For multi-module repos, use the subproject path:
"com.myorg:checkout-ui,:checkout" <- resolves from :checkout subproject
The wrkx { } block controls which repos are active in your build:
wrkx {
// Set a working branch for all enabled repos
workingBranch = "feature/checkout-flow"
// Enable everything from JSON
enableAll()
// Or: start clean, enable only what you need
disableAll()
enable(checkoutUi, sharedModels)
// Per-repo access via bracket syntax
this["hostApp"].enable(true)
}| Method | Description |
|---|---|
enableAll() |
Enable all repos and include them as composite builds |
disableAll() |
Disable all repos (does not remove already-included builds) |
enable(vararg repos) |
Enable specific repos and include them as composite builds |
this["name"] |
Access a repo by name for per-repo configuration |
workingBranch = "branch" |
Set working branch for wrkx-checkout |
The plugin reads wrkx.json and registers all repos, but does not include any as composite builds until you enable them in the DSL. Calling enable() or enableAll() immediately wires the repo via settings.includeBuild() during settings evaluation. Tasks (clone, pull, checkout) work for all repos regardless of enablement.
If no wrkx { } block is present, no repos are included as composite builds. You must be explicit.
Repos in workspace-repos/ can be real clones or symlinks to local checkouts -- the plugin resolves canonical paths automatically.
Epic: new checkout flow -- three repos, one branch:
wrkx {
workingBranch = "feature/checkout-flow"
disableAll()
enable(checkoutUi, sharedModels, hostApp)
}Quick fix: patch a library -- one repo, everything else from Maven:
wrkx {
disableAll()
enable(sharedModels)
}Explore: pull everything, build nothing locally:
// No wrkx {} block -- repos are cloned but not included
// ./gradlew wrkx-clone still works| Task | Description |
|---|---|
wrkx |
List all available workspace tasks |
wrkx-clone |
Clone all repos defined in wrkx.json |
wrkx-clone-<name> |
Clone a single repo from its remote |
wrkx-pull |
Pull baseBranch for all repos from their remotes |
wrkx-pull-<name> |
Pull baseBranch for a single repo |
wrkx-checkout |
Checkout workingBranch (or baseBranch) across all repos |
wrkx-checkout-<name> |
Checkout workingBranch (or baseBranch) for a single repo |
wrkx-status |
Generate workspace status report at .wrkx/repos.md |
wrkx-prune |
Remove repo directories not defined in wrkx.json |
./gradlew wrkx-clone # clone all repos
./gradlew wrkx-clone-gort # clone just gort
./gradlew wrkx-pull # pull baseBranch for all repos
./gradlew wrkx-checkout # checkout workingBranch or baseBranch
./gradlew wrkx-status # generate workspace status report
./gradlew wrkx-prune # remove orphaned repo directoriesLifecycle tasks (wrkx-clone, wrkx-pull, wrkx-checkout) run git operations across all repos in parallel using a fixed thread pool (4 threads). Each repo's result is reported individually, and the task fails if any repo fails.
- With
workingBranchset: creates/checks out that branch frombaseBranchin all enabled repos. Fails if working directory is dirty -- commit or stash first. - Without
workingBranch: checks out each repo'sbaseBranch.
- Fetches and fast-forward merges
origin/<baseBranch>for each repo. - Repos with no remote are skipped with a log message.
- Plugin reads
wrkx.jsonat settings evaluation time - All repos are registered in a container (tasks work for all)
- DSL runs: you enable/disable repos, set workingBranch
enable()/enableAll()immediately callsettings.includeBuild()during settings evaluation -- this ensures IntelliJ IDE sync can resolve the project model correctly- Symlinked repo directories are resolved to their canonical paths before inclusion (works around IDEA-329756)
- Missing repo directories are warned, not failed -- so
wrkx-cloneworks on fresh checkouts - Inclusion is idempotent -- calling
enable()on the same repo twice is safe
Repos are cloned to a sibling directory:
~/dev/
├── my-workspace/ <- has wrkx.json + settings.gradle.kts
│ ├── settings.gradle.kts
│ ├── build.gradle.kts
│ └── wrkx.json
└── my-workspace-repos/ <- repos cloned here by wrkx-clone
├── checkout-ui/
├── shared-models/
└── host-app/
Prior to 0.40.0, IntelliJ Gradle sync would fail with Missing ExternalProject for : when workspace repos were symlinks. This was caused by a path mismatch between Gradle (which uses the symlink path) and IntelliJ's TAPI model builder (which resolves to canonical paths). The plugin now resolves all paths via File.canonicalFile before calling settings.includeBuild().
Gradle composite builds with dependency substitution do not work when the consumer project uses the Kotlin Multiplatform plugin. The Kotlin Gradle plugin's KmpPartiallyResolvedDependenciesChecker calls Project#beforeEvaluate() on included build projects during dependency resolution, which Gradle blocks at that lifecycle stage.
This affects all current Kotlin versions up to and including 2.4.0-Beta1. It is not a wrkx issue -- it reproduces with raw settings.includeBuild() and --include-build as well.
What works:
- JVM project → JVM included build (e.g. a Kotlin/JVM app consuming a Kotlin/JVM library via wrkx substitution)
- Clone, pull, checkout, status, prune tasks work for all project types
What doesn't work:
- KMP project → any included build with dependency substitution
Track the upstream fix at KT-52172.
| Property | Default | Description |
|---|---|---|
zone.clanker.wrkx.enabled |
true |
Disable the plugin entirely |
bash install.sh
# or
curl -fsSL https://raw.githubusercontent.com/ClankerGuru/wrkx/main/install.sh | bash
# uninstall
bash install.sh --uninstallWrites an init script to ~/.gradle/init.d/ so every Gradle project gets wrkx tasks.
- JDK 17+ (JetBrains Runtime recommended)
- Gradle 9.4.1 (included via wrapper)
- Docker (for integration tests with Testcontainers)
git clone git@github.com:ClankerGuru/wrkx.git
cd wrkx
git config core.hooksPath config/hooksGit hooks enforce:
- pre-commit: runs
./gradlew build(compile + test + detekt + ktlint + coverage) - pre-push: blocks direct pushes to
main(forces PRs)
./gradlew buildThis single command runs everything:
| Step | Task | What it checks |
|---|---|---|
| Compile | compileKotlin |
Kotlin source compiles |
| Detekt | detekt |
Static analysis against config/detekt.yml |
| ktlint | ktlintCheck |
Code formatting against .editorconfig |
| Unit tests | test |
Model, task, and plugin behavior |
| Integration tests | test |
Full lifecycle against Gitea in Testcontainers |
| Architecture tests | slopTest |
Konsist: naming, packages, annotations, forbidden patterns |
| Coverage | koverVerify |
Line coverage >= 95% enforced |
| Plugin validation | validatePlugins |
Gradle plugin descriptor is valid |
./gradlew build # full build (everything)
./gradlew assemble # just compile
./gradlew test # unit + integration tests
./gradlew detekt # static analysis only
./gradlew ktlintCheck # formatting check only
./gradlew ktlintFormat # auto-fix formatting
./gradlew slopTest # architecture tests (Konsist)
./gradlew check # all verification tasks
./gradlew publishToMavenLocal # publish to ~/.m2 for local testingCoverage is enforced at 95% minimum line coverage via Kover.
# Check coverage threshold (fails if below 95%)
./gradlew koverVerify
# Print coverage summary to terminal
./gradlew koverLog
# Generate HTML report
./gradlew koverHtmlReport
open build/reports/kover/html/index.html
# Generate XML report (for CI integration)
./gradlew koverXmlReport
# output: build/reports/kover/report.xmlNo classes are excluded from coverage. All code is tested directly or through Gradle TestKit.
Detekt runs with the configuration at config/detekt.yml:
- Max issues: 0 (zero tolerance)
- Warnings treated as errors
- Max line length: 120
- Cyclomatic complexity threshold: 15
- Nested block depth: 4
- Magic numbers enforced (except -1, 0, 1, 2)
./gradlew detekt
# report: build/reports/detekt/detekt.htmlktlint enforces formatting rules from .editorconfig:
- ktlint official style
- Trailing commas required
- 120 char line length
- No wildcard imports
./gradlew ktlintCheck # check
./gradlew ktlintFormat # auto-fixArchitecture is enforced via Konsist in src/slopTest/:
| Test | Enforces |
|---|---|
PackageBoundaryTest |
Models never import from tasks or reports. Reports never import from tasks. |
NamingConventionTest |
Task classes end with Task. Report classes end with Renderer. No generic suffixes (Helper, Manager, Util, etc.). |
TaskAnnotationTest |
Every task class has @UntrackedTask annotation. |
ForbiddenPackageTest |
No junk-drawer packages (utils, helpers, common, misc, shared, etc.). |
ForbiddenPatternTest |
No try-catch (use runCatching). No standalone constant files. No wildcard imports. |
./gradlew slopTest
# report: build/reports/tests/slopTest/index.htmlUnit tests (src/test/) -- model serialization, value class validation, task behavior with local git repos. No Docker needed.
| Test file | What it covers |
|---|---|
WrkxExtensionTest |
DSL behavior: enableAll, disableAll, enable(vararg), operator[], repos(action), workingBranch, duplicate build name detection, includeEnabled, includeRepo idempotency, symlink resolution, clonePath early return |
WrkxSettingsPluginTest |
Plugin lifecycle: disabled property, already-applied guard, resolveRepoDir, createExtension, populateFromConfig, JSON parsing |
WrkxApplyTest |
Gradle TestKit: plugin applies cleanly via settings DSL |
WrkxPluginTest |
Gradle TestKit: enableAll, disableAll, enable, workingBranch, composite build wiring, missing repos warn, empty wrkx.json default |
model/*Test |
Value class validation: RepositoryUrl, GitReference, ArtifactSubstitution, RepositoryEntry, WorkspaceRepository |
task/*Test |
Task behavior: CloneTask, PullTask, CheckoutTask, PruneTask, StatusTask, GitOperations parallel execution |
Integration tests (src/test/CloneIntegrationTest.kt) -- full clone lifecycle against a Gitea server in Testcontainers. Requires Docker. Skipped automatically when Docker is unavailable.
Architecture tests (src/slopTest/) -- Konsist structural rules (see Architecture tests below).
All build configuration is managed through precompiled script plugins:
| Plugin | Provides |
|---|---|
clkx-conventions |
Applies all conventions below |
clkx-module |
java-library + Kotlin JVM + JUnit Platform |
clkx-toolchain |
JDK toolchain configuration |
clkx-plugin |
java-gradle-plugin setup |
clkx-publish |
Maven Central publishing via Vanniktech |
clkx-serialization |
kotlinx.serialization plugin |
clkx-testing |
Kotest + Testcontainers + Kover + Konsist + slopTest source set |
clkx-detekt |
Detekt static analysis with config/detekt.yml |
clkx-ktlint |
ktlint formatting with .editorconfig rules |
The main build.gradle.kts is one line:
plugins {
id("clkx-conventions")
}wrkx/
├── .github/workflows/
│ └── build.yml <- CI: build + test on push/PR to main
├── config/
│ ├── detekt.yml <- Detekt static analysis rules
│ └── hooks/
│ ├── pre-commit <- Runs ./gradlew build before every commit
│ └── pre-push <- Blocks direct push to main
├── build-logic/ <- Convention plugins (clkx-*)
│ ├── build.gradle.kts <- Plugin dependencies
│ ├── settings.gradle.kts
│ └── src/main/kotlin/ <- 9 convention plugin scripts
├── src/
│ ├── main/kotlin/zone/clanker/gradle/wrkx/
│ │ ├── Wrkx.kt <- SettingsPlugin + SettingsExtension + constants
│ │ ├── WrkxDsl.kt <- Settings.wrkx {} type-safe extension function
│ │ ├── model/
│ │ │ ├── ArtifactId.kt <- Maven group:artifact value class
│ │ │ ├── ArtifactSubstitution.kt <- artifact-to-project mapping
│ │ │ ├── GitReference.kt <- Branch/tag/SHA value class
│ │ │ ├── ProjectPath.kt <- Gradle project path value class
│ │ │ ├── RepositoryEntry.kt <- JSON deserialization model
│ │ │ ├── RepositoryUrl.kt <- Git URL value class
│ │ │ └── WorkspaceRepository.kt <- Gradle-managed repo in the container
│ │ ├── report/
│ │ │ └── ReposCatalogRenderer.kt <- Markdown report builder for wrkx-status
│ │ └── task/
│ │ ├── CheckoutTask.kt <- git checkout per repo
│ │ ├── CloneTask.kt <- git clone per repo
│ │ ├── PruneTask.kt <- remove orphaned repo directories
│ │ ├── PullTask.kt <- git fetch + merge per repo
│ │ └── StatusTask.kt <- generate .wrkx/repos.md
│ ├── test/kotlin/ <- Unit + integration + plugin tests (Kotest BDD)
│ └── slopTest/kotlin/ <- Architecture tests (Konsist)
│ ├── PackageBoundaryTest.kt
│ ├── NamingConventionTest.kt
│ ├── TaskAnnotationTest.kt
│ ├── ForbiddenPackageTest.kt
│ └── ForbiddenPatternTest.kt
├── .editorconfig <- ktlint + editor formatting rules
├── build.gradle.kts <- One line: id("clkx-conventions")
├── settings.gradle.kts <- build-logic (named wrkx-build-logic), clkx-settings, root name
├── gradle.properties <- Version, Maven coordinates, POM metadata
└── install.sh <- Global installer via Gradle init script