diff --git a/biz.aQute.bndlib.tests/test/test/ProjectTest.java b/biz.aQute.bndlib.tests/test/test/ProjectTest.java index 1855204d83..6811bd70b8 100644 --- a/biz.aQute.bndlib.tests/test/test/ProjectTest.java +++ b/biz.aQute.bndlib.tests/test/test/ProjectTest.java @@ -18,7 +18,10 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.jar.Manifest; import java.util.regex.Pattern; @@ -39,6 +42,7 @@ import aQute.bnd.osgi.Processor; import aQute.bnd.osgi.Resource; import aQute.bnd.osgi.eclipse.EclipseClasspath; +import aQute.bnd.service.JarLifecycleListener; import aQute.bnd.service.RepositoryPlugin; import aQute.bnd.service.Strategy; import aQute.bnd.test.jupiter.InjectTemporaryDirectory; @@ -546,6 +550,72 @@ private void stale(Project project, boolean b) throws Exception { file.setLastModified(project.lastModified() + 10000); } + /** + * Verify that JarLifecycleListener plugins are called at the right times. + */ + @Test + public void testJarLifecycleListenerIntegration() throws Exception { + Workspace ws = getWorkspace(IO.getFile("testresources/ws")); + Project project = ws.getProject("p-stale"); + assertNotNull(project); + + // Register a mock listener to verify it's called + AtomicBoolean beforeCalled = new AtomicBoolean(); + AtomicBoolean afterCalled = new AtomicBoolean(); + + JarLifecycleListener mockListener = new JarLifecycleListener() { + @Override + public void beforeWrite(Project p, Jar jar, File outputFile, Map context) { + beforeCalled.set(true); + } + + @Override + public void afterWrite(Project p, File outputFile, Jar jar, Map context) { + afterCalled.set(true); + } + }; + + ws.addBasicPlugin(mockListener); + + // Build + File[] built = project.build(); + assertNotNull(built); + assertThat(built.length).isGreaterThan(0); + + // Verify listeners were called + assertThat(beforeCalled).isTrue(); + assertThat(afterCalled).isTrue(); + } + + /** + * Verify that the context map is passed correctly and allows data exchange. + */ + @Test + public void testJarArtifactWriteContextDataExchange() throws Exception { + Workspace ws = getWorkspace(IO.getFile("testresources/ws")); + Project project = ws.getProject("p-stale"); + + AtomicReference afterData = new AtomicReference<>(); + + JarLifecycleListener testListener = new JarLifecycleListener() { + @Override + public void beforeWrite(Project p, Jar jar, File outputFile, Map context) { + context.put("testKey", "testValue"); + } + + @Override + public void afterWrite(Project p, File outputFile, Jar jar, Map context) { + afterData.set((String) context.get("testKey")); + } + }; + + ws.addBasicPlugin(testListener); + project.build(); + + assertThat(afterData.get()).isEqualTo("testValue"); + } + + /** * Check multiple repos * diff --git a/biz.aQute.bndlib/src/aQute/bnd/build/Project.java b/biz.aQute.bndlib/src/aQute/bnd/build/Project.java index 8434f4f2b3..bb939928c5 100644 --- a/biz.aQute.bndlib/src/aQute/bnd/build/Project.java +++ b/biz.aQute.bndlib/src/aQute/bnd/build/Project.java @@ -95,6 +95,7 @@ import aQute.bnd.service.CommandPlugin; import aQute.bnd.service.DependencyContributor; import aQute.bnd.service.Deploy; +import aQute.bnd.service.JarLifecycleListener; import aQute.bnd.service.RepositoryPlugin; import aQute.bnd.service.RepositoryPlugin.PutOptions; import aQute.bnd.service.RepositoryPlugin.PutResult; @@ -2103,9 +2104,16 @@ public File saveBuild(Jar jar) throws Exception { private File saveBuildWithoutClose(Jar jar) throws Exception { File outputFile = getOutputFile(jar.getName(), jar.getVersion()); + // Notify BEFORE listeners + Map context = new HashMap(); + beforeJarWrite(jar, outputFile, context); + reportNewer(outputFile.lastModified(), jar); File logicalFile = write(jar::write, outputFile); + // Notify AFTER listeners + afterJarWrite(this, outputFile, jar, context); + logger.debug("{} ({}) {}", jar.getName(), outputFile.getName(), jar.getResources() .size()); // @@ -2135,6 +2143,30 @@ private File saveBuildWithoutClose(Jar jar) throws Exception { return logicalFile; } + private void beforeJarWrite(Jar jar, File outputFile, Map context) throws Exception { + List listeners = getPlugins(JarLifecycleListener.class); + + for (JarLifecycleListener listener : listeners) { + try { + listener.beforeWrite(this, jar, outputFile, context); + } catch (Exception e) { + logger.debug("Exception in JarLifecycleListener.beforeWrite", e); + } + } + } + + private void afterJarWrite(Project project, File outputFile, Jar jar, Map context) + throws Exception { + List listeners = getPlugins(JarLifecycleListener.class); + for (JarLifecycleListener listener : listeners) { + try { + listener.afterWrite(project, outputFile, jar, context); + } catch (Exception e) { + logger.debug("Exception in JarArtifactLifecycleListener.afterJarWrite", e); + } + } + } + private File write(ConsumerWithException jar, File outputFile) throws IOException, InterruptedException, Exception { File logicalFile = outputFile; diff --git a/biz.aQute.bndlib/src/aQute/bnd/service/JarLifecycleListener.java b/biz.aQute.bndlib/src/aQute/bnd/service/JarLifecycleListener.java new file mode 100644 index 0000000000..c8bad8f96b --- /dev/null +++ b/biz.aQute.bndlib/src/aQute/bnd/service/JarLifecycleListener.java @@ -0,0 +1,181 @@ +package aQute.bnd.service; + +import java.io.File; +import java.util.Map; + +import aQute.bnd.build.Project; +import aQute.bnd.exceptions.ConsumerWithException; +import aQute.bnd.osgi.Jar; + +/** + * A plugin interface for hooking into the JAR artifact lifecycle during project + * builds. + *

+ * Implementations can observe and participate in key phases of JAR artifact + * handling: before the JAR is written to disk and after it has been written. + * This enables plugins to compute metadata, validate artifacts, sign JARs, or + * perform other side-effects without modifying the core build logic. + *

+ * Listeners are called in order of registration. If one listener throws an + * exception, the exception is logged but does not prevent other listeners from + * running. + *

+ *

Thread Safety

+ *

+ * Implementations must be thread-safe. Each JAR write receives a fresh context + * map, so listeners can use the context to pass data between the before and + * after phases without storing mutable state. + *

+ *

Typical Use Cases

+ *
    + *
  • Artifact metadata – Compute and store digests, checksums, or + * signatures as sidecar files (e.g., `.digest`, `.asc`, `.md5`).
  • + *
  • Rebuild optimization – Detect whether the JAR content or API has + * changed to avoid unnecessary cascade rebuilds in incremental build + * scenarios.
  • + *
  • JAR signing – Sign the JAR post-write using cryptographic + * keys.
  • + *
  • Build artifacts indexing – Log or index built artifacts in a + * repository or build database.
  • + *
  • Binary validation – Post-write validation or security + * scanning.
  • + *
  • Bytecode enhancement – Transform JAR contents before write (e.g., + * AspectJ weaving, code coverage instrumentation).
  • + *
+ *

+ *

Usage Example

+ *

+ * To compute and store a digest before writing, then validate and restore a + * timestamp after writing: + *

+ * + *

+ * public class DigestListener implements JarLifecycleListener {
+ *
+ * 	@Override
+ * 	public void beforeWrite(Project project, Jar jar, File outputFile, Map<String, Object> context)
+ * 		throws Exception {
+ * 		// Compute digest before the JAR is written to disk
+ * 		byte[] digest = jar.getTimelessDigest();
+ * 		String digestHex = Hex.toHexString(digest);
+ * 		context.put("digestHex", digestHex);
+ * 	}
+ *
+ * 	@Override
+ * 	public void afterWrite(Project project, File outputFile, Jar jar, Map<String, Object> context)
+ * 		throws Exception {
+ * 		// Retrieve the computed digest
+ * 		String digestHex = (String) context.get("digestHex");
+ *
+ * 		// Store it in a sidecar file
+ * 		File digestFile = new File(outputFile.getParentFile(), outputFile.getName() + ".digest");
+ * 		IO.store(digestHex, digestFile);
+ * 	}
+ * }
+ * 
+ *

+ *

Context Map

+ *

+ * The {@code context} parameter is a mutable map that persists across the + * before and after phases for a single JAR write. Listeners can use this to + * exchange data: + *

    + *
  • The {@code beforeJarWrite} phase can compute and store values in the + * context.
  • + *
  • The {@code afterJarWrite} phase can retrieve those values to perform + * post-write actions.
  • + *
  • Multiple listeners can coordinate through shared context keys (with + * appropriate synchronization if needed).
  • + *
+ *

+ * Each JAR write receives a fresh context map; there is no data leakage between + * different JAR writes. + *

+ *

Order of Execution

+ *
    + *
  1. All {@code beforeJarWrite} methods are invoked in registration + * order.
  2. + *
  3. The JAR is written to disk (via + * {@link Project#write(ConsumerWithException, File)}).
  4. + *
  5. All {@code afterJarWrite} methods are invoked in registration order.
  6. + *
+ *

+ *

Error Handling

+ *

+ * If a listener throws an exception: + *

    + *
  • The exception is caught and logged.
  • + *
  • Other listeners continue to run.
  • + *
  • The JAR write itself is not affected (exceptions do not roll back the + * write).
  • + *
+ *

+ * Implementations should avoid throwing exceptions unless they represent truly + * exceptional conditions. Use logging for informational messages. + *

+ * + * @see Project#saveBuild(Jar) + */ +public interface JarLifecycleListener { + + /** + * Invoked before the JAR is written to the output file. + *

+ * This phase is suitable for: + *

    + *
  • Computing metadata (digests, checksums, API signatures) that should + * be based on the in-memory JAR state.
  • + *
  • Preparing data structures for the post-write phase.
  • + *
  • Validating the JAR before it is persisted.
  • + *
+ *

+ * The listener can store computed values in the {@code context} map for use + * in the {@link #afterWrite afterJarWrite} phase. + *

+ * + * @param project the project being built + * @param jar the in-memory JAR object, before being written to disk + * @param outputFile the file where the JAR will be written + * @param context a mutable map for sharing data between before and after + * phases; persists across both phases for a single JAR write + * @throws Exception if an error occurs; the exception is logged but does + * not prevent other listeners or the JAR write from proceeding + */ + default void beforeWrite(Project project, Jar jar, File outputFile, Map context) + throws Exception {} + + /** + * Invoked after the JAR has been successfully written to the output file. + *

+ * This phase is suitable for: + *

    + *
  • Storing metadata (digests, signatures) in sidecar files.
  • + *
  • Modifying file properties (timestamps, permissions).
  • + *
  • Triggering post-write notifications or side-effects.
  • + *
  • Validating the written file.
  • + *
+ *

+ * The listener can retrieve data computed in the {@link #beforeWrite + * beforeJarWrite} phase from the {@code context} map. + *

+ * Note: The JAR object may be in a different state after writing + * (e.g., resources may have been closed). Listeners should not rely on the + * JAR being fully functional; use the {@code outputFile} and the + * {@code context} map instead. + *

+ * + * @param project the project that was built + * @param outputFile the file to which the JAR was written; the file is + * guaranteed to exist on the filesystem + * @param jar the JAR object that was written (state may have changed) + * @param context the same mutable map passed to {@link #beforeWrite + * beforeJarWrite}, allowing access to data computed in that + * phase + * @throws Exception if an error occurs; the exception is logged but does + * not prevent other listeners from running + */ + default void afterWrite(Project project, File outputFile, Jar jar, Map context) + throws Exception {} + + +} \ No newline at end of file diff --git a/biz.aQute.bndlib/src/aQute/bnd/service/package-info.java b/biz.aQute.bndlib/src/aQute/bnd/service/package-info.java index a7d1e38580..c09bf5d76f 100644 --- a/biz.aQute.bndlib/src/aQute/bnd/service/package-info.java +++ b/biz.aQute.bndlib/src/aQute/bnd/service/package-info.java @@ -1,4 +1,4 @@ -@Version("4.10.0") +@Version("4.11.0") package aQute.bnd.service; import org.osgi.annotation.versioning.Version; diff --git a/bndtools.builder/src/org/bndtools/builder/BndtoolsBuilder.java b/bndtools.builder/src/org/bndtools/builder/BndtoolsBuilder.java index 0b7adb665d..5fdd7bdc91 100644 --- a/bndtools.builder/src/org/bndtools/builder/BndtoolsBuilder.java +++ b/bndtools.builder/src/org/bndtools/builder/BndtoolsBuilder.java @@ -307,9 +307,10 @@ protected IProject[] build(int kind, Map args, IProgressMonitor }); if (model.isCnf()) { Job job = Job.create("refresh workspace", (m) -> { - model.getWorkspace() - .refresh(); // this is for bnd plugins built in - // cnf + Workspace wsttmp = model.getWorkspace(); + wsttmp.refresh(); // this is for bnd plugins built + // in cnf + Central.applyRebuildTriggerPolicy(wsttmp); }); job.schedule(10); } diff --git a/bndtools.builder/src/org/bndtools/builder/CnfWatcher.java b/bndtools.builder/src/org/bndtools/builder/CnfWatcher.java index 08cc961649..ace7aed81b 100644 --- a/bndtools.builder/src/org/bndtools/builder/CnfWatcher.java +++ b/bndtools.builder/src/org/bndtools/builder/CnfWatcher.java @@ -75,6 +75,7 @@ private void processEvent(IResourceChangeEvent event) { public IStatus runInWorkspace(IProgressMonitor arg0) throws CoreException { try { workspace.refresh(); + Central.applyRebuildTriggerPolicy(workspace); BndtoolsBuilder.dirty.addAll(allProjects); MarkerSupport ms = new MarkerSupport(cnfProject); ms.deleteMarkers("*"); diff --git a/bndtools.core/bnd.bnd b/bndtools.core/bnd.bnd index 3ec42903a6..41e5c832f9 100644 --- a/bndtools.core/bnd.bnd +++ b/bndtools.core/bnd.bnd @@ -122,6 +122,7 @@ Export-Package: org.osgi.service.metatype.annotations org.eclipse.jdt.core.compiler.batch -testpath: \ + biz.aQute.bnd.test;version=project,\ slf4j.api,\ slf4j.simple,\ ${junit},\ diff --git a/bndtools.core/src/bndtools/central/Central.java b/bndtools.core/src/bndtools/central/Central.java index ce48e6afc3..c44a4a7439 100644 --- a/bndtools.core/src/bndtools/central/Central.java +++ b/bndtools.core/src/bndtools/central/Central.java @@ -72,6 +72,7 @@ import aQute.bnd.memoize.Memoize; import aQute.bnd.osgi.Constants; import aQute.bnd.osgi.Processor; +import aQute.bnd.service.JarLifecycleListener; import aQute.bnd.service.Refreshable; import aQute.bnd.service.RepositoryPlugin; import aQute.bnd.service.progress.ProgressPlugin.Task; @@ -282,6 +283,7 @@ private static void adjustWorkspace(Workspace ws) throws Exception { ws.forceRefresh(); ws.refresh(); ws.refreshProjects(); + applyRebuildTriggerPolicy(ws); return tryResolve(cnfWorkspaceDeferred); } else if (workspaceDirectory == null && !ws.isDefaultWorkspace()) { // There is no "cnf" project and the current workspace is @@ -291,6 +293,7 @@ private static void adjustWorkspace(Workspace ws) throws Exception { ws.forceRefresh(); ws.refresh(); ws.refreshProjects(); + applyRebuildTriggerPolicy(ws); return null; } return null; @@ -329,6 +332,7 @@ private static Workspace createWorkspace() { } ws.setOffline(new BndPreferences().isWorkspaceOffline()); + applyRebuildTriggerPolicy(ws); ws.addBasicPlugin(new SWTClipboard()); ws.addBasicPlugin(getInstance().repoListenerTracker); @@ -350,6 +354,25 @@ private static Workspace createWorkspace() { } } + /** + * Applies the Eclipse rebuild trigger policy preference to the workspace. + *

+ * This must be called after every {@link Workspace#refresh()} because refresh recreates the + * workspace property map from {@code build.bnd}, wiping any in-memory overrides. + *

+ * + * @param ws the workspace to configure + */ + public static void applyRebuildTriggerPolicy(Workspace ws) { + BndPreferences prefs = new BndPreferences(); + String policy = prefs.getRebuildTriggerPolicy(); + ws.getPlugins(JarLifecycleListener.class) + .stream() + .filter(p -> p instanceof RebuildTriggerPolicyPlugin) + .forEach(ws::removeBasicPlugin); + ws.addBasicPlugin(new RebuildTriggerPolicyPlugin(policy)); + } + public static Promise onAnyWorkspace(Consumer callback) { return callback(anyWorkspaceDeferred.getPromise(), callback, "onAnyWorkspace callback failed"); } diff --git a/bndtools.core/src/bndtools/central/RebuildTriggerPolicy.java b/bndtools.core/src/bndtools/central/RebuildTriggerPolicy.java new file mode 100644 index 0000000000..e48850076b --- /dev/null +++ b/bndtools.core/src/bndtools/central/RebuildTriggerPolicy.java @@ -0,0 +1,317 @@ +package bndtools.central; + +import java.io.File; +import java.security.MessageDigest; +import java.util.jar.Manifest; + +import org.bndtools.api.ILogger; +import org.bndtools.api.Logger; + +import aQute.bnd.differ.DiffPluginImpl; +import aQute.bnd.osgi.Constants; +import aQute.bnd.osgi.Jar; +import aQute.bnd.service.diff.Tree; +import aQute.lib.hex.Hex; +import aQute.lib.io.IO; + +/** + * Encapsulates the logic for determining whether a built artifact (JAR) should + * retain its existing filesystem timestamp after a rebuild. + *

+ * This is used to avoid unnecessary downstream rebuilds in systems that rely on + * timestamp-based staleness checks. Instead of always updating the output file + * timestamp, this policy compares digests of the newly built artifact against + * previously stored values. + *

+ *

Supported policies

+ *
    + *
  • always – Always treat the build as changed. The output file + * receives a new timestamp.
  • + *
  • api – Preserve the timestamp when either: + *
      + *
    • The full content digest is unchanged (byte-identical JAR), or
    • + *
    • The public API surface is unchanged, even if implementation details + * differ.
    • + *
    + *
  • + *
+ *

+ * The API-based optimization helps prevent rebuild cascades in dependent + * projects when only internal implementation changes occur. + *

Timestamp Preservation Strategy

+ *

+ * To avoid unnecessary cascade rebuilds when only non-API changes occur, this + * implementation preserves the output JAR's timestamp when: + *

+ *
    + *
  • Content digest matches (byte-identical), OR
  • + *
  • API digest matches (exported API unchanged)
  • + *
+ *

Mechanism

+ *

+ * The system computes content and API digests before writing + * the JAR, compares them against previously stored digests, and if a match is + * found, restores the old timestamp after the JAR is written. This prevents + * downstream projects from seeing a "new" dependency based on file timestamp + * alone. + *

+ *

Limitations

+ *

+ * This pragmatic approach trades perfect accuracy for simplicity: + *

+ *
    + *
  • Timestamps are preserved by directly setting {@code lastModified()}. This + * works well in most incremental build scenarios but may not perfectly reflect + * semantic changes in all edge cases.
  • + *
  • Attempting to perfectly distinguish between "this JAR actually changed" + * vs. "this build cycle recomputed the same thing" across multiple asynchronous + * build tool invocations is complex and fragile.
  • + *
  • Not recommended for: highly dynamic build environments, + * complex classpath interdependencies, or scenarios requiring perfect timestamp + * accuracy for external build tools.
  • + *
+ *

+ * In practice, this approach provides substantial benefits by preventing most + * unnecessary rebuilds while maintaining reliability. + *

+ * + * @see RebuildTriggerPolicy#doRebuildTriggerPolicy(Jar, File) + */ +public class RebuildTriggerPolicy { + + public static final String REBUILDTRIGGERPOLICY_ALWAYS = "always"; + public static final String REBUILDTRIGGERPOLICY_API = "api"; + + private static final ILogger logger = Logger.getLogger(RebuildTriggerPolicy.class); + private final String rebuildTriggerPolicyKey; + + /** + * Result of evaluating the build change policy. + *

+ * This record contains both the decision (whether to preserve the + * timestamp) and the computed digest values needed for persisting state for + * future builds. + *

+ * + * @param preserveTimestamp The timestamp to restore on the output file, or + * {@code 0} if the file should receive a new timestamp. + * @param contentDigestFile The file used to store the content digest, or + * {@code null} if not applicable. + * @param newContentDigestHex The newly computed content digest + * (hex-encoded), or {@code null} if not computed. + * @param apiDigestFile The file used to store the API digest, or + * {@code null} if not applicable. + * @param newApiDigestHex The newly computed API digest (hex-encoded), or + * {@code null} if not computed. + */ + record RebuildTriggerPolicyResult(long preserveTimestamp, File contentDigestFile, String newContentDigestHex, + File apiDigestFile, String newApiDigestHex) { + + static final RebuildTriggerPolicyResult REBUILD_ALWAYS = new RebuildTriggerPolicyResult(0, null, null, null, + null); + } + + RebuildTriggerPolicy(String rebuildTriggerPolicyKey) { + this.rebuildTriggerPolicyKey = rebuildTriggerPolicyKey; + } + + /** + * Applies the configured build change policy to determine whether the + * output file's timestamp should be preserved. + *

+ * Depending on the selected policy, this method may compute one or both of + * the following: + *

+ *
    + *
  • A content digest — a stable hash of the entire JAR + * contents.
  • + *
  • An API digest — a hash of the exported public/protected API + * surface.
  • + *
+ *

+ * The method attempts to reuse previously stored digests (if present) to + * detect whether the newly built artifact is equivalent to the previous + * one, either byte-for-byte or at the API level. + *

+ *

+ * The returned {@link RebuildTriggerPolicyResult} contains both the + * decision (timestamp preservation) and the newly computed digest values, + * which callers are expected to persist for future comparisons. + *

+ *

Behavior summary

+ *
    + *
  • If policy is {@code "always"}, no comparison is performed and the + * output is treated as changed.
  • + *
  • If the output file does not yet exist, it is treated as a new + * build.
  • + *
  • If the content digest matches the previous build, the timestamp is + * preserved.
  • + *
  • If the policy allows API comparison and the API digest matches, the + * timestamp is also preserved.
  • + *
  • Otherwise, the output file receives a new timestamp.
  • + *
+ * + * @param ws The processor providing configuration (notably the + * {@code -buildchangepolicy} setting). + * @param jar The newly built JAR to analyze. + * @param outputFile The output file whose timestamp may be preserved. + * @return a {@link RebuildTriggerPolicyResult} describing the preservation + * decision and the computed digest values + */ + RebuildTriggerPolicyResult doRebuildTriggerPolicy(Jar jar, File outputFile) { + if (REBUILDTRIGGERPOLICY_ALWAYS.equals(rebuildTriggerPolicyKey)) { + return RebuildTriggerPolicyResult.REBUILD_ALWAYS; + } + + File contentDigestFile = getContentDigestFile(outputFile); + String newContentDigestHex = calcContentDigest(jar); + + // Fast path: no existing output means nothing to preserve. + if (!outputFile.isFile()) { + return new RebuildTriggerPolicyResult(0, contentDigestFile, newContentDigestHex, null, null); + } + + long existingTimestamp = outputFile.lastModified(); + + // Check 1: byte-identical content -> preserve immediately. + if (digestMatches(contentDigestFile, newContentDigestHex, outputFile, "stored content digest")) { + return new RebuildTriggerPolicyResult(existingTimestamp, contentDigestFile, newContentDigestHex, null, + null); + } + + // Check 2: compute API digest + File apiDigestFile = getApiDigestFile(outputFile); + String newApiDigestHex = calcApiDigest(jar); + + if (digestMatches(apiDigestFile, newApiDigestHex, outputFile, "stored API digest")) { + // Content changed but API unchanged -> preserving timestamp" + return new RebuildTriggerPolicyResult(existingTimestamp, contentDigestFile, newContentDigestHex, + apiDigestFile, newApiDigestHex); + } + + return new RebuildTriggerPolicyResult(0, contentDigestFile, newContentDigestHex, apiDigestFile, + newApiDigestHex); + + } + + private boolean digestMatches(File digestFile, String newDigestHex, File outputFile, String digestDescription) { + if (newDigestHex == null || !digestFile.isFile()) { + return false; + } + try { + String oldDigestHex = IO.collect(digestFile) + .trim(); + return newDigestHex.equals(oldDigestHex); + } catch (Exception e) { + logger.logWarning("Failed to read " + digestDescription + " for " + outputFile.getName(), e); + return false; + } + } + + private String calcContentDigest(Jar jar) { + String newDigestHex = null; + try { + byte[] digest = jar.getTimelessDigest(); + if (digest != null) { + newDigestHex = Hex.toHexString(digest); + } + } catch (Exception e) { + logger.logWarning("Failed to compute timeless digest for " + jar.getName(), e); + } + return newDigestHex; + } + + /** + * Compute a digest of the exported API surface of the JAR. This captures + * the public/protected types, methods, and fields in exported packages. + * Internal implementation changes that don't affect the exported API will + * produce the same digest, allowing dependent projects to skip rebuilding. + * + * @param jar the built JAR to analyze + * @return hex-encoded SHA-1 digest of the API surface, or null on failure + */ + private String calcApiDigest(Jar jar) { + try { + Manifest manifest = jar.getManifest(); + if (manifest == null) { + return null; + } + String exportPackage = manifest.getMainAttributes() + .getValue(Constants.EXPORT_PACKAGE); + if (exportPackage == null || exportPackage.isEmpty()) { + return null; + } + Tree tree = new DiffPluginImpl().tree(jar); + Tree apiTree = tree.get(""); + if (apiTree == null) { + return null; + } + MessageDigest md = MessageDigest.getInstance("SHA-1"); + digestTree(md, apiTree); + return Hex.toHexString(md.digest()); + } catch (Exception e) { + logger.logWarning("Failed to compute API digest for " + jar.getName(), e); + return null; + } + } + + /** + * Recursively feed the tree's type and name into the digest. Children are + * already sorted in the Element constructor, so the digest is + * deterministic. + */ + private void digestTree(MessageDigest md, Tree tree) { + md.update(tree.getType() + .name() + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + md.update((byte) ':'); + md.update(tree.getName() + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + md.update((byte) '\n'); + for (Tree child : tree.getChildren()) { + digestTree(md, child); + } + } + + static File getContentDigestFile(File outputFile) { + return new File(outputFile.getParentFile(), outputFile.getName() + ".digest"); + } + + static File getApiDigestFile(File outputFile) { + return new File(outputFile.getParentFile(), outputFile.getName() + ".api-digest"); + } + + void persistRebuildTriggerPolicyResult(File outputFile, RebuildTriggerPolicyResult result) { + if (REBUILDTRIGGERPOLICY_ALWAYS.equals(this.rebuildTriggerPolicyKey)) { + // do not store any .digest files by default + // to avoid polluting / confusing existing projects + return; + } + + // Store the content digest for future comparisons + if (result.newContentDigestHex() != null) { + try { + IO.store(result.newContentDigestHex(), result.contentDigestFile()); + } catch (Exception e) { + logger.logWarning("Failed to store content digest for " + outputFile.getName(), e); + } + } + + // Store the API digest for future comparisons + if (result.newApiDigestHex() != null) { + try { + IO.store(result.newApiDigestHex(), result.apiDigestFile()); + } catch (Exception e) { + logger.logWarning("Failed to store API digest for " + outputFile.getName(), e); + } + } + + // If the content or API was unchanged, restore the old timestamp + // to prevent downstream cascade rebuilds + if (result.preserveTimestamp() > 0) { + // Preserved timestamp: This is the main thing this + // RebuildTriggerPolicy is about + outputFile.setLastModified(result.preserveTimestamp()); + } + } +} diff --git a/bndtools.core/src/bndtools/central/RebuildTriggerPolicyPlugin.java b/bndtools.core/src/bndtools/central/RebuildTriggerPolicyPlugin.java new file mode 100644 index 0000000000..6d297e11d8 --- /dev/null +++ b/bndtools.core/src/bndtools/central/RebuildTriggerPolicyPlugin.java @@ -0,0 +1,60 @@ +package bndtools.central; + +import java.io.File; +import java.util.Map; + +import aQute.bnd.build.Project; +import aQute.bnd.osgi.Jar; +import aQute.bnd.service.JarLifecycleListener; +import bndtools.central.RebuildTriggerPolicy.RebuildTriggerPolicyResult; + +public class RebuildTriggerPolicyPlugin implements JarLifecycleListener { + + /** + * The rebuild trigger policy. + *

+ * The rebuild trigger policy determines whether a rebuilt bundle's output + * JAR file timestamp is preserved when the rebuild produces an unchanged + * artifact. This prevents unnecessary cascade rebuilds of dependent + * projects in incremental build scenarios. + *

+ * Two policies are supported: + *

    + *
  • always – (default) Every rebuild updates the JAR timestamp, + * triggering rebuilds in all downstream projects.
  • + *
  • api – Preserves the JAR timestamp when the content is + * byte-identical or the exported API surface is unchanged. Only projects + * that consume the exported API will rebuild when it changes; internal-only + * changes do not cascade.
  • + *
+ *

+ * This setting is workspace-wide and applies to all projects in the + * workspace. It is typically set by the build environment (e.g. Eclipse IDE + * preferences, Maven/Gradle plugin configuration) rather than hardcoded in + * build files. + * + */ + private final String rebuildTriggerPolicy; + private final RebuildTriggerPolicy policy; + + public RebuildTriggerPolicyPlugin(String rebuildTriggerPolicyKey) { + this.rebuildTriggerPolicy = rebuildTriggerPolicyKey; + this.policy = new RebuildTriggerPolicy(this.rebuildTriggerPolicy); + } + + @Override + public void beforeWrite(Project project, Jar jar, File outputFile, Map context) { + // Compute digests before write + RebuildTriggerPolicyResult res = policy + .doRebuildTriggerPolicy(jar, outputFile); + context.put("result", res); + } + + @Override + public void afterWrite(Project project, File outputFile, Jar jar, Map context) { + // Persist digests and restore timestamp if needed + RebuildTriggerPolicyResult res = (RebuildTriggerPolicyResult) context.get("result"); + policy.persistRebuildTriggerPolicyResult(outputFile, res); + } + +} \ No newline at end of file diff --git a/bndtools.core/src/bndtools/explorer/BndtoolsExplorer.java b/bndtools.core/src/bndtools/explorer/BndtoolsExplorer.java index 8d89b84098..7452f5922a 100644 --- a/bndtools.core/src/bndtools/explorer/BndtoolsExplorer.java +++ b/bndtools.core/src/bndtools/explorer/BndtoolsExplorer.java @@ -1,5 +1,7 @@ package bndtools.explorer; +import static bndtools.central.RebuildTriggerPolicy.REBUILDTRIGGERPOLICY_ALWAYS; +import static bndtools.central.RebuildTriggerPolicy.REBUILDTRIGGERPOLICY_API; import static org.eclipse.jface.layout.GridLayoutFactory.fillDefaults; import java.beans.PropertyChangeListener; @@ -242,6 +244,9 @@ public void linkActivated(HyperlinkEvent e) { toolBarManager.add(HelpButtons.HELP_BTN_BNDTOOLS_EXPLORER); toolBarManager.update(true); + // Add rebuild trigger policy indicator + addRebuildTriggerPolicyIndicator(toolBarManager); + return header; } @@ -472,6 +477,52 @@ public void run() { return pin; } + private void addRebuildTriggerPolicyIndicator(ToolBarManager toolBarManager) { + // Create a custom action that displays the current rebuild trigger + // policy + Action rebuildPolicyAction = new Action("Rebuild Trigger Policy") { + @Override + public void run() { + // Optional: Open preferences or show a menu + PreferenceDialog dialog = PreferencesUtil.createPreferenceDialogOn(null, + BndPreferencePage.PAGE_ID_BUILD, new String[] {}, null); + dialog.open(); + } + }; + + // Update the action based on preferences + Runnable updatePolicyIndicator = () -> { + String policy = preferences.getRebuildTriggerPolicy(); + + if (REBUILDTRIGGERPOLICY_API.equals(policy)) { + rebuildPolicyAction.setText("Rebuild: Optimized"); + rebuildPolicyAction.setToolTipText( + "Rebuild Trigger Policy: API-based rebuild optimization enabled - non-API changes just rebuild the current project and won't trigger cascades"); + rebuildPolicyAction.setImageDescriptor(null); + } else if (REBUILDTRIGGERPOLICY_ALWAYS.equals(policy)) { + rebuildPolicyAction.setText("Rebuild: Always"); + rebuildPolicyAction.setToolTipText("Rebuild Trigger Policy: All changes trigger downstream rebuilds"); + rebuildPolicyAction.setImageDescriptor(null); + } else { + rebuildPolicyAction.setText("Rebuild: Unknown"); + rebuildPolicyAction.setToolTipText("Rebuild Trigger Policy: Unknown value '" + policy + "'"); + rebuildPolicyAction.setImageDescriptor(Icons.desc("errors")); + } + toolBarManager.update(true); + }; + + // Update on preference changes + closeables.add(preferences.onString("rebuildTriggerPolicy", (value) -> { + updatePolicyIndicator.run(); + })); + + // Initial update + updatePolicyIndicator.run(); + + // Add to toolbar + toolBarManager.add(rebuildPolicyAction); + } + private void fixupRefactoringPasteAction(FilterPanelPart filterPart) { IActionBars actionBars = getViewSite().getActionBars(); IAction originalPaste = actionBars.getGlobalActionHandler(ActionFactory.PASTE.getId()); diff --git a/bndtools.core/src/bndtools/preferences/BndPreferences.java b/bndtools.core/src/bndtools/preferences/BndPreferences.java index 7a71467e75..70a97decd5 100644 --- a/bndtools.core/src/bndtools/preferences/BndPreferences.java +++ b/bndtools.core/src/bndtools/preferences/BndPreferences.java @@ -1,5 +1,8 @@ package bndtools.preferences; +import static bndtools.central.RebuildTriggerPolicy.REBUILDTRIGGERPOLICY_ALWAYS; +import static bndtools.central.RebuildTriggerPolicy.REBUILDTRIGGERPOLICY_API; + import java.io.Closeable; import java.util.Arrays; import java.util.Collection; @@ -47,6 +50,7 @@ public class BndPreferences { private static final String PREF_WORKSPACE_TEMPLATE_INDEXES = "workspaceTemplateIndexes"; private static final String PREF_EXPLORER_PROMPT = "prompt"; private static final String PREF_PARALLEL = "parallel"; + private static final String PREF_REBUILD_TRIGGER_POLICY = "rebuildTriggerPolicy"; static final String PREF_WORKSPACE_OFFLINE = "workspaceIsOffline"; @@ -69,6 +73,7 @@ public BndPreferences() { store.setDefault(PREF_WORKSPACE_TEMPLATE_INDEXES, FragmentTemplateEngine.DEFAULT_INDEX); store.setDefault(PREF_WORKSPACE_OFFLINE, false); store.setDefault(PREF_PARALLEL, false); + store.setDefault(PREF_REBUILD_TRIGGER_POLICY, REBUILDTRIGGERPOLICY_ALWAYS); store.setDefault(PREF_USE_ALIAS_REQUIREMENTS, true); store.setDefault(QuickFixVersioning.PREFERENCE_KEY, QuickFixVersioning.DEFAULT.toString()); store.setDefault(PREF_EXPLORER_PROMPT, ""); @@ -332,6 +337,33 @@ public boolean isParallel() { return store.getBoolean(PREF_PARALLEL); } + /** + * Returns the stored rebuild trigger policy preference. + * + * @return the stored policy string; defaults to {@code "always"}. + */ + public String getRebuildTriggerPolicy() { + return store.getString(PREF_REBUILD_TRIGGER_POLICY); + } + + /** + * Sets the rebuild trigger policy. + * + * @param policy the policy to use; accepted values are {@code "always"} + * (rebuild on every change), {@code "api"} (skip rebuild when + * only non-API changes are detected), or Any other non-empty + * value is stored as-is but treated as {@code "always"} by the + * build infrastructure. + */ + public void setRebuildTriggerPolicy(String policy) { + String normalized = REBUILDTRIGGERPOLICY_API.equals(policy) ? policy : REBUILDTRIGGERPOLICY_ALWAYS; + store.setValue(PREF_REBUILD_TRIGGER_POLICY, normalized); + + Workspace ws = Central.getWorkspaceIfPresent(); + if (ws != null) { + Central.applyRebuildTriggerPolicy(ws); + } + } public Closeable onString(String key, Consumer listener) { IPropertyChangeListener l = e -> { diff --git a/bndtools.core/src/bndtools/preferences/ui/BndBuildPreferencePage.java b/bndtools.core/src/bndtools/preferences/ui/BndBuildPreferencePage.java index ce9d964b0a..023606e6c7 100644 --- a/bndtools.core/src/bndtools/preferences/ui/BndBuildPreferencePage.java +++ b/bndtools.core/src/bndtools/preferences/ui/BndBuildPreferencePage.java @@ -1,5 +1,8 @@ package bndtools.preferences.ui; +import static bndtools.central.RebuildTriggerPolicy.REBUILDTRIGGERPOLICY_ALWAYS; +import static bndtools.central.RebuildTriggerPolicy.REBUILDTRIGGERPOLICY_API; + import org.eclipse.jface.preference.PreferencePage; import org.eclipse.swt.SWT; import org.eclipse.swt.events.SelectionAdapter; @@ -14,6 +17,8 @@ import org.eclipse.ui.IWorkbench; import org.eclipse.ui.IWorkbenchPreferencePage; +import aQute.bnd.build.Workspace; +import bndtools.central.Central; import bndtools.preferences.BndPreferences; public class BndBuildPreferencePage extends PreferencePage implements IWorkbenchPreferencePage { @@ -21,6 +26,8 @@ public class BndBuildPreferencePage extends PreferencePage implements IWorkbench private BndPreferences prefs; private int buildLogging; private Button parallel; + private Button rbAlways; + private Button rbOptimized; @Override public void init(IWorkbench workbench) { @@ -41,17 +48,68 @@ protected Control createContents(Composite parent) { final Combo cmbBuildLogging = new Combo(composite, SWT.READ_ONLY); cmbBuildLogging.setItems(Messages.BndPreferencePage_cmbBuildLogging_None, Messages.BndPreferencePage_cmbBuildLogging_Basic, Messages.BndPreferencePage_cmbBuildLogging_Full); + cmbBuildLogging.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); // Allow Build parallel new Label(composite, SWT.NONE).setText("Allow build in parallel (highly experimental)"); parallel = new Button(composite, SWT.CHECK); parallel.setSelection(prefs.isParallel()); - cmbBuildLogging.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + // Rebuild trigger policy + Label lblRebuildPolicy = new Label(composite, SWT.NONE); + lblRebuildPolicy.setText("Rebuild Trigger Policy:"); + lblRebuildPolicy.setLayoutData(new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + Composite policyGroup = new Composite(composite, SWT.NONE); + GridLayout policyLayout = new GridLayout(1, false); + policyLayout.marginWidth = 0; + policyLayout.marginHeight = 0; + policyGroup.setLayout(policyLayout); + policyGroup.setLayoutData(new GridData(SWT.FILL, SWT.BEGINNING, true, false)); + + // Radio button: Always rebuild + rbAlways = new Button(policyGroup, SWT.RADIO); + rbAlways.setText("Always rebuild (default)"); + rbAlways.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); + + Label lblAlwaysDesc = new Label(policyGroup, SWT.WRAP); + lblAlwaysDesc + .setText( + "Every change triggers rebuilds in all downstream projects.\nSimple and predictable behavior, but can lead to long rebuild cascades in larger workspaces."); + lblAlwaysDesc.setForeground(composite.getDisplay() + .getSystemColor(SWT.COLOR_DARK_GRAY)); + GridData descData = new GridData(SWT.FILL, SWT.CENTER, true, false); + descData.horizontalIndent = 20; + lblAlwaysDesc.setLayoutData(descData); + + // Radio button: Optimized (API-based) + rbOptimized = new Button(policyGroup, SWT.RADIO); + rbOptimized.setText("Optimized (Experimental)"); + rbOptimized.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); + + Label lblOptimizedDesc = new Label(policyGroup, SWT.WRAP); + lblOptimizedDesc.setText( + "Prevents unnecessary rebuilds cascades in incremental build scenarios,\n" + + "which can speed up development for larger workspaces.\n" + + "It is done by preserving JAR timestamps when only non-API changes occur.\n" + + "Limitations: If you have a jar dependency on the path that you are pulling classes into your bundle \n" + + "e.g. via -includepackage or -conditionalpackage, the bundle need to be rebuilt \n" + + "even if the public API of the dependency hasn't changed. " + + "This policy does not work well in such cases."); + lblOptimizedDesc.setForeground(composite.getDisplay() + .getSystemColor(SWT.COLOR_DARK_GRAY)); + descData = new GridData(SWT.FILL, SWT.CENTER, true, false); + descData.horizontalIndent = 20; + lblOptimizedDesc.setLayoutData(descData); // Load Data cmbBuildLogging.select(buildLogging); - + String currentPolicy = prefs.getRebuildTriggerPolicy(); + if (REBUILDTRIGGERPOLICY_API.equals(currentPolicy)) { + rbOptimized.setSelection(true); + } else { + rbAlways.setSelection(true); // Default + } // Listeners cmbBuildLogging.addSelectionListener(new SelectionAdapter() { @Override @@ -67,7 +125,23 @@ public void widgetSelected(SelectionEvent e) { public boolean performOk() { prefs.setBuildLogging(buildLogging); prefs.setParallel(parallel.getSelection()); + String policy = getRebuildTriggerPolicy(); + prefs.setRebuildTriggerPolicy(policy); + + Workspace ws = Central.getWorkspaceIfPresent(); + if (ws != null) { + Central.applyRebuildTriggerPolicy(ws); + } + return true; } + private String getRebuildTriggerPolicy() { + if (rbOptimized.getSelection()) { + return REBUILDTRIGGERPOLICY_API; + } + return REBUILDTRIGGERPOLICY_ALWAYS; + } + + } diff --git a/bndtools.core/src/bndtools/preferences/ui/BndPreferencePage.java b/bndtools.core/src/bndtools/preferences/ui/BndPreferencePage.java index f9b96aec41..6878afe24b 100644 --- a/bndtools.core/src/bndtools/preferences/ui/BndPreferencePage.java +++ b/bndtools.core/src/bndtools/preferences/ui/BndPreferencePage.java @@ -24,6 +24,7 @@ public class BndPreferencePage extends PreferencePage implements IWorkbenchPrefe public BndPreferencePage() {} public static final String PAGE_ID = "bndtools.prefPages.basic"; + public static final String PAGE_ID_BUILD = "bndtools.prefPages.build"; private boolean noCheckCnf = false; private boolean warnExistingLaunch = true; diff --git a/bndtools.core/test/bndtools/tasks/RebuildPolicyTriggerTest.java b/bndtools.core/test/bndtools/tasks/RebuildPolicyTriggerTest.java new file mode 100644 index 0000000000..793a5e7cf8 --- /dev/null +++ b/bndtools.core/test/bndtools/tasks/RebuildPolicyTriggerTest.java @@ -0,0 +1,200 @@ +package bndtools.tasks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; + +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import aQute.bnd.build.Project; +import aQute.bnd.build.Workspace; +import aQute.bnd.test.jupiter.InjectTemporaryDirectory; +import aQute.lib.io.IO; +import bndtools.central.RebuildTriggerPolicy; +import bndtools.central.RebuildTriggerPolicyPlugin; + +@ExtendWith(SoftAssertionsExtension.class) +public class RebuildPolicyTriggerTest { + + @InjectTemporaryDirectory + File tmp; + + private Workspace getWorkspace(File file) throws Exception { + IO.copy(file, tmp); + return new Workspace(tmp); + } + + /** + * Check that the content-hash optimization prevents unnecessary JAR + * rewrites when the JAR content is unchanged between builds. This avoids + * cascading rebuilds of dependent projects. + */ + @Test + public void testContentHashSkipsBuildWhenUnchanged() throws Exception { + Workspace ws = getWorkspace(IO.getFile("testdata/ws")); + ws.addBasicPlugin(new RebuildTriggerPolicyPlugin(RebuildTriggerPolicy.REBUILDTRIGGERPOLICY_API)); + Project project = ws.getProject("p-stale"); + assertNotNull(project); + + // First build - should create JAR and digest files + File[] firstBuild = project.build(); + assertNotNull(firstBuild); + assertTrue(firstBuild.length > 0); + + File jarFile = firstBuild[0]; + assertTrue(jarFile.isFile()); + long firstTimestamp = jarFile.lastModified(); + + // Verify digest file was created + File digestFile = getDigestFile(jarFile); + assertTrue(digestFile.isFile(), "Digest file should be created after build"); + String firstDigest = IO.collect(digestFile) + .trim(); + assertFalse(firstDigest.isEmpty(), "Digest should not be empty"); + + // Simulate time passing by adjusting the JAR timestamp backward, + // then mark the project as changed. If the optimization works + // correctly, the JAR's timestamp will be restored to this value + // since the content hasn't changed. + long adjustedTimestamp = firstTimestamp - 10000; + jarFile.setLastModified(adjustedTimestamp); + + // Mark project as changed so it rebuilds + project.setChanged(); + project.refresh(); + + // Second build - content is unchanged, JAR timestamp should be + // preserved + File[] secondBuild = project.build(); + assertNotNull(secondBuild); + assertTrue(secondBuild.length > 0); + + File jarFile2 = secondBuild[0]; + assertTrue(jarFile2.isFile()); + + // The JAR timestamp should be preserved since content didn't change + assertEquals(adjustedTimestamp, jarFile2.lastModified(), + "JAR timestamp should be preserved when content is unchanged"); + + // Digest file should still exist with the same content + assertTrue(digestFile.isFile()); + String secondDigest = IO.collect(digestFile) + .trim(); + assertEquals(firstDigest, secondDigest, "Digest should be unchanged"); + } + + /** + * Check that when JAR content actually changes, the JAR is rewritten and + * the digest is updated. + */ + @Test + public void testContentHashRewritesWhenChanged() throws Exception { + Workspace ws = getWorkspace(IO.getFile("testdata/ws")); + ws.addBasicPlugin(new RebuildTriggerPolicyPlugin(RebuildTriggerPolicy.REBUILDTRIGGERPOLICY_API)); + Project project = ws.getProject("p-stale"); + assertNotNull(project); + + // First build + File[] firstBuild = project.build(); + assertNotNull(firstBuild); + File jarFile = firstBuild[0]; + File digestFile = getDigestFile(jarFile); + assertTrue(digestFile.isFile()); + String firstDigest = IO.collect(digestFile) + .trim(); + + // Change the project content so the JAR will be different + project.setChanged(); + project.refresh(); + project.setProperty("Include-Resource", "p;literal=\"changed content\""); + + // Second build - content changed, JAR should be rewritten + File[] secondBuild = project.build(); + assertNotNull(secondBuild); + File jarFile2 = secondBuild[0]; + + // Digest should have changed + assertTrue(digestFile.isFile()); + String secondDigest = IO.collect(digestFile) + .trim(); + assertThat(secondDigest).as("Digest should change when content changes") + .isNotEqualTo(firstDigest); + } + + private static File getDigestFile(File jarFile) { + return new File(jarFile.getParentFile(), jarFile.getName() + ".digest"); + } + + /** + * Verify that timestamp preservation does not engage when the JAR content + * changes and an API digest cannot be computed (e.g. a resource-only bundle + * with no Export-Package). + */ + @Test + public void testTimestampNotPreservedWhenApiDigestMissing() throws Exception { + Workspace ws = getWorkspace(IO.getFile("testdata/ws")); + ws.addBasicPlugin(new RebuildTriggerPolicyPlugin(RebuildTriggerPolicy.REBUILDTRIGGERPOLICY_API)); + Project project = ws.getProject("p-stale"); + assertNotNull(project); + + // First build — establishes the baseline JAR and digest files + File[] firstBuild = project.build(); + assertNotNull(firstBuild); + assertTrue(firstBuild.length > 0); + + File jarFile = firstBuild[0]; + assertTrue(jarFile.isFile()); + + // Record the old timestamp and adjust it to simulate time + long adjustedTimestamp = jarFile.lastModified() - 10000; + jarFile.setLastModified(adjustedTimestamp); + + // The content digest from the first build + File digestFile = getDigestFile(jarFile); + assertTrue(digestFile.isFile(), "Content digest file should exist after build"); + + // Write a fake API digest sidecar. The next build will produce + // the same resource-only bundle (no Export-Package), so + // calcApiDigest() returns null. However, when we change the + // project content the content digest will differ. To simulate + // the API-digest-unchanged path, we need a non-null API digest. + // We'll change the content, then verify the mechanism by + // checking the timestamp. Since calcApiDigest returns null for + // resource-only bundles, the API digest path won't engage here, + // but we can verify the infrastructure is correctly wired: + // the API digest file should remain from the first build if no + // new one is computed. + File apiDigestFile = new File(jarFile.getParentFile(), jarFile.getName() + ".api-digest"); + + // Change the project content so the JAR will differ + project.setChanged(); + project.refresh(); + project.setProperty("Include-Resource", "p;literal=\"changed content\""); + + // Rebuild — content changed so content digest differs, and + // calcApiDigest returns null for resource-only bundles. The + // timestamp should NOT be preserved (no API digest fallback). + File[] secondBuild = project.build(); + assertNotNull(secondBuild); + + File jarFile2 = secondBuild[0]; + // Content changed so the digest should differ + String newDigest = IO.collect(digestFile).trim(); + // The JAR should have a new timestamp since both content and + // API digests indicate a change (or API digest is absent) + assertThat(jarFile2.lastModified()) + .as("Timestamp should NOT be preserved when content changes and no API digest exists") + .isNotEqualTo(adjustedTimestamp); + + // The content digest file should still exist + assertTrue(digestFile.isFile(), "Content digest file should exist after rebuild"); + } + +} + diff --git a/bndtools.core/testdata/ws/cnf/build.bnd b/bndtools.core/testdata/ws/cnf/build.bnd new file mode 100644 index 0000000000..a577603ae6 --- /dev/null +++ b/bndtools.core/testdata/ws/cnf/build.bnd @@ -0,0 +1,4 @@ +build = sometime + + +-runrepos: testing \ No newline at end of file diff --git a/bndtools.core/testdata/ws/p-stale-dep/.gitignore b/bndtools.core/testdata/ws/p-stale-dep/.gitignore new file mode 100644 index 0000000000..9e0adcc107 --- /dev/null +++ b/bndtools.core/testdata/ws/p-stale-dep/.gitignore @@ -0,0 +1 @@ +/generated/ diff --git a/bndtools.core/testdata/ws/p-stale-dep/bnd.bnd b/bndtools.core/testdata/ws/p-stale-dep/bnd.bnd new file mode 100644 index 0000000000..06d1caf08d --- /dev/null +++ b/bndtools.core/testdata/ws/p-stale-dep/bnd.bnd @@ -0,0 +1,3 @@ +Bundle-Version: 1.2.3.${build} +Include-Resource: p;literal="hello" +-runbuilds: false \ No newline at end of file diff --git a/bndtools.core/testdata/ws/p-stale/.gitignore b/bndtools.core/testdata/ws/p-stale/.gitignore new file mode 100644 index 0000000000..9e0adcc107 --- /dev/null +++ b/bndtools.core/testdata/ws/p-stale/.gitignore @@ -0,0 +1 @@ +/generated/ diff --git a/bndtools.core/testdata/ws/p-stale/bnd.bnd b/bndtools.core/testdata/ws/p-stale/bnd.bnd new file mode 100644 index 0000000000..532f2556be --- /dev/null +++ b/bndtools.core/testdata/ws/p-stale/bnd.bnd @@ -0,0 +1,3 @@ +-resourceonly: true +Include-Resource: p;literal="hello" +-dependson: p-stale-dep diff --git a/docs/_chapters/150-build.md b/docs/_chapters/150-build.md index 92d9807b9e..b8dd5a4009 100644 --- a/docs/_chapters/150-build.md +++ b/docs/_chapters/150-build.md @@ -9,9 +9,9 @@ This chapter lays out the rules of the file system for bnd projects. It discusse ## Workspace A Workspace is a single directory with all its sub-directories and their files, similar to a git workspace. The core idea of the workspace is that it is easy to move around, that is, it allows the use of relative file names. It also prevents a lot of potential problems that occur when you allow projects to be anywhere on the file system. KISS! -Workspaces should be named according to the bundle symbolic names of its projects. Using such a naming strategy will simplify finding the correct namespace given a bundle symbolic name. +Workspaces should be named according to the bundle symbolic names of its projects. Using such a naming strategy will simplify finding the correct namespace given a bundle symbolic name. -A bndlib workspace is a _valid_ workspace when it contains a `cnf` file. If this is a text file, its content is read and interpreted as a path to the `cnf` directory (which can again be a path to a cnf directory, ad infinitum). The retrieved path is trimmed after which it is resolved relative to its parent directory. +A bndlib workspace is a _valid_ workspace when it contains a `cnf` file. If this is a text file, its content is read and interpreted as a path to the `cnf` directory (which can again be a path to a cnf directory, ad infinitum). The retrieved path is trimmed after which it is resolved relative to its parent directory. However, the advised model is to use a directory with a `cnf/build.bnd` file. The purpose of the `cnf` directory is to provide a place for shared information. Though this includes bndlib setup information, it also can be used to define for example shared licensing, copyright, and vendor headers for your organization. @@ -21,7 +21,7 @@ The `cnf` directory can have an `ext` directory. Files in this directory are add of the workspace. They can have the following extensions: * `.bnd` – Contain bnd properties -* `.pmvn` – An index file for a [Maven Bnd Repository](/plugins/maven.html). The first lines can contain properties for this plugin in the format of `# key = value`, e.g. `# name = OSGi R8`. +* `.pmvn` – An index file for a [Maven Bnd Repository](/plugins/maven.html). The first lines can contain properties for this plugin in the format of `# key = value`, e.g. `# name = OSGi R8`. * `.pobr` – An OSGi Repository file in XML. #### Automatically registering repositories @@ -44,7 +44,7 @@ The `ext` directory is a convenient way to add add reusable components. See [tem ### The cnf/cache directory -To cache some intermediate files, bndlib will create a `cnf/cache/` directory, this file should not be under source control. E.g. in Git it should be defined in the `.gitignore` file. +To cache some intermediate files, bndlib will create a `cnf/cache/` directory, this file should not be under source control. E.g. in Git it should be defined in the `.gitignore` file. ### Folder structure @@ -57,7 +57,7 @@ Overall, this gives us the following layout for a workspace: build.bnd organization setup plugins/ directory for plugins cache/ bnd cache directory, should not be saved in an scm - com.acme.prime.speaker/ project directory + com.acme.prime.speaker/ project directory The root of the workspace is generally used to hold other files. For example the `.git` directory for Git, or gradle and ant files for continuous integration. However, designers that add functionality to the workspace should strive to minimize the clutter in the root. For example, the bnd gradle support adds a few files to the root but these link to a `cnf/gradle` directory for their actual content. @@ -91,7 +91,7 @@ testbin ${target-dir}/test-classes This example configuration, placed in `cnf/build.bnd` means: - eclipse build puts the build output directly into the `generated` folder of each project. -- gradle build puts the build outputs in `generated/gradle`. +- gradle build puts the build outputs in `generated/gradle`. ### Extension Files @@ -103,12 +103,12 @@ For example, the Maven plugin that is built-in to bndlib has an extension file c # Plugin maven setup # -plugin.maven = aQute.bnd.plugin.maven.MavenPlugin - - + + # # Change disk layout to fit maven # - + -outputmask = ${@bsn}-${versionmask;===S;${@version}}.jar src=src/main/java bin=target/classes @@ -134,7 +134,7 @@ After reading the extension files, bndlib reads the `cnf/build.bnd` file, this f ### Workspace Plugins -A _plugin_ is a piece of code that runs inside bnd. The workspace provides a number of standard built-in plugins like an Executor, a Randum number generator, itself, etc. Additional plugins can be added with the `-plugin.*` instructions. +A _plugin_ is a piece of code that runs inside bnd. The workspace provides a number of standard built-in plugins like an Executor, a Randum number generator, itself, etc. Additional plugins can be added with the `-plugin.*` instructions. ## Project Properties There are a number of built in properties that are set by bnd: diff --git a/docs/_plugins/00-overview.md b/docs/_plugins/00-overview.md index ef0245e6a2..62953cfbbc 100644 --- a/docs/_plugins/00-overview.md +++ b/docs/_plugins/00-overview.md @@ -20,12 +20,6 @@ The following directive is defined for all plugin: | --- | --- | | `path:` | A path to the jar file that contains the plugin. The directory/jar at that location is placed on your classpath for that plugin. | -## Tagging of repository plugins - -Repository plugins are usually referenced in `cnf/build.bnd` and implement the [Tagged](https://github.com/bndtools/bnd/blob/master/biz.aQute.bndlib/src/aQute/bnd/service/tags/Tagged.java) interface. - -The `tags` property of repositories' configuration allows to add a comma separated list of tags to a repository. These tags will be used for filtering a list of repositories. -For example the [-runrepos](/instructions/runrepos.html) instruction in `.bndrun` considers only those repositories for resolution which have either the `resolve` tag or no `tags` property defined. This allows including and excluding repositories based on their tags. ## Index @@ -52,3 +46,30 @@ For example the [-runrepos](/instructions/runrepos.html) instruction in `.bndrun + +### Registration + +Plugins are registered in `cnf/build.bnd` or extension files: + +```properties +-plugin.my-plugin = com.example.MyPlugin +``` + +Or programmatically: + +```java +workspace.addBasicPlugin(new MyPlugin()); +``` + +Your plugin needs to implement an interface which is used in the bnd codebase. For examples search the code for `getPlugins(SomeInterface.class)` to find out which plugin interfaces are used, for which you could add a plugin implementation. + +### Error Handling + +Most plugins follow a pattern where exceptions are logged but do not stop the build. Consult the specific plugin documentation for error behavior. + +## Tagging of repository plugins + +Repository plugins are usually referenced in `cnf/build.bnd` and implement the [Tagged](https://github.com/bndtools/bnd/blob/master/biz.aQute.bndlib/src/aQute/bnd/service/tags/Tagged.java) interface. + +The `tags` property of repositories' configuration allows to add a comma separated list of tags to a repository. These tags will be used for filtering a list of repositories. +For example the [-runrepos](/instructions/runrepos.html) instruction in `.bndrun` considers only those repositories for resolution which have either the `resolve` tag or no `tags` property defined. This allows including and excluding repositories based on their tags. diff --git a/docs/_plugins/bndtools-rebuild-policy-plugin.md b/docs/_plugins/bndtools-rebuild-policy-plugin.md new file mode 100644 index 0000000000..6bf88504e9 --- /dev/null +++ b/docs/_plugins/bndtools-rebuild-policy-plugin.md @@ -0,0 +1,107 @@ +--- +title: Bndtools Rebuild Trigger Policy Plugin +layout: bnd +parent: Plugins +nav_order: 15 +--- + +# Rebuild Trigger Policy (Bndtools Eclipse Plugin) + +> **Note:** This is an **optional Bndtools Eclipse IDE optimization**, not a core bnd feature. The underlying mechanism is the [JAR Lifecycle Listener](jar-lifecycle.html) plugin interface. + +## Problem + +In bnd workspaces, downstream projects check whether to rebuild by inspecting their dependency JAR's `lastModified` timestamp. By default, **every rebuild updates this timestamp**, causing all downstream projects to rebuild even when nothing relevant to them changed. + +This cascade rebuild problem is particularly painful in incremental build scenarios (IDE, CI) where many small changes accumulate, causing unnecessary full rebuilds. + +## Solution + +The **rebuild trigger policy** is a Bndtools plugin that preserves JAR timestamps when the rebuild does not produce a meaningfully different artifact. It uses two levels of comparison: + +1. **Content digest** – SHA-1 of entire JAR (byte-for-byte identical) +2. **API digest** – SHA-1 of exported API surface (public/protected types, methods, fields in `Export-Package`) + +If either matches the previous build, the timestamp is preserved, preventing downstream cascade rebuilds. + +## Policies + +| Policy | Behavior | +|--------|----------| +| `always` (default) | Every rebuild updates the JAR timestamp. All downstream projects consider the dependency stale and rebuild. | +| `api` | Preserves timestamp when content is byte-identical OR exported API is unchanged. Only consuming projects rebuild when the API actually changes. | + +## How It Works + +The Bndtools `RebuildTriggerPolicyListener` implements the [JAR Lifecycle Listener](jar-lifecycle.html) interface: + +**Before write phase:** +- Compute SHA-1 digest of JAR bytes (timeless, excludes embedded timestamps) +- Compute SHA-1 digest of exported API surface (using `DiffPluginImpl`) +- Store in context for after phase + +**After write phase:** +- Load previous digests from sidecar files (`.digest`, `.api-digest`) +- If content digest matches → **preserve timestamp** (fast path, no changes) +- Else if API digest matches → **preserve timestamp** (content changed but API didn't) +- Else → **use new timestamp** (both content and API changed) +- Store new digests for next build + +## Sidecar Files + +When the `api` policy is active, Bndtools stores two small sidecar files next to each output JAR: + +``` +myproject.jar +myproject.jar.digest # SHA-1 of entire JAR content (hex-encoded) +myproject.jar.api-digest # SHA-1 of exported API surface (hex-encoded) +``` + +These files are regenerated on every build and should be added to `.gitignore`. + +> **Note:** Only _exported_ packages are analyzed. Changes to private packages or internal implementation classes are intentionally invisible (because downstream bundles cannot legally depend on them), so the timestamp is preserved. + +## Eclipse Bndtools Configuration + +Bndtools Eclipse IDE provides a UI to enable this optimization: + +**Preferences → Bndtools → Build → Rebuild Trigger Policy** + +Options: +- **"Always rebuild (default)"** – Standard behavior; all changes trigger downstream rebuilds +- **"Optimized (skip if API unchanged)"** – Enable digest-based timestamp preservation + +### Current Status + +The Bndtools Explorer toolbar displays the current rebuild policy status: + +- **"Rebuild: Always"** – Default policy is active +- **"Rebuild: Optimized"** – API-based optimization is enabled + +Click the status to open preferences. + +## Workspace-Level Configuration + +The policy is configured programmatically at the workspace level (not via `build.bnd` properties): + +This is typically done by Bndtools before building, based on the user's Eclipse preferences. + +## Future Extensibility + +The plugin-based design enables other build tools to implement similar optimizations without modifying core bnd: + +## Limitations + +This pragmatic approach trades perfect accuracy for simplicity: + +- ✅ **Works well for** typical Eclipse IDE incremental builds where small changes accumulate +- ❌ **Not recommended for** highly dynamic build environments with complex classpath interdependencies and heavy use of bnd features like `-includeresource` or `-conditionalpackage` which may not always be triggered for rebuild by this plugin. If you find problems, just switch back to the default 'always' policy or do a full workspace clean build +- ❌ **Not recommended for** scenarios requiring perfect timestamp accuracy for external build tools + +In practice, this optimization provides substantial benefits by preventing most unnecessary rebuilds while maintaining reliability. + +## See Also + +- [JAR Lifecycle Listener](jar-lifecycle.html) – Core plugin interface +- [Plugin Overview](00-overview.html) +- [Build Chapter](../chapters/150-build.html) – General build concepts diff --git a/docs/_plugins/jar-lifecycle.md b/docs/_plugins/jar-lifecycle.md new file mode 100644 index 0000000000..f1247a98f0 --- /dev/null +++ b/docs/_plugins/jar-lifecycle.md @@ -0,0 +1,170 @@ +--- +title: JAR Lifecycle Listener +layout: bnd +parent: Plugins +nav_order: 10 +--- + +# JAR Lifecycle Listener Plugin + +The **`JarLifecycleListener`** interface allows plugins to observe and participate in the JAR artifact lifecycle without modifying core bnd code. + +## Purpose + +This plugin enables: + +- **Metadata generation** – Compute and store digests, checksums, signatures +- **Rebuild optimization** – Detect unchanged content/API to prevent cascade rebuilds +- **JAR signing** – Sign artifacts post-write +- **Artifact tracking** – Index or report built artifacts +- **Binary validation** – Post-write scanning or verification +- **Bytecode enhancement** – Transform JAR contents before write + +## Interface + +```java +public interface JarLifecycleListener { + + /** + * Called before the JAR is written to disk. + * Store computed metadata in context for the afterWrite phase. + */ + default void beforeWrite(Project project, Jar jar, File outputFile, Map context) + throws Exception {} + + /** + * Called after the JAR is written to disk. + * Retrieve metadata from context and perform post-write actions. + */ + default void afterWrite(Project project, File outputFile, Jar jar, Map context) + throws Exception {} + +} +``` + +## Phases + +### beforeWrite + +Called **before** the JAR is written to disk. + +**Use cases:** +- Compute metadata (digests, checksums, API signatures) based on in-memory JAR state +- Validate the JAR before persisting +- Prepare data structures for the after phase + +**Context:** The `context` map is empty. Store computed values for use in `afterWrite`. + +### afterWrite + +Called **after** the JAR is successfully written to disk. + +**Use cases:** +- Store metadata in sidecar files +- Modify file properties (timestamps, permissions) +- Trigger post-write notifications +- Validate the written file + +**Context:** The `context` map contains data stored by `beforeWrite`. + +## Context Map + +The `context` parameter enables stateless, thread-safe data exchange between phases: + +```java +public class MyListener implements JarLifecycleListener { + + @Override + public void beforeWrite(Project project, Jar jar, File outputFile, Map context) { + // Compute metadata + byte[] digest = jar.getTimelessDigest(); + String digestHex = Hex.toHexString(digest); + + // Store in context for after phase + context.put("contentDigest", digestHex); + } + + @Override + public void afterWrite(Project project, File outputFile, Jar jar, Map context) { + // Retrieve metadata computed in before phase + String digestHex = (String) context.get("contentDigest"); + + // Perform post-write action + File digestFile = new File(outputFile.getParentFile(), + outputFile.getName() + ".digest"); + IO.store(digestHex, digestFile); + } +} +``` + +**Key properties:** +- ✅ **Thread-safe** – Each JAR write gets a fresh map instance +- ✅ **Stateless listeners** – Listeners don't store state; data is externalized +- ✅ **Composable** – Multiple listeners can share context keys +- ✅ **Testable** – Easy to mock for unit tests + + +## Error Handling + +If a listener throws an exception: +- The exception is **logged at debug level** (not as an error) +- **Other listeners continue** to run +- **The JAR write is unaffected** – exceptions do not roll back the write + +Avoid throwing exceptions for informational messages; use logging instead. + +## Registration + +### In Code + +```java +JarLifecycleListener listener = new MyListener(); +workspace.addBasicPlugin(listener); +``` + +### In Configuration + +Add to `cnf/build.bnd` or a file in `cnf/ext/`: + +```properties +-plugin.my-listener = com.example.MyJarListener +``` + +## Examples + +### Example 1: Simple Digest Sidecar + +```java +public class DigestListener implements JarLifecycleListener { + + @Override + public void beforeWrite(Project project, Jar jar, File outputFile, Map context) + throws Exception { + byte[] digest = jar.getTimelessDigest(); + context.put("digest", Hex.toHexString(digest)); + } + + @Override + public void afterWrite(Project project, File outputFile, Jar jar, Map context) + throws Exception { + String digestHex = (String) context.get("digest"); + File digestFile = new File(outputFile.getParentFile(), + outputFile.getName() + ".digest"); + IO.store(digestHex, digestFile); + } +} +``` + +## Best Practices + +1. **Compute expensive metadata in `beforeWrite`** – Before the JAR is written +2. **Use context for data exchange** – Don't store state in the listener +3. **Log at debug level** – Use `logger.debug()` for diagnostic messages +4. **Avoid throwing exceptions** – Use logging for non-fatal issues +5. **Make listeners stateless** – Thread-safe plugins work with parallel builds + +## See Also + +- [Plugin Overview](00-overview.html) +- [Bndtools Rebuild Policy Plugin](bndtools-rebuild-policy-plugin.html) – Example bndtools implementation +