Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<path>`, its `driver=` read as the target names the driver.
Choosing a driver's directory brings its cases. `--cases` and `--source-cases`; by default
`<tree>/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
Expand Down
39 changes: 33 additions & 6 deletions docs/promotion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<its path>`, 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 `<tree>/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.
Expand Down Expand Up @@ -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 <target> --from <source> [--name <pair>]
```

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/<source's name>.name = <target's 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 <target> --from <source> [--name <pair>] [--dry-run] [--force] [--by <who>] [--json]
idm promote.pair <target> --from <source> [--name <pair>] [--propose-map] [--pending <path or key>]… [--accept <path or key>]… [--dry-run] [--force] [--by <who>] [--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`
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/pointblue/dirxml/dev/promote/Pair.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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<String, String> map) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> 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<String> files(String name) {
return List.of(DIR + "/" + name + Overrides.EXT, DIR + "/" + name + ".known.json", DIR + "/" + name + ".json");
Expand Down
59 changes: 57 additions & 2 deletions src/main/java/com/pointblue/dirxml/dev/promote/ProjectCompare.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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/<source's>.name = <target's>} 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<String, String> proposedMap(Projects projects) throws IOException {
Pair pair = projects.readPair();
Sides s = projects.sides(pair);
Map<String, String> 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);
Expand Down
51 changes: 51 additions & 0 deletions src/main/java/com/pointblue/dirxml/dev/promote/Projects.java
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<String, String> map) {
return new Projects(target, source, pair, targetCases, sourceCases, map);
}

/** A tree's cases by the usual layouts: {@code <tree>/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);
}
}
Loading
Loading