diff options
| author | Leitwolf11 | 2026-11-12 22:40:35 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-11-12 22:40:35 +0300 |
| commit | d73260a011d463f492448bc2e29aa46fba657326 (patch) | |
| tree | 838f7b44bd0853b1cd7c5ff82aa9a49f8ff61217 | |
| parent | 86214c96285f73d352f934b0da33d231150898f0 (diff) | |
| download | tazel-feature/action-level-incremental.tar.gz | |
Make builds incremental per action, by contentfeature/action-level-incremental
Every compile, archive, link and Zig build is now an action: a command
line with its directory and environment, the files it reads and the
files it writes. ActionCache keeps a record beside the first output of
each action, with the digest of the action and the SHA-256 of every
input and output. An action runs again only when:
- its command line, directory or environment changed,
- a file it read has other content: the declared inputs, and what the
compiler included, from the depfile it wrote,
- or an output is gone or no longer what the action wrote.
Files are compared by content, not by time. Touching a file reruns
nothing; a translation unit recompiled to the same object (a comment in
a header, say) reruns neither the archive nor the program that use it.
Digests are kept per build by size and modification time, so each file
is read once.
- Zig writes no depfile, so every .zig file beside and below the root
file, and the libraries it links, are the inputs of a Zig build. It
no longer runs on every build.
- Staged headers and data files are copied only when their bytes
differ.
- The .cmd records and the time comparisons of UpToDate are gone.
Tests: the action cache (command, environment, touched, changed and
restored inputs, altered outputs, forgotten records) and, with the C
compiler of the machine, a touch that runs nothing, a comment in a
header that recompiles two units but does not archive or link, and a
change of code that recompiles, archives and relinks.
7 files changed, 323 insertions, 180 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 73127ed..d33f147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Versions follow [Semantic Versioning](https://semver.org). The earlier Tazel, 0. taken from the legacy Tazel. `tazelPlugin { }` now holds `group`, `name` and `version`, which the plugin descriptor records; the group must be a reversed domain and the version a Semantic Version. - `tazel build` and `tazel run` for C, C++, Objective-C, Objective-C++ and Zig targets, with `--release` and - `--triple`. Each translation unit is compiled on its own and again only when the depfile of the compiler or the - command line says it changed; libraries and programs are archived or linked when an input changed. Clang, GCC and + `--triple`. Builds are incremental per action: each compile, archive, link and Zig build runs again only when its command + line, its environment or the content of a file it read changed (headers from the depfile of the compiler), or an + output it wrote is gone or altered. Touching a file reruns nothing, and an object rebuilt to the same bytes reruns + neither its archive nor its program. Clang, GCC and `zig cc` are found on PATH; Clang and `zig cc` build for other triples. Outputs go to `.tazel/out/<triple>/<mode>`. diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/Actions.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/Actions.kt new file mode 100644 index 0000000..730c19b --- /dev/null +++ b/sources/main/kotlin/com/progmasoft/tazel/driver/Actions.kt @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + */ + +package com.progmasoft.tazel.driver + +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +/** + * The inputs a Make-style dependency file names, as compilers write it with `-MD -MF`: the target, + * a colon, then the inputs, with `\ ` for a space and a backslash at the end of a line to go on. + */ +fun depfileInputs(text: String): List<String> { + val body = text.substringAfter(": ", text.substringAfter(":\n", "")) + val inputs = mutableListOf<String>() + val current = StringBuilder() + var index = 0 + fun flush() { + if (current.isNotEmpty()) inputs += current.toString() + current.clear() + } + while (index < body.length) { + val c = body[index] + when { + c == '\\' && index + 1 < body.length && body[index + 1] == ' ' -> { + current.append(' ') + index++ + } + c == '\\' && index + 1 < body.length && body[index + 1] == '\n' -> { + flush() + index++ + } + c == '\\' && + index + 2 < body.length && + body[index + 1] == '\r' && + body[index + 2] == '\n' -> { + flush() + index += 2 + } + c == '$' && index + 1 < body.length && body[index + 1] == '$' -> { + current.append('$') + index++ + } + c.isWhitespace() -> flush() + else -> current.append(c) + } + index++ + } + flush() + return inputs +} + +/** + * The SHA-256 of files, kept while a file keeps its size and modification time, so a build reads a + * file once and a file that is touched but not changed costs one more read. + */ +class Digests { + private data class Stamp(val size: Long, val modified: Long) + + private val known = java.util.concurrent.ConcurrentHashMap<Path, Pair<Stamp, String>>() + + /** The digest of [file] as hex, or `null` when it is not a regular file. */ + fun of(file: Path): String? { + val attributes = + try { + Files.readAttributes(file, java.nio.file.attribute.BasicFileAttributes::class.java) + } catch (_: IOException) { + return null + } + if (!attributes.isRegularFile) return null + val stamp = Stamp(attributes.size(), attributes.lastModifiedTime().toMillis()) + known[file]?.let { (seen, digest) -> if (seen == stamp) return digest } + val digest = + try { + sha256(Files.newInputStream(file).use { it.readAllBytes() }) + } catch (_: IOException) { + return null + } + known[file] = stamp to digest + return digest + } + + companion object { + fun sha256(bytes: ByteArray): String = + java.security.MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { + "%02x".format(it) + } + } +} + +/** + * One action of a build: a command line with its environment, the files it reads and the files it + * writes. Inputs the command finds by itself, such as headers, come from [discovered] after it ran. + */ +class Action( + val command: List<String>, + val directory: Path, + val inputs: List<Path>, + val outputs: List<Path>, + val environment: Map<String, String> = emptyMap(), + val discovered: () -> List<Path> = { emptyList() }, +) { + /** + * The digest of what the action is apart from its files: the command, directory and environment. + */ + val key: String + get() = + Digests.sha256( + (command + + "\u0001" + + directory.toString() + + "\u0001" + + environment.toSortedMap().map { (name, value) -> "$name=$value" }) + .joinToString("\u0000") + .toByteArray() + ) +} + +/** + * Action-level incremental builds. Each action keeps a record beside its first output: its key, and + * the digest of every input it read and every output it wrote. An action is current while its key + * is the same, every input has the digest it had, and every output is still what it wrote. Inputs + * are compared by content, so touching a file reruns nothing, and an output rebuilt to the same + * bytes reruns none of the actions that read it. + */ +class ActionCache(private val digests: Digests = Digests()) { + /** Whether [action] would write what it wrote last time. */ + fun current(action: Action): Boolean { + val record = read(recordOf(action)) ?: return false + if (record.key != action.key) return false + return record.files.all { (file, digest) -> digests.of(file) == digest } && + action.outputs.all { it in record.files.keys } + } + + /** Records [action] after it succeeded. */ + fun record(action: Action) { + val files = linkedMapOf<Path, String>() + for (file in + (action.inputs + action.discovered() + action.outputs).map { + it.toAbsolutePath().normalize() + }) { + files[file] = digests.of(file) ?: throw IOException("$file was not written or cannot be read") + } + val text = buildString { + appendLine("key ${action.key}") + files.forEach { (file, digest) -> appendLine("file $digest $file") } + } + Files.writeString(recordOf(action), text) + } + + /** Forgets [action], so that a failed or stopped run is redone. */ + fun forget(action: Action) { + Files.deleteIfExists(recordOf(action)) + } + + private class Record(val key: String, val files: Map<Path, String>) + + private fun read(file: Path): Record? { + val lines = + try { + Files.readAllLines(file) + } catch (_: IOException) { + return null + } + val key = + lines.firstOrNull()?.removePrefix("key ")?.takeIf { lines.first().startsWith("key ") } + ?: return null + val files = linkedMapOf<Path, String>() + for (line in lines.drop(1)) { + if (!line.startsWith("file ")) return null + val digest = line.substring(5, minOf(line.length, 5 + 64)) + files[Path.of(line.substring(5 + 64 + 1))] = digest + } + return Record(key, files) + } + + private fun recordOf(action: Action): Path { + val first = action.outputs.first() + return first.resolveSibling(first.fileName.toString() + ".action") + } +} diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/Builder.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/Builder.kt index c0811f6..283ceb3 100644 --- a/sources/main/kotlin/com/progmasoft/tazel/driver/Builder.kt +++ b/sources/main/kotlin/com/progmasoft/tazel/driver/Builder.kt @@ -25,9 +25,10 @@ class BuildFailure(message: String) : Exception(message) data class BuildResult(val products: Map<TargetLabel, Path>, val commandsRun: Int) /** - * Builds the targets of a [BuildPlan]: each translation unit is compiled on its own and only when - * its object is out of date (by the depfile the compiler writes and the command line it ran with); - * a library or program is archived or linked when one of its inputs changed. + * Builds the targets of a [BuildPlan] as actions: each translation unit is compiled on its own, + * then each library is archived and each program linked. An action runs only when its command line, + * environment or the content of a file it read changed, or an output it wrote is gone or altered + * (see [ActionCache]); what a compiler includes comes from the depfile it writes. */ class Builder( private val plan: BuildPlan, @@ -41,6 +42,8 @@ class Builder( ) { private val fileSets = FileSets(ignoreCase = host.os == Os.WINDOWS) private val commandsRun = AtomicInteger() + private val digests = Digests() + private val cache = ActionCache(digests) fun build(): BuildResult { val units = mutableListOf<BuildUnit<Unit>>() @@ -73,17 +76,20 @@ class Builder( val objectFile = commands.objectOf(target, source) BuildUnit("compile ${target.label} $source", "compile $source", after) { val depfile = objectFile.resolveSibling(objectFile.fileName.toString() + ".d") - val command = commands.compile(target, source, objectFile, depfile) - if (!UpToDate.checkObject(objectFile, depfile, command, target.packageDirectory)) { - Files.createDirectories(objectFile.parent) - run( - "Compiling", - "${target.label} ${target.packageDirectory.relativize(source)}", - command, + Files.createDirectories(objectFile.parent) + val action = + Action( + commands.compile(target, source, objectFile, depfile), target.packageDirectory, - objectFile, + inputs = listOf(source), + outputs = listOf(objectFile, depfile), + discovered = { + depfileInputs(Files.readString(depfile)).map { + target.packageDirectory.resolve(it).normalize() + } + }, ) - } + run("Compiling", "${target.label} ${target.packageDirectory.relativize(source)}", action) } } val objects = sources.map { commands.objectOf(target, it) } @@ -93,15 +99,26 @@ class Builder( "link ${target.label}", compiles.map { it.id }.toSet() + after, ) { - val inputs = objects + target.libraries.map { it.product } + val settings = target.target.settings + val scripts = + listOfNotNull(settings.linkerScript, settings.defFile).map { + target.packageDirectory.resolve(it).normalize() + } val (verb, command) = if (target.kind == TargetKind.STATICLIB) "Archiving" to commands.archive(target, objects) else "Linking" to commands.link(target, objects) - if (!UpToDate.check(target.product, command, inputs)) { + val action = + Action( + command, + target.packageDirectory, + inputs = objects + target.libraries.map { it.product } + scripts, + outputs = listOf(target.product), + ) + // ar adds to an archive that is there, so an archive is made anew. + run(verb, target.label.toString(), action) { if (target.kind == TargetKind.STATICLIB) Files.deleteIfExists(target.product) - run(verb, target.label.toString(), command, target.packageDirectory, target.product) - if (target.kind == TargetKind.SHAREDLIB) linkUnversioned(target) } + if (target.kind == TargetKind.SHAREDLIB) linkUnversioned(target) copyData(target) } return compiles + final @@ -112,7 +129,26 @@ class Builder( return listOf( BuildUnit(finalUnit(target.label), "zig ${target.label}", after) { val command = zigCommand(target) - run("Building", target.label.toString(), command, target.packageDirectory, target.product) + val root = + target.packageDirectory + .resolve((target.target.settings.sources.single() as FilePath).path) + .normalize() + // Zig writes no depfile: every Zig file beside and below the root file is an input. + val zigFiles = + Files.walk(root.parent).use { files -> + files + .filter { Files.isRegularFile(it) && it.toString().endsWith(".zig") } + .sorted() + .toList() + } + val action = + Action( + command, + target.packageDirectory, + inputs = zigFiles + target.libraries.map { it.product }, + outputs = listOf(target.product), + ) + run("Building", target.label.toString(), action) copyData(target) } ) @@ -166,6 +202,8 @@ class Builder( if (plan.triple.os == Os.WINDOWS) return val name = target.target.settings.outputName ?: target.label.name val plain = target.product.resolveSibling(OutputNames.sharedLibrary(name, plan.triple.os, null)) + if (Files.isSymbolicLink(plain) && Files.readSymbolicLink(plain) == target.product.fileName) + return Files.deleteIfExists(plain) try { Files.createSymbolicLink(plain, target.product.fileName) @@ -182,34 +220,33 @@ class Builder( target.product.resolveSibling( target.packageDirectory.relativize(file).toString().replace("..", "__") ) - Files.createDirectories(destination.parent) - Files.copy( - file, - destination, - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.COPY_ATTRIBUTES, - ) + copyIfChanged(file, destination) } } - private fun run( - verb: String, - what: String, - command: List<String>, - directory: Path, - output: Path, - ) { + /** Copies [source] to [destination] unless it already holds the same bytes. */ + private fun copyIfChanged(source: Path, destination: Path) { + if (Files.isRegularFile(destination) && digests.of(destination) == digests.of(source)) return + Files.createDirectories(destination.parent) + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING) + } + + /** Runs [action] unless it is current; [before] prepares its outputs. */ + private fun run(verb: String, what: String, action: Action, before: () -> Unit = {}) { + if (cache.current(action)) return synchronized(out) { out.appendLine("%12s %s".format(verb, what)) } - UpToDate.forget(output) + cache.forget(action) + before() commandsRun.incrementAndGet() - val result = processes.runGathered(command, directory) - if (result.output.isNotBlank()) + val result = processes.runGatheredWith(action.command, action.directory, action.environment) + if (result.output.isNotBlank()) { synchronized(out) { out.append(result.output.trimEnd()).appendLine() } + } if (result.exitCode != 0) { throw BuildFailure( - "$what: ${Path.of(command.first()).fileName} exited with ${result.exitCode}" + "$what: ${Path.of(action.command.first()).fileName} exited with ${result.exitCode}" ) } - UpToDate.record(output, command) + cache.record(action) } } diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/CcCommands.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/CcCommands.kt index c05a9c3..b5f7cdc 100644 --- a/sources/main/kotlin/com/progmasoft/tazel/driver/CcCommands.kt +++ b/sources/main/kotlin/com/progmasoft/tazel/driver/CcCommands.kt @@ -88,16 +88,10 @@ class CcCommands(private val plan: BuildPlan, private val compiler: CcCompiler) val destination = staged.resolve(settings.includePrefix?.let { "$it/$relative" } ?: relative) Files.createDirectories(destination.parent) - if ( - !Files.exists(destination) || - Files.getLastModifiedTime(destination) != Files.getLastModifiedTime(file) - ) { - Files.copy( - file, - destination, - java.nio.file.StandardCopyOption.REPLACE_EXISTING, - java.nio.file.StandardCopyOption.COPY_ATTRIBUTES, - ) + // A staged header is rewritten only when its bytes differ, so what includes it is + // recompiled only for a real change. + if (!Files.isRegularFile(destination) || Files.mismatch(file, destination) != -1L) { + Files.copy(file, destination, java.nio.file.StandardCopyOption.REPLACE_EXISTING) } } } diff --git a/sources/main/kotlin/com/progmasoft/tazel/driver/UpToDate.kt b/sources/main/kotlin/com/progmasoft/tazel/driver/UpToDate.kt deleted file mode 100644 index a185177..0000000 --- a/sources/main/kotlin/com/progmasoft/tazel/driver/UpToDate.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> - * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 - */ - -package com.progmasoft.tazel.driver - -import java.io.IOException -import java.nio.file.Files -import java.nio.file.Path - -/** - * The inputs a Make-style dependency file names, as compilers write it with `-MD -MF`: the target, - * a colon, then the inputs, with `\ ` for a space and a backslash at the end of a line to go on. - */ -fun depfileInputs(text: String): List<String> { - val body = text.substringAfter(": ", text.substringAfter(":\n", "")) - val inputs = mutableListOf<String>() - val current = StringBuilder() - var index = 0 - fun flush() { - if (current.isNotEmpty()) inputs += current.toString() - current.clear() - } - while (index < body.length) { - val c = body[index] - when { - c == '\\' && index + 1 < body.length && body[index + 1] == ' ' -> { - current.append(' ') - index++ - } - c == '\\' && index + 1 < body.length && body[index + 1] == '\n' -> { - flush() - index++ - } - c == '\\' && - index + 2 < body.length && - body[index + 1] == '\r' && - body[index + 2] == '\n' -> { - flush() - index += 2 - } - c == '$' && index + 1 < body.length && body[index + 1] == '$' -> { - current.append('$') - index++ - } - c.isWhitespace() -> flush() - else -> current.append(c) - } - index++ - } - flush() - return inputs -} - -/** - * Decides whether an output is still what its command would make. The command line is kept beside - * the output in a `.cmd` file; the output is current when that line is the same and no input is - * newer than the output. - */ -object UpToDate { - /** Whether [output] made by [command] from [inputs] is current. */ - fun check(output: Path, command: List<String>, inputs: List<Path>): Boolean { - if (!Files.isRegularFile(output)) return false - val recorded = - try { - Files.readString(commandFile(output)) - } catch (_: IOException) { - return false - } - if (recorded != commandText(command)) return false - val made = Files.getLastModifiedTime(output).toMillis() - return inputs.all { input -> - try { - Files.getLastModifiedTime(input).toMillis() <= made - } catch (_: IOException) { - false - } - } - } - - /** Whether an object made by [command] is current, with its inputs from the depfile [depfile]. */ - fun checkObject(objectFile: Path, depfile: Path, command: List<String>, base: Path): Boolean { - val text = - try { - Files.readString(depfile) - } catch (_: IOException) { - return false - } - val inputs = depfileInputs(text).map { base.resolve(it).normalize() } - return inputs.isNotEmpty() && check(objectFile, command, inputs) - } - - /** Records that [output] was made by [command]. */ - fun record(output: Path, command: List<String>) { - Files.writeString(commandFile(output), commandText(command)) - } - - /** Forgets how [output] was made, so a failed or stopped run is redone. */ - fun forget(output: Path) { - Files.deleteIfExists(commandFile(output)) - } - - private fun commandFile(output: Path) = output.resolveSibling(output.fileName.toString() + ".cmd") - - private fun commandText(command: List<String>) = command.joinToString("\u0000") -} diff --git a/sources/test/kotlin/com/progmasoft/tazel/cli/BuildCommandTest.kt b/sources/test/kotlin/com/progmasoft/tazel/cli/BuildCommandTest.kt index 36e1323..fa5d0d5 100644 --- a/sources/test/kotlin/com/progmasoft/tazel/cli/BuildCommandTest.kt +++ b/sources/test/kotlin/com/progmasoft/tazel/cli/BuildCommandTest.kt @@ -12,6 +12,7 @@ import com.progmasoft.tazel.driver.SystemProcesses import com.progmasoft.tazel.dsl.TargetTriple import java.nio.file.Files import java.nio.file.Path +import java.nio.file.attribute.FileTime import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -126,16 +127,29 @@ class BuildCommandTest { assertEquals(ExitCode.SUCCESS, again, againOutput.stderr()) assertContains(againOutput.stdout(), "0 command(s) run") - // A header change recompiles what includes it, and relinks. + // A touch changes no content: nothing runs. val header = root.resolve("core/include/core.h") + Files.setLastModifiedTime(header, FileTime.fromMillis(System.currentTimeMillis() + 60_000)) + assertContains( + tazel(root, ".", processes, "build", "//...").second.stdout(), + "0 command(s) run", + ) + + // A comment recompiles both units that include the header; their objects come out the same, + // so neither the archive nor the program is made again. Files.writeString(header, "int sample_add(int a, int b); /* changed */\n") - Files.setLastModifiedTime( - header, - java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() + 5_000), + val (comment, commentOutput) = tazel(root, ".", processes, "build", "//...") + assertEquals(ExitCode.SUCCESS, comment, commentOutput.stderr()) + assertContains(commentOutput.stdout(), "2 command(s) run") + + // A change of code recompiles its unit, archives and relinks. + Files.writeString( + root.resolve("core/src/core.c"), + "#include <sample/core.h>\nint sample_add(int a, int b) { return b + a + SAMPLE_OFFSET - 1; }\n", ) - val (changed, changedOutput) = tazel(root, ".", processes, "build", "//...") - assertEquals(ExitCode.SUCCESS, changed, changedOutput.stderr()) - assertContains(changedOutput.stdout(), "4 command(s) run") + val (code, codeOutput) = tazel(root, ".", processes, "build", "//...") + assertEquals(ExitCode.SUCCESS, code, codeOutput.stderr()) + assertContains(codeOutput.stdout(), "Archiving") val (ran, ranOutput) = tazel(root, "app", processes, "run", "--", "extra") assertEquals(ExitCode.SUCCESS, ran, ranOutput.stderr()) diff --git a/sources/test/kotlin/com/progmasoft/tazel/driver/DriverTest.kt b/sources/test/kotlin/com/progmasoft/tazel/driver/DriverTest.kt index 5f7d831..ddde6e9 100644 --- a/sources/test/kotlin/com/progmasoft/tazel/driver/DriverTest.kt +++ b/sources/test/kotlin/com/progmasoft/tazel/driver/DriverTest.kt @@ -30,26 +30,45 @@ class DriverTest { } @Test - fun anOutputIsCurrentUntilItsCommandOrAnInputChanges() { - val directory = Files.createTempDirectory("tazel-up-to-date") + fun anActionIsCurrentUntilItsCommandOrTheContentOfAFileChanges() { + val directory = Files.createTempDirectory("tazel-actions") val input = Files.writeString(directory.resolve("a.c"), "int a;") + val header = Files.writeString(directory.resolve("a.h"), "int b;") val output = Files.writeString(directory.resolve("a.o"), "object") - Files.setLastModifiedTime(input, FileTime.fromMillis(1_000_000)) - Files.setLastModifiedTime(output, FileTime.fromMillis(2_000_000)) - val command = listOf("cc", "-c", "a.c") - assertFalse(UpToDate.check(output, command, listOf(input)), "never recorded") - UpToDate.record(output, command) - assertTrue(UpToDate.check(output, command, listOf(input))) - assertFalse(UpToDate.check(output, command + "-O2", listOf(input)), "another command") - Files.setLastModifiedTime(input, FileTime.fromMillis(3_000_000)) - assertFalse(UpToDate.check(output, command, listOf(input)), "a newer input") - Files.setLastModifiedTime(input, FileTime.fromMillis(1_000_000)) - assertFalse( - UpToDate.check(output, command, listOf(directory.resolve("gone.h"))), - "a missing input", - ) - UpToDate.forget(output) - assertFalse(UpToDate.check(output, command, listOf(input)), "forgotten") + fun action( + command: List<String> = listOf("cc", "-c", "a.c"), + environment: Map<String, String> = emptyMap(), + ) = + Action( + command, + directory, + listOf(input), + listOf(output), + environment, + discovered = { listOf(header) }, + ) + val cache = ActionCache() + assertFalse(cache.current(action()), "never recorded") + cache.record(action()) + assertTrue(cache.current(action())) + assertFalse(cache.current(action(listOf("cc", "-c", "a.c", "-O2"))), "another command") + assertFalse(cache.current(action(environment = mapOf("LANG" to "C"))), "another environment") + + // A touch changes no content, so nothing is redone. + Files.setLastModifiedTime(input, FileTime.fromMillis(System.currentTimeMillis() + 60_000)) + Files.setLastModifiedTime(header, FileTime.fromMillis(System.currentTimeMillis() + 60_000)) + assertTrue(ActionCache().current(action()), "touched only") + + Files.writeString(header, "int b; int c;") + assertFalse(ActionCache().current(action()), "a discovered input changed") + Files.writeString(header, "int b;") + assertTrue(ActionCache().current(action()), "back to what it was") + + Files.writeString(output, "altered") + assertFalse(ActionCache().current(action()), "an output was altered") + Files.writeString(output, "object") + cache.forget(action()) + assertFalse(ActionCache().current(action()), "forgotten") } @Test |