forgedock 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/outcome-codes.mjs +13 -0
  3. package/bin/engine/phases.mjs +54 -4
  4. package/bin/engine/runlog.mjs +10 -2
  5. package/bin/engine/telemetry-seam.mjs +3 -0
  6. package/bin/engine/telemetry.test.mjs +3 -2
  7. package/bin/engine-cli.mjs +32 -2
  8. package/bin/engine.mjs +187 -10
  9. package/bin/runner.mjs +89 -13
  10. package/bin/stats/aggregate.mjs +7 -3
  11. package/bin/stats/cache.mjs +7 -3
  12. package/bin/stats/github-source.mjs +136 -28
  13. package/bin/stats/index.mjs +46 -9
  14. package/bin/stats/render.mjs +1 -1
  15. package/bin/stats/stats.test.mjs +201 -40
  16. package/commands/orchestrate/phase-4-execution.md +1 -1
  17. package/commands/review-pr-agents/protocols.md +19 -0
  18. package/commands/review-pr-staging.md +4 -4
  19. package/commands/review-pr.md +31 -20
  20. package/commands/work-on/build/architect.md +54 -1
  21. package/commands/work-on/build/implement.md +28 -1
  22. package/commands/work-on/build/validate.md +321 -29
  23. package/commands/work-on/build.md +184 -17
  24. package/commands/work-on/close.md +9 -4
  25. package/commands/work-on/investigate.md +7 -0
  26. package/commands/work-on/remediate.md +43 -17
  27. package/commands/work-on/review.md +43 -22
  28. package/commands/work-on.md +4 -4
  29. package/docs/CONFIG.md +93 -2
  30. package/docs/OUTCOME-CODES.md +5 -1
  31. package/docs/RELEASE-NOTES.md +15 -0
  32. package/docs/STATS.md +11 -4
  33. package/docs/TELEMETRY.md +5 -2
  34. package/docs/WORK-ON-RUNTIME.md +29 -3
  35. package/package.json +2 -2
  36. package/packages/protocol/src/types.js +1 -1
  37. package/scripts/acceptance-red-green.sh +90 -0
  38. package/scripts/acceptance-red-green.test.sh +74 -0
  39. package/scripts/architect-resume-match.test.sh +12 -3
  40. package/scripts/build-knowledge-index.mjs +8 -5
  41. package/scripts/builder-resume-match.test.sh +12 -3
  42. package/scripts/calibration.mjs +5 -2
  43. package/scripts/cap-findings.test.sh +2 -0
  44. package/scripts/check-contract-scope.test.sh +2 -0
  45. package/scripts/check-edge-cases.sh +143 -0
  46. package/scripts/check-edge-cases.test.sh +175 -0
  47. package/scripts/check-spec-markers.sh +4 -0
  48. package/scripts/check-test-tmp-cleanup.sh +56 -0
  49. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  50. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  51. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  52. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  53. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  54. package/scripts/ci-local/adapters/common.mjs +115 -0
  55. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  56. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  57. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  58. package/scripts/ci-local/adapters/portability.mjs +104 -0
  59. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  60. package/scripts/ci-local/cli.mjs +50 -0
  61. package/scripts/ci-local/lib.mjs +379 -0
  62. package/scripts/ci-local/plan.mjs +186 -0
  63. package/scripts/ci-local/run.mjs +451 -0
  64. package/scripts/ci-local/scoping.mjs +531 -0
  65. package/scripts/ci-local.sh +98 -0
  66. package/scripts/ci-local.test.sh +1108 -0
  67. package/scripts/classify-finding.sh +33 -7
  68. package/scripts/classify-finding.test.sh +23 -0
  69. package/scripts/classify-test-run.sh +69 -0
  70. package/scripts/classify-test-run.test.sh +90 -0
  71. package/scripts/close-parent-c3.test.sh +2 -0
  72. package/scripts/consumer-sweep.sh +569 -0
  73. package/scripts/consumer-sweep.test.sh +357 -0
  74. package/scripts/detect-append-registries.sh +106 -0
  75. package/scripts/detect-append-registries.test.sh +64 -0
  76. package/scripts/file-deferred-followups.sh +91 -30
  77. package/scripts/file-deferred-followups.test.sh +101 -8
  78. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  79. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  80. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  81. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  82. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  83. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  84. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  85. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  86. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  87. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  88. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  89. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  90. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  91. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  92. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  93. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  94. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  95. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  96. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  97. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  98. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  99. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  100. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  101. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  102. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  103. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  104. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  105. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  106. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  107. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  108. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  109. package/scripts/forge-root.test.sh +1 -1
  110. package/scripts/lib/telemetry.sh +3 -0
  111. package/scripts/remediate-approved-head.test.sh +2 -0
  112. package/scripts/remediate-basesync.test.sh +1 -1
  113. package/scripts/review-acceptance-gate.sh +2 -2
  114. package/scripts/review-acceptance-gate.test.sh +20 -0
  115. package/scripts/review-delta.sh +8 -1
  116. package/scripts/review-delta.test.sh +19 -0
  117. package/scripts/review-finalize.sh +294 -53
  118. package/scripts/review-finalize.test.sh +347 -9
  119. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  120. package/scripts/review-prepare.sh +5 -3
  121. package/scripts/review-prepare.test.sh +18 -2
  122. package/scripts/review-roster.sh +18 -5
  123. package/scripts/review-roster.test.sh +57 -1
  124. package/scripts/run-shell-tests.sh +5 -0
  125. package/scripts/selfreview-plan.sh +86 -0
  126. package/scripts/selfreview-plan.test.sh +90 -0
  127. package/scripts/spawn-depth-check.sh +5 -4
  128. package/scripts/spawn-depth-check.test.sh +3 -0
  129. package/scripts/telemetry.test.sh +10 -0
  130. package/scripts/transition-label.sh +26 -0
  131. package/scripts/trusted-comments.test.sh +2 -1
  132. package/scripts/verify-phase-trail.test.sh +14 -0
  133. package/scripts/wait-ci-green.sh +61 -9
  134. package/scripts/wait-ci-green.test.sh +41 -3
  135. package/scripts/with-run-tmpdir.sh +20 -0
@@ -0,0 +1,13 @@
1
+ name: CI
2
+ on:
3
+ pull_request:
4
+ jobs:
5
+ test-a:
6
+ runs-on: ubuntu-latest
7
+ steps:
8
+ - run: bash packages/a/tests/a.test.sh
9
+ - run: bash packages/b/tests/b.test.sh
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - run: shellcheck -S error packages/a/src/a.sh packages/b/src/b.sh
@@ -0,0 +1 @@
1
+ {"lockfileVersion":1}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ a() { echo a; }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ . "$(dirname "$0")/../src/a.sh"
3
+ [ "$(a)" = a ]
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ b() { echo b; }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ . "$(dirname "$0")/../src/b.sh"
3
+ [ "$(b)" = b ]
@@ -0,0 +1,6 @@
1
+ test:
2
+ @echo make-test
3
+ lint:
4
+ @echo make-lint
5
+ build:
6
+ @echo build
@@ -0,0 +1 @@
1
+ { "name": "noci", "version": "1.0.0", "scripts": { "test": "node --test tests/", "lint": "echo lint-ok" } }
@@ -0,0 +1 @@
1
+ module.exports = 1
@@ -0,0 +1 @@
1
+ module.exports = 2
@@ -0,0 +1,2 @@
1
+ const t = require('node:test');
2
+ t('a', () => require('../src/a.js'));
@@ -0,0 +1,2 @@
1
+ const t = require('node:test');
2
+ t('b', () => require('../src/b.js'));
@@ -464,7 +464,7 @@ for f in $STUB_SITES; do
464
464
  expect "$f: forge_root calls without a preceding locator in the same fence" 0 "$(awk '/^[ ]*```/{loc=0} /^[ ]*type forge_resolve >\/dev\/null/{loc=1} /FORGE_ROOT="\$\(forge_root /&&!loc{bad++} END{print bad+0}' "$ROOT/$f")"
465
465
  done
466
466
  # the spec-count of stub resolutions pins the conversion (a copy dropped or re-inlined changes it)
467
- for pair in work-on.md:3 work-on/review.md:2 work-on/build/validate.md:2 work-on/investigate.md:1 work-on/decompose.md:1 work-on/close.md:1 work-on/build.md:1 quality-gate.md:4 orchestrate/phase-1-resolve.md:2 orchestrate/phase-4-execution.md:8; do
467
+ for pair in work-on.md:3 work-on/review.md:2 work-on/build/validate.md:5 work-on/investigate.md:1 work-on/decompose.md:1 work-on/close.md:1 work-on/build.md:1 quality-gate.md:4 orchestrate/phase-1-resolve.md:2 orchestrate/phase-4-execution.md:8; do
468
468
  f="commands/${pair%%:*}"; n="${pair##*:}"
469
469
  expect "$f: FORGE_ROOT stub resolutions" "$n" "$(grep -cF "$FR_LINE" "$ROOT/$f" || true)"
470
470
  done
@@ -51,6 +51,9 @@ _forge_tel_record() { # event k=v... -> one JSON line on stdout
51
51
  run: ($run | str), phase: ($phase | str), event: $ev, eid: $eid} + $p' --args "${args[@]}"
52
52
  }
53
53
 
54
+ # build_selfreview (forge#3971: tier, agents, findings, fixed, declined, skipped=<reason>; ids and counts only) has no review-side
55
+ # routing: it defaults to the issue target below, like diff_size.
56
+ # finding_disposition payload is generic key=value (incl. scope=introduced|pre-existing|unknown); no per-field allowlist applies.
54
57
  # Prints "<number> <repo>" of the comment target (issue or PR) for this event, or nothing when none is usable.
55
58
  _forge_tel_target() {
56
59
  local ev="$1" repo="$2" n
@@ -5,6 +5,8 @@
5
5
  # SPDX-License-Identifier: AGPL-3.0-or-later
6
6
 
7
7
  set -uo pipefail
8
+ # Every mktemp below lands in one private dir that the EXIT trap removes (forge#4016).
9
+ _SUITE_TMP=$(mktemp -d); export TMPDIR="$_SUITE_TMP"; trap 'rm -rf "$_SUITE_TMP"' EXIT
8
10
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
11
  ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
10
12
  SPEC="$ROOT/commands/work-on/remediate.md"
@@ -66,7 +66,7 @@ eq "MERGE_RC/MERGE_OUT read only in the fence that sets them" "$FENCE_USE" "$FEN
66
66
  has "refused park posts needs-human from the sync fence" 'echo "BLOCKED: base-sync: merge refused'
67
67
 
68
68
  # Behavioural: dirty tree + base change -> refused (rc!=0, no MERGE_HEAD, empty U-list); real conflict -> MERGE_HEAD present.
69
- T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
69
+ T="$(mktemp -d)"; trap 'rm -rf "$T" "$CAPDIR"' EXIT
70
70
  (
71
71
  cd "$T" && git init -q -b main . && git config user.email t@t && git config user.name t
72
72
  echo a > f && git add f && git commit -qm base
@@ -79,12 +79,12 @@ while IFS= read -r check_line; do
79
79
  ERR=$(eval "$TARGET" 2>&1 >/dev/null); RC=$?
80
80
  # grep exit >= 2 is a usage error, not a verdict; only a positively identified usage error (classify-grep-exit.sh) is malformed; a missing operand stays a real FAIL even with stderr suppressed.
81
81
  [ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
82
- [ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -qF 'command not found' && MALFORMED="command not found (rc 127): the target is not an executable command"
82
+ [ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && { FIRST=${TARGET#"${TARGET%%[![:space:]]*}"}; FIRST=${FIRST%%[[:space:]]*}; [ -n "$FIRST" ] && [ "${FIRST#*/}" = "$FIRST" ] && ! command -v -- "$FIRST" >/dev/null 2>&1 && printf '%s\n' "$ERR" | grep -qE "(^|: )$(printf '%s' "$FIRST" | sed 's/[][\.*^$(){}?+|/]/\\&/g'):"; } && MALFORMED="command not found (rc 127): the target is not an executable command"
83
83
  [ "$RC" -eq 0 ] || [ -n "$MALFORMED" ] || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
84
84
  else
85
85
  ERRF=$(mktemp); OUTPUT=$(eval "$TARGET" 2>"$ERRF"); RC=$?; ERR=$(cat "$ERRF"); rm -f "$ERRF"; [ -z "$ERR" ] || OUTPUT="${OUTPUT}${OUTPUT:+$'\n'}${ERR}" # classify on stderr only, match on both
86
86
  [ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
87
- [ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -qF 'command not found' && MALFORMED="command not found (rc 127): the target is not an executable command"
87
+ [ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && { FIRST=${TARGET#"${TARGET%%[![:space:]]*}"}; FIRST=${FIRST%%[[:space:]]*}; [ -n "$FIRST" ] && [ "${FIRST#*/}" = "$FIRST" ] && ! command -v -- "$FIRST" >/dev/null 2>&1 && printf '%s\n' "$ERR" | grep -qE "(^|: )$(printf '%s' "$FIRST" | sed 's/[][\.*^$(){}?+|/]/\\&/g'):"; } && MALFORMED="command not found (rc 127): the target is not an executable command"
88
88
  if [ -n "$MALFORMED" ]; then :
89
89
  else
90
90
  printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER" || printf '%s\n' "$OUTPUT" | grep -qF -- "$MATCHER" \
@@ -90,6 +90,23 @@ inv "$(chk ac-1 true)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed pa
90
90
  grep -q ':PASSED:' "$TMP/out" && grep -qi 'malformed' "$TMP/out" && ok "mixed: PASSED with malformed listed" || bad "mixed: wrong output"
91
91
  inv "$(chk ac-1 false)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed fail+malformed: fails closed" "$rc" 1
92
92
 
93
+ # rc 127 downgrade only when the target's own first word is the missing command (forge#3842).
94
+ printf '#!/bin/bash\nno-such-tool-xyzzy\n' > "$TMP/wt/inner.sh"
95
+ inv "$(chk ac-8 'no-such-cmd-xyzzy --help')"; rc=$(run); eq "first word missing: malformed, rc 0" "$rc" 0
96
+ grep -qi 'malformed' "$TMP/out" && ok "first word missing reported malformed" || bad "first word missing not reported"
97
+ inv "$(chk ac-8 'bash inner.sh')"; rc=$(run); eq "script with missing inner tool is a real fail" "$rc" 1
98
+ inv "$(chk ac-8 'true && no-such-cmd-xyzzy')"; rc=$(run); eq "missing command not first word is a real fail" "$rc" 1
99
+ export LANG=de_DE.UTF-8 LC_MESSAGES=de_DE.UTF-8
100
+ inv "$(chk ac-8 'no-such-cmd-xyzzy --help')"; rc=$(run); eq "non-English locale: first word missing still malformed" "$rc" 0
101
+ grep -qi 'malformed' "$TMP/out" && ok "locale: reported malformed" || bad "locale: not reported malformed"
102
+ unset LANG LC_MESSAGES
103
+ # target runs in its native locale: a locale-dependent verdict (wc -m on multibyte) must not be forced to C
104
+ export LC_ALL=C.UTF-8
105
+ inv "$(chk ac-8 'printf "\303\251" | wc -m | grep -qx 1')"; rc=$(run); eq "target keeps native locale (wc -m multibyte)" "$rc" 0
106
+ unset LC_ALL
107
+ inv 'ACCEPTANCE_CHECK: id=ac-9 type=command target="no-such-cmd-xyzzy" matcher="QQQ" phase=review description=d'; rc=$(run); eq "matcher branch: first word missing malformed" "$rc" 0
108
+ inv 'ACCEPTANCE_CHECK: id=ac-9 type=command target="bash inner.sh" matcher="QQQ" phase=review description=d'; rc=$(run); eq "matcher branch: inner missing tool is a real fail" "$rc" 1
109
+
93
110
  # Selection parity with B6.5 (forge#3809): non-review checks are selected by the `gh pr` target only, never by a phase= text match.
94
111
  bt() { printf 'ACCEPTANCE_CHECK: id=%s type=command target="%s" matcher="exit_0" phase=%s description=%s' "$1" "$2" "$3" "$4"; }
95
112
  inv "$(bt ac-1 'gh pr view 1 && false' build d)"; rc=$(run); eq "phase=build gh pr check is evaluated (fails)" "$rc" 1
@@ -99,5 +116,8 @@ inv "$(bt ac-1 'false' build d)"; rc=$(run); eq "phase=build non-gh-pr check is
99
116
  inv "$(bt ac-1 'gh pr view 1 && false' build 'x phase=review y')"; rc=$(run); eq "phase=review in description does not change selection" "$rc" 1
100
117
  inv "$(bt ac-1 'false' build 'x phase=review y')"; rc=$(run); eq "phase=review in description does not select a non-gh-pr check" "$rc" 0
101
118
  inv 'ACCEPTANCE_CHECK: id=ac-1 type=command target="gh pr view 1 && false" matcher="exit_0" description=untagged'; rc=$(run); eq "untagged gh pr check is evaluated" "$rc" 1
119
+ # test= tolerance (forge#3630): a test="..." token between phase= and description= does not change phase handling or selection.
120
+ inv 'ACCEPTANCE_CHECK: id=ac-1 type=command target="gh pr view 1 && false" matcher="exit_0" phase=review test="node --test x.mjs" description=d'; rc=$(run); eq "test= token on a phase=review check is tolerated (evaluated, fails)" "$rc" 1
121
+ inv 'ACCEPTANCE_CHECK: id=ac-1 type=command target="false" matcher="exit_0" phase=build test="node --test x.mjs" description=d'; rc=$(run); eq "test= token on a phase=build non-gh-pr check is not selected" "$rc" 0
102
122
 
103
123
  echo "$PASS passed, $FAILN failed"; [ "$FAILN" -eq 0 ]
@@ -172,7 +172,14 @@ while IFS= read -r c; do
172
172
  if [ "$rc" -ne 0 ]; then SYNC=0; break; fi # second parent is not base history
173
173
  CC=$(G diff-tree --cc --no-commit-id -r "$c" 2>/dev/null); rc=$?
174
174
  [ "$rc" -eq 0 ] || full
175
- if [ -n "$CC" ]; then SYNC=0; break; fi # conflict-resolution content in the merge
175
+ if [ -n "$CC" ]; then
176
+ # Non-empty --cc is either hand-written resolution content (not mechanical) or a merge=union result (forge#3969).
177
+ # Accept only when re-running the merge mechanically reproduces the committed tree exactly: a clean
178
+ # `merge-tree` (rc 0) whose tree equals the merge commit's. A hand-resolved or hand-edited merge never matches.
179
+ MT=$(G merge-tree --write-tree "$2" "$3" 2>/dev/null); rc=$?
180
+ MT=$(printf '%s\n' "$MT" | sed -n 1p)
181
+ if [ "$rc" -ne 0 ] || [ -z "$MT" ] || [ "$MT" != "$(G rev-parse "${c}^{tree}" 2>/dev/null)" ]; then SYNC=0; break; fi
182
+ fi
176
183
  done <<< "$CHAIN"
177
184
 
178
185
  # per-path shape of a diff: modes, status and path for every file, plus both blob ids for binary files
@@ -83,6 +83,25 @@ commit shared.txt "feat side" "feat edits shared"; R=$(sha HEAD); panel "$R"
83
83
  g merge -q --no-ff origin/main >/dev/null 2>&1; printf 'resolved\n' > "$W/shared.txt"; g add -A >/dev/null; g commit -q -m "merge main (resolved)" >/dev/null; HEAD=$(sha HEAD)
84
84
  out=$(run_rd | sed -n 1p); case "$out" in BASE_SYNC_ONLY*) bad "conflict-resolution merge must not be BASE_SYNC_ONLY (got $out)" ;; *) ok ;; esac
85
85
 
86
+ # 5b. union-merged base merge (forge#3969): both sides append a different line to a union-attributed registry.
87
+ # merge=union makes the same-spot append conflict-free, so the sync merge has no resolution edit and stays BASE_SYNC_ONLY.
88
+ mkrepo; g checkout -q main; printf 'e1\ne2\n' > "$W/list.txt"; g add -A >/dev/null; g commit -q -m "registry" >/dev/null; g push -q origin main
89
+ g checkout -q feat; g merge -q --no-ff -m "sync" main >/dev/null 2>&1
90
+ printf 'list.txt merge=union\n' > "$W/.git/info/attributes"
91
+ printf 'e-pr\n' >> "$W/list.txt"; g add -A >/dev/null; g commit -q -m "pr appends" >/dev/null; R=$(sha HEAD); panel "$R"
92
+ g checkout -q main; printf 'e-base\n' >> "$W/list.txt"; g add -A >/dev/null; g commit -q -m "base appends" >/dev/null; g push -q origin main; g checkout -q feat; g fetch -q origin
93
+ if g merge -q --no-ff -m "merge main" origin/main >/dev/null 2>&1; then
94
+ HEAD=$(sha HEAD)
95
+ grep -qxF e-pr "$W/list.txt" && grep -qxF e-base "$W/list.txt" && ok || bad "union-merged base merge lost a line"
96
+ expect "union-merged base merge" "BASE_SYNC_ONLY $R" 1
97
+ else
98
+ bad "union-merged base merge should be conflict-free"
99
+ fi
100
+ # same union merge, then hand-edited before the merge commit: the mechanical re-merge no longer matches -> not BASE_SYNC_ONLY
101
+ g reset -q --hard "$R"; g merge -q --no-ff --no-commit origin/main >/dev/null 2>&1; printf 'e-hand\n' >> "$W/list.txt"; g add -A >/dev/null; g commit -q -m "merge main (edited)" >/dev/null; HEAD=$(sha HEAD)
102
+ out=$(run_rd | sed -n 1p); case "$out" in BASE_SYNC_ONLY*) bad "hand-edited union merge must not be BASE_SYNC_ONLY (got $out)" ;; *) ok ;; esac
103
+ rm -f "$W/.git/info/attributes"
104
+
86
105
  # 6. mixed: base merge plus a regular commit -> DELTA
87
106
  mkrepo; R=$(sha HEAD); panel "$R"; base_advance b.txt "b1"; g merge -q --no-ff -m "merge main" origin/main >/dev/null 2>&1; commit pr.txt "pr2" "pr2"; HEAD=$(sha HEAD)
88
107
  expect "base merge plus regular commit" "DELTA $R..$HEAD" 1