Skip to content

ClankerGuru/wrkx

Repository files navigation

wrkx

Build Maven Central Kotlin Gradle Coverage License: MIT

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.

Why wrkx

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

Quick start

A wrkx workspace needs three files:

settings.gradle.kts

pluginManagement {
    repositories {
        mavenLocal()
        mavenCentral()
        gradlePluginPortal()
    }
}

plugins {
    id("zone.clanker.gradle.wrkx") version "latest"
}

rootProject.name = "my-workspace"

wrkx {
    workingBranch = "feature/checkout-flow"
    enableAll()
}

wrkx.json

[
  {
    "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"
  }
]

build.gradle.kts

plugins {
    base
}

Then

./gradlew wrkx-clone       # clone all repos
./gradlew wrkx-checkout    # checkout feature/checkout-flow in all repos
./gradlew build             # host-app uses local checkoutUi and sharedModels

Changes to sharedModels are instantly visible in checkoutUi and hostApp. No publishing. No version bumps. Just code.

wrkx.json format

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".

Substitution format

"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

DSL reference

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

How enablement works

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.

Workflow examples

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

Tasks

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 directories

Parallel execution

Lifecycle 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.

Checkout behavior

  • With workingBranch set: creates/checks out that branch from baseBranch in all enabled repos. Fails if working directory is dirty -- commit or stash first.
  • Without workingBranch: checks out each repo's baseBranch.

Pull behavior

  • Fetches and fast-forward merges origin/<baseBranch> for each repo.
  • Repos with no remote are skipped with a log message.

How it works

  1. Plugin reads wrkx.json at settings evaluation time
  2. All repos are registered in a container (tasks work for all)
  3. DSL runs: you enable/disable repos, set workingBranch
  4. enable() / enableAll() immediately call settings.includeBuild() during settings evaluation -- this ensures IntelliJ IDE sync can resolve the project model correctly
  5. Symlinked repo directories are resolved to their canonical paths before inclusion (works around IDEA-329756)
  6. Missing repo directories are warned, not failed -- so wrkx-clone works on fresh checkouts
  7. 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/

Known issues

IntelliJ sync with symlinked repos (fixed in 0.40.0)

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().

Kotlin Multiplatform composite builds

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.

Properties

Property Default Description
zone.clanker.wrkx.enabled true Disable the plugin entirely

Install globally

bash install.sh
# or
curl -fsSL https://raw.githubusercontent.com/ClankerGuru/wrkx/main/install.sh | bash
# uninstall
bash install.sh --uninstall

Writes an init script to ~/.gradle/init.d/ so every Gradle project gets wrkx tasks.


Contributing

Requirements

  • JDK 17+ (JetBrains Runtime recommended)
  • Gradle 9.4.1 (included via wrapper)
  • Docker (for integration tests with Testcontainers)

Clone and set up

git clone git@github.com:ClankerGuru/wrkx.git
cd wrkx
git config core.hooksPath config/hooks

Git hooks enforce:

  • pre-commit: runs ./gradlew build (compile + test + detekt + ktlint + coverage)
  • pre-push: blocks direct pushes to main (forces PRs)

Build

./gradlew build

This 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

Common commands

./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 testing

Code coverage

Coverage 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.xml

No classes are excluded from coverage. All code is tested directly or through Gradle TestKit.

Static analysis

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.html

ktlint enforces formatting rules from .editorconfig:

  • ktlint official style
  • Trailing commas required
  • 120 char line length
  • No wildcard imports
./gradlew ktlintCheck              # check
./gradlew ktlintFormat             # auto-fix

Architecture tests

Architecture 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.html

Test suites

Unit 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).

Convention plugins (build-logic)

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")
}

Project structure

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

License

MIT

About

Multi-repository workspace management for Gradle

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors