Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
6bcdda1
Use path mapping when json-serializing PathRefs
lefou Oct 23, 2025
38bc0a4
Use path-roots mapping for path serialization
lefou Oct 23, 2025
91dfcac
Hack: explicitly set outPath before executing anything
lefou Oct 24, 2025
793c899
Set oupath when writing `mill-runner-state.json`
lefou Oct 24, 2025
db57502
Fix outpath propagation to test runner
lefou Nov 7, 2025
c3fb774
fix expected show output
lefou Oct 24, 2025
4f0bbca
Name tuple items
lefou Oct 25, 2025
426b770
Don't use current evaluator to get the current output path
lefou Oct 25, 2025
b2252ce
Stabilize PathRef hashcode by using the encoded path
lefou Oct 25, 2025
a93fedf
Fix tests
lefou Oct 25, 2025
1c250ae
Refactor outDir init and propagation
lefou Oct 26, 2025
936662c
Replace expected literals in itests
lefou Oct 26, 2025
e4feeb5
Set outPath in UnitTester
lefou Oct 26, 2025
963e723
Change design: root path mapping is now dynamic
lefou Oct 27, 2025
2f81616
Addd more checks
lefou Oct 27, 2025
7d15893
Fix defaults
lefou Oct 27, 2025
a77ed17
Cleanup
lefou Oct 27, 2025
486e223
Always use current mapping when serializing to json
lefou Oct 27, 2025
ab58247
Ensure, we don't map any root paths in RPC communication
lefou Oct 27, 2025
e97a96c
Don't use placeholders in test runner testargs files
lefou Oct 27, 2025
ce8680a
Made test condition on Java version
lefou Oct 27, 2025
774457e
Readd pathref config in GroupExecution
lefou Oct 27, 2025
5b7783e
Fixed test expectation
lefou Oct 27, 2025
6a69ac5
Revert more eager expected test output changes
lefou Oct 27, 2025
5de36b7
Ajust reading of json files
lefou Oct 27, 2025
8c4856e
Fix tests
lefou Oct 27, 2025
660a95c
Fix test
lefou Oct 27, 2025
b20be46
cleanup
lefou Oct 28, 2025
f2139e9
cleanup
lefou Oct 28, 2025
ed1a763
Moved new API to `MappedRoots` object
lefou Oct 28, 2025
7da1ec4
Renamings
lefou Oct 28, 2025
0ac506a
Ensure `MappedRoots.withMillDefaults` is used with named parameters
lefou Oct 28, 2025
7e5691c
cleanup
lefou Oct 28, 2025
078eba6
Use named parameters
lefou Nov 3, 2025
d3ee20a
Fix merge error
lefou Nov 3, 2025
45a7b97
Add MappedRoots.requireMappedPaths API
lefou Nov 3, 2025
cc98220
Fixed expected outputs of new tests
lefou Nov 4, 2025
d69ce4c
Use named parameter
lefou Nov 4, 2025
65733a5
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 4, 2025
4235a31
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions core/api/src/mill/api/JsonFormatters.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ trait JsonFormatters {

implicit val pathReadWrite: RW[os.Path] = upickle.readwriter[String]
.bimap[os.Path](
_.toString,
os.Path(_)
p => MappedRoots.encodeKnownRootsInPath(p),
s => os.Path(MappedRoots.decodeKnownRootsInPath(s))
)

implicit val relPathRW: RW[os.RelPath] = upickle.readwriter[String]
Expand Down
95 changes: 95 additions & 0 deletions core/api/src/mill/api/MappedRoots.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package mill.api

import mill.api.internal.NamedParameterOnlyDummy
import mill.constants.PathVars

import scala.annotation.unused
import scala.util.DynamicVariable

type MappedRoots = Seq[(key: String, path: os.Path)]

object MappedRoots extends MappedRootsImpl

trait MappedRootsImpl {

private val rootMapping: DynamicVariable[MappedRoots] = DynamicVariable(Seq())

def get: MappedRoots = rootMapping.value

def toMap: Map[String, os.Path] = get.map(m => (m.key, m.path)).toMap

def withMillDefaults[T](
@unused t: NamedParameterOnlyDummy = new NamedParameterOnlyDummy,
outPath: os.Path,
workspacePath: os.Path = BuildCtx.workspaceRoot,
homePath: os.Path = os.home
)(thunk: => T): T = withMapping(
Seq(
("MILL_OUT", outPath),
("WORKSPACE", workspacePath),
// TODO: add coursier here
("HOME", homePath)
)
)(thunk)

def withMapping[T](mapping: MappedRoots)(thunk: => T): T = withMapping(_ => mapping)(thunk)

def withMapping[T](mapping: MappedRoots => MappedRoots)(thunk: => T): T = {
val newMapping = mapping(rootMapping.value)
var seenKeys = Set[String]()
var seenPaths = Set[os.Path]()
newMapping.foreach { case m =>
require(!m.key.startsWith("$"), "Key must not start with a `$`.")
require(m.key != PathVars.ROOT, s"Invalid key, '${PathVars.ROOT}' is a reserved key name.")
require(
!seenKeys.contains(m.key),
s"Key must be unique, but '${m.key}' was given multiple times."
)
require(
!seenPaths.contains(m.path),
s"Paths must be unique, but '${m.path}' was given multiple times."
)
seenKeys += m.key
seenPaths += m.path
}
rootMapping.withValue(newMapping)(thunk)
}

def encodeKnownRootsInPath(p: os.Path): String = {
MappedRoots.get.collectFirst {
case rep if p.startsWith(rep.path) =>
s"$$${rep.key}${
if (p != rep.path) {
s"/${p.subRelativeTo(rep.path).toString()}"
} else ""
}"
}.getOrElse(p.toString)
}

def decodeKnownRootsInPath(encoded: String): String = {
if (encoded.startsWith("$")) {
val offset = 1 // "$".length
MappedRoots.get.collectFirst {
case mapping if encoded.startsWith(mapping.key, offset) =>
s"${mapping.path.toString}${encoded.substring(mapping.key.length + offset)}"
}.getOrElse(encoded)
} else {
encoded
}
}

/**
* Use this to assert at runtime, that a root path with the given `key` is defined.
* @throws NoSuchElementException when no path was mapped under the given `key`.
*/
def requireMappedPaths(key: String*): Unit = {
val map = toMap
for {
singleKey <- key
} {
if (!map.contains(singleKey))
throw new NoSuchElementException(s"No root path mapping defined for '${key}'")
}
}

}
48 changes: 39 additions & 9 deletions core/api/src/mill/api/PathRef.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import java.util.concurrent.ConcurrentHashMap
import scala.annotation.nowarn
import scala.language.implicitConversions
import scala.util.DynamicVariable
import scala.util.hashing.MurmurHash3

/**
* A wrapper around `os.Path` that calculates it's hashcode based
* on the contents of the filesystem underneath it. Used to ensure filesystem
* changes can bust caches which are keyed off hashcodes.
* A wrapper around `os.Path` that calculates a `sig` (which ends up in the [[hashCode]])
* based on the contents of the filesystem underneath it.
* Used to ensure filesystem changes can bust caches which are keyed off hashcodes.
*/
case class PathRef private[mill] (
path: os.Path,
Expand All @@ -24,6 +25,18 @@ case class PathRef private[mill] (
) extends PathRefApi {
private[mill] def javaPath = path.toNIO

/**
* The path with common mapped path roots replaced, to make it relocatable.
* See [[MappedRoots]].
*/
private val mappedPath: String = MappedRoots.encodeKnownRootsInPath(path)

/**
* Apply the current contextual path mapping to this PathRef.
* Updates [[mappedPath]] but does not recalculate the [[sig]].
*/
def remap: PathRef = PathRef(path, quick, sig, revalidate)

def recomputeSig(): Int = PathRef.apply(path, quick).sig
def validate(): Boolean = recomputeSig() == sig

Expand All @@ -38,16 +51,33 @@ case class PathRef private[mill] (
def withRevalidate(revalidate: PathRef.Revalidate): PathRef = copy(revalidate = revalidate)
def withRevalidateOnce: PathRef = copy(revalidate = PathRef.Revalidate.Once)

override def toString: String = {
private def toStringPrefix: String = {
val quick = if (this.quick) "qref:" else "ref:"

val valid = revalidate match {
case PathRef.Revalidate.Never => "v0:"
case PathRef.Revalidate.Once => "v1:"
case PathRef.Revalidate.Always => "vn:"
}
val sig = String.format("%08x", this.sig: Integer)
quick + valid + sig + ":" + path.toString()
s"${quick}${valid}${sig}:"
}

override def toString: String = {
toStringPrefix + path.toString()
}

// Instead of using `path` we need to use `mappedPath` to make the hashcode stable as cache key
override def hashCode(): Int = {
var h = MurmurHash3.productSeed
h = MurmurHash3.mix(h, "PathRef".hashCode)
h = MurmurHash3.mix(h, mappedPath.hashCode)
h = MurmurHash3.mix(h, quick.##)
h = MurmurHash3.mix(h, sig.##)
h = MurmurHash3.mix(h, revalidate.##)
MurmurHash3.finalizeHash(h, 4)
}

}

object PathRef {
Expand Down Expand Up @@ -195,12 +225,12 @@ object PathRef {
implicit def jsonFormatter: RW[PathRef] = upickle.readwriter[String].bimap[PathRef](
p => {
storeSerializedPaths(p)
p.toString()
p.toStringPrefix + MappedRoots.encodeKnownRootsInPath(p.path)
},
{
case s"$prefix:$valid0:$hex:$pathString" if prefix == "ref" || prefix == "qref" =>
case s"$prefix:$valid0:$hex:$pathVal" if prefix == "ref" || prefix == "qref" =>

val path = os.Path(pathString)
val path = os.Path(MappedRoots.decodeKnownRootsInPath(pathVal))
val quick = prefix match {
case "qref" => true
case "ref" => false
Expand All @@ -219,7 +249,7 @@ object PathRef {
pr
case s =>
mill.api.BuildCtx.withFilesystemCheckerDisabled(
PathRef(os.Path(s, currentOverrideModulePath.value))
PathRef(os.Path(MappedRoots.decodeKnownRootsInPath(s), currentOverrideModulePath.value))
)
}
)
Expand Down
48 changes: 48 additions & 0 deletions core/api/test/src/mill/api/MappedRootsTests.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package mill.api

import utest.*

import java.nio.file.Files
import mill.api.{MappedRoots => MR}

object MappedRootsTests extends TestSuite {
val tests: Tests = Tests {
test("encode") {
withTmpDir { tmpDir =>
val workspaceDir = tmpDir / "workspace"
val outDir = workspaceDir / "out"
MR.withMillDefaults(outPath = outDir, workspacePath = workspaceDir) {

def check(path: os.Path, encContains: Seq[String], containsNot: Seq[String]) = {
val enc = MR.encodeKnownRootsInPath(path)
val dec = MR.decodeKnownRootsInPath(enc)
assert(path.toString == dec)
encContains.foreach(s => assert(enc.containsSlice(s)))
containsNot.foreach(s => assert(!enc.containsSlice(s)))

path -> enc
}

val file1 = tmpDir / "file1"
val file2 = workspaceDir / "file2"
val file3 = outDir / "file3"

Seq(
"mapping" -> MR.get,
check(file1, Seq(file1.toString), Seq("$WORKSPACE", "$MILL_OUT")),
check(file2, Seq("$WORKSPACE/file2"), Seq("$MILL_OUT")),
check(file3, Seq("$MILL_OUT/file3"), Seq("$WORKSPACE"))
)
}
}
}
}

private def withTmpDir[T](body: os.Path => T): T = {
val tmpDir = os.Path(Files.createTempDirectory(""))
val res = body(tmpDir)
os.remove.all(tmpDir)
res
}

}
12 changes: 10 additions & 2 deletions core/api/test/src/mill/api/PathRefTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ object PathRefTests extends TestSuite {
val sig2 = PathRef(file, quick).sig
assert(sig1 != sig2)
}

test("qref") - check(quick = true)
test("ref") - check(quick = false)
}
Expand All @@ -43,12 +44,18 @@ object PathRefTests extends TestSuite {
val file = tmpDir / "foo.txt"
val content = "hello"
os.write.over(file, content)
Files.setPosixFilePermissions(file.wrapped, PosixFilePermissions.fromString("rw-rw----"))
Files.setPosixFilePermissions(
file.wrapped,
PosixFilePermissions.fromString("rw-rw----")
)
val rwSig = PathRef(file, quick).sig
val rwSigb = PathRef(file, quick).sig
assert(rwSig == rwSigb)

Files.setPosixFilePermissions(file.wrapped, PosixFilePermissions.fromString("rwxrw----"))
Files.setPosixFilePermissions(
file.wrapped,
PosixFilePermissions.fromString("rwxrw----")
)
val rwxSig = PathRef(file, quick).sig

assert(rwSig != rwxSig)
Expand Down Expand Up @@ -76,6 +83,7 @@ object PathRefTests extends TestSuite {
val sig2 = PathRef(tmpDir, quick).sig
assert(sig1 == sig2)
}

test("qref") - check(quick = true)
test("ref") - check(quick = false)
}
Expand Down
22 changes: 22 additions & 0 deletions core/constants/src/mill/constants/PathVars.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package mill.constants;

/**
* Central place containing all the path variables that Mill uses in <code>PathRef</code> or <code>os.Path</code>.
*/
public interface PathVars {

/**
* Output directory where Mill workers' state and Mill tasks output should be
* written to
*/
String MILL_OUT = "MILL_OUT";

/**
* The Mill project workspace root directory.
*/
String WORKSPACE = "WORKSPACE";

String HOME = "HOME";

String ROOT = "ROOT";
}
36 changes: 18 additions & 18 deletions core/exec/src/mill/exec/Execution.scala
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,24 @@ private[mill] case class Execution(
offline: Boolean,
enableTicker: Boolean
) = this(
baseLogger,
new JsonArrayLogger.Profile(os.Path(outPath) / millProfile),
os.Path(workspace),
os.Path(outPath),
os.Path(externalOutPath),
rootModule,
classLoaderSigHash,
classLoaderIdentityHash,
workerCache,
env,
failFast,
ec,
codeSignatures,
systemExit,
exclusiveSystemStreams,
getEvaluator,
offline,
enableTicker
baseLogger = baseLogger,
profileLogger = new JsonArrayLogger.Profile(os.Path(outPath) / millProfile),
workspace = os.Path(workspace),
outPath = os.Path(outPath),
externalOutPath = os.Path(externalOutPath),
rootModule = rootModule,
classLoaderSigHash = classLoaderSigHash,
classLoaderIdentityHash = classLoaderIdentityHash,
workerCache = workerCache,
env = env,
failFast = failFast,
ec = ec,
codeSignatures = codeSignatures,
systemExit = systemExit,
exclusiveSystemStreams = exclusiveSystemStreams,
getEvaluator = getEvaluator,
offline = offline,
enableTicker = enableTicker
)

def withBaseLogger(newBaseLogger: Logger) = this.copy(baseLogger = newBaseLogger)
Expand Down
10 changes: 5 additions & 5 deletions core/exec/src/mill/exec/GroupExecution.scala
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ trait GroupExecution {
executionContext: mill.api.TaskCtx.Fork.Api,
exclusive: Boolean,
upstreamPathRefs: Seq[PathRef]
): GroupExecution.Results = {
): GroupExecution.Results = MappedRoots.withMillDefaults(outPath = outPath) {

val inputsHash = {
val externalInputsHash = MurmurHash3.orderedHash(
Expand Down Expand Up @@ -242,8 +242,8 @@ trait GroupExecution {
newEvaluated = newEvaluated.toSeq,
cached = if (labelled.isInstanceOf[Task.Input[?]]) null else false,
inputsHash = inputsHash,
previousInputsHash = cached.map(_._1).getOrElse(-1),
valueHashChanged = !cached.map(_._3).contains(valueHash),
previousInputsHash = cached.map(_.inputHash).getOrElse(-1),
valueHashChanged = !cached.map(_.valueHash).contains(valueHash),
serializedPaths = serializedPaths
)
}
Expand Down Expand Up @@ -453,7 +453,7 @@ trait GroupExecution {
inputsHash: Int,
labelled: Task.Named[?],
paths: ExecutionPaths
): Option[(Int, Option[(Val, Seq[PathRef])], Int)] = {
): Option[(inputHash: Int, valOpt: Option[(Val, Seq[PathRef])], valueHash: Int)] = {
for {
cached <-
try Some(upickle.read[Cached](paths.meta.toIO, trace = false))
Expand Down Expand Up @@ -618,7 +618,7 @@ object GroupExecution {
classLoader: ClassLoader
)(t: => T): T = {
// Tasks must be allowed to write to upstream worker's dest folders, because
// the point of workers is to manualy manage long-lived state which includes
// the point of workers is to manually manage long-lived state which includes
// state on disk.
val validWriteDests =
deps.collect { case n: Task.Worker[?] =>
Expand Down
Loading
Loading