diff --git a/CHANGELOG.md b/CHANGELOG.md index 373f126..bd18cb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Added + +- **`Promote.preview`:** every file that differs between two projects, as the target has it and as + a promotion writes it (the source's logic, the target's values), for a review that shows both + sides and lets a person merge line by line. DirXMLDevWeb's *Compare with project…* uses it. + ## [0.53.0] - 2026-10-26 ### Added diff --git a/src/main/java/com/pointblue/dirxml/dev/promote/Promote.java b/src/main/java/com/pointblue/dirxml/dev/promote/Promote.java index e96fa22..f39394c 100644 --- a/src/main/java/com/pointblue/dirxml/dev/promote/Promote.java +++ b/src/main/java/com/pointblue/dirxml/dev/promote/Promote.java @@ -25,6 +25,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.TreeMap; import java.util.TreeSet; import java.util.stream.Stream; @@ -248,17 +249,7 @@ static Result run(Path target, Path source, String pairName, Selection sel, bool r.refusal = "the promoted tree can't be read: " + e.getMessage(); return r; } - // values: the target's, but for the ones chosen - for (Map.Entry e : s.targetValues.entrySet()) { - Overrides.Target t = Overrides.resolve(after, e.getKey()); - if (!t.resolved()) { - continue; - } - String want = values.contains(e.getKey()) ? s.sourceValues.get(e.getKey()) : e.getValue(); - if (!Objects.equals(t.value(), want)) { - t.set(want == null && t.definition != null ? "" : want); - } - } + restoreValues(after, s, values); AsCodeWriter.write(after, out); Map produced = Sides.read(out); @@ -358,16 +349,85 @@ static Result run(Path target, Path source, String pairName, Selection sel, bool } } + /** The target's values back in a promoted model, but for the ones chosen, which take the source's. */ + private static void restoreValues(DriverSet after, Sides s, Set chosen) { + for (Map.Entry e : s.targetValues.entrySet()) { + Overrides.Target t = Overrides.resolve(after, e.getKey()); + if (!t.resolved()) { + continue; + } + String want = chosen.contains(e.getKey()) ? s.sourceValues.get(e.getKey()) : e.getValue(); + if (!Objects.equals(t.value(), want)) { + t.set(want == null && t.definition != null ? "" : want); + } + } + } + + /** A comparison with, for each differing file, its bytes in the target now and once promoted (null: that side has none). */ + public static final class Preview { + public final ProjectCompare compare; + public final Map before = new TreeMap<>(); + public final Map after = new TreeMap<>(); + + Preview(ProjectCompare compare) { + this.compare = compare; + } + } + + /** + * What a review shows: every differing file as the target has it and as a promotion of it writes it, the source's + * logic with the target's values (no value chosen). A file whose promotion depends on its manifest is shown as the + * whole of the source's files promoted together write it. + */ + public static Preview preview(Path target, Path source, String pairName) throws IOException { + Pair pair = Pair.read(target, pairName); + Sides s = Sides.read(target, source, pair.map); + Preview pv = new Preview(ProjectCompare.of(s, pair)); + Path sourceWritten = Files.createTempDirectory("idm-promote-src"); + Path work = Files.createTempDirectory("idm-promote-work"); + try { + AsCodeWriter.write(s.source, sourceWritten); + copyTree(target, work); + for (ProjectCompare.Item i : pv.compare.items) { + if (i.value) { + continue; + } + Path t = target.resolve(i.path); + pv.before.put(i.path, Files.isRegularFile(t) ? Files.readAllBytes(t) : null); + Path from = sourceWritten.resolve(i.path); + Path w = work.resolve(i.path); + if (Files.isRegularFile(from)) { + Files.createDirectories(w.getParent()); + Files.copy(from, w, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } else { + Files.deleteIfExists(w); + } + } + DriverSet after = AsCodeReader.read(work); + restoreValues(after, s, Set.of()); + Map produced = Sides.written(after); + for (ProjectCompare.Item i : pv.compare.items) { + if (!i.value) { + pv.after.put(i.path, produced.get(i.path)); + } + } + return pv; + } finally { + Sides.delete(sourceWritten); + Sides.delete(work); + } + } + /** The manifest that lists a tree file. */ static String manifestOf(String path) { String[] parts = path.split("/"); - if (parts.length >= 3 && parts[0].equals("drivers")) { + if (parts.length >= 3 && parts[0].equals("drivers") && !(parts.length == 3 && parts[2].equals(AsCodeWriter.DRIVER_MANIFEST))) { return "drivers/" + parts[1] + "/" + AsCodeWriter.DRIVER_MANIFEST; } - if (parts.length >= 2 && parts[0].equals("library")) { + if (parts.length >= 2 && parts[0].equals("library") && !path.equals("library/" + AsCodeWriter.LIBRARY_MANIFEST)) { return "library/" + AsCodeWriter.LIBRARY_MANIFEST; } - return AsCodeWriter.DRIVERSET_MANIFEST; + return AsCodeWriter.DRIVERSET_MANIFEST; // the drivers, the Library and the set's own objects } private static List newErrors(Report before, Report after) { diff --git a/src/test/java/com/pointblue/dirxml/dev/promote/PromoteTest.java b/src/test/java/com/pointblue/dirxml/dev/promote/PromoteTest.java index 607c7ee..b8ff25b 100644 --- a/src/test/java/com/pointblue/dirxml/dev/promote/PromoteTest.java +++ b/src/test/java/com/pointblue/dirxml/dev/promote/PromoteTest.java @@ -286,6 +286,37 @@ public void aMergedFileIsRecordedAsTheTargetHasIt() throws Exception { assertEquals("still differs, but as the promotion left it", ProjectCompare.Bucket.UNCHANGED, compare().item(POLICY).bucket); } + @Test + public void aNewDriversManifestIsListedByTheDriverSet() { + assertEquals("drivers/AD/driver.xml", Promote.manifestOf("drivers/AD/subscriber/sub-ctp.policy.xml")); + assertEquals("drivers/AD/driver.xml", Promote.manifestOf("drivers/AD/config-values.xml")); + assertEquals("driverset.xml", Promote.manifestOf("drivers/AD/driver.xml")); + assertEquals("library/library.xml", Promote.manifestOf("library/lib-shared.policy.xml")); + assertEquals("driverset.xml", Promote.manifestOf("library/library.xml")); + assertEquals("driverset.xml", Promote.manifestOf("jobs/StatisticsJob.xml")); + } + + @Test + public void aPreviewShowsEachFileAsAPromotionWritesIt() throws Exception { + setUp(); + edit(qaTree, ds -> { + setPolicy(ds, "AD QA", "sub-ctp", "qa changed it"); + Driver d = ds.driver("AD QA"); + d.subscriber.policies.add(new Policy("sub-new", Scope.SUBSCRIBER, "AD QA", ValidatorTest.xml(rule("new")))); + d.links.add(new PolicyLink(PolicySet.SUB_COMMAND, "drivers/AD QA/subscriber/sub-new", 1)); + }); + Promote.Preview pv = Promote.preview(prodTree, qaTree, "acme-qa"); + String added = "drivers/AD/subscriber/sub-new.policy.xml"; + assertNull("not in the target yet", pv.before.get(added)); + assertTrue(new String(pv.after.get(added), StandardCharsets.UTF_8).contains("new")); + assertTrue(new String(pv.before.get(POLICY), StandardCharsets.UTF_8).contains("r1")); + assertTrue(new String(pv.after.get(POLICY), StandardCharsets.UTF_8).contains("qa changed it")); + String manifest = new String(pv.after.get(MANIFEST), StandardCharsets.UTF_8); + assertTrue(manifest, manifest.contains("sub-new") && manifest.contains("cn=AD,cn=dvs,o=system")); + assertFalse("the value stays the target's: config-values.xml doesn't differ", pv.after.containsKey("drivers/AD/config-values.xml")); + assertFalse("a preview writes nothing", Files.exists(prodTree.resolve(added))); + } + @Test public void aPromotionThatWouldNotValidateIsRefused() throws Exception { setUp();