diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb1dec..31c53d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Added + +- **Cases move with their driver** (docs/promotion.md): when both projects have simulator cases, + each case's files compare as `cases/`, its `driver=` read as the target names the driver. + Choosing a driver's directory brings its cases. `--cases` and `--source-cases`; by default + `/cases`, or `cases/` beside a `tree/` directory. +- **`promote.map`:** a proposed map, printed. It covers the look-alike drivers, and each source + server onto the target server with the nearest name when the map names none. +- **`promote.pair` choices:** `--propose-map` writes the proposal and pairs with it, `--pending` + leaves a difference pending, `--accept` accepts one as known. +- **API:** `Projects` (both trees, the pair, each side's cases, and a map not written yet) for + `ProjectCompare.of`, `Promote.run`, `preview`, `pair` (with `Pairing`) and `accept` (several + at once). + ## [0.54.0] - 2026-10-26 ### Added diff --git a/docs/promotion.md b/docs/promotion.md index b022199..b2a8ee7 100644 --- a/docs/promotion.md +++ b/docs/promotion.md @@ -68,6 +68,12 @@ written as files again. Then: - **Values** are compared on their own, key by key, as an overrides file keys them (`drivers/AD.gcv.drv.users`). Only values both sides hold are listed. A value only one side has comes with its file. +- **Cases** are compared too, when both projects have them: each file of a + simulator case compares as `cases/`, and a case's `driver=` is read + as the target names the driver. A case belongs to its driver: choosing a + driver's directory (`--object drivers/AD`) brings its cases with it. By + default the cases are `/cases`, or `cases/` beside a `tree/` directory; + `--cases` and `--source-cases` name others. - Not compared: `overrides/`, `promotions/` and anything starting with `.` (`.package-baseline/`, `.gitattributes`). A promoted file's package baseline goes with it. @@ -136,19 +142,41 @@ target's name, or null), `bucket`, `state` (`differs`, `only in the source`, and either `detail` (the changed lines, target `-` to source `+`, with two lines of context) or `source` and `target` (the two values). +### `promote.map` + +``` +idm promote.map --from [--name ] +``` + +Prints a map to start from, in the map file's form, and writes nothing. It +starts from the pair's own lines, if any. Then: + +- a `drivers/.name = ` line for each source + driver that looks like one only the target has (the same shim, a similar name, + policies in common); +- when the map names no server and the two sides' servers differ, each source + server onto the target server with the nearest name. + +Review it before you use it. + ### `promote.pair` ``` -idm promote.pair --from [--name ] [--dry-run] [--force] [--by ] [--json] +idm promote.pair --from [--name ] [--propose-map] [--pending ]… [--accept ]… [--dry-run] [--force] [--by ] [--json] ``` Takes the first record: every file and value of both sides as they are now, -including the ones that differ. From then on only new changes show. Write the -map first. Refused if the pair already has a record, unless `--force`, which -starts the record over. +including the ones that differ. From then on only new changes show. Refused if +the pair already has a record, unless `--force`, which starts the record over. + +| Option | Accepted values | +|---|---| +| `--propose-map` | writes `promote.map`'s proposal as the map and pairs with it | +| `--pending` | a difference to leave pending: recorded as the target has it, so the source's side shows as changed. Repeatable | +| `--accept` | a difference to accept as known (`intended`). Repeatable | ```bash -bin/idm promote.pair acme-prod/tree --from acme-qa/tree --by jdoe +bin/idm promote.pair acme-prod/tree --from acme-qa/tree --propose-map --pending "drivers/AD/subscriber/sub-ctp.policy.xml" --by jdoe ``` ### `promote.plan` and `promote.apply` @@ -223,7 +251,6 @@ bin/idm promote.accept acme-prod/tree --from acme-qa/tree --object "drivers/AD/s ## Limits -- **Cases:** `cases/` are not promoted yet. - **Environment lists:** a driver's or object's `environments` and `planned` lists move with its manifest as they are. - **Renaming a driver in the map:** the record names objects by the target's diff --git a/src/main/java/com/pointblue/dirxml/dev/promote/Pair.java b/src/main/java/com/pointblue/dirxml/dev/promote/Pair.java index d004a4e..da205a6 100644 --- a/src/main/java/com/pointblue/dirxml/dev/promote/Pair.java +++ b/src/main/java/com/pointblue/dirxml/dev/promote/Pair.java @@ -78,6 +78,20 @@ public static Pair read(Path target, String name) throws IOException { return new Pair(name, map, known, record); } + /** The same pair with another map: one proposed and not written yet. */ + public Pair withMap(Map other) { + return new Pair(name, new LinkedHashMap<>(other), known, record); + } + + /** The map as its file holds it, one {@code key = value} line each. */ + public static String formatMap(Map map) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : map.entrySet()) { + sb.append(e.getKey()).append(" = ").append(e.getValue()).append('\n'); + } + return sb.toString(); + } + /** The files this pair keeps, tree-relative. */ public static List files(String name) { return List.of(DIR + "/" + name + Overrides.EXT, DIR + "/" + name + ".known.json", DIR + "/" + name + ".json"); diff --git a/src/main/java/com/pointblue/dirxml/dev/promote/ProjectCompare.java b/src/main/java/com/pointblue/dirxml/dev/promote/ProjectCompare.java index 9f35086..a59dd6f 100644 --- a/src/main/java/com/pointblue/dirxml/dev/promote/ProjectCompare.java +++ b/src/main/java/com/pointblue/dirxml/dev/promote/ProjectCompare.java @@ -113,8 +113,12 @@ private ProjectCompare(String pair, Path target, Path source, boolean paired) { } public static ProjectCompare of(Path target, Path source, String pairName) throws IOException { - Pair pair = Pair.read(target, pairName); - return of(Sides.read(target, source, pair.map), pair); + return of(Projects.of(target, source, pairName)); + } + + public static ProjectCompare of(Projects projects) throws IOException { + Pair pair = projects.readPair(); + return of(projects.sides(pair), pair); } static ProjectCompare of(Sides s, Pair pair) { @@ -149,6 +153,57 @@ static ProjectCompare of(Sides s, Pair pair) { return c; } + /** + * A map to start a pair from: the pair's own lines, then for each source driver that looks like one only the target + * has ({@link EnvironmentCompare#matches}) a {@code drivers/.name = } line, and, when the map + * names no server and the two sides' servers differ, each source server onto the target server whose name is + * nearest. Proposed, never written: a person reviews it. + */ + public static Map proposedMap(Projects projects) throws IOException { + Pair pair = projects.readPair(); + Sides s = projects.sides(pair); + Map out = new LinkedHashMap<>(pair.map); + for (EnvironmentCompare.Match m : EnvironmentCompare.matches(s.source, s.target, pair.name, Pair.nameOf(s.targetTree))) { + out.putIfAbsent("drivers/" + m.a + "." + com.pointblue.dirxml.dev.deploy.Overrides.NAME, m.b); + } + boolean serversMapped = out.keySet().stream().anyMatch(com.pointblue.dirxml.dev.deploy.ServerMap::isKey); + if (!serversMapped && !s.target.servers.isEmpty()) { + for (String server : s.source.servers) { + if (s.target.servers.stream().anyMatch(server::equalsIgnoreCase)) { + continue; + } + String name = com.pointblue.dirxml.dev.deploy.Servers.dirName(server); + String best = null; + int bestDistance = Integer.MAX_VALUE; + for (String t : s.target.servers) { + int d = distance(name, com.pointblue.dirxml.dev.deploy.Servers.dirName(t)); + if (d < bestDistance) { + best = t; + bestDistance = d; + } + } + out.put(com.pointblue.dirxml.dev.deploy.ServerMap.PREFIX + name, best); + } + } + return out; + } + + private static int distance(String a, String b) { + int[] prev = new int[b.length() + 1]; + for (int j = 0; j <= b.length(); j++) { + prev[j] = j; + } + for (int i = 1; i <= a.length(); i++) { + int[] cur = new int[b.length() + 1]; + cur[0] = i; + for (int j = 1; j <= b.length(); j++) { + cur[j] = Math.min(Math.min(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + (a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1)); + } + prev = cur; + } + return prev[b.length()]; + } + /** A value's fingerprint: a value the model holds as none is a value too. */ static String valuePrint(String v) { return PromotionRecord.fingerprint(v == null ? "\u0000none" : v); diff --git a/src/main/java/com/pointblue/dirxml/dev/promote/Projects.java b/src/main/java/com/pointblue/dirxml/dev/promote/Projects.java new file mode 100644 index 0000000..a3f18bc --- /dev/null +++ b/src/main/java/com/pointblue/dirxml/dev/promote/Projects.java @@ -0,0 +1,51 @@ +package com.pointblue.dirxml.dev.promote; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * The two projects of a comparison or a promotion (docs/promotion.md): the target's and the source's trees, the pair's + * name, each side's simulator cases (or none), and a map to use in place of the pair's file (a map proposed and not yet + * written), or null. + */ +public record Projects(Path target, Path source, String pair, Path targetCases, Path sourceCases, Map map) { + + public static Projects of(Path target, Path source, String pair) { + return new Projects(target, source, pair, null, null, null); + } + + public Projects withCases(Path targetCases, Path sourceCases) { + return new Projects(target, source, pair, targetCases, sourceCases, map); + } + + public Projects withMap(Map map) { + return new Projects(target, source, pair, targetCases, sourceCases, map); + } + + /** A tree's cases by the usual layouts: {@code /cases}, or {@code cases/} beside a {@code tree/} directory; null when neither exists. */ + public static Path casesOf(Path tree) { + Path inside = tree.resolve("cases"); + if (Files.isDirectory(inside)) { + return inside; + } + Path abs = tree.toAbsolutePath().normalize(); + if (abs.getFileName() != null && abs.getFileName().toString().equals("tree") && abs.getParent() != null) { + Path beside = abs.getParent().resolve("cases"); + if (Files.isDirectory(beside)) { + return beside; + } + } + return null; + } + + Pair readPair() throws IOException { + Pair p = Pair.read(target, pair); + return map == null ? p : p.withMap(map); + } + + Sides sides(Pair p) throws IOException { + return Sides.read(target, source, p.map, targetCases, sourceCases); + } +} 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 f39394c..8523cf3 100644 --- a/src/main/java/com/pointblue/dirxml/dev/promote/Promote.java +++ b/src/main/java/com/pointblue/dirxml/dev/promote/Promote.java @@ -144,15 +144,20 @@ public String json() { /** {@code promote.apply}, or with {@code dryRun} {@code promote.plan}: everything but the writing. */ public static Result run(Path target, Path source, String pairName, Selection sel, boolean dryRun, boolean force, String by) throws IOException { - return run(target, source, pairName, sel, dryRun, force, by, Validator.standard()); + return run(Projects.of(target, source, pairName), sel, dryRun, force, by); } - static Result run(Path target, Path source, String pairName, Selection sel, boolean dryRun, boolean force, String by, Validator validator) - throws IOException { + public static Result run(Projects projects, Selection sel, boolean dryRun, boolean force, String by) throws IOException { + return run(projects, sel, dryRun, force, by, Validator.standard()); + } + + static Result run(Projects projects, Selection sel, boolean dryRun, boolean force, String by, Validator validator) throws IOException { + Path target = projects.target(); + Path source = projects.source(); Result r = new Result(dryRun ? "promote.plan" : "promote.apply"); r.dryRun = dryRun; - Pair pair = Pair.read(target, pairName); - Sides s = Sides.read(target, source, pair.map); + Pair pair = projects.readPair(); + Sides s = projects.sides(pair); ProjectCompare cmp = ProjectCompare.of(s, pair); r.notes.addAll(s.notes); @@ -160,9 +165,12 @@ static Result run(Path target, Path source, String pairName, Selection sel, bool Set chosen = new TreeSet<>(); for (String o : sel.objects) { String p = o.endsWith("/") ? o.substring(0, o.length() - 1) : o; + // a driver's directory brings the driver's cases too + String driver = p.matches("drivers/[^/]+") ? s.driverOf(p + "/") : null; List hit = new ArrayList<>(); for (ProjectCompare.Item i : cmp.items) { - if (!i.value && (i.path.equals(p) || i.path.startsWith(p + "/"))) { + if (!i.value && (i.path.equals(p) || i.path.startsWith(p + "/") + || (driver != null && i.path.startsWith(Sides.CASES) && driver.equals(i.driver)))) { hit.add(i.path); } } @@ -218,6 +226,9 @@ static Result run(Path target, Path source, String pairName, Selection sel, bool copyTree(target, work); Set present = new HashSet<>(); for (String p : chosen) { + if (p.startsWith(Sides.CASES)) { + continue; // cases live beside the tree: written below + } Path w = work.resolve(p); Path from = sourceWritten.resolve(p); if (sel.contents.containsKey(p)) { @@ -254,6 +265,9 @@ static Result run(Path target, Path source, String pairName, Selection sel, bool AsCodeWriter.write(after, out); Map produced = Sides.read(out); for (String p : chosen) { + if (p.startsWith(Sides.CASES)) { + continue; + } boolean wanted = present.contains(p); if (wanted && !produced.containsKey(p)) { r.refusal = p + " isn't part of the target's model once promoted: " + manifestOf(p) + " doesn't list it. Promote " @@ -294,7 +308,34 @@ static Result run(Path target, Path source, String pairName, Selection sel, bool } } } + // a case's files, into the target's cases: the source's (its driver= as the target names it), or none + Map caseFiles = new TreeMap<>(); + for (String p : chosen) { + if (!p.startsWith(Sides.CASES)) { + continue; + } + byte[] bytes = sel.contents.containsKey(p) ? sel.contents.get(p).getBytes(StandardCharsets.UTF_8) : s.sourceFiles.get(p); + caseFiles.put(p, bytes); + Path t = s.targetCases.resolve(p.substring(Sides.CASES.length())); + if (bytes != null) { + if (!Files.isRegularFile(t) || !Arrays.equals(Files.readAllBytes(t), bytes)) { + r.changedFiles.add(p); + if (!dryRun) { + Files.createDirectories(t.getParent()); + Files.write(t, bytes); + } + } + } else if (Files.isRegularFile(t)) { + r.deletedFiles.add(p); + if (!dryRun) { + Files.delete(t); + } + } + } for (String p : chosen) { + if (p.startsWith(Sides.CASES)) { + continue; + } String rel = AsCodeWriter.BASELINE_DIR + "/" + p; Path w = work.resolve(rel); Path t = target.resolve(rel); @@ -321,8 +362,9 @@ static Result run(Path target, Path source, String pairName, Selection sel, bool Map afterValues = com.pointblue.dirxml.dev.deploy.EnvironmentCompare.values(after); String at = now(); for (String p : chosen) { + byte[] now = p.startsWith(Sides.CASES) ? caseFiles.get(p) : afterFiles.get(p); pair.record.objects.put(p, new PromotionRecord.Entry(PromotionRecord.fingerprint(s.sourceFiles.get(p)), - PromotionRecord.fingerprint(afterFiles.get(p)), r.sourceCommit, at, by)); + PromotionRecord.fingerprint(now), r.sourceCommit, at, by)); } for (String k : values) { pair.record.values.put(k, new PromotionRecord.Entry(ProjectCompare.valuePrint(s.sourceValues.get(k)), @@ -380,8 +422,13 @@ public static final class Preview { * 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); + return preview(Projects.of(target, source, pairName)); + } + + public static Preview preview(Projects projects) throws IOException { + Path target = projects.target(); + Pair pair = projects.readPair(); + Sides s = projects.sides(pair); Preview pv = new Preview(ProjectCompare.of(s, pair)); Path sourceWritten = Files.createTempDirectory("idm-promote-src"); Path work = Files.createTempDirectory("idm-promote-work"); @@ -392,6 +439,11 @@ public static Preview preview(Path target, Path source, String pairName) throws if (i.value) { continue; } + if (i.path.startsWith(Sides.CASES)) { + pv.before.put(i.path, s.targetFiles.get(i.path)); // a case's files are its own: no values to set aside + pv.after.put(i.path, s.sourceFiles.get(i.path)); + 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); @@ -407,7 +459,7 @@ public static Preview preview(Path target, Path source, String pairName) throws restoreValues(after, s, Set.of()); Map produced = Sides.written(after); for (ProjectCompare.Item i : pv.compare.items) { - if (!i.value) { + if (!i.value && !i.path.startsWith(Sides.CASES)) { pv.after.put(i.path, produced.get(i.path)); } } @@ -473,70 +525,143 @@ private static String now() { * only new changes show. Refused when the pair has a record already, unless {@code force} (which starts it over). */ public static Result pair(Path target, Path source, String pairName, boolean dryRun, boolean force, String by) throws IOException { + return pair(Projects.of(target, source, pairName), new Pairing(), dryRun, force, by); + } + + /** What a person settled when pairing: the map to write, the differences left pending, those accepted as known. */ + public static final class Pairing { + /** Written as {@code promotions/.properties} when not null (else the pair's file, as it is, is used). */ + public Map map; + /** The map as a person wrote it, comments and all: written as it is, and read for {@link #map}. */ + public String mapText; + /** Differences (files or values) left to promote: recorded as the target has them, the source's side unknown. */ + public final Set pending = new TreeSet<>(); + /** Differences accepted as they are: path to {@code {decision, note}}. */ + public final Map known = new LinkedHashMap<>(); + } + + /** + * {@code promote.pair}: the first record. Every file and value of both sides is recorded as it is now, so only new + * changes show from here on, but for the differences {@code choices} leaves pending, which show as pending, and the + * ones it accepts as known. With a map in {@code choices}, the map is written too and the comparison uses it. + */ + public static Result pair(Projects projects, Pairing choices, boolean dryRun, boolean force, String by) throws IOException { + Path target = projects.target(); Result r = new Result("promote.pair"); r.dryRun = dryRun; - Pair pair = Pair.read(target, pairName); + if (choices.mapText != null) { + choices.map = Overrides.parse(choices.mapText); + } + Projects p = choices.map == null ? projects : projects.withMap(choices.map); + Pair pair = p.readPair(); if (!pair.record.isEmpty() && !force) { r.refusal = "'" + pair.name + "' is paired already (" + Pair.DIR + "/" + pair.name + ".json); --force starts its record over"; return r; } - Sides s = Sides.read(target, source, pair.map); + Sides s = p.sides(pair); + ProjectCompare cmp = ProjectCompare.of(s, pair); + for (String path : choices.pending) { + if (cmp.item(path) == null) { + r.refusal = "nothing differs at '" + path + "' to leave pending"; + return r; + } + } r.notes.addAll(s.notes); - r.sourceCommit = DeployLog.treeCommit(source); + r.sourceCommit = DeployLog.treeCommit(projects.source()); pair.record.objects.clear(); pair.record.values.clear(); String at = now(); TreeSet paths = new TreeSet<>(s.targetFiles.keySet()); paths.addAll(s.sourceFiles.keySet()); int differ = 0; - for (String p : paths) { - byte[] src = s.sourceFiles.get(p); - byte[] tgt = s.targetFiles.get(p); - pair.record.objects.put(p, new PromotionRecord.Entry(PromotionRecord.fingerprint(src), PromotionRecord.fingerprint(tgt), r.sourceCommit, at, by)); + for (String path : paths) { + byte[] src = s.sourceFiles.get(path); + byte[] tgt = s.targetFiles.get(path); + // left pending: the source's side as before pairing (no fingerprint matches it), so its change shows + String sourcePrint = choices.pending.contains(path) ? BEFORE_PAIRING : PromotionRecord.fingerprint(src); + pair.record.objects.put(path, new PromotionRecord.Entry(sourcePrint, PromotionRecord.fingerprint(tgt), r.sourceCommit, at, by)); if (src == null || tgt == null || !Arrays.equals(src, tgt)) { differ++; } } for (Map.Entry e : s.targetValues.entrySet()) { if (s.sourceValues.containsKey(e.getKey())) { - pair.record.values.put(e.getKey(), new PromotionRecord.Entry(ProjectCompare.valuePrint(s.sourceValues.get(e.getKey())), - ProjectCompare.valuePrint(e.getValue()), r.sourceCommit, at, by)); + String sourcePrint = choices.pending.contains(e.getKey()) ? BEFORE_PAIRING : ProjectCompare.valuePrint(s.sourceValues.get(e.getKey())); + pair.record.values.put(e.getKey(), new PromotionRecord.Entry(sourcePrint, ProjectCompare.valuePrint(e.getValue()), r.sourceCommit, at, by)); } } - r.notes.add(paths.size() + " file(s) and " + pair.record.values.size() + " value(s) recorded; " + differ - + " file(s) differ and are taken as they are"); + String refusal = acceptInto(pair, cmp, choices.known, by, r); + if (refusal != null) { + r.refusal = refusal; + return r; + } + r.notes.add(paths.size() + " file(s) and " + pair.record.values.size() + " value(s) recorded; " + differ + " file(s) differ: " + + choices.pending.size() + " left pending, " + choices.known.size() + " accepted as known, the rest taken as they are"); + if (choices.map != null) { + r.changedFiles.add(Pair.DIR + "/" + pair.name + Overrides.EXT); + } r.changedFiles.add(Pair.DIR + "/" + pair.name + ".json"); + if (!choices.known.isEmpty()) { + r.changedFiles.add(Pair.DIR + "/" + pair.name + ".known.json"); + } if (!dryRun) { + if (choices.map != null) { + Files.createDirectories(target.resolve(Pair.DIR)); + Files.writeString(target.resolve(Pair.DIR).resolve(pair.name + Overrides.EXT), + choices.mapText != null ? choices.mapText : Pair.formatMap(choices.map), StandardCharsets.UTF_8); + } pair.write(target); } r.written = !dryRun; return r; } + /** A source fingerprint no content has: a difference left pending at pairing. */ + static final String BEFORE_PAIRING = "before-pairing"; + + /** Each decision into the pair's known differences; the refusal, or null. */ + private static String acceptInto(Pair pair, ProjectCompare cmp, Map decisions, String by, Result r) { + for (Map.Entry e : decisions.entrySet()) { + String path = e.getKey(); + String d = e.getValue().length > 0 && e.getValue()[0] != null ? e.getValue()[0] : "intended"; + String note = e.getValue().length > 1 ? e.getValue()[1] : null; + if (!d.equals("intended") && !d.equals("later")) { + return "a decision is intended (different on purpose) or later (to decide), not '" + d + "'"; + } + ProjectCompare.Item i = cmp.item(path); + if (i == null) { + return "nothing differs at '" + path + "'"; + } + pair.known.removeIf(k -> k.path.equals(path)); + pair.known.add(new KnownDifference(pair.name, path, i.fingerprint, d, (i.value ? "value " : "") + path + ": " + i.state(), note, + by == null ? "" : by, now())); + r.promoted.add(path); + } + return null; + } + /** {@code promote.accept}: a difference accepted as it is, until either side changes it. */ public static Result accept(Path target, Path source, String pairName, String path, String decision, String note, String by, boolean dryRun) throws IOException { + Map one = new LinkedHashMap<>(); + one.put(path, new String[] {decision, note}); + return accept(Projects.of(target, source, pairName), one, by, dryRun); + } + + /** {@code promote.accept} for several differences at once: path to {@code {decision, note}}. */ + public static Result accept(Projects projects, Map decisions, String by, boolean dryRun) throws IOException { Result r = new Result("promote.accept"); r.dryRun = dryRun; - String d = decision == null ? "intended" : decision; - if (!d.equals("intended") && !d.equals("later")) { - r.refusal = "--decision is intended (different on purpose) or later (to decide)"; + Pair pair = projects.readPair(); + ProjectCompare cmp = ProjectCompare.of(projects.sides(pair), pair); + String refusal = acceptInto(pair, cmp, decisions, by, r); + if (refusal != null) { + r.refusal = refusal; return r; } - Pair pair = Pair.read(target, pairName); - ProjectCompare cmp = ProjectCompare.of(Sides.read(target, source, pair.map), pair); - ProjectCompare.Item i = cmp.item(path); - if (i == null) { - r.refusal = "nothing differs at '" + path + "'"; - return r; - } - pair.known.removeIf(k -> k.path.equals(path)); - pair.known.add(new KnownDifference(pair.name, path, i.fingerprint, d, (i.value ? "value " : "") + path + ": " + i.state(), note, - by == null ? "" : by, now())); - r.promoted.add(path); r.changedFiles.add(Pair.DIR + "/" + pair.name + ".known.json"); if (!dryRun) { - pair.write(target); + pair.write(projects.target()); } r.written = !dryRun; return r; diff --git a/src/main/java/com/pointblue/dirxml/dev/promote/PromoteCli.java b/src/main/java/com/pointblue/dirxml/dev/promote/PromoteCli.java index 06b11d9..882ad63 100644 --- a/src/main/java/com/pointblue/dirxml/dev/promote/PromoteCli.java +++ b/src/main/java/com/pointblue/dirxml/dev/promote/PromoteCli.java @@ -28,9 +28,11 @@ public static boolean handles(String command) { "usage: tree.compare --with [--name ] [--json]\n" + " promote.plan --from [--name ] [--object ]… [--value ]… [--pending] [--contents ] [--json]\n" + " promote.apply --from [--name ] [--object ]… [--value ]… [--pending] [--contents ] [--force] [--by ] [--json]\n" - + " promote.pair --from [--name ] [--dry-run] [--force] [--by ] [--json]\n" + + " promote.map --from [--name ] (prints a proposed map; writes nothing)\n" + + " promote.pair --from [--name ] [--propose-map] [--pending ]… [--accept ]… [--dry-run] [--force] [--by ] [--json]\n" + " promote.accept --from [--name ] --object [--decision intended|later] [--note ] [--by ] [--dry-run] [--json]\n" - + " promote.forget (--from | --name ) --object [--dry-run] [--json]\n"; + + " promote.forget (--from | --name ) --object [--dry-run] [--json]\n" + + " each takes [--cases ] [--source-cases ]; by default /cases or cases/ beside a tree/ directory\n"; public static int run(String[] argv) throws Exception { String cmd = argv[0]; @@ -44,13 +46,23 @@ public static int run(String[] argv) throws Exception { String decision = null; String note = null; Promote.Selection sel = new Promote.Selection(); + Promote.Pairing pairing = new Promote.Pairing(); + boolean proposeMap = false; + Path cases = null; + Path sourceCases = null; for (int i = 1; i < argv.length; i++) { String a = argv[i]; switch (a) { case "--json": json = true; continue; case "--dry-run": dryRun = true; continue; case "--force": force = true; continue; - case "--pending": sel.pending = true; continue; + case "--pending": + if (cmd.equals("promote.pair")) { + break; // promote.pair's --pending names a difference + } + sel.pending = true; + continue; + case "--propose-map": proposeMap = true; continue; default: } if (!a.startsWith("--")) { @@ -72,6 +84,10 @@ public static int run(String[] argv) throws Exception { case "--by" -> by = v; case "--decision" -> decision = v; case "--note" -> note = v; + case "--pending" -> pairing.pending.add(v); + case "--accept" -> pairing.known.put(v, new String[] {"intended", null}); + case "--cases" -> cases = Paths.get(v); + case "--source-cases" -> sourceCases = Paths.get(v); case "--contents" -> { for (Map.Entry e : Json.asMap(Json.parse(Files.readString(Paths.get(v), StandardCharsets.UTF_8))).entrySet()) { sel.contents.put(e.getKey(), Json.asString(e.getValue())); @@ -97,27 +113,44 @@ public static int run(String[] argv) throws Exception { if (!Pair.validName(name)) { return usage("a pair's name is letters, digits, '.', '_' and '-' (got '" + name + "'): give one with --name"); } + Projects projects = Projects.of(target, source, name); + if (source != null) { + Path tc = cases != null ? cases : Projects.casesOf(target); + Path sc = sourceCases != null ? sourceCases : Projects.casesOf(source); + if (tc != null && sc != null) { + projects = projects.withCases(tc, sc); + } + } switch (cmd) { + case "promote.map": { + System.out.print(Pair.formatMap(ProjectCompare.proposedMap(projects))); + return 0; + } case "tree.compare": { - ProjectCompare c = ProjectCompare.of(target, source, name); + ProjectCompare c = ProjectCompare.of(projects); System.out.print(json ? c.json() + "\n" : c.text()); return c.hasOpen() ? 1 : 0; } case "promote.plan": case "promote.apply": { - Promote.Result r = Promote.run(target, source, name, sel, cmd.equals("promote.plan") || dryRun, force, by); + Promote.Result r = Promote.run(projects, sel, cmd.equals("promote.plan") || dryRun, force, by); return print(r, json); } case "promote.pair": - return print(Promote.pair(target, source, name, dryRun, force, by), json); + if (proposeMap) { + pairing.map = ProjectCompare.proposedMap(projects); + } + return print(Promote.pair(projects, pairing, dryRun, force, by), json); case "promote.accept": case "promote.forget": { List objects = new ArrayList<>(sel.objects); if (objects.size() != 1) { return usage("--object names the one difference"); } + Map one = new java.util.LinkedHashMap<>(); + one.put(objects.get(0), new String[] {decision, note}); Promote.Result r = cmd.equals("promote.accept") - ? Promote.accept(target, source, name, objects.get(0), decision, note, by, dryRun) + ? Promote.accept(projects, one, by, dryRun) : Promote.forget(target, name, objects.get(0), dryRun); return print(r, json); } diff --git a/src/main/java/com/pointblue/dirxml/dev/promote/Sides.java b/src/main/java/com/pointblue/dirxml/dev/promote/Sides.java index 55806d9..4dd4f59 100644 --- a/src/main/java/com/pointblue/dirxml/dev/promote/Sides.java +++ b/src/main/java/com/pointblue/dirxml/dev/promote/Sides.java @@ -38,6 +38,9 @@ final class Sides { final Map sourceFiles; final Map targetValues; final Map sourceValues; + /** Each side's simulator cases directory, or null: its files compare as {@code cases/}, with the trees' files. */ + Path targetCases; + Path sourceCases; /** The target's driver name to the source's, for the drivers the map renames. */ final Map sourceNames = new TreeMap<>(); final List notes = new ArrayList<>(); @@ -55,6 +58,14 @@ private Sides(Path targetTree, Path sourceTree, DriverSet target, DriverSet sour } static Sides read(Path targetTree, Path sourceTree, Map map) throws IOException { + return read(targetTree, sourceTree, map, null, null); + } + + /** + * With each side's cases: a case's files compare beside the tree's, as {@code cases/}, its + * {@code case.properties} naming the driver as the target does. Either cases directory may be null (no cases). + */ + static Sides read(Path targetTree, Path sourceTree, Map map, Path targetCases, Path sourceCases) throws IOException { DriverSet target = AsCodeReader.read(targetTree); DriverSet source = AsCodeReader.read(sourceTree); List notes = new ArrayList<>(); @@ -67,9 +78,80 @@ static Sides read(Path targetTree, Path sourceTree, Map map) thr Sides s = new Sides(targetTree, sourceTree, target, source, files(targetNeutral), files(sourceNeutral)); s.notes.addAll(notes); renamed.forEach((from, to) -> s.sourceNames.put(to, from)); + if (targetCases != null && sourceCases != null) { + s.targetCases = targetCases; + s.sourceCases = sourceCases; + s.targetFiles.putAll(cases(targetCases, Map.of())); + s.sourceFiles.putAll(cases(sourceCases, renamed)); + } return s; } + /** A tree path's prefix for the files of the simulator's cases, which live beside the tree. */ + static final String CASES = "cases/"; + + /** Every file under a cases directory, as {@code cases/}; a case's {@code driver=} renamed through {@code renames}. */ + static Map cases(Path dir, Map renames) throws IOException { + Map out = new TreeMap<>(); + if (!Files.isDirectory(dir)) { + return out; + } + for (Map.Entry e : read(dir).entrySet()) { + if (e.getKey().startsWith(".") || e.getKey().contains("/.")) { + continue; + } + byte[] b = e.getValue(); + if (e.getKey().endsWith("/" + CASE_FILE) || e.getKey().equals(CASE_FILE)) { + b = renameDriver(new String(b, java.nio.charset.StandardCharsets.UTF_8), renames).getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + out.put(CASES + e.getKey(), b); + } + return out; + } + + static final String CASE_FILE = "case.properties"; + + /** A case's {@code driver=} line given the target's name; the rest of the file as it is. */ + static String renameDriver(String caseProperties, Map renames) { + if (renames.isEmpty()) { + return caseProperties; + } + String[] lines = caseProperties.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + java.util.regex.Matcher m = java.util.regex.Pattern.compile("^(\\s*driver\\s*[=:]\\s*)(.*?)(\\s*\r?)$").matcher(lines[i]); + if (m.matches() && renames.containsKey(m.group(2))) { + lines[i] = m.group(1) + renames.get(m.group(2)) + m.group(3); + } + } + return String.join("\n", lines); + } + + /** The driver a case's file belongs to: its case's {@code driver=}, as the target names it; null when none says. */ + String caseDriver(String path) { + String dir = path.substring(0, path.lastIndexOf('/')); + while (dir.startsWith(CASES) || dir.equals("cases")) { + for (Map side : List.of(sourceFiles, targetFiles)) { + byte[] b = side.get(dir + "/" + CASE_FILE); + if (b != null) { + java.util.Properties p = new java.util.Properties(); + try { + p.load(new java.io.StringReader(new String(b, java.nio.charset.StandardCharsets.UTF_8))); + } catch (IOException e) { + return null; + } + String d = p.getProperty("driver"); + return d == null || d.isBlank() ? null : d.trim(); + } + } + int slash = dir.lastIndexOf('/'); + if (slash < 0) { + break; + } + dir = dir.substring(0, slash); + } + return null; + } + /** * The source as the target names it, in place: drivers renamed and servers mapped by the pair's map, and the * target's identity taken. Returns the renames applied (source's name to the target's); problems go to notes. @@ -185,6 +267,9 @@ static void delete(Path dir) throws IOException { /** The driver a tree path or value key belongs to, by the target's name; null for the set's own. */ String driverOf(String pathOrKey) { + if (pathOrKey.startsWith(CASES)) { + return caseDriver(pathOrKey); + } if (!pathOrKey.startsWith("drivers/")) { return null; } 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 b8ff25b..b78f4be 100644 --- a/src/test/java/com/pointblue/dirxml/dev/promote/PromoteTest.java +++ b/src/test/java/com/pointblue/dirxml/dev/promote/PromoteTest.java @@ -19,6 +19,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import static org.junit.Assert.assertEquals; @@ -286,6 +287,90 @@ public void aMergedFileIsRecordedAsTheTargetHasIt() throws Exception { assertEquals("still differs, but as the promotion left it", ProjectCompare.Bucket.UNCHANGED, compare().item(POLICY).bucket); } + private Path prodCases; + private Path qaCases; + + /** Each project's cases beside its tree: one case for the AD driver, as each project names it. */ + private void cases() throws IOException { + prodCases = prodTree.getParent().resolve("cases"); + qaCases = qaTree.getParent().resolve("cases"); + for (Path c : List.of(prodCases.resolve("ad-add"), qaCases.resolve("ad-add"), qaCases.resolve("ad-modify"))) { + Files.createDirectories(c); + } + Files.writeString(prodCases.resolve("ad-add/case.properties"), "driver=AD\nchannel=subscriber\n", StandardCharsets.UTF_8); + Files.writeString(prodCases.resolve("ad-add/input.xml"), "\n", StandardCharsets.UTF_8); + Files.writeString(qaCases.resolve("ad-add/case.properties"), "driver=AD QA\nchannel=subscriber\n", StandardCharsets.UTF_8); + Files.writeString(qaCases.resolve("ad-add/input.xml"), "\n", StandardCharsets.UTF_8); + Files.writeString(qaCases.resolve("ad-modify/case.properties"), "driver = AD QA\nchannel=subscriber\n", StandardCharsets.UTF_8); + Files.writeString(qaCases.resolve("ad-modify/input.xml"), "\n", StandardCharsets.UTF_8); + } + + private Projects projects() { + return Projects.of(prodTree, qaTree, "acme-qa").withCases(prodCases, qaCases); + } + + @Test + public void casesCompareAndMoveWithTheirDriver() throws Exception { + setUp(); + cases(); + assertEquals("the layout found beside tree/", prodCases.toAbsolutePath().normalize(), Projects.casesOf(prodTree)); + ProjectCompare c = ProjectCompare.of(projects()); + assertNull("the same case once QA's driver name is the target's", c.item("cases/ad-add/case.properties")); + ProjectCompare.Item modify = c.item("cases/ad-modify/case.properties"); + assertNotNull(c.text(), modify); + assertEquals("AD", modify.driver); + assertEquals("only in the source", modify.state()); + + // a driver's directory brings its cases + edit(qaTree, ds -> setPolicy(ds, "AD QA", "sub-ctp", "qa changed it")); + Promote.Result r = Promote.run(projects(), objects("drivers/AD"), false, false, "tester"); + assertTrue(r.text(), r.ok()); + assertTrue(r.promoted.toString(), r.promoted.containsAll(List.of(POLICY, "cases/ad-modify/case.properties", "cases/ad-modify/input.xml"))); + assertEquals("driver = AD\nchannel=subscriber\n", Files.readString(prodCases.resolve("ad-modify/case.properties"))); + assertTrue(Files.readString(prodCases.resolve("ad-modify/input.xml")).contains("")); + assertFalse(ProjectCompare.of(projects()).text(), ProjectCompare.of(projects()).hasOpen()); + Promote.Preview pv = Promote.preview(projects()); + assertTrue(pv.after.isEmpty()); + } + + @Test + public void pairingWritesTheMapLeavesSomePendingAndAcceptsOthers() throws Exception { + setUp(); + Files.delete(prodTree.resolve(Pair.DIR).resolve("acme-qa.properties")); + Projects p = Projects.of(prodTree, qaTree, "acme-qa"); + Map proposed = ProjectCompare.proposedMap(p); + assertEquals(proposed.toString(), "AD", proposed.get("drivers/AD QA.name")); + assertEquals(PROD_SERVER, proposed.get("servers.vault01qa")); + + edit(qaTree, ds -> setPolicy(ds, "AD QA", "sub-ctp", "qa changed it")); + edit(prodTree, ds -> setInput(ds, "AD", "prod's own input")); + Promote.Pairing choices = new Promote.Pairing(); + choices.map = proposed; + choices.pending.add(POLICY); + choices.known.put("drivers/AD.gcv.drv.users", new String[] {"intended", "each project's own container"}); + Promote.Result r = Promote.pair(p, choices, false, false, "tester"); + assertTrue(r.text(), r.ok()); + assertTrue(r.changedFiles.toString(), r.changedFiles.containsAll(List.of("promotions/acme-qa.properties", "promotions/acme-qa.json", "promotions/acme-qa.known.json"))); + assertTrue(Files.readString(prodTree.resolve("promotions/acme-qa.properties")).contains("drivers/AD QA.name = AD")); + + ProjectCompare c = compare(); + assertEquals("left pending", ProjectCompare.Bucket.PENDING, c.item(POLICY).bucket); + assertEquals("taken as it is", ProjectCompare.Bucket.UNCHANGED, c.item("drivers/AD/itp.policy.xml").bucket); + assertEquals(ProjectCompare.Bucket.KNOWN, c.item("drivers/AD.gcv.drv.users").bucket); + + Promote.Pairing wrong = new Promote.Pairing(); + wrong.pending.add("drivers/AD/nope.policy.xml"); + assertFalse(Promote.pair(p, wrong, true, true, "tester").ok()); + + // several accepted at once + Map decisions = new java.util.LinkedHashMap<>(); + decisions.put(POLICY, new String[] {"later", null}); + decisions.put("drivers/AD/itp.policy.xml", new String[] {"intended", "prod's input"}); + assertTrue(Promote.accept(p, decisions, "tester", false).ok()); + assertEquals(ProjectCompare.Bucket.KNOWN, compare().item(POLICY).bucket); + assertFalse(Promote.accept(p, Map.of(POLICY, new String[] {"maybe"}), "tester", true).ok()); + } + @Test public void aNewDriversManifestIsListedByTheDriverSet() { assertEquals("drivers/AD/driver.xml", Promote.manifestOf("drivers/AD/subscriber/sub-ctp.policy.xml"));