You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/10/18 17:09:35 UTC
(juneau) branch master updated: Condense the juneau-run console to one icon-prefixed row per activity, with a Prechecks step in push.py, and move the shared push git helpers (index-keeping rebase, upstream verdict, unreviewed-changes refusal, tracked push) into juneau_scripts; cap module dot rows at one line; and switch the bulk-selection JS test back to Array.sort so it runs on CI's Node.
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 4d14d90006 Condense the juneau-run console to one icon-prefixed row per activity, with a Prechecks step in push.py, and move the shared push git helpers (index-keeping rebase, upstream verdict, unreviewed-changes refusal, tracked push) into juneau_scripts; cap module dot rows at one line; and switch the bulk-selection JS test back to Array.sort so it runs on CI's Node.
4d14d90006 is described below
commit 4d14d900069f82d99444d0c90b90b9ca0cffb0f3
Author: James Bognar <ja...@apache.org>
AuthorDate: Sun Oct 18 13:09:29 2026 -0400
Condense the juneau-run console to one icon-prefixed row per activity, with a Prechecks step in push.py, and move the shared push git helpers (index-keeping rebase, upstream verdict, unreviewed-changes refusal, tracked push) into juneau_scripts; cap module dot rows at one line; and switch the bulk-selection JS test back to Array.sort so it runs on CI's Node.
---
.../src/test/js/bulk-selection.cjs | 2 +-
juneau-run/src/main/python/juneau_run.py | 452 +++++++++++++++---
.../src/main/python/juneau_scripts/__init__.py | 2 +-
.../src/main/python/juneau_scripts/checks.py | 132 ++++++
juneau-run/src/main/python/juneau_scripts/git.py | 344 +++++++++++++-
juneau-run/src/main/python/juneau_scripts/run.py | 136 +++++-
juneau-run/src/main/python/juneau_scripts/steps.py | 14 +
juneau-run/src/test/python/support.py | 3 +-
juneau-run/src/test/python/test_maven_setup_row.py | 4 +-
juneau-run/src/test/python/test_module_line.py | 5 +-
juneau-run/src/test/python/test_render.py | 503 ++++++++++++++++++---
juneau-run/src/test/python/test_runner.py | 6 +
juneau-run/src/test/python/test_scripts_checks.py | 206 +++++++++
juneau-run/src/test/python/test_scripts_git.py | 435 ++++++++++++++++++
juneau-run/src/test/python/test_scripts_run.py | 22 +
juneau-run/src/test/python/test_session_plan.py | 8 +-
scripts/push.py | 466 +++++++++----------
scripts/tests/conftest.py | 14 +
scripts/tests/test_bell.py | 19 +-
scripts/tests/test_push.py | 331 +++++++++++---
scripts/tests/test_run_adoption.py | 79 ++--
21 files changed, 2689 insertions(+), 494 deletions(-)
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/bulk-selection.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/bulk-selection.cjs
index e2a6849447..495ee741af 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/bulk-selection.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/bulk-selection.cjs
@@ -408,7 +408,7 @@ t('runBulkAction_perRow_offScreenRowUsesSnapshotForEndpointTokens', async () =>
const urls = [];
f.env.setFetch(url => { urls.push(url); return Promise.resolve(jsonResponse('', { status: 200 })); });
await f.NS.runBulkAction(perRowAction, f.table, f.ctx, f.state, ['a', 'zz']);
- assert.deepEqual(urls.toSorted(), ['/api/changes/a/abort', '/api/changes/zz/abort']);
+ assert.deepEqual(urls.sort(), ['/api/changes/a/abort', '/api/changes/zz/abort']); // NOSONAR javascript:S4043 -- the CI Node lacks Array.prototype.toSorted; urls is a local array, so sorting it in place is safe
});
t('runBulkAction_perRow_missingCsrfToken_failsClosedWithoutFetch', async () => {
diff --git a/juneau-run/src/main/python/juneau_run.py b/juneau-run/src/main/python/juneau_run.py
index 4e7a716269..4a03e40a60 100644
--- a/juneau-run/src/main/python/juneau_run.py
+++ b/juneau-run/src/main/python/juneau_run.py
@@ -167,17 +167,29 @@ def run(mode, project, branch, head):
class Step:
"""Mutable outcome of a step() block; defaults to ok. summary is the console row's text, when set."""
- def __init__(self):
+ def __init__(self, id=None): # NOSONAR python:S5806 - parameter names mirror protocol field names
+ self.id = id
self.status = "ok"
self.exit = None
self.summary = None
+ self.warned = False
def fail(self, exit=None): # NOSONAR python:S5806 - parameter names mirror protocol field names
self.status = "fail"
self.exit = exit
- def skip(self):
+ def skip(self, reason=None):
+ """End the step skipped; reason, when given, is the row's text."""
self.status = "skip"
+ if reason is not None:
+ self.summary = reason
+
+ def warn(self, text):
+ """The step still ends ok, but its row draws ⚠ with text as its reason: a warn note on the step."""
+ self.warned = True
+ note("warn", text, step=self.id)
+ if not _console_owns_screen():
+ _out(f"⚠ {text}\n")
@contextmanager
@@ -186,7 +198,7 @@ def step(id, n, title, *, parent=None, label=None): # NOSONAR python:S5806 - pa
emit("step", id=id, n=n, title=title, parent=parent)
publish("step_start", id=id, n=n, title=title, parent=parent, tool=None, sig="",
**({"label": label} if label else {}))
- s = Step()
+ s = Step(id)
t0 = time.monotonic()
try:
yield s
@@ -287,12 +299,28 @@ def failure(text, step=None):
publish("failure", text=text, step=step)
+def hint(lines, step=None):
+ """Lines that tell the user what to do about a failure, at every detail level: printed as-is, or by the console
+ view when it owns the screen, beneath the running row (held until it ends) or at once when none is running."""
+ lines = [lines] if isinstance(lines, str) else list(lines)
+ if not _console_owns_screen():
+ _out("".join(line + "\n" for line in lines))
+ publish("hint", step=step, lines=lines)
+
+
+def tick():
+ """Let the console view redraw (the spinner, the elapsed time) during script work that blocks. Throttled by the
+ view; a no-op with no session."""
+ if _console is not None:
+ _console.tick()
+
+
@contextmanager
def row(id, title, *, label=None): # NOSONAR python:S5806 - parameter names mirror protocol field names
"""A console row around a block, with no marker. For script work that is not a protocol step (Perf, Timing)."""
publish("step_start", id=id, n=None, title=title, parent=None, tool=None, sig="",
**({"label": label} if label else {}))
- s = Step()
+ s = Step(id)
t0 = time.monotonic()
try:
yield s
@@ -361,16 +389,23 @@ LABEL_WIDTH_ENV = "JUNEAU_RUN_LABEL_WIDTH"
LIVE_ENV = "JUNEAU_RUN_LIVE"
HEARTBEAT_SECONDS = 15.0
# "run" is a unit that has started but not ended (distinct from PENDING, which has not started)
-PLAIN_GLYPHS = {"pass": "#", "fail": "F", "skip": ".", "run": "~"}
-NO_COLOR_GLYPHS = {"pass": "█", "fail": "F", "skip": "·", "run": "▒"}
+# "warn" is a checks unit that would refuse or only warns; a checks bar never shows "run", so the two yellows can't meet
+PLAIN_GLYPHS = {"pass": "#", "fail": "F", "skip": ".", "run": "~", "warn": "!"}
+NO_COLOR_GLYPHS = {"pass": "█", "fail": "F", "skip": "·", "run": "▒", "warn": "!"}
COLOR_GLYPHS = {"pass": "\x1b[32m█\x1b[0m", "fail": "\x1b[31m█\x1b[0m", "skip": "\x1b[2m█\x1b[0m",
- "run": "\x1b[33m█\x1b[0m"}
+ "run": "\x1b[33m█\x1b[0m", "warn": "\x1b[33m█\x1b[0m"}
PENDING = "░"
DOT_GLYPHS = {"pass": "•", "fail": "F", "skip": "·"}
COLOR_DOT_GLYPHS = {"pass": "\x1b[32m•\x1b[0m", "fail": "\x1b[31m✗\x1b[0m", "skip": "\x1b[2m•\x1b[0m"}
_MARK_COLORS = {"✓": "\x1b[32m✓\x1b[0m", "✗": "\x1b[31m✗\x1b[0m"}
_MARK_RE = re.compile("(?<!m)[✓✗]") # not one already coloured (right after an SGR escape)
_NOTE_PREFIX = {"info": "ℹ", "warn": "⚠", "error": "✗"}
+PENDING_ENV = "JUNEAU_RUN_PENDING" # a parent session's labelled pending rows, [[id, label], ...], for a nested one
+# A row's icon (spec 5.2): a spinner while it runs, then its outcome. No VS16 after ⚠: it must stay one column.
+SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
+ICONS = {"ok": "✔", "warn": "⚠", "skip": "⊘", "fail": "✗", "pending": "○"}
+_ICON_COLORS = {"running": "\x1b[36m", "ok": "\x1b[32m", "warn": "\x1b[33m", "skip": "\x1b[2m", "fail": "\x1b[31m",
+ "pending": "\x1b[2m"}
def tint(text):
@@ -508,6 +543,7 @@ def overall_text(o):
UNIT_PASS, UNIT_FAIL, UNIT_SKIP = 1, 2, 3 # bytes in a row's unit_cells; 0 is "not finished"
UNIT_RUN = 4 # only in a view built by _Row.unit_view(), never stored in unit_cells: started, not ended
+UNIT_WARN = 5 # a checks unit that warned
def unit_bar(cells, total, glyphs):
@@ -519,6 +555,7 @@ def unit_bar(cells, total, glyphs):
for i in range(max(0, total)):
v = cells[i] if i < len(cells) else 0
out.append(glyphs["fail"] if v == UNIT_FAIL else glyphs["run"] if v == UNIT_RUN
+ else glyphs["warn"] if v == UNIT_WARN
else glyphs["pass"] if v == UNIT_PASS else glyphs["skip"] if v == UNIT_SKIP else PENDING)
return out
@@ -579,6 +616,7 @@ class _Setup:
self.ids = list(dict.fromkeys(ids)) # pom module ids, in reactor order, once each
self.boxes = {i: None for i in self.ids} # None while pending, else pass or fail
self.t0 = t0
+ self.ended = False # every box set, or its owner row ended: the row is permanent (spec 5.4)
@property
def total(self):
@@ -588,7 +626,13 @@ class _Setup:
def done(self):
return sum(1 for b in self.boxes.values() if b is not None)
+ @property
+ def failed(self):
+ return any(b == "fail" for b in self.boxes.values())
+
def add(self, ids):
+ if self.ended:
+ return
for i in dict.fromkeys(ids):
if i not in self.boxes:
self.ids.append(i)
@@ -619,7 +663,14 @@ class _Row:
self.totals = None
self.modules = OrderedDict()
self.order = [] # module ids, in the order their boxes were granted
- self.notes = [] # (level, text), held until the row ends
+ self.notes = [] # (level, text), held until the row ends: info ones for the screen at `all`, warn
+ # and error ones (full text) for the condensed log
+ self.warned = False # a warn (or an error on a row that still ends ok) was attributed to it: ⚠ when ok
+ self.reason = None # the newest warn or error text's first line, cut only when drawn
+ self.reason_level = None # warn or error; an error reason is never replaced by a warn
+ self.hints = [] # lines printed beneath the row when it ends failed (spec 5.1)
+ self.held_failure = False # failure text is among the hints: they print however the row ends
+ self.failed_mods = [] # titles of its failed modules, in failure order, for the row's text
self.size = None # reactor size N
self.poms = set()
self.tests_mode = False
@@ -655,6 +706,15 @@ class _Row:
return view
+def _parent_pending():
+ """JUNEAU_RUN_PENDING as [(id, label)], or [] when it is unset or malformed."""
+ try:
+ value = json.loads(os.environ.get(PENDING_ENV) or "[]")
+ return [(str(i), str(label)) for i, label in value]
+ except (ValueError, TypeError):
+ return []
+
+
class Board:
"""The run's state for the console view. apply(kind, fields) returns the blocks that event produces."""
@@ -672,13 +732,78 @@ class Board:
self.aliases = {} # a run_tool sub-row's step id -> its parent row's id (parent=, release.py)
self.setup = None # the run's one Setup row (pom modules), owned by the first Maven row that has poms
self.plan = None # the planned top-level step ids, in order (spec 6), or None
+ self.plan_labels = {} # planned step id -> label, for the ones that draw a pending row
+ self.parent_pending = _parent_pending() if nested else [] # a parent session's pending rows, (id, label)
+ self.finished = False # the done event arrived: nothing is pending any more
self.shown_end = None # the Overall figure's projected end (session seconds) last displayed, held by damp_total
self.plan_estimates = {} # planned step id -> history wall_s or None, asked of the estimator once per plan
self.session_t0 = self.t0 # when the session event arrived (the Overall figure's E)
def width(self):
env = os.environ.get(LABEL_WIDTH_ENV, "")
- return max([int(env) if env.isdigit() else 10] + [len(r.label) for r in self.rows.values()])
+ return max([int(env) if env.isdigit() else 10] + [len(r.label) for r in self.rows.values()]
+ + [len(label) for label in self.plan_labels.values()]
+ + [len(label) for _, label in self.parent_pending])
+
+ def pending(self):
+ """The pending rows, as (id, label): the labelled planned steps that have not started, in plan order, then
+ a parent session's (JUNEAU_RUN_PENDING) that this one has no row or pending row for. A planned step that a
+ later started one has passed over is not pending (as in overall())."""
+ if self.finished:
+ return []
+ out = []
+ if self.plan:
+ last = max((i for i, sid in enumerate(self.plan) if sid in self.rows), default=-1)
+ out = [(sid, self.plan_labels[sid]) for i, sid in enumerate(self.plan)
+ if i > last and sid in self.plan_labels and sid not in self.rows]
+ have = set(self.rows) | {i for i, _ in out}
+ return out + [(i, label) for i, label in self.parent_pending if i not in have]
+
+ def setup_failed(self):
+ """The titles of the Setup row's failed pom modules, in reactor order."""
+ owner = self.rows.get(self.setup.row) if self.setup is not None else None
+ return [owner.modules[i].title if owner is not None and i in owner.modules else i
+ for i in self.setup.ids if self.setup.boxes[i] == "fail"] if self.setup is not None else []
+
+ def _end_setup(self):
+ """The Setup row goes permanent, once: when every pom box is set or its owner row ends."""
+ if self.setup is None or self.setup.ended:
+ return []
+ self.setup.ended = True
+ return [("setup",)]
+
+ def _setup_settled(self):
+ if self.setup is not None and self.setup.total and self.setup.done == self.setup.total:
+ return self._end_setup()
+ return []
+
+ def _attribute(self, step_id):
+ """The row a note, say, hint or failure belongs to: the step's (or its module's), else the newest running
+ row."""
+ if step_id:
+ r = self._target(step_id) or self.module_rows.get(step_id)
+ if r is not None:
+ return r
+ return self._latest_running()
+
+ def _hold(self, r, lines):
+ """Hint lines for r, less those a failure event already showed."""
+ r.hints += [line for line in lines if line.strip() not in self.failure_lines]
+
+ def _reason(self, r, level, text):
+ """A warn or error attributed to a running row: its first line is the row's reason (an error is never
+ replaced by a warn), an error's other lines are hints, and the row is warned."""
+ lines = text.splitlines()
+ first = next((i for i, line in enumerate(lines) if line.strip()), None)
+ r.warned = True
+ if first is None:
+ return
+ if level == "error" or r.reason_level != "error":
+ if level == "error" and r.reason_level == "error" and r.reason:
+ self._hold(r, [r.reason]) # an older error is not lost: it joins the hints
+ r.reason, r.reason_level = lines[first].strip(), level
+ if level == "error":
+ self._hold(r, lines[first + 1:])
def running(self):
return [r for r in self.rows.values() if r.running]
@@ -731,6 +856,7 @@ class Board:
def _on_plan(self, f):
self.plan = list(f["ids"])
+ self.plan_labels = dict(f.get("labels") or {})
self.plan_estimates = {}
return []
@@ -803,6 +929,7 @@ class Board:
self.module_rows[f["module"]] = r
if f["module"] in r.poms: # a pom has no work to wait for; under -T its end only comes with the summary
self._setup_box(r, r.modules[f["module"]], "pass")
+ return self._setup_settled()
return []
def _on_reactor(self, f):
@@ -868,7 +995,7 @@ class Board:
r.units_total += 1
self._resize_cells(r)
- OUTCOME_BYTE = {"pass": UNIT_PASS, "fail": UNIT_FAIL, "skip": UNIT_SKIP} # anything else counts as a pass
+ OUTCOME_BYTE = {"pass": UNIT_PASS, "fail": UNIT_FAIL, "skip": UNIT_SKIP, "warn": UNIT_WARN} # anything else: pass
def _on_units(self, f):
r = self._adopt(f["step"])
@@ -965,9 +1092,15 @@ class Board:
failed = m.status == "fail" or bool(m.totals and m.totals[1] + m.totals[2])
if m.dropped and (m.outcomes or (m.totals and m.totals[0])):
self._undrop_unit(r, m) # its totals arrived late: it did run tests
+ blocks = []
if m.id in r.poms: # a pom lives on the Setup row only, never in a bar's order
m.box = "fail" if failed else "pass"
+ was_failed = self.setup is not None and self.setup.failed
self._setup_box(r, m, m.box)
+ if self.setup is not None and self.setup.ended and self.setup.failed and not was_failed:
+ blocks = [("setup",)] # it went permanent green at the pom's start: print it again, red
+ else:
+ blocks = self._setup_settled()
else:
if failed:
self._grant(r, m, "fail")
@@ -975,6 +1108,8 @@ class Board:
self._grant(r, m, "skip" if m.status == "skip" else "pass")
# a module already boxed red (compile or test failure) stays a failed unit when finish() closes it as skip
failed = failed or m.box == "fail"
+ if failed and m.title not in r.failed_mods:
+ r.failed_mods.append(m.title)
if r.tests_mode and not failed and m.unit_index is None and not (m.outcomes or (m.totals and m.totals[0])):
self._drop_unit(r, m) # ended without ever running a test (no module_tested, or a skipped module)
else:
@@ -982,7 +1117,10 @@ class Board:
self._count_unit(r, m, "fail" if failed else "skip" if m.status == "skip" else
self._tests_outcome(m) if tested else "pass")
has_tests = bool(m.totals and m.totals[0])
- return [("module", r, m)] if shows_module(self.detail, failed, has_tests) else []
+ # finished-module lines only at modules and all: below that a failed module is in its row's text (spec 5.1)
+ if self.detail in ("modules", "all") and shows_module(self.detail, failed, has_tests):
+ blocks.append(("module", r, m))
+ return blocks
def _on_step_progress(self, f):
r = self._target(f["step"])
@@ -1018,31 +1156,79 @@ class Board:
for m in r.modules.values(): # never ended: it ran nothing we saw (close_children ends the open ones)
if m.box is None and m.id not in r.poms:
self._grant(r, m, "fail" if "fail" in m.outcomes else "pass" if m.outcomes else "skip")
+ if m.box == "fail" and m.title not in r.failed_mods:
+ r.failed_mods.append(m.title)
if r.status == "fail" and self.failed_title is None:
self.failed_title = r.label
- notes = []
+ blocks = self._end_setup() if self.setup is not None and self.setup.row == r.id else []
+ blocks.append(("row", r))
+ hints = r.status not in ("ok", "skip") or r.held_failure or (r.unit_kind == "checks" and r.warned)
+ shown = {line.strip() for line in r.hints} if hints else set()
for level, text in r.notes:
kept = [line for line in text.splitlines() if line.strip() not in self.failure_lines]
- if kept:
- notes.append(("text", f" {_NOTE_PREFIX[level]} " + "\n ".join(kept)))
- return [("row", r)] + notes
+ if level == "say":
+ blocks += [("text", "\n".join(kept))] if kept else []
+ elif level == "info":
+ blocks += [("text", f" {_NOTE_PREFIX[level]} " + "\n ".join(kept))] if kept else []
+ else: # the full warn or error text, for the condensed log only (D6), less what the hints repeat
+ kept = kept[:1] + [line for line in kept[1:] if line.strip() not in shown]
+ blocks += [("logonly", f" {_NOTE_PREFIX[level]} " + "\n ".join(kept))] if kept else []
+ if hints and r.hints:
+ blocks.append(("hint", r, list(r.hints)))
+ return blocks
def _on_note(self, f):
- if not shows_note(self.detail, f["level"]):
- return []
- r = self._target(f.get("step")) or self.module_rows.get(f.get("step")) or self._latest_running()
+ """A warn or error note on a running row sets its reason and emits nothing (at every level: summary and
+ actionable look the same on screen); an info one is held for the row's end at `all` only. With no running
+ row the note prints at once, filtered by the detail level."""
+ level = f["level"]
+ r = self._attribute(f.get("step"))
if r is not None and r.running:
- r.notes.append((f["level"], f["text"]))
+ if level != "info":
+ self._reason(r, level, f["text"])
+ r.notes.append((level, f["text"]))
+ elif shows_note(self.detail, level):
+ r.notes.append((level, f["text"]))
return []
- line = f"{_NOTE_PREFIX[f['level']]} {f['text']}"
- return [("text", " " + line if r is not None else line)]
+ if not shows_note(self.detail, level):
+ return []
+ line = f"{_NOTE_PREFIX[level]} {f['text']}"
+ return [("text", " " + line if r is not None else line)]
def _on_failure(self, f):
- self.failure_lines.update(line.strip() for line in f["text"].splitlines())
+ """Failure text: held in the running row's hints (it prints when the row ends), else printed at once."""
+ lines = f["text"].splitlines()
+ new = {line.strip() for line in lines}
+ self.failure_lines.update(new)
+ r = self._attribute(f.get("step"))
+ if r is not None and r.running:
+ r.hints = [line for line in r.hints if line.strip() not in new] + lines
+ r.held_failure = True
+ return []
return [("text", f["text"])]
+ def _on_hint(self, f):
+ """Hint lines: held on the running row (beneath it when it ends failed), else printed at once."""
+ r = self._attribute(f.get("step"))
+ if r is not None and r.running:
+ self._hold(r, f["lines"])
+ return []
+ lines = [line for line in f["lines"] if line.strip() not in self.failure_lines]
+ return [("hint", None, lines)] if lines else []
+
def _on_say(self, f):
- return [("text", f["text"])] if shows_note(self.detail, f["level"]) else []
+ """A say is a note on the running row (warn and error set its reason, info is held at `all`), else printed
+ at once, filtered by the detail level."""
+ level = f["level"]
+ r = self._attribute(f.get("step"))
+ if r is not None and r.running:
+ if level != "info":
+ self._reason(r, level, f["text"])
+ r.notes.append((level, f["text"]))
+ elif shows_note(self.detail, level):
+ r.notes.append(("say", f["text"]))
+ return []
+ return [("text", f["text"])] if shows_note(self.detail, level) else []
def _on_ask(self, f):
return [("logonly", f"{f['prompt']}{f['answer']}")]
@@ -1066,14 +1252,49 @@ class Board:
return []
def _on_done(self, f):
+ self.finished = True
if self.nested:
return []
return [("final", f["status"], int((self.clock() - self.t0) * 1000), self.failed_title, self.full_log)]
-def row_cells(row, glyphs, now, pending=False):
- """A step row as (cells, rest, elapsed): its bar's cells (None when it has no bar), the count and totals text
- after the bar, and the elapsed time. Elapsed is empty under a second, so instant rows (Install) show none."""
+def row_state(row):
+ """running, ok, warn, skip or fail (spec 5.2): fail wins; a skip is always skip; ok with a warn is warn."""
+ if row.running:
+ return "running"
+ if row.status == "skip":
+ return "skip"
+ if row.status == "ok":
+ return "warn" if row.warned else "ok"
+ return "fail"
+
+
+def icon(state, now=0.0, color=False):
+ """The glyph for a row state (or "pending"): the spinner frame for `now` while running. color adds its SGR."""
+ if state == "running":
+ glyph = SPINNER[int(now / LiveRenderer.REDRAW_SECONDS) % len(SPINNER)]
+ else:
+ glyph = ICONS[state]
+ return f"{_ICON_COLORS[state]}{glyph}\x1b[0m" if color else glyph
+
+
+def row_icon(row, now, color):
+ """The row's icon: a spinner (cyan) while it runs, then ✔ (green), ⚠ (yellow), ⊘ (dim) or ✗ (red)."""
+ return icon(row_state(row), now, color)
+
+
+def _failed_text(row, expected):
+ """A row with failed modules: `N failed: <first> [+N-1]`, or in tests mode the totals and `: <first> [+N-1]`."""
+ names = row.failed_mods[0] + (f" +{len(row.failed_mods) - 1}" if len(row.failed_mods) > 1 else "")
+ totals = fmt_totals(row.totals, False, expected) if row.tests_mode else ""
+ return f"{totals}: {names}" if totals else f"{len(row.failed_mods)} failed: {names}"
+
+
+def row_text(row, glyphs, now, pending=False):
+ """A step row as (cells, count, text, elapsed, cut): its bar's cells (None when it has no bar), the count and
+ percent after the bar, one text (the failed modules, else the reason, else the summary or totals), the elapsed
+ time, and whether text may be cut to fit (a reason or failed-module text). Elapsed is empty under a second, so
+ instant rows (Install) show none."""
cells, parts = None, []
# A module row can carry a unit bar too, hence the `row.modules` test below. A unit bar's PENDING cells also
# show on a finished row whose units did not all report.
@@ -1098,10 +1319,25 @@ def row_cells(row, glyphs, now, pending=False):
if elapsed:
elapsed = timer_text(ms / 1000, total)
expected = row.estimate.tests if row.running and row.estimate is not None else None
- text = row.summary if row.summary is not None else fmt_totals(row.totals, row.tests_mode and not row.running, expected)
- if text:
- parts.append(text)
- return cells, " ".join(parts), elapsed
+ cut = True
+ if row.failed_mods:
+ text = _failed_text(row, expected)
+ elif row.reason:
+ text = row.reason
+ else:
+ cut = False
+ text = row.summary if row.summary is not None else fmt_totals(row.totals, row.tests_mode and not row.running,
+ expected)
+ if row.status == "skip" and text and text.startswith("(") and text.endswith(")"):
+ text = text[1:-1] # "⊘ Install done by Tests"
+ return cells, " ".join(parts), text or "", elapsed, cut
+
+
+def row_cells(row, glyphs, now, pending=False):
+ """A step row as (cells, rest, elapsed): its bar's cells (None when it has no bar), the count and text after the
+ bar (row_text), and the elapsed time."""
+ cells, count, text, elapsed, _ = row_text(row, glyphs, now, pending)
+ return cells, " ".join(p for p in (count, text) if p), elapsed
def row_parts(row, glyphs, now, pending=False):
@@ -1110,19 +1346,48 @@ def row_parts(row, glyphs, now, pending=False):
return (" ".join(p for p in ("".join(cells), rest) if p) if cells is not None else rest), elapsed
+def row_label(row, width, now, color=False):
+ """The row's label column: its icon, a space and the label padded to width."""
+ return f"{row_icon(row, now, color)} {row.label.ljust(width)}"
+
+
+def pending_line(label, color=False):
+ """A planned step that has not started: a dim ○ and its label."""
+ return f"\x1b[2m○ {label}\x1b[0m" if color else f"○ {label}"
+
+
def module_line(mod, glyphs, now):
ms = mod.ms if mod.ms is not None else int((now - mod.t0) * 1000)
tests = fmt_totals(mod.totals)
- return f" {glyphs[mod.box or 'pass']} {mod.title} {fmt_elapsed(ms)}" + (f" {tests}" if tests else "")
+ return f" {glyphs[mod.box or 'pass']} {mod.title} {fmt_elapsed(ms)}" + (f" {tests}" if tests else "")
+
+
+def hint_lines(block):
+ """A hint block's lines: indented under the row's label when it belongs to a row, else as given."""
+ _, row, lines = block
+ return [(" " + line if row is not None else line) for line in lines]
-def setup_line(board, glyphs, cols=None):
- """The Setup row as a line: the label column, one box per pom module and the totals text."""
+def setup_line(board, glyphs, cols=None, now=0.0, color=False):
+ """The Setup row as a line: its icon (a spinner until every pom box is set or its owner ends, then ✔ or ✗), the
+ label column, one box per pom module, and the totals text or, when a pom failed, `N failed: <pom>`."""
setup = board.setup
- label = "Setup".ljust(board.width())
+ state = "running" if not setup.ended else "fail" if setup.failed else "ok"
+ label = f"{icon(state, now, color)} {'Setup'.ljust(board.width())}"
+ failed = board.setup_failed()
+ text = (f"{len(failed)} failed: {failed[0]}" + (f" +{len(failed) - 1}" if len(failed) > 1 else "")
+ if failed else setup_text(setup))
if cols is None:
- return label + " " + "".join(setup_cells(setup, glyphs)) + " " + setup_text(setup)
- return _fit_row(label, setup_cells(setup, glyphs), setup_text(setup), "", cols)
+ return label + " " + "".join(setup_cells(setup, glyphs)) + " " + text
+ return _fit_row(label, setup_cells(setup, glyphs), text, "", cols)
+
+
+def _cut_text(text, room, minimum=12):
+ """text cut with an ellipsis to fit room columns, but never below minimum (the bar gives way after that)."""
+ if _vlen(text) <= room:
+ return text
+ keep = max(room, min(minimum, _vlen(text)))
+ return _vcut(text, max(0, keep - 1)) + "…" if keep >= 1 else ""
class PlainRenderer:
@@ -1136,7 +1401,6 @@ class PlainRenderer:
self.clock = clock
self.last_beat = clock()
self.prompting = False # the last write left a watchdog prompt's partial line (Console._close_screen)
- self.setup_printed = False # the Setup line goes out once, with its owning row
def _write(self, text):
self.write(text)
@@ -1148,11 +1412,13 @@ class PlainRenderer:
if kind in ("header", "text"):
self._write(block[1] + "\n")
elif kind == "row":
- if board.setup is not None and board.setup.row == block[1].id and not self.setup_printed:
- self.setup_printed = True
- self._write(setup_line(board, self.glyphs) + "\n")
text, elapsed = row_parts(block[1], self.glyphs, self.clock())
- self._write(" ".join(p for p in (block[1].label.ljust(board.width()), text, elapsed) if p) + "\n")
+ label = row_label(block[1], board.width(), self.clock())
+ self._write(" ".join(p for p in (label, text, elapsed) if p) + "\n")
+ elif kind == "setup":
+ self._write(setup_line(board, self.glyphs) + "\n")
+ elif kind == "hint":
+ self._write("".join(line + "\n" for line in hint_lines(block)))
elif kind == "module":
self._write(module_line(block[2], self.glyphs, self.clock()) + "\n")
elif kind == "raw":
@@ -1305,7 +1571,6 @@ class LiveRenderer:
REDRAW_SECONDS, and never taller than the terminal's rows minus 2."""
REDRAW_SECONDS = 0.1
- MAX_SUB_LINES = 3
def __init__(self, write, size=shutil.get_terminal_size, clock=time.monotonic, color=True):
self.write = write
@@ -1342,9 +1607,11 @@ class LiveRenderer:
elif kind in ("header", "text"):
lines.append(block[1])
elif kind == "row":
- if board.setup is not None and board.setup.row == block[1].id:
- lines.append(setup_line(board, self.glyphs, self.size().columns - 1))
lines.append(self._row(board, block[1], self.size().columns - 1))
+ elif kind == "setup":
+ lines.append(setup_line(board, self.glyphs, self.size().columns - 1, self.clock(), self.color))
+ elif kind == "hint":
+ lines += hint_lines(block)
elif kind == "module":
lines.append(module_line(block[2], self.glyphs, self.clock()))
elif kind == "final":
@@ -1354,12 +1621,18 @@ class LiveRenderer:
self._draw(force=bool(blocks))
def _row(self, board, r, cols, pending=False):
- cells, rest, elapsed = row_cells(r, self.glyphs, self.clock(), pending)
+ now = self.clock()
+ cells, count, text, elapsed, cut = row_text(r, self.glyphs, now, pending)
+ label = row_label(r, board.width(), now, self.color)
+ if cut and text: # a reason gives way (cut with …) before the bar does
+ used = _vlen(label) + (2 + len(cells) if cells is not None else 0) + (2 + _vlen(count) if count else 0)
+ text = _cut_text(text, cols - (len(elapsed) + 2 if elapsed else 0) - used - 2)
+ rest = " ".join(p for p in (count, text) if p)
if pending and r.quiet:
rest += f" · quiet {r.quiet}s"
- if r.running and r.progress and not r.unit_kind:
+ if r.running and r.progress and (not r.unit_kind or r.unit_kind == "checks"):
rest = f"{rest} · {r.progress}" if rest else r.progress
- return _fit_row(r.label.ljust(board.width()), cells, rest, elapsed, cols, wrap=bool(r.unit_kind))
+ return _fit_row(label, cells, rest, elapsed, cols, wrap=bool(r.unit_kind))
def _flush(self, lines):
if lines:
@@ -1374,6 +1647,9 @@ class LiveRenderer:
self._draw()
def region(self, board, cols, max_lines):
+ """Overall, the running rows in start order (each with its Setup row and sub-lines), then the pending rows in
+ plan order. When it is taller than max_lines the pending rows fold first (into "○ +N more pending", kept
+ while anything is pending), then the module sub-lines (+N more running), then the bottom is cut."""
running = board.running()
inflight = [(r, m) for r in running for m in r.modules.values() if m.in_progress and m.id not in r.poms]
subs = [(r, m) for r, m in inflight if m.outcomes or m.totals]
@@ -1382,40 +1658,51 @@ class LiveRenderer:
names = [m.title for owner, m in inflight if owner is r and not (m.outcomes or m.totals)]
if names:
prefix = common_prefix([m.title for m in r.modules.values() if m.id not in r.poms])
- collapsed[r.id] = " " + collapse_names([t[len(prefix):] or t for t in names], cols)
+ collapsed[r.id] = " " + collapse_names([t[len(prefix):] or t for t in names], cols - 2)
blocks = {id(m): self._sub_lines(m, cols) for _, m in subs}
- owner_running = board.setup is not None and any(r.id == board.setup.row for r in running)
+ setup_running = (board.setup is not None and not board.setup.ended
+ and any(r.id == board.setup.row for r in running))
overall = board.overall()
- row_text = {r.id: self._row(board, r, cols, pending=True) for r in running}
- row_lines = sum(t.count("\n") + 1 for t in row_text.values()) # a wrapped unit bar spans several lines
- budget = max_lines - row_lines - len(collapsed) - (1 if owner_running else 0) - (1 if overall else 0)
+ row_text_ = {r.id: self._row(board, r, cols, pending=True) for r in running}
+ row_lines = sum(t.count("\n") + 1 for t in row_text_.values()) # a wrapped unit bar spans several lines
+ fixed = row_lines + len(collapsed) + (1 if setup_running else 0) + (1 if overall else 0)
+ pending = board.pending()
+ shown = len(pending)
+ if pending and fixed + sum(len(b) for b in blocks.values()) + shown > max_lines:
+ shown = max(0, max_lines - fixed - sum(len(b) for b in blocks.values()) - 1)
+ if shown + 1 >= len(pending):
+ shown = len(pending)
+ pending_lines = [_vcut(pending_line(label, self.color), cols) for _, label in pending[:shown]]
+ if shown < len(pending):
+ pending_lines.append(pending_line(f"+{len(pending) - shown} more pending", self.color))
+ budget = max_lines - fixed - len(pending_lines)
folded = {}
while subs and sum(len(blocks[id(m)]) for _, m in subs) + len(folded) > budget:
r, _ = subs.pop(0)
folded[r.id] = folded.get(r.id, 0) + 1
lines = [_vcut(overall_text(overall), cols)] if overall else []
for r in running:
- if board.setup is not None and board.setup.row == r.id:
- lines.append(setup_line(board, self.glyphs, cols))
- lines.append(row_text[r.id])
+ if setup_running and board.setup.row == r.id:
+ lines.append(setup_line(board, self.glyphs, cols, self.clock(), self.color))
+ lines.append(row_text_[r.id])
if r.id in collapsed:
lines.append(collapsed[r.id])
if r.id in folded:
- lines.append(f" +{folded[r.id]} more running")
+ lines.append(f" +{folded[r.id]} more running")
lines += [line for owner, m in subs if owner is r for line in blocks[id(m)]]
+ lines += pending_lines
return [part for line in lines for part in line.split("\n")][:max(1, max_lines)]
def _sub_lines(self, m, cols):
- prefix = f" {_vcut(m.title, max(1, cols // 2))} "
+ """One line per module: the newest dots that fit, behind a "… +N " prefix counting the ones dropped."""
+ prefix = f" {_vcut(m.title, max(1, cols // 2))} "
per = max(1, cols - _vlen(prefix))
cells = [self.dots[o] for o in m.outcomes]
- cap = per * self.MAX_SUB_LINES
- if len(cells) > cap:
+ if len(cells) > per:
width = len(f"… +{len(cells)} ")
- keep = max(1, cap - width)
+ keep = max(1, per - width)
cells = list(f"… +{len(cells) - keep} ".ljust(width)) + cells[-keep:]
- chunks = [cells[i:i + per] for i in range(0, len(cells), per)] or [[]]
- return [(prefix if i == 0 else " " * _vlen(prefix)) + "".join(c) for i, c in enumerate(chunks)]
+ return [prefix + "".join(cells)]
def _draw(self, force=False):
if self.closed or self.paused or self.prompting or self.board is None:
@@ -1526,6 +1813,8 @@ class Console:
def __call__(self, kind, fields):
owned = not self.stopped # say() and failure() left this event's text to the view
+ if kind in self._FULL_TEXT:
+ self._append_full(kind, fields)
if self.board_failed:
self._plain(kind, fields, self._final_blocks(kind, fields), owned)
return
@@ -1552,6 +1841,25 @@ class Console:
self._note(f"⚠ condensed log stopped ({e!r})")
self._plain(kind, fields, unshown, owned)
+ # The script text each event carries, as the full log gets it: every level, whatever the view shows (spec 5.1).
+ _FULL_TEXT = {
+ "say": lambda f: f["text"],
+ "note": lambda f: f"{_NOTE_PREFIX.get(f['level'], '')} {f['text']}",
+ "hint": lambda f: "\n".join(f["lines"]),
+ "failure": lambda f: f["text"],
+ }
+
+ def _append_full(self, kind, fields):
+ """Append the event's text to the session's full log, best-effort: never a reason to fail the run."""
+ if self.sinks.full_path is None:
+ return
+ try:
+ text = self._FULL_TEXT[kind](fields)
+ with open(self.sinks.full_path, "ab") as f:
+ f.write((text + "\n").encode("utf-8", errors="replace"))
+ except (OSError, TypeError, KeyError):
+ pass
+
def tick(self):
if self.screen is not None:
try:
@@ -1703,12 +2011,15 @@ def session(title, header=(), *, detail=None, sinks=None, repo="."):
def plan(ids):
- """Declare the planned top-level step ids, in order, for the Overall figure (spec 6.1). Replaces any earlier
- plan; a duplicate id raises ValueError."""
- ids = list(ids) # validated here as well as in Session.plan so a bad plan fails in a plain run, not only under a view
+ """Declare the planned top-level steps, in order, for the Overall figure (spec 6.1). Each entry is a step id, or
+ (id, label): a labelled step draws as a dim pending row (○) until it starts; an unlabelled one only counts toward
+ Overall. Replaces any earlier plan; a duplicate id raises ValueError."""
+ entries = [e if isinstance(e, str) else tuple(e) for e in ids]
+ ids = [e if isinstance(e, str) else e[0] for e in entries]
+ # validated here as well as in Session.plan so a bad plan fails in a plain run, not only under a view
if len(set(ids)) != len(ids):
raise ValueError("duplicate step id in plan: " + ", ".join(sorted({i for i in ids if ids.count(i) > 1})))
- publish("plan", ids=ids)
+ publish("plan", ids=ids, labels={e[0]: e[1] for e in entries if not isinstance(e, str) and e[1]})
def _end_session():
@@ -1831,12 +2142,17 @@ def passthrough(cmd, step_id, title, *, n=None, parent=None, cwd=None, env=None,
@contextmanager
def handoff():
"""Give the terminal to a child session (push.py running test.py) for the block. Yields the env the child needs
- so its rows line up with ours (label width, detail level); empty with no session."""
+ so its rows line up with ours (label width, detail level) and our pending rows stay visible under its own
+ (JUNEAU_RUN_PENDING); empty with no session."""
if _console is None:
yield {}
return
+ env = {LABEL_WIDTH_ENV: str(_console.board.width()), DETAIL_ENV: _console.board.detail}
+ pending = list(_console.board.pending())
+ if pending:
+ env[PENDING_ENV] = json.dumps([[i, label] for i, label in pending])
with _paused():
- yield {LABEL_WIDTH_ENV: str(_console.board.width()), DETAIL_ENV: _console.board.detail}
+ yield env
# ---------------------------------------------------------------------------------------------------------------------
diff --git a/juneau-run/src/main/python/juneau_scripts/__init__.py b/juneau-run/src/main/python/juneau_scripts/__init__.py
index b183bcb651..264acd5e93 100644
--- a/juneau-run/src/main/python/juneau_scripts/__init__.py
+++ b/juneau-run/src/main/python/juneau_scripts/__init__.py
@@ -14,7 +14,7 @@
juneau_scripts: helpers for writing push.py and release.py scripts. A toolkit, not a framework: every policy (what
may be committed, which identity is required, what a dry run means) is the script author's own code.
-Modules: git, run, tools, notify, java, lock, prompt, state, steps, proc, coverage, testing. Import them explicitly
+Modules: git, checks, run, tools, notify, java, lock, prompt, state, steps, proc, coverage, testing. Import them explicitly
(`from juneau_scripts import git, run`); this file imports nothing heavy, and never testing or its pytest plugin.
No helper in git, run or tools runs `git add`, passes `--force` or `--no-verify`, or writes git config. The toolkit
diff --git a/juneau-run/src/main/python/juneau_scripts/checks.py b/juneau-run/src/main/python/juneau_scripts/checks.py
new file mode 100644
index 0000000000..46384531d5
--- /dev/null
+++ b/juneau-run/src/main/python/juneau_scripts/checks.py
@@ -0,0 +1,132 @@
+# ***************************************************************************************************************************
+# * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file
+# * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file
+# * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance
+# * with the License. You may obtain a copy of the License at
+# *
+# * http://www.apache.org/licenses/LICENSE-2.0
+# *
+# * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
+# * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# * specific language governing permissions and limitations under the License.
+# ***************************************************************************************************************************
+"""
+Gate checks as data: a Check names a function that returns a Verdict. run.checks (and Session.checks,
+Registry.checks) runs a list of them as one console row, one cell per check. This module has no UI.
+
+The factories wrap the git readers. None of them changes the repository: they only read it (upstream fetches).
+"""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from . import git
+
+STATUSES = ("pass", "warn", "fail")
+
+
+@dataclass(frozen=True)
+class Verdict:
+ """A check's outcome. status is pass, warn or fail; reason is one line for the row; hint is the lines printed
+ beneath the row."""
+ status: str
+ reason: str = ""
+ hint: tuple = ()
+
+ def __post_init__(self):
+ if self.status not in STATUSES:
+ raise ValueError(f"status must be one of {', '.join(STATUSES)}, not {self.status!r}")
+ object.__setattr__(self, "hint", tuple([self.hint] if isinstance(self.hint, str) else self.hint))
+
+
+PASS = Verdict("pass")
+
+
+@dataclass(frozen=True)
+class Check:
+ """name is the row's progress text while the check runs, and heads its hint block. fn() returns a Verdict, or
+ True/None for a pass and False for a fail with no text."""
+ name: str
+ fn: Callable
+
+
+def evaluate(check, *, dry_run=False):
+ """Run one check and return its Verdict. A check that raises fails closed with "could not check <name>: <error>".
+ With dry_run a fail becomes a warn whose reason starts "would refuse: "."""
+ try:
+ out = check.fn()
+ except Exception as e: # NOSONAR python:S2221 - a gate that cannot decide refuses
+ out = Verdict("fail", f"could not check {check.name}: {e}")
+ if out is None or out is True:
+ out = PASS
+ elif out is False:
+ out = Verdict("fail")
+ elif not isinstance(out, Verdict):
+ out = Verdict("fail", f"could not check {check.name}: it returned {type(out).__name__}, not a Verdict")
+ if dry_run and out.status == "fail":
+ out = Verdict("warn", "would refuse: " + (out.reason or f"{check.name} failed"), out.hint)
+ return out
+
+
+def summary(verdicts):
+ """(level, reason, hint lines) for a run of checks, given {name: Verdict} in check order. level is "error" when
+ any check failed, "warn" when one warned, else None. The reason is the first such check's reason, plus " +N" for
+ the others at that level; the hint lines are every failed or warned check's hint, its first line headed
+ "<name>: ", in check order."""
+ fails = [(name, v) for name, v in verdicts.items() if v.status == "fail"]
+ warns = [(name, v) for name, v in verdicts.items() if v.status == "warn"]
+ top = fails or warns
+ if not top:
+ return None, "", ()
+ name, first = top[0]
+ reason = (first.reason or f"{name} failed") + (f" +{len(top) - 1}" if len(top) > 1 else "")
+ hint = []
+ for name, v in verdicts.items():
+ if v.status != "pass" and v.hint:
+ hint.append(f"{name}: {v.hint[0]}")
+ hint.extend(v.hint[1:])
+ return ("error" if fails else "warn"), reason, tuple(hint)
+
+
+def identity(repo=".", *, required=None, label="identity"):
+ """Fail when git's user.email is unset or, with required, isn't that address. The hint is the config command;
+ the check never writes git config."""
+ def fn():
+ email = git.user_email(repo)
+ if not email:
+ return Verdict("fail", "git user.email is not set",
+ ("set it (this script never writes git config):", f" git config user.email {required or '<email>'}"))
+ if required is not None and email != required:
+ return Verdict("fail", f"git user.email is {email}, not {required}",
+ ("set it (this script never writes git config):", f" git config user.email {required}"))
+ return PASS
+ return Check(label, fn)
+
+
+def unreviewed(repo=".", *, exempt=(), git_cmd="git", label="unreviewed",
+ outcome="Nothing was committed or pushed."):
+ """Fail when any path is unstaged or untracked (git.unreviewed_paths, less the exempt ones). outcome is the
+ sentence the hint uses to say what the refusal held back; git_cmd is how the hint's commands reach the repo."""
+ def fn():
+ paths = git.unreviewed_paths(repo, exempt=exempt)
+ if not paths:
+ return PASS
+ reason, hint = git.unreviewed_refusal(paths, outcome=outcome, git_cmd=git_cmd)
+ return Verdict("fail", reason, hint)
+ return Check(label, fn)
+
+
+def upstream(repo=".", *, git_cmd="git", label="upstream"):
+ """Fail when the branch is behind its upstream, or the check can't tell (git.upstream_verdict). No upstream
+ passes."""
+ def fn():
+ v = git.upstream_verdict(repo, git_cmd=git_cmd)
+ return PASS if v.ok else Verdict("fail", v.reason, v.hint)
+ return Check(label, fn)
+
+
+def staged(repo=".", *, label="staged"):
+ """Warn "nothing staged" when the index matches HEAD."""
+ def fn():
+ return PASS if git.staged_paths(repo) else Verdict("warn", "nothing staged")
+ return Check(label, fn)
diff --git a/juneau-run/src/main/python/juneau_scripts/git.py b/juneau-run/src/main/python/juneau_scripts/git.py
index c485e867c1..9ae03584c2 100644
--- a/juneau-run/src/main/python/juneau_scripts/git.py
+++ b/juneau-run/src/main/python/juneau_scripts/git.py
@@ -20,18 +20,31 @@ Writers return a Result and relay git's output as Foundry's push.py does: inheri
(out and err stay empty), captured and shown as notes under a console view (info on success, error on failure), and
captured into the Result in either mode with capture=True.
-No helper here runs `git add`, passes --force or --no-verify, or writes git config. commit_index commits the index
-exactly as found. commit_paths is a pathspec commit with git's --only semantics: it commits the working-tree content
-of the named tracked paths, including unstaged edits to them, and leaves every other staged path staged.
+Under an active console every command is polled rather than waited on: the helpers call juneau_run's tick() every
+POLL_SECONDS, so the spinner and the elapsed time keep moving during a `git fetch` or `git push`.
+
+No helper here runs `git add`, passes --force or --no-verify, or writes git config, and only rebase_onto_upstream
+passes --autostash (when asked; rebase_keeping_index never does). commit_index commits the index exactly as found. commit_paths is a pathspec commit with git's --only semantics: it commits the working-tree
+content of the named tracked paths, including unstaged edits to them, and leaves every other staged path staged.
+
+Every `hint` field below is a tuple of lines, ready to print beneath a row.
"""
import os
import shlex
import subprocess
+import sys
+from dataclasses import dataclass
from . import _base
from ._base import Result
+#: How often a polled git command calls juneau_run's tick() under an active console.
+POLL_SECONDS = 0.1
+
+#: The message of the stash rebase_keeping_index holds the reviewed changes in.
+STASH_MESSAGE = "juneau_scripts: reviewed changes held across the rebase"
+
class GitError(RuntimeError):
"""A git reader could not run or the directory isn't a repository. result is the failed command's Result."""
@@ -53,15 +66,45 @@ def env(extra=None):
return merged
-def _run(cmd, *, cwd=".", input=None, capture=True):
- """The one subprocess entry point. A git that can't start gives Result(False, 127, err=str(e))."""
+def _ticker():
+ """juneau_run's tick() while a console view owns the screen, else None."""
+ jr = _base.jr()
+ if jr is None or not jr.console_active():
+ return None
+ return getattr(jr, "tick", None)
+
+
+def _polled(cmd, cwd, input, pipe, text, tick):
+ stdin = subprocess.PIPE if input is not None else None
+ with subprocess.Popen(cmd, cwd=cwd, env=env(), stdin=stdin, stdout=pipe, stderr=pipe, **text) as proc:
+ while True:
+ try:
+ # communicate, not wait: it drains the pipes, so a chatty command can't block on a full one.
+ out, err = proc.communicate(input, timeout=POLL_SECONDS)
+ return proc.returncode, out, err
+ except subprocess.TimeoutExpired:
+ tick()
+
+
+def _run(cmd, *, cwd=".", input=None, capture=True, binary=False):
+ """
+ The one subprocess entry point. A git that can't start gives Result(False, 127, err=str(e)). binary=True reads
+ out (and takes input) as bytes; err is still decoded. Polled under an active console (see the module docstring).
+ """
pipe = subprocess.PIPE if capture else None
+ text = {} if binary else {"text": True, "errors": "replace"}
+ tick = _ticker()
try:
- done = subprocess.run(cmd, cwd=cwd, env=env(), input=input, text=True, errors="replace", stdout=pipe,
- stderr=pipe)
+ if tick is None:
+ done = subprocess.run(cmd, cwd=cwd, env=env(), input=input, stdout=pipe, stderr=pipe, **text)
+ code, out, err = done.returncode, done.stdout, done.stderr
+ else:
+ code, out, err = _polled(cmd, cwd, input, pipe, text, tick)
except OSError as e:
- return Result(False, 127, err=str(e))
- return Result(done.returncode == 0, done.returncode, out=done.stdout or "", err=done.stderr or "")
+ return Result(False, 127, out=b"" if binary else "", err=str(e))
+ if binary:
+ return Result(code == 0, code, out=out or b"", err=(err or b"").decode(errors="replace"))
+ return Result(code == 0, code, out=out or "", err=err or "")
def _last(result, fallback):
@@ -77,7 +120,11 @@ def _read(cmd, repo):
def _paths(cmd, repo):
- return [p for p in _read(cmd, repo).split("\0") if p]
+ # Bytes, not text: text mode folds a CR in a name. -z output is never C-quoted, so each name is the real path.
+ result = _run(cmd, cwd=repo, binary=True)
+ if not result.ok:
+ raise GitError(cmd, result)
+ return [os.fsdecode(p) for p in result.out.split(b"\0") if p]
def _require_repo(repo):
@@ -86,9 +133,10 @@ def _require_repo(repo):
def staged_paths(repo="."):
- """`git diff --cached --name-only`. Raises GitError when git fails."""
+ """`git diff --cached --name-only --no-renames`: a staged rename lists both sides. Raises GitError when git
+ fails."""
_require_repo(repo)
- return _paths(["git", "diff", "--cached", "--name-only", "-z"], repo)
+ return _paths(["git", "diff", "--cached", "--name-only", "--no-renames", "-z"], repo)
def unstaged_paths(repo="."):
@@ -102,6 +150,32 @@ def untracked_paths(repo="."):
return _paths(["git", "ls-files", "--others", "--exclude-standard", "-z"], repo)
+def unreviewed_paths(repo=".", *, exempt=()):
+ """
+ The paths staging hasn't vouched for: unstaged modifications and deletions plus untracked, non-ignored files,
+ sorted, minus the repo-relative paths in exempt (compared literally). Raises GitError when git fails.
+ """
+ skip = {os.fspath(p) for p in exempt}
+ return sorted({*unstaged_paths(repo), *untracked_paths(repo)} - skip)
+
+
+def unreviewed_refusal(paths, *, outcome, git_cmd="git"):
+ """
+ The shared refusal for unreviewed paths: (reason, hint). reason is one line ("2 paths are not staged"); hint
+ lists the paths, says why they matter, then outcome ("Nothing was built, committed or pushed.") and the stash
+ commands, with git_cmd as the git invocation (e.g. "git -C ../juneau-docs").
+ """
+ n = len(paths)
+ reason = f"{n} path{'' if n == 1 else 's'} {'is' if n == 1 else 'are'} not staged"
+ hint = ("these paths are not staged (staging is how this script knows you reviewed them):",
+ *(f" {p}" for p in paths),
+ "Why: everything this pushes should be something you've read, not just something that happened to be "
+ "in the tree.",
+ f"{outcome} Stage each path above, or set it aside for this run and restore it after:",
+ *(f" {line}" for line in stash_hint(paths, git_cmd=git_cmd).splitlines()))
+ return reason, hint
+
+
def changed_under(repo=".", prefix="", *, base="HEAD", untracked=True):
"""Paths under prefix that differ from base (staged or not), plus untracked ones when asked. Sorted. Paths are
relative to the repository root when repo is its top level."""
@@ -162,9 +236,57 @@ def ahead_behind(repo=".", *, fetch):
return ahead, behind, None
-def stash_hint(paths):
- """`git stash push -- <shell-quoted paths>`, then `git stash pop` on a second line."""
- return "git stash push -- " + " ".join(shlex.quote(p) for p in paths) + "\ngit stash pop"
+def stash_hint(paths, *, git_cmd="git"):
+ """`git stash push -- <shell-quoted paths>`, then `git stash pop` on a second line; git_cmd replaces "git"."""
+ return f"{git_cmd} stash push -- " + " ".join(shlex.quote(p) for p in paths) + f"\n{git_cmd} stash pop"
+
+
+@dataclass(frozen=True)
+class UpstreamVerdict:
+ """
+ upstream_verdict's answer. status is one of:
+ "ok" not behind (ahead or level);
+ "no-upstream" nothing to compare against, so the push proceeds;
+ "behind" behind only: hint is the ff-only merge;
+ "diverged" behind and ahead: hint is stash, pull --rebase, stash pop --index;
+ "error" the fetch or the comparison failed: fail closed.
+ ahead and behind are None for "no-upstream" and "error".
+ """
+ status: str
+ ahead: int | None = None
+ behind: int | None = None
+ reason: str = ""
+ hint: tuple = ()
+
+ @property
+ def ok(self):
+ """True when the push may proceed ("ok" or "no-upstream")."""
+ return self.status in ("ok", "no-upstream")
+
+
+def upstream_verdict(repo=".", *, git_cmd="git", fetch=True):
+ """
+ Whether the branch may be pushed as far as its upstream goes: ahead_behind(fetch=fetch) turned into an
+ UpstreamVerdict. git_cmd is the git invocation shown in the hint. Never raises: a git failure is "error".
+ """
+ try:
+ ahead, behind, err = ahead_behind(repo, fetch=fetch)
+ except GitError as e:
+ return UpstreamVerdict("error", reason=f"could not check upstream: {e}")
+ if err == "no upstream":
+ return UpstreamVerdict("no-upstream", reason="no upstream branch configured")
+ if err:
+ return UpstreamVerdict("error", reason=f"could not check upstream: {err}")
+ if not behind:
+ return UpstreamVerdict("ok", ahead, behind)
+ commits = f"{behind} commit{'' if behind == 1 else 's'}"
+ if ahead:
+ return UpstreamVerdict("diverged", ahead, behind, f"{commits} behind upstream",
+ (f"the branch is {commits} behind its upstream and {ahead} ahead of it.",
+ f" {git_cmd} stash push && {git_cmd} pull --rebase && {git_cmd} stash pop --index"))
+ return UpstreamVerdict("behind", ahead, behind, f"{commits} behind upstream",
+ (f"the branch is {commits} behind its upstream.",
+ f" {git_cmd} merge --ff-only @{{upstream}}"))
def _write(cmd, repo, *, input=None, capture=False):
@@ -180,6 +302,15 @@ def _write(cmd, repo, *, input=None, capture=False):
return result
+def _warn(text):
+ """A warning: a warn note under a console view, else a line on stderr."""
+ jr = _base.jr()
+ if jr is not None and jr.console_active():
+ jr.say(text, "warn")
+ else:
+ print(text, file=sys.stderr)
+
+
def commit_index(message, repo=".", *, stdin=False, capture=False):
"""Commit the index exactly as found: `git commit -m <message>`, or `git commit -F -` with the message on stdin."""
if stdin:
@@ -205,11 +336,190 @@ def push(repo=".", *, remote="origin", branch=None, set_upstream=False, capture=
return _write(["git", "push", *(["-u"] if set_upstream else []), remote, branch], repo, capture=capture)
-def rebase_onto_upstream(repo=".", *, autostash=True, capture=False):
- """`git rebase [--autostash] @{upstream}`."""
+def push_command(repo="."):
+ """
+ The push to the branch's tracked upstream: `git push <remote> HEAD:<merge>` from branch.<b>.remote and
+ branch.<b>.merge, or `git push -u origin <branch>` when the branch tracks nothing. Raises GitError on a detached
+ HEAD, on tracking config it can't resolve (only one of the two keys set, or a git failure reading them), on a
+ local upstream (remote "."), or on a merge ref outside refs/heads/. Never falls back to -u origin when an upstream
+ is set.
+ """
+ _require_repo(repo)
+ refused = ["git", "push"]
+ branch = current_branch(repo)
+ if branch is None:
+ raise GitError(refused, Result(False, 1, err="detached HEAD: there is no branch to push"))
+ keys = {}
+ for key in ("remote", "merge"):
+ cmd = ["git", "config", "--get", f"branch.{branch}.{key}"]
+ result = _run(cmd, cwd=repo)
+ if result.code not in (0, 1): # 1 is "not set"
+ raise GitError(cmd, result)
+ keys[key] = result.out.strip() if result.ok else None
+ remote, merge = keys["remote"], keys["merge"]
+ if remote is None and merge is None:
+ return ["git", "push", "-u", "origin", branch]
+ if not remote or not merge:
+ raise GitError(refused, Result(False, 1, err=f"could not resolve the upstream of {branch} "
+ f"(branch.{branch}.remote={remote!r}, merge={merge!r})"))
+ if remote == ".":
+ raise GitError(refused, Result(False, 1, err=f"{branch} tracks a local branch ({merge}); nothing to push"))
+ if not merge.startswith("refs/heads/"):
+ raise GitError(refused, Result(False, 1, err=f"{branch} tracks {merge}, which is not a branch"))
+ return ["git", "push", remote, f"HEAD:{merge}"]
+
+
+def push_tracked(repo=".", *, capture=False):
+ """Run push_command(repo). A refusal from push_command gives Result(ok=False, code=1, err=<why>) without
+ pushing."""
+ try:
+ cmd = push_command(repo)
+ except GitError as e:
+ return Result(False, 1, err=str(e))
+ return _write(cmd, repo, capture=capture)
+
+
+def rebase_onto_upstream(repo=".", *, autostash=False, capture=False):
+ """`git rebase [--autostash] @{upstream}`. autostash is off by default: its pop restores the stash without
+ --index, so staged changes come back unstaged. rebase_keeping_index keeps them staged."""
return _write(["git", "rebase", *(["--autostash"] if autostash else []), "@{upstream}"], repo, capture=capture)
+def stash_ref_sha(repo="."):
+ """The commit refs/stash points at, or None when there is no stash."""
+ return _scalar(["git", "rev-parse", "-q", "--verify", "refs/stash"], repo)
+
+
+def stash_entry_ref(repo, sha):
+ """The `stash@{N}` entry holding commit sha, or None when no stash entry does."""
+ listed = _run(["git", "stash", "list", "--format=%H"], cwd=repo)
+ for n, entry in enumerate(listed.out.split() if listed.ok else ()):
+ if entry == sha:
+ return f"stash@{{{n}}}"
+ return None
+
+
+def restore_stash_index(repo, sha):
+ """
+ Re-apply stash sha's staged changes (its index commit against its base) to the index and the working tree, all or
+ nothing, then drop that stash entry. This is `git stash pop --index` except that a staged deletion the rebased
+ HEAD no longer has is left out: the path is gone either way, and pop --index fails on it. Bytes throughout
+ (binary patches, names with a CR) and literal pathspecs, so a path with glob characters only matches itself.
+
+ Returns the failed command's Result, or Result(ok=True); a stash that can't be dropped afterwards only warns (err
+ holds the warning).
+ """
+ def git(*argv, stdin=None):
+ return _run(["git", "--literal-pathspecs", *argv], cwd=repo, input=stdin, binary=True)
+
+ def names(out):
+ return [os.fsdecode(n) for n in out.split(b"\0") if n]
+
+ base, index = f"{sha}^1", f"{sha}^2"
+ changed = git("diff", "--name-only", "--no-renames", "-z", base, index)
+ deleted = git("diff", "--name-only", "--no-renames", "--diff-filter=D", "-z", base, index)
+ if not (changed.ok and deleted.ok):
+ return changed if not changed.ok else deleted
+ deleted_paths = set(names(deleted.out))
+ in_head = set()
+ if deleted_paths:
+ listed = git("ls-tree", "-z", "--name-only", "HEAD", "--", *sorted(deleted_paths))
+ if not listed.ok:
+ return listed
+ in_head = set(names(listed.out))
+ paths = [p for p in names(changed.out) if p not in deleted_paths or p in in_head]
+ if paths:
+ patch = git("diff", "--binary", "--no-renames", base, index, "--", *paths)
+ if not patch.ok:
+ return patch
+ applied = git("apply", "--index", "--binary", "-", stdin=patch.out)
+ if not applied.ok:
+ return applied
+ ref = stash_entry_ref(repo, sha)
+ if ref:
+ dropped = git("stash", "drop", "-q", ref)
+ if not dropped.ok:
+ warning = (f"the staged changes were restored, but stash {sha} could not be dropped: "
+ f"{_last(dropped, 'git stash drop failed')}")
+ _warn(warning)
+ return Result(True, 0, err=warning)
+ return Result(True, 0)
+
+
+@dataclass(frozen=True)
+class KeepIndex:
+ """
+ rebase_keeping_index's answer. status is one of:
+ "rebased" rebased, with any staged changes restored as staged;
+ "up-to-date" nothing to rebase onto;
+ "no-upstream" the branch tracks nothing (ok, nothing done);
+ "refused" unreviewed paths: nothing was stashed or rebased;
+ "error" a detached HEAD, a failed fetch, or a stash that didn't happen; nothing was rebased;
+ "rebase-conflict" the rebase stopped; the reviewed changes are kept in the stash;
+ "restore-conflict" rebased, but the reviewed changes no longer apply; they are kept in the stash.
+ stash_sha and stash_ref name the held stash (stash_ref may be None if the entry can't be found).
+ """
+ ok: bool
+ status: str
+ stash_sha: str | None = None
+ stash_ref: str | None = None
+ reason: str = ""
+ hint: tuple = ()
+
+
+def rebase_keeping_index(repo=".", *, fetch=True, merges=False, exempt=()):
+ """
+ Rebase the branch onto its upstream and bring the staged (reviewed) changes back staged, with the reviewed blobs.
+ In order: refuse on unreviewed_paths(repo, exempt=exempt) before anything is stashed; fetch (when fetch); stop at
+ "up-to-date" when not behind; stash the index (`git stash push -q -m STASH_MESSAGE`, checked by refs/stash
+ moving); `git rebase [--rebase-merges] @{upstream}`; restore_stash_index. On a conflict the stash is kept and the
+ hint names it. Never uses --autostash, `git stash pop` or `git add`. Returns a KeepIndex; the caller decides
+ whether to fail. Raises GitError when the readers can't run.
+ """
+ unreviewed = unreviewed_paths(repo, exempt=exempt)
+ if unreviewed:
+ reason, hint = unreviewed_refusal(unreviewed, outcome="Nothing was stashed or rebased.")
+ return KeepIndex(False, "refused", reason=reason, hint=hint)
+ if current_branch(repo) is None:
+ return KeepIndex(False, "error", reason="detached HEAD: there is no branch to rebase")
+ ahead, behind, err = ahead_behind(repo, fetch=fetch)
+ if err == "no upstream":
+ return KeepIndex(True, "no-upstream", reason="no upstream branch configured")
+ if err:
+ return KeepIndex(False, "error", reason=f"could not check upstream: {err}")
+ if not behind:
+ return KeepIndex(True, "up-to-date")
+
+ sha = None
+ if staged_paths(repo):
+ before = stash_ref_sha(repo)
+ stashed = _write(["git", "stash", "push", "-q", "-m", STASH_MESSAGE], repo, capture=True)
+ sha = stash_ref_sha(repo)
+ if not stashed.ok or sha is None or sha == before:
+ return KeepIndex(False, "error", reason=f"git stash push created no stash: "
+ f"{_last(stashed, 'check git status')}")
+ ref = stash_entry_ref(repo, sha) if sha else None
+ held = (f"Your reviewed changes are kept in stash {ref or sha}.",
+ f" git stash pop --index {ref or sha} (gets them back staged)") if sha else ()
+
+ rebased = _write(["git", "rebase", *(["--rebase-merges"] if merges else []), "@{upstream}"], repo, capture=True)
+ if not rebased.ok:
+ return KeepIndex(False, "rebase-conflict", sha, ref, f"rebase stopped: {_last(rebased, 'conflict')}",
+ ("Resolve the conflicts and run `git rebase --continue`, or `git rebase --abort`; then "
+ "restore the stash and retry." if sha else
+ "Resolve the conflicts and run `git rebase --continue`, or `git rebase --abort`, then "
+ "retry.", *held))
+ if sha is None:
+ return KeepIndex(True, "rebased")
+ restored = restore_stash_index(repo, sha)
+ if not restored.ok:
+ return KeepIndex(False, "restore-conflict", sha, ref,
+ f"the reviewed changes no longer apply: {_last(restored, 'git apply failed')}",
+ ("The rebase succeeded, but the staged changes overlap upstream's. Resolve the overlap so "
+ "the tree is clean, then restore the stash and retry.", *held))
+ return KeepIndex(True, "rebased", reason=restored.err)
+
+
def tag(name, repo=".", *, message=None, sign=False, capture=False):
"""An annotated tag (-a) when message is given, a signed one (-s) with sign=True, otherwise a lightweight tag."""
if sign or message is not None:
diff --git a/juneau-run/src/main/python/juneau_scripts/run.py b/juneau-run/src/main/python/juneau_scripts/run.py
index e7783dd862..5d96b431fd 100644
--- a/juneau-run/src/main/python/juneau_scripts/run.py
+++ b/juneau-run/src/main/python/juneau_scripts/run.py
@@ -25,12 +25,15 @@ import time
from types import MappingProxyType
from . import _base, git, notify
+from . import checks as _checks
from ._base import Result
ACTIVE_ENV = "JUNEAU_RUN_ACTIVE"
SESSION_ENV = "JUNEAU_RUN_SESSION"
PUSH_ACTIVE_ENV = "JUNEAU_PUSH_ACTIVE"
N_BASE_ENV = "JUNEAU_RUN_N_BASE"
+CONSOLE_ENV = "JUNEAU_RUN_CONSOLE"
+CONSOLE_MODES = ("full", "condensed", "none")
PROTOCOL_ENV = ("RUN_MARKERS", "RUN_ARTIFACTS")
# The gate-script contract (Juneau scripts/push.py:405-413 at HEAD): 0 pass, 2 fail, 3 skip. Not pytest's.
GATE_CODES = MappingProxyType({0: "pass", 2: "fail", 3: "skip"})
@@ -66,6 +69,30 @@ def console_requested():
return jr is not None and not jr.Sinks().is_default
+def export_console(flag=None, *, default="condensed"):
+ """Resolve the console mode and export it as JUNEAU_RUN_CONSOLE, so the session and child scripts use it: flag
+ (the --console value, None when not given), else JUNEAU_RUN_CONSOLE, else default. Call it before session().
+ An unknown mode raises ValueError. Returns the mode."""
+ value = flag or os.environ.get(CONSOLE_ENV) or default
+ if value not in CONSOLE_MODES:
+ raise ValueError(f"console must be one of {', '.join(CONSOLE_MODES)}, not {value!r}")
+ os.environ[CONSOLE_ENV] = value
+ return value
+
+
+def add_console_argument(parser):
+ """Add --console {full,condensed,none} (default None, so export_console(args.console) applies the precedence)."""
+ parser.add_argument("--console", choices=CONSOLE_MODES, default=None,
+ help="console output: full, condensed or none (also JUNEAU_RUN_CONSOLE; the flag wins)")
+
+
+def paused():
+ """jr.paused() when juneau_run is loaded: the console view is cleared for the block, so a prompt or an
+ interactive child isn't drawn over. A no-op context otherwise."""
+ jr = _base.jr()
+ return jr.paused() if jr is not None else contextlib.nullcontext()
+
+
def say(text="", level="info", *, step=None):
"""print() in a plain run; jr.say under markers or the console (a filtered note when the view owns the screen)."""
jr = _base.jr()
@@ -83,9 +110,13 @@ class _NullStep:
def fail(self, exit=None): # NOSONAR python:S5806 - mirrors juneau_run.Step.fail
pass
- def skip(self):
+ def skip(self, reason=None):
pass
+ def warn(self, text):
+ """Mirrors juneau_run.Step.warn: in a plain run the text is printed."""
+ print(f"⚠ {text}", flush=True)
+
@contextlib.contextmanager
def step(id, title, *, n=None, parent=None, label=None): # NOSONAR python:S5806 - mirrors juneau_run.step
@@ -202,6 +233,79 @@ def child_env(*, strip_protocol=True, keep=("JUNEAU_RUN_PY",)):
return env
+@dataclasses.dataclass(frozen=True)
+class CheckResult:
+ """checks()'s answer: ok is False when a check failed (never in a dry run); verdicts maps each check's name to
+ its checks.Verdict, in check order."""
+ ok: bool
+ verdicts: dict
+
+
+_VERDICT_ICON = {"pass": "✔", "warn": "⚠", "fail": "✗"}
+
+
+def checks(id, title, checks, *, label="Prechecks", n=None, dry_run=False): # NOSONAR python:S5806 - mirrors step()
+ """
+ Run gate checks (juneau_scripts.checks.Check) as one step: one bar cell per check (green pass, yellow warn, red
+ fail), the running check's name as the row's progress, and when any check fails or warns the row's reason (the
+ first one's, plus " +N" for the others) and every hint beneath the row, each headed "<name>: ". Every check runs,
+ even after a failure. A check that raises fails with "could not check <name>: <error>". A failure fails the
+ row. With dry_run each fail is a warn whose reason starts "would refuse: ", the hints still print, and the
+ result is ok.
+
+ The step is one marker step with a note per warn or fail. Without a console view (a plain run, markers, or
+ --console full) each check prints a line instead: "✔ <name>", "⚠ <name>: <reason>" or "✗ <name>: <reason>",
+ then the hints. Returns CheckResult(ok, verdicts).
+ """
+ checks = list(checks)
+ _base.remember(title)
+ with step(id, title, n=n, label=label) as st:
+ result = drive_checks(id, st, checks, dry_run=dry_run)
+ return result
+
+
+def drive_checks(id, st, checks, *, dry_run=False): # NOSONAR python:S5806 - mirrors step()
+ """The body of checks() for a step that is already open (id, and st the step it yielded): runs the checks on
+ its row, fails st when one fails, and returns the CheckResult. Registry.checks uses it."""
+ jr = _base.jr()
+ verdicts = {}
+ if jr is None:
+ verdicts = {c.name: _checks.evaluate(c, dry_run=dry_run) for c in checks}
+ _print_verdicts(verdicts)
+ else:
+ jr.publish("units", step=id, total=len(checks), kind="checks")
+ for i, c in enumerate(checks):
+ jr.publish("step_progress", step=id, text=c.name)
+ jr.tick()
+ v = verdicts[c.name] = _checks.evaluate(c, dry_run=dry_run)
+ jr.publish("unit", step=id, done=i + 1, outcome=v.status)
+ if v.status != "pass":
+ jr.emit("note", level="error" if v.status == "fail" else "warn",
+ text=f"{c.name}: {v.reason or 'failed'}", href=None, step=id)
+ jr.tick()
+ level, reason, hint = _checks.summary(verdicts)
+ if jr is not None and not jr.console_active():
+ _print_verdicts(verdicts)
+ elif jr is not None and level is not None:
+ jr.publish("note", level=level, text=reason, href=None, step=id)
+ if hint:
+ jr.publish("hint", step=id, lines=list(hint))
+ if level == "error":
+ st.fail()
+ return CheckResult(level != "error", verdicts)
+
+
+_run_checks = checks # Session.checks's parameter of the same name hides it
+
+
+def _print_verdicts(verdicts):
+ for name, v in verdicts.items():
+ say(f"{_VERDICT_ICON[v.status]} {name}" + (f": {v.reason}" if v.status != "pass" and v.reason else ""),
+ "info" if v.status == "pass" else "warn" if v.status == "warn" else "error")
+ for line in _checks.summary(verdicts)[2]:
+ say(f" {line}", "error")
+
+
class _SessionFailed(BaseException):
"""Raised by Session.fail; only session() catches it. BaseException, so a script's `except Exception` can't
swallow it."""
@@ -216,24 +320,40 @@ class Session:
self.code = None
def plan(self, ids):
- """Declare (or replace) the planned top-level step ids, in order, for the Overall figure. Does nothing in a
- plain run."""
- ids = list(ids)
+ """Declare (or replace) the planned top-level steps, in order, for the Overall figure. Each entry is a step
+ id, or (id, label): a labelled step draws as a dim pending row until it starts. Does nothing in a plain
+ run."""
+ entries = [e if isinstance(e, str) else tuple(e) for e in ids]
+ ids = [e if isinstance(e, str) else e[0] for e in entries]
# validated here even in a plain run (where jr is None) so a bad plan fails the same way everywhere
if len(set(ids)) != len(ids):
raise ValueError("duplicate step id in plan: " + ", ".join(sorted({i for i in ids if ids.count(i) > 1})))
jr = _base.jr()
if jr is not None:
- jr.plan(ids)
+ jr.plan(entries)
def fail(self, msg, *, hint=None):
"""Print msg verbatim (level error), then the hint lines, and end the session as failed. msg is required
- (Decision Q5): a failure always says why."""
+ (Decision Q5): a failure always says why. The hint goes out as a hint event, which every detail level
+ shows: beneath the running row when there is one, else at once."""
say(msg, "error")
- for line in [hint] if isinstance(hint, str) else hint or ():
- say(line)
+ lines = [hint] if isinstance(hint, str) else list(hint or ())
+ jr = _base.jr()
+ if jr is None:
+ for line in lines:
+ print(line, flush=True)
+ elif lines:
+ jr.hint(lines)
raise _SessionFailed
+ def checks(self, id, title, checks, **kwargs): # NOSONAR python:S5806 - mirrors run.checks
+ """run.checks(...), and on a failure (never in a dry run) end the session failed with no further message:
+ the row already says why."""
+ result = _run_checks(id, title, checks, **kwargs)
+ if not result.ok:
+ raise _SessionFailed
+ return result
+
def require(self, result, msg=None, *, hint=None):
"""result when result.ok (a skipped gate is ok); otherwise fail with msg or a default naming the last step."""
if result.ok:
diff --git a/juneau-run/src/main/python/juneau_scripts/steps.py b/juneau-run/src/main/python/juneau_scripts/steps.py
index 9b3f02552c..88e25eb23c 100644
--- a/juneau-run/src/main/python/juneau_scripts/steps.py
+++ b/juneau-run/src/main/python/juneau_scripts/steps.py
@@ -21,6 +21,7 @@ class Registry:
self._steps = [] # (name, title, fn), in registration order
self._labels = {} # name -> the console row's label, for the steps registered with one
self._prog = None
+ self._current = None # the open step's handle while a step's fn runs (Registry.checks)
@property
def names(self):
@@ -40,6 +41,18 @@ class Registry:
return fn
return register
+ def checks(self, name, title, checks, *, label=None):
+ """Register the next step as a run of gate checks (run.checks semantics, on this step's row): checks is a
+ list of checks.Check, or fn(ctx) returning one. A failed check fails the step, which prints the resume
+ hint."""
+ def fn(ctx):
+ listed = checks(ctx) if callable(checks) else checks
+ st = self._current
+ result = run.drive_checks(name, st, list(listed))
+ return Result(True, 0) if result.ok else Result(False, 1)
+ self.step(name, title, label=label)(fn)
+ return fn
+
def add_arguments(self, parser):
"""--list-steps, --start-step NAME, --skip-step NAME (repeatable) and --resume. Unknown names are argparse
usage errors (exit 2); --start-step and --resume are mutually exclusive."""
@@ -121,6 +134,7 @@ class Registry:
else:
run.say(f"⏭ Skipping {n}. {title}")
else:
+ self._current = st
try:
out = fn(ctx)
except Exception as e: # NOSONAR python:S2221 - a step's error is reported, then the hint
diff --git a/juneau-run/src/test/python/support.py b/juneau-run/src/test/python/support.py
index 331b85e8e4..915ba381c3 100644
--- a/juneau-run/src/test/python/support.py
+++ b/juneau-run/src/test/python/support.py
@@ -50,7 +50,8 @@ def reset_run_state():
jr._console.close()
jr._console = None
for key in (jr.ACTIVE_ENV, "JUNEAU_RUN_FULL_LOG", "JUNEAU_RUN_SESSION", "JUNEAU_RUN_DETAIL", "JUNEAU_RUN_LABEL_WIDTH",
- "JUNEAU_RUN_LIVE", "JUNEAU_RUN_CONSOLE", "RUN_MARKERS", "JUNEAU_RUN_CONDENSED_LOG"):
+ "JUNEAU_RUN_LIVE", "JUNEAU_RUN_CONSOLE", "RUN_MARKERS", "JUNEAU_RUN_CONDENSED_LOG",
+ "JUNEAU_RUN_PENDING"):
os.environ.pop(key, None)
diff --git a/juneau-run/src/test/python/test_maven_setup_row.py b/juneau-run/src/test/python/test_maven_setup_row.py
index 3ee6389086..fa0c0a0716 100644
--- a/juneau-run/src/test/python/test_maven_setup_row.py
+++ b/juneau-run/src/test/python/test_maven_setup_row.py
@@ -140,7 +140,7 @@ class SetupRowTest(unittest.TestCase):
def test_region_draws_setup_above_first_owning_row_and_counts_height(self):
b, _ = self.board()
lines = live_region_lines(b, rows=24, cols=100) # no plan is set, so no Overall line (G2) precedes it
- self.assertEqual(lines[0].split()[0], "Setup")
+ self.assertEqual(lines[0].split()[1], "Setup")
self.assertIn("16 pom modules", lines[0])
self.assertLessEqual(len(lines), 22) # max_lines = rows - 2 still honoured
@@ -153,7 +153,7 @@ class SetupRowTest(unittest.TestCase):
def test_plain_renderer_prints_one_setup_line(self):
out = plain_run(setup_events())
- self.assertEqual(len([l for l in out.splitlines() if l.startswith("Setup")]), 1)
+ self.assertEqual(len([l for l in out.splitlines() if l.split()[1:2] == ["Setup"]]), 1)
self.assertIn("3 pom modules", out)
diff --git a/juneau-run/src/test/python/test_module_line.py b/juneau-run/src/test/python/test_module_line.py
index 5f722f17a4..fd288d3b09 100644
--- a/juneau-run/src/test/python/test_module_line.py
+++ b/juneau-run/src/test/python/test_module_line.py
@@ -86,10 +86,11 @@ class RegionCollapsedWidthTest(unittest.TestCase):
live = jr.LiveRenderer(lambda t: None, size=lambda: os.terminal_size((60, 24)), clock=lambda: 100.0, color=False)
size = live.size()
lines = live.region(b, size.columns - 1, size.lines - 2)
- collapsed = [l for l in lines if l.startswith(" ")][0]
+ collapsed = [l for l in lines if l.startswith(" ")][0]
self.assertLessEqual(jr._vlen(collapsed), 59)
names = [f"{i:02d}" for i in range(30)]
- self.assertEqual(collapsed, " " + jr.collapse_names(names, 59)) # region's cols already is columns - 1: no second -1
+ # region's cols already is columns - 1: no second -1; the 4-space indent takes two more than the default 2
+ self.assertEqual(collapsed, " " + jr.collapse_names(names, 57))
if __name__ == "__main__":
diff --git a/juneau-run/src/test/python/test_render.py b/juneau-run/src/test/python/test_render.py
index 10bd14854d..d55e88596d 100644
--- a/juneau-run/src/test/python/test_render.py
+++ b/juneau-run/src/test/python/test_render.py
@@ -14,6 +14,7 @@
import contextlib
import io
+import json
import os
import re
import shlex
@@ -138,7 +139,7 @@ class BoardTest(unittest.TestCase):
self.assertEqual(b.rows["py"].progress, "52% \u00b7 test_foo.py")
blocks = feed(b, ("step_end", {"id": "py", "status": "ok", "ms": 2000, "exit": None, "summary": "3 passed",
"totals": None}))
- self.assertEqual(plain(b, blocks, self.clock), "Python".ljust(10) + " 3 passed 0:02\n")
+ self.assertEqual(plain(b, blocks, self.clock), "✔ " + "Python".ljust(10) + " 3 passed 0:02\n")
self.assertIsNone(b.rows["py"].progress)
def test_a_sub_rows_step_end_clears_the_progress_it_put_on_its_parent_row(self):
@@ -169,23 +170,25 @@ class BoardTest(unittest.TestCase):
self.assertEqual(blocks, [])
blocks = feed(b, ("step_end", {"id": "bom", "status": "ok", "ms": 2000, "exit": None,
"summary": "73 modules", "totals": None}))
- self.assertEqual(plain(b, blocks, self.clock), "BOM".ljust(10) + " 73 modules 0:02\n")
+ self.assertEqual(plain(b, blocks, self.clock), "✔ " + "BOM".ljust(10) + " 73 modules 0:02\n")
def test_elapsed_under_a_second_is_left_off(self):
b = self.board()
blocks = feed(b, ("step_start", {"id": "install", "n": 7, "title": "Install", "parent": None}),
("step_end", {"id": "install", "status": "ok", "ms": 3, "exit": None,
"summary": "(done by Tests)", "totals": None}))
- self.assertEqual(plain(b, blocks, self.clock), "Install".ljust(10) + " (done by Tests)\n")
+ self.assertEqual(plain(b, blocks, self.clock), "✔ " + "Install".ljust(10) + " (done by Tests)\n")
def test_tests_mode_boxes_and_the_failed_module_keeps_its_sub_row(self):
b = self.board()
blocks = feed(b, *maven_events())
- self.assertEqual(plain(b, blocks, self.clock), " F Mod B 0:02 10 tests, 2 failed\n")
+ # Setup finishes once every pom box is set; actionable prints no module line (the row's text names it)
+ self.assertEqual(plain(b, blocks, self.clock), "✔ " + "Setup".ljust(10) + " # 1 pom module\n")
blocks = feed(b, ("step_end", {"id": "tests", "status": "fail", "ms": 161000, "exit": 1, "summary": None,
"totals": {"total": 7149, "fail": 2, "err": 0, "skip": 0}}))
- # the pom parent left the bar for the Setup row, so the bar counts the two real modules (spec 2.1)
- self.assertEqual(plain(b, blocks, self.clock), "Setup".ljust(10) + " # 1 pom module\n" + "Tests".ljust(10) + " #F 2/2 7,149 tests, 2 failed 2:41\n")
+ # the pom parent left the bar for the Setup row, so the bar counts the two real modules
+ self.assertEqual(plain(b, blocks, self.clock),
+ "✗ " + "Tests".ljust(10) + " #F 2/2 7,149 tests, 2 failed: Mod B 2:41\n")
self.assertEqual(b.failed_title, "Tests")
def test_compile_mode_boxes_come_from_module_compiled(self):
@@ -193,10 +196,11 @@ class BoardTest(unittest.TestCase):
events = [e for e in maven_events("build", tests_mode=False) if e[0] != "module_tested"]
events[0] = ("step_start", {"id": "build", "n": 3, "title": "Build", "parent": None, "label": "Compile"})
events.insert(12, ("module_compiled", {"step": "build", "module": "build/b", "ok": False}))
- feed(b, *events)
- blocks = feed(b, ("step_end", {"id": "build", "status": "fail", "ms": 36000, "exit": 1, "summary": None,
- "totals": None}))
- self.assertEqual(plain(b, blocks, self.clock), "Setup".ljust(10) + " # 1 pom module\n" + "Compile".ljust(10) + " #F 2/2 0:36\n")
+ blocks = feed(b, *events)
+ blocks += feed(b, ("step_end", {"id": "build", "status": "fail", "ms": 36000, "exit": 1, "summary": None,
+ "totals": None}))
+ self.assertEqual(plain(b, blocks, self.clock), "✔ " + "Setup".ljust(10) + " # 1 pom module\n"
+ + "✗ " + "Compile".ljust(10) + " #F 2/2 1 failed: Mod B 0:36\n")
def test_a_module_that_compiled_green_turns_red_on_a_later_error(self):
b = self.board()
@@ -227,11 +231,16 @@ class BoardTest(unittest.TestCase):
self.assertEqual(feed(b, ("note", {"level": "warn", "text": "careful", "step": "tests"}),
("note", {"level": "error", "text": "Bad.java:[1,21] incompatible types",
"step": "tests"})), [])
- self.assertEqual(feed(b, ("failure", {"text": "Bad.java:[1,21] incompatible types", "step": "tests"})),
- [("text", "Bad.java:[1,21] incompatible types")])
+ # failure text on a running row is held and goes out beneath it, replacing the error's identical line
+ self.assertEqual(feed(b, ("failure", {"text": "Bad.java:[1,21] incompatible types", "step": "tests"})), [])
blocks = feed(b, ("step_end", {"id": "tests", "status": "fail", "ms": 0, "exit": 1, "summary": "x",
"totals": None}))
- self.assertEqual(blocks[1:], [("text", " ⚠ careful")])
+ r = b.rows["tests"]
+ self.assertEqual((r.reason, r.reason_level), ("Bad.java:[1,21] incompatible types", "error"))
+ self.assertEqual(blocks[0], ("row", r))
+ self.assertIn(("logonly", " ⚠ careful"), blocks)
+ hints = [blk for blk in blocks if blk[0] == "hint"]
+ self.assertEqual(hints, [("hint", r, ["Bad.java:[1,21] incompatible types"])])
def test_a_note_without_a_step_attaches_to_the_latest_running_row(self):
b = self.board()
@@ -239,7 +248,10 @@ class BoardTest(unittest.TestCase):
("note", {"level": "warn", "text": "13 over baseline", "step": None}))
blocks = feed(b, ("step_end", {"id": "perf", "status": "ok", "ms": 0, "exit": None, "summary": "x",
"totals": None}))
- self.assertEqual(blocks[1:], [("text", " ⚠ 13 over baseline")])
+ r = b.rows["perf"]
+ self.assertEqual((r.reason, r.warned), ("13 over baseline", True))
+ self.assertEqual(blocks[1:], [("logonly", " ⚠ 13 over baseline")])
+ self.assertEqual(plain(b, blocks, self.clock), "⚠ " + "Perf".ljust(10) + " 13 over baseline\n")
def test_a_note_with_no_row_prints_at_once(self):
self.assertEqual(feed(self.board(), ("note", {"level": "error", "text": "no", "step": None})),
@@ -286,23 +298,25 @@ class BoardTest(unittest.TestCase):
def test_a_sub_rows_maven_modules_draw_on_its_parent_row(self):
"""release.py: each command is a run_tool sub-row (parent=<step>), and Maven's modules fill the step's bar."""
b = self.board()
- feed(b, ("step_start", {"id": "verify", "n": 1, "title": "Clean verify", "parent": None, "label": "Verify"}),
+ blocks = feed(b, ("step_start", {"id": "verify", "n": 1, "title": "Clean verify", "parent": None,
+ "label": "Verify"}),
("step_start", {"id": "verify.1", "n": 1, "title": "git status", "parent": "verify"}),
("step_end", {"id": "verify.1", "status": "ok", "ms": 10, "exit": None, "summary": None,
"totals": None}))
self.assertEqual(b.rows["verify"].boxes(), ["pass"]) # a generic command is one box
events = maven_events("verify.2")
events[0] = ("step_start", {"id": "verify.2", "n": 2, "title": "mvn -B clean verify", "parent": "verify"})
- feed(b, *events, ("quiet", {"step": "verify.2", "seconds": 20}))
+ blocks += feed(b, *events, ("quiet", {"step": "verify.2", "seconds": 20}))
r = b.rows["verify"]
self.assertEqual(list(b.rows), ["verify"])
self.assertEqual((r.boxes(), r.size, r.quiet), (["pass", "fail"], 3, 20)) # the pom is on the Setup row, not in the bar
self.assertEqual(feed(b, ("step_end", {"id": "verify.2", "status": "fail", "ms": 5000, "exit": 1,
"summary": None,
"totals": {"total": 7149, "fail": 2, "err": 0, "skip": 0}})), [])
- blocks = feed(b, ("step_end", {"id": "verify", "status": "fail", "ms": 6000, "exit": None, "summary": None,
- "totals": None}))
- self.assertEqual(plain(b, blocks, self.clock), "Setup".ljust(10) + " # 1 pom module\n" + "Verify".ljust(10) + " #F 2/2 7,149 tests, 2 failed 0:06\n")
+ blocks += feed(b, ("step_end", {"id": "verify", "status": "fail", "ms": 6000, "exit": None, "summary": None,
+ "totals": None}))
+ self.assertEqual(plain(b, blocks, self.clock), "✔ " + "Setup".ljust(10) + " # 1 pom module\n"
+ + "✗ " + "Verify".ljust(10) + " #F 2/2 7,149 tests, 2 failed: Mod B 0:06\n")
def test_a_module_that_never_ended_is_skipped_when_its_step_ends(self):
b = self.board()
@@ -361,8 +375,8 @@ class ConsoleTest(unittest.TestCase):
c("step_end", {"id": "tests", "status": "fail", "ms": 20000, "exit": 1, "summary": None, "totals": None})
c.close()
text = log.read_text(encoding="utf-8")
- self.assertIn(" F Mod B 0:02 10 tests, 2 failed\n", text)
- self.assertIn("Setup".ljust(10) + " # 1 pom module\n" + "Tests".ljust(10) + " #F 2/2", text)
+ self.assertNotIn("Mod B 0:02", text) # no module lines at actionable
+ self.assertIn("✔ " + "Setup".ljust(10) + " # 1 pom module\n" + "✗ " + "Tests".ljust(10) + " #F 2/2", text)
self.assertNotIn("…", text)
def test_plain_screen_heartbeat_every_15_seconds(self):
@@ -421,19 +435,19 @@ class OverallDisplayTest(unittest.TestCase):
b = self.board()
lines = self.live().region(b, 79, 18)
self.assertEqual(lines[0], "Overall 25% ~1:30 left · 0 of 2 steps") # 30 / (30 + 30 + 60)
- self.assertTrue(lines[1].startswith("A"))
+ self.assertTrue(lines[1][2:].startswith("A"))
self.assertEqual(len(self.live().region(b, 79, 1)), 1) # the budget still holds
def test_absent_without_a_plan_without_history_and_when_nested(self):
for b in (self.board(plan=None), self.board(hist={}), self.board(nested=True)):
- self.assertTrue(self.live().region(b, 79, 18)[0].startswith("A"))
+ self.assertTrue(self.live().region(b, 79, 18)[0][2:].startswith("A"))
def test_setup_row_sits_below_the_overall_line(self):
b = self.board()
b.apply("reactor", dict(step="a", size=5, poms=["p0", "p1"], tests=False))
lines = self.live().region(b, 79, 18)
self.assertTrue(lines[0].startswith("Overall"))
- self.assertEqual(lines[1].split()[0], "Setup")
+ self.assertEqual(lines[1].split()[1], "Setup")
def test_plain_heartbeat_gains_percent_and_remaining(self):
out = []
@@ -674,27 +688,31 @@ class LiveTest(unittest.TestCase):
self.clock.advance(1)
self.live.render(self.board, feed(self.board, *events))
+ def spin(self):
+ return jr.icon("running", self.clock()) + " "
+
def test_a_running_row_and_its_module_redraw_in_place(self):
self.push(*start(), mod_start("a"))
self.push(cls("a"), cls("a", "fail"))
self.assertEqual(self.screen.text(), "\n".join([
- ("Tests".ljust(10) + " ▒░░ 0/3").ljust(59 - 4) + "0:01", # Mod A is in flight
- " Mod A •F",
+ (self.spin() + "Tests".ljust(10) + " ▒░░ 0/3").ljust(59 - 4) + "0:01", # Mod A is in flight
+ " Mod A •F",
]))
self.push(("module_end", {"step": "tests", "module": "tests/a", "status": "fail", "ms": 1000,
"totals": (2, 1, 0, 0)}))
self.push(("step_end", {"id": "tests", "status": "fail", "ms": 4000, "exit": 1, "summary": None,
"totals": None}))
self.assertEqual(self.screen.text(), "\n".join([
- " F Mod A 0:01 2 tests, 1 failed",
- ("Tests".ljust(10) + " F░░ 1/3 no tests run").ljust(59 - 4) + "0:04", # a unit bar keeps its pending cells
+ # no module line at actionable: the row names the failed module; a unit bar keeps its pending cells
+ ("✗ " + "Tests".ljust(10) + " F░░ 1/3 1 failed: Mod A").ljust(59 - 4) + "0:04",
]))
self.assertFalse(self.screen.clamped)
def test_a_running_row_shows_its_progress_and_step_end_clears_it(self):
self.push(*start(size=0))
self.push(("step_progress", {"step": "tests", "text": "52% \u00b7 test_foo.py"}))
- self.assertEqual(self.screen.text(), ("Tests".ljust(10) + " 52% \u00b7 test_foo.py").ljust(59 - 4) + "0:01")
+ self.assertEqual(self.screen.text(),
+ (self.spin() + "Tests".ljust(10) + " 52% \u00b7 test_foo.py").ljust(59 - 4) + "0:01")
self.push(("step_progress", {"step": "tests", "text": "60% \u00b7 test_foo.py"}))
self.assertIn("60% \u00b7 test_foo.py", self.screen.text())
self.assertNotIn("52%", self.screen.text())
@@ -745,7 +763,7 @@ class LiveTest(unittest.TestCase):
self.assertGreater(len(lines), 1)
self.assertTrue(all(len(line) <= 79 for line in lines), lines)
self.assertEqual(sum(line.count("#") for line in lines), 84)
- indent = len("Tests".ljust(self.board.width())) + 2
+ indent = len("⠋ " + "Tests".ljust(self.board.width())) + 2
self.assertTrue(all(line.startswith(" " * indent + "#") for line in lines[1:]), lines)
self.assertIn("84/84", lines[0]) # count stays on the first line
self.assertTrue(all("84/84" not in line for line in lines[1:]))
@@ -809,27 +827,27 @@ class LiveTest(unittest.TestCase):
self.push(*start(size=5), *[mod_start(n) for n in "abcde"], *[cls(n) for n in "abcde"])
region = self.live.region(self.board, 59, 4)
self.assertEqual(len(region), 4)
- self.assertEqual(region[1], " +3 more running")
- self.assertTrue(region[2].startswith(" Mod D ") and region[3].startswith(" Mod E "), region)
+ self.assertEqual(region[1], " +3 more running")
+ self.assertTrue(region[2].startswith(" Mod D ") and region[3].startswith(" Mod E "), region)
self.assertTrue(all(len(line) <= 59 for line in region), region)
self.assertEqual(len(self.screen.text().splitlines()), 4)
self.assertTrue(all(len(line) <= 59 for line in self.screen.lines), self.screen.lines)
self.assertFalse(self.screen.clamped)
- def test_boxes_wrap_and_cap_at_three_lines(self):
+ def test_boxes_cap_at_one_line(self):
self.dims = (40, 10)
self.push(*start(), mod_start("a"), *[cls("a") for _ in range(200)])
- lines = self.live.region(self.board, 39, 8)[1:]
- self.assertEqual(lines, [
- " Mod A … +117 " + "•" * 23,
- " " * 9 + "•" * 30,
- " " * 9 + "•" * 30,
- ])
-
- def test_a_short_module_wraps_without_the_cap(self):
+ self.assertEqual(self.live.region(self.board, 39, 8)[1:], [" Mod A … +179 " + "•" * 21])
+
+ def test_a_module_that_exactly_fits_has_no_prefix(self):
+ self.dims = (40, 10)
+ self.push(*start(), mod_start("a"), *[cls("a") for _ in range(28)])
+ self.assertEqual(self.live.region(self.board, 39, 8)[1:], [" Mod A " + "•" * 28])
+
+ def test_a_module_one_over_shows_the_prefix(self):
self.dims = (40, 10)
- self.push(*start(), mod_start("a"), *[cls("a") for _ in range(35)])
- self.assertEqual(self.live.region(self.board, 39, 8)[1:], [" Mod A " + "•" * 30, " " * 9 + "•" * 5])
+ self.push(*start(), mod_start("a"), *[cls("a") for _ in range(29)])
+ self.assertEqual(self.live.region(self.board, 39, 8)[1:], [" Mod A … +7 " + "•" * 22])
def test_shrinking_the_region_clears_the_old_lines(self):
self.push(*start(), mod_start("a"), mod_start("b"))
@@ -889,7 +907,7 @@ class LiveTest(unittest.TestCase):
self.live.tick(self.board)
self.assertEqual(self.screen.text(), "")
self.live.resume()
- self.assertTrue(self.screen.text().startswith("Tests"))
+ self.assertTrue(self.screen.text().startswith(self.spin() + "Tests"))
def test_a_watchdog_prompt_stops_redraws_until_the_line_ends(self):
self.push(*start())
@@ -900,16 +918,18 @@ class LiveTest(unittest.TestCase):
self.live.tick(self.board)
self.assertEqual(self.screen.text(), "Passphrase:")
self.push(("prompt_end", {"step": "tests"}))
- self.assertTrue(self.screen.text().splitlines()[1].startswith("Tests"))
+ self.assertTrue(self.screen.text().splitlines()[1].startswith(self.spin() + "Tests"))
self.assertFalse(self.screen.cursor_visible)
def test_a_line_printed_during_a_prompt_starts_on_its_own_line(self):
self.push(*start())
self.push(("prompt", {"step": "tests", "text": "Passphrase: "}))
- self.push(("say", {"text": "⚠ dirty tree", "level": "warn", "step": None}))
- self.assertEqual(self.screen.text().splitlines()[:2], ["Passphrase:", "⚠ dirty tree"])
+ self.push(("step_end", {"id": "tests", "status": "ok", "ms": 0, "exit": None, "summary": "done",
+ "totals": None}))
+ self.assertEqual(self.screen.text().splitlines()[:2], ["Passphrase:", "✔ " + "Tests".ljust(10) + " done"])
def test_a_large_reactor_keeps_its_count_and_totals_and_wraps_the_bar(self):
+ self.dims = (64, 10) # 8 region lines of 10 cells hold the 80 modules
self.push(*start(size=80))
for i in range(80):
name = f"m{i}"
@@ -917,16 +937,16 @@ class LiveTest(unittest.TestCase):
"ms": 1, "totals": (1, 0, 0, 0)}))
self.push(("tests", {"step": "tests", "total": 7139, "fail": 2, "err": 0, "skip": 0}))
lines = self.screen.text().splitlines()
- self.assertTrue(all(len(line) <= 59 for line in lines), lines)
+ self.assertTrue(all(len(line) <= 63 for line in lines), lines)
self.assertNotIn("\u2026", "".join(lines)) # no cell is cut: the bar wraps instead
self.assertEqual("".join(lines).count("\u2588"), 80)
self.assertTrue(lines[0].rstrip().endswith("80/80 7,139 tests, 2 failed 1:21"), lines[0])
self.push(("step_end", {"id": "tests", "status": "fail", "ms": 90000, "exit": 1, "summary": None,
"totals": None}))
row = self.screen.text().splitlines()[0]
- self.assertLessEqual(len(row), 59, row)
+ self.assertLessEqual(len(row), 63, row)
self.assertTrue(row.endswith("80/80 7,139 tests, 2 failed 1:30"), row)
- self.assertTrue(row.startswith("Tests".ljust(10) + " ██"), row)
+ self.assertTrue(row.startswith("✗ " + "Tests".ljust(10) + " ██"), row)
def test_a_row_too_long_even_without_its_bar_is_cut_from_the_right(self):
line = jr._fit_row("A very long step label", ["█"] * 5, "5/5 7,139 tests, 2 failed", "0:01", 20)
@@ -941,10 +961,9 @@ class LiveTest(unittest.TestCase):
self.push(*start(), ("module_start", {"step": "tests", "module": "tests/a", "title": "测试" * 20}),
*[cls("a") for _ in range(100)])
subs = self.live.region(self.board, 39, 8)[1:]
- self.assertEqual(len(subs), 3)
- self.assertTrue(all(jr._vlen(line) <= 39 for line in subs), subs)
- self.assertTrue(subs[0].startswith(" " + "测试" * 4 + "测 "), subs[0])
- self.assertEqual(jr._vlen(subs[1]), 39)
+ self.assertEqual(len(subs), 1)
+ self.assertTrue(jr._vlen(subs[0]) <= 39, subs)
+ self.assertTrue(subs[0].startswith(" " + "测试" * 4 + "测 "), subs[0])
def test_close_is_idempotent_and_stops_redraws(self):
writes = []
@@ -958,8 +977,9 @@ class LiveTest(unittest.TestCase):
self.assertEqual(len(writes), closed)
live.render(self.board, feed(self.board, ("module_end", {"step": "tests", "module": "tests/a",
"status": "ok", "ms": 1, "totals": None}),
- ("say", {"text": "⚠ late", "level": "warn", "step": None})))
- self.assertEqual("".join(writes[closed:]), "⚠ late\n")
+ ("step_end", {"id": "tests", "status": "ok", "ms": 0, "exit": None,
+ "summary": "late", "totals": None})))
+ self.assertEqual("".join(writes[closed:]), "✔ " + "Tests".ljust(10) + " late\n")
def test_step_end_clears_the_quiet_state(self):
b = jr.Board("actionable", clock=self.clock)
@@ -1012,20 +1032,20 @@ class CollapsedModuleLineTest(unittest.TestCase):
self.board = jr.Board(clock=lambda: 100.0)
lines = self.region(*start(step, 4), mod_start("a", step), mod_start("b", step), mod_start("c", step))
self.assertEqual(len(lines), 2)
- self.assertEqual(lines[1], " A, B, C")
+ self.assertEqual(lines[1], " A, B, C")
def test_modules_with_results_keep_their_own_sub_lines(self):
lines = self.region(*start(), mod_start("a"), mod_start("b"), cls("a"), cls("a", "fail"))
self.assertEqual(len(lines), 3)
- self.assertEqual(lines[1], " B") # name-only first, then the detail sub-lines
- self.assertTrue(lines[2].startswith(" Mod A "))
+ self.assertEqual(lines[1], " B") # name-only first, then the detail sub-lines
+ self.assertTrue(lines[2].startswith(" Mod A "))
def test_a_module_with_a_first_result_leaves_the_collapsed_line_on_the_next_redraw(self):
lines = self.region(*start(), mod_start("a"), mod_start("b"))
- self.assertEqual(lines[1], " A, B")
+ self.assertEqual(lines[1], " A, B")
lines = self.region(cls("b"))
- self.assertEqual(lines[1], " A")
- self.assertTrue(lines[2].startswith(" Mod B "))
+ self.assertEqual(lines[1], " A")
+ self.assertTrue(lines[2].startswith(" Mod B "))
def test_pom_modules_are_never_on_the_collapsed_line(self):
feed(self.board, ("step_start", {"id": "mvn", "n": 1, "title": "mvn", "parent": None}),
@@ -1033,13 +1053,13 @@ class CollapsedModuleLineTest(unittest.TestCase):
("module_start", {"step": "mvn", "module": "mvn/p", "title": "Mod P"}))
lines = self.region(("module_start", {"step": "mvn", "module": "mvn/a", "title": "Mod A"}),
("module_start", {"step": "mvn", "module": "mvn/b", "title": "Mod B"}))
- self.assertEqual([line for line in lines if line.startswith(" ")], [" A, B"])
+ self.assertEqual([line for line in lines if line.startswith(" ")], [" A, B"])
def test_the_prefix_uses_every_module_seen_not_only_those_in_flight(self):
lines = self.region(*start(), mod_start("a"), mod_start("b"), mod_start("c"),
("module_end", {"step": "tests", "module": "tests/b", "status": "ok", "ms": 1}),
("module_end", {"step": "tests", "module": "tests/c", "status": "ok", "ms": 1}))
- self.assertEqual(lines[1], " A") # still "Mod " stripped though only one is in flight
+ self.assertEqual(lines[1], " A") # still "Mod " stripped though only one is in flight
class LiveConsoleTest(unittest.TestCase):
@@ -1134,7 +1154,8 @@ class RobustnessTest(unittest.TestCase):
console = self.live_console(out)
console("step_start", {"id": "a", "n": 1, "title": "Alpha", "parent": None})
with mock.patch.object(jr, "row_cells", side_effect=RuntimeError("shared")), \
- mock.patch.object(jr, "row_parts", side_effect=RuntimeError("shared")):
+ mock.patch.object(jr, "row_parts", side_effect=RuntimeError("shared")), \
+ mock.patch.object(jr, "row_text", side_effect=RuntimeError("shared")):
console("step_end", {"id": "a", "status": "ok", "ms": 10, "exit": None, "summary": None, "totals": None})
console("step_start", {"id": "b", "n": 2, "title": "Beta", "parent": None})
console.tick()
@@ -1170,7 +1191,10 @@ class RobustnessTest(unittest.TestCase):
for kind, fields in start() + [mod_start("a"), cls("a")]:
console(kind, fields)
with mock.patch.object(jr.LiveRenderer, "_sub_lines", side_effect=RuntimeError("sub")):
- console("say", {"text": "⚠ hello-once", "level": "warn", "step": None})
+ # a warn say would become the running row's reason, so print a finished row instead
+ console("step_start", {"id": "b", "n": 2, "title": "Beta", "parent": None})
+ console("step_end", {"id": "b", "status": "ok", "ms": 0, "exit": None, "summary": "hello-once",
+ "totals": None})
self.assertIsInstance(console.screen, jr.PlainRenderer)
self.assertEqual(out.getvalue().count("hello-once"), 1)
@@ -1471,5 +1495,352 @@ class DampedEstimateTest(unittest.TestCase):
self.assertEqual(jr.damp_total(None, 472, 10), 465)
+def step_end(step, status="ok", ms=2000, summary=None, totals=None, exit=None):
+ return ("step_end", {"id": step, "status": status, "ms": ms, "exit": exit, "summary": summary, "totals": totals})
+
+
+def step_start(step, title=None, n=1, parent=None):
+ return ("step_start", {"id": step, "n": n, "title": title or step.title(), "parent": parent})
+
+
+def note(level, text, step=None):
+ return ("note", {"level": level, "text": text, "href": None, "step": step})
+
+
+class RowCase(unittest.TestCase):
+ def setUp(self):
+ reset_run_state()
+ self.addCleanup(reset_run_state)
+ self.clock = Clock()
+
+ def board(self, detail="actionable", **kw):
+ return jr.Board(detail, nested=kw.pop("nested", False), full_log=None, clock=self.clock, **kw)
+
+ def finished(self, *events, detail="actionable"):
+ """A board fed `events` then the row "a"'s end (ok unless an event set otherwise); returns (row, blocks)."""
+ b = self.board(detail)
+ blocks = feed(b, step_start("a"), *events)
+ return b, b.rows["a"], blocks
+
+
+class IconTest(RowCase):
+ def test_spinner_frame_follows_clock(self):
+ frames = [jr.icon("running", 100.0 + i * jr.LiveRenderer.REDRAW_SECONDS + 0.01) for i in range(11)]
+ self.assertEqual("".join(frames[:10]), "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
+ self.assertEqual(frames[10], frames[0])
+ self.assertEqual(jr.icon("running", 100.01, color=True), "\x1b[36m⠋\x1b[0m") # cyan
+
+ def test_finished_icons_coloured(self):
+ for state, glyph, sgr in (("ok", "✔", "32"), ("warn", "⚠", "33"), ("fail", "✗", "31"), ("pending", "○", "2")):
+ with self.subTest(state=state):
+ self.assertEqual(jr.icon(state, color=True), f"\x1b[{sgr}m{glyph}\x1b[0m")
+
+ def test_skip_icon_dim_single_column(self):
+ self.assertEqual(jr.icon("skip", color=True), "\x1b[2m⊘\x1b[0m")
+ self.assertEqual(jr._vlen("⊘"), 1)
+ self.assertNotIn("️", "".join(jr.ICONS.values())) # never with VS16
+
+ def test_no_color_same_glyphs(self):
+ for state, glyph in jr.ICONS.items():
+ with self.subTest(state=state):
+ self.assertEqual(jr.icon(state), glyph)
+
+ def test_plain_and_log_static_icon(self):
+ b = self.board()
+ blocks = feed(b, step_start("a"), step_end("a"), step_start("b"), step_end("b", "skip", summary="(skipped)"))
+ self.assertEqual(plain(b, blocks, self.clock), "✔ A 0:02\n⊘ B skipped 0:02\n".replace(
+ "A ", "A".ljust(10) + " ").replace("B ", "B".ljust(10) + " "))
+ self.assertNotIn("\x1b[", plain(b, blocks, self.clock))
+
+
+class RowStatusTest(RowCase):
+ def test_warn_note_flips_ok_row_to_warn(self):
+ b, r, _ = self.finished(note("warn", "13 over baseline"), step_end("a"))
+ self.assertEqual((r.status, jr.row_state(r), jr.row_icon(r, 0, False)), ("ok", "warn", "⚠"))
+
+ def test_error_note_on_ok_row_is_warn(self):
+ b, r, _ = self.finished(note("error", "flaky"), step_end("a"))
+ self.assertEqual(jr.row_state(r), "warn")
+
+ def test_fail_beats_warn(self):
+ b, r, _ = self.finished(note("warn", "careful"), step_end("a", "fail", exit=1))
+ self.assertEqual(jr.row_state(r), "fail")
+
+ def test_every_skip_is_skip_icon(self):
+ for summary in ("(skipped)", "(done by Tests)", "(done earlier)", None):
+ with self.subTest(summary=summary):
+ b, r, blocks = self.finished(step_end("a", "skip", summary=summary))
+ self.assertEqual(jr.row_icon(r, 0, False), "⊘")
+ if summary:
+ self.assertIn(summary.strip("()"), plain(b, blocks, self.clock))
+ self.assertNotIn("(", plain(b, blocks, self.clock))
+
+ def test_warn_on_skipped_row_keeps_skip_icon(self):
+ b, r, blocks = self.finished(note("warn", "no docs checkout"), step_end("a", "skip", summary="(skipped)"))
+ self.assertEqual((jr.row_icon(r, 0, False), r.reason), ("⊘", "no docs checkout"))
+ self.assertIn("no docs checkout", plain(b, blocks, self.clock))
+
+ def test_newest_warn_is_reason(self):
+ b, r, blocks = self.finished(note("warn", "first"), note("warn", "second\nmore"), step_end("a"))
+ self.assertEqual((r.reason, r.reason_level), ("second", "warn"))
+ self.assertIn("⚠ " + "A".ljust(10) + " second", plain(b, blocks, self.clock))
+
+ def test_error_reason_not_replaced_by_warn(self):
+ b, r, _ = self.finished(note("error", "broke"), note("warn", "later"), step_end("a", "fail", exit=1))
+ self.assertEqual((r.reason, r.reason_level), ("broke", "error"))
+
+ def test_reason_cut_before_bar(self):
+ b = self.board()
+ feed(b, step_start("a"), ("units", {"step": "a", "total": 5, "kind": "checks"}),
+ *[("unit", {"step": "a", "done": i + 1, "outcome": "pass"}) for i in range(5)],
+ note("warn", "a reason far too long to fit on the row beside its bar " * 3))
+ live = jr.LiveRenderer(lambda t: None, size=lambda: os.terminal_size((60, 10)), clock=self.clock, color=False)
+ line = live._row(b, b.rows["a"], 59, pending=True)
+ self.assertNotIn("\n", line)
+ self.assertLessEqual(jr._vlen(line), 59)
+ self.assertIn("█████ 5/5 a reason", line) # every cell kept
+ self.assertIn("…", line)
+
+ def test_failed_modules_reason_first_plus_n(self):
+ b = self.board()
+ events = [step_start("a", "Build"), ("reactor", {"step": "a", "size": 3, "poms": [], "tests": False})]
+ for name, status in (("x", "fail"), ("y", "ok"), ("z", "fail")):
+ events += [("module_start", {"step": "a", "module": name, "title": f"Mod {name.upper()}"}),
+ ("module_end", {"step": "a", "module": name, "status": status, "ms": 1, "totals": None})]
+ blocks = feed(b, *events, step_end("a", "fail", exit=1))
+ self.assertIn("2 failed: Mod X +1", plain(b, blocks, self.clock))
+
+ def test_tests_row_reason_appends_failed_module(self):
+ b = self.board()
+ blocks = feed(b, *maven_events(), step_end("tests", "fail", exit=1,
+ totals={"total": 7149, "fail": 2, "err": 0, "skip": 0}))
+ self.assertIn("7,149 tests, 2 failed: Mod B ", plain(b, blocks, self.clock))
+
+
+class HintTest(RowCase):
+ def test_fail_prints_hint_lines_beneath_row(self):
+ b, r, blocks = self.finished(("hint", {"step": "a", "lines": ["run this:", " git pull"]}))
+ blocks += feed(b, step_end("a", "fail", exit=1))
+ self.assertEqual(plain(b, blocks, self.clock).splitlines()[1:], [" run this:", " git pull"])
+
+ def test_failure_text_held_until_row_ends(self):
+ b, r, blocks = self.finished(("failure", {"text": "Bad.java:[1,2] oops", "step": "a"}))
+ self.assertEqual(blocks, [])
+ blocks = feed(b, step_end("a", "fail", exit=1))
+ self.assertEqual(plain(b, blocks, self.clock).splitlines()[1:], [" Bad.java:[1,2] oops"])
+
+ def test_hint_dedup_with_failure_lines(self):
+ b, r, _ = self.finished(("failure", {"text": "same line", "step": "a"}),
+ ("hint", {"step": "a", "lines": ["same line", "new line"]}))
+ blocks = feed(b, step_end("a", "fail", exit=1))
+ self.assertEqual(plain(b, blocks, self.clock).splitlines()[1:], [" same line", " new line"])
+
+ def test_session_fail_hint_reaches_screen_at_actionable(self):
+ import scripts_support # noqa: F401 - puts juneau_scripts on sys.path
+ from juneau_scripts import run
+ out = io.StringIO()
+ with mock.patch.dict(os.environ, {"RUN_MARKERS": ""}), mock.patch.dict(sys.modules, {"juneau_run": jr}):
+ jr._console = jr.subscribe(jr.Console(jr.Sinks(console="condensed"), stream=out))
+ self.assertEqual(jr._console.board.detail, "actionable")
+ with self.assertRaises(run._SessionFailed):
+ run.Session().fail("❌ No.", hint=[" try this"])
+ jr._console.close()
+ self.assertIn(" try this", out.getvalue())
+
+ def test_hint_without_running_row_prints_at_once(self):
+ b = self.board()
+ blocks = feed(b, ("hint", {"step": None, "lines": ["do this"]}))
+ self.assertEqual(plain(b, blocks, self.clock), "do this\n")
+
+
+class FullLogNotesTest(RowCase):
+ def test_say_note_hint_failure_appended_to_full_log(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ full = Path(tmp) / "full.log"
+ c = jr.Console(jr.Sinks(console="none", full_log=str(full)), stream=io.StringIO(), clock=self.clock)
+ c("step_start", {"id": "a", "n": 1, "title": "A", "parent": None})
+ c("say", {"text": "said", "level": "info", "step": None})
+ c("note", {"level": "warn", "text": "noted", "href": None, "step": "a"})
+ c("hint", {"step": "a", "lines": ["hint one", "hint two"]})
+ c("failure", {"text": "failed text", "step": "a"})
+ c.close()
+ text = full.read_text(encoding="utf-8")
+ self.assertEqual(text.splitlines()[-5:], ["said", "⚠ noted", "hint one", "hint two", "failed text"])
+
+
+class PendingCase(RowCase):
+ def live(self, rows=20, cols=80):
+ return jr.LiveRenderer(lambda t: None, size=lambda: os.terminal_size((cols, rows)), clock=self.clock,
+ color=False)
+
+ def planned(self, plan, **kw):
+ b = self.board(**kw)
+ feed(b, ("plan", {"ids": [e if isinstance(e, str) else e[0] for e in plan],
+ "labels": {e[0]: e[1] for e in plan if not isinstance(e, str)}}))
+ return b
+
+
+class PendingTest(PendingCase):
+ PLAN = [("pre", "Prechecks"), ("tests", "Tests"), "hidden", ("install", "Install"), ("commit", "Commit")]
+
+ def test_labelled_plan_draws_pending_rows(self):
+ b = self.planned(self.PLAN)
+ feed(b, step_start("pre", "Prechecks"))
+ region = self.live().region(b, 79, 18)
+ self.assertEqual(region[-3:], ["○ Tests", "○ Install", "○ Commit"])
+
+ def test_unlabelled_ids_count_but_do_not_draw(self):
+ b = self.planned(self.PLAN)
+ feed(b, step_start("pre", "Prechecks"))
+ self.assertNotIn("○ hidden", self.live().region(b, 79, 18))
+ self.assertEqual(b.plan, ["pre", "tests", "hidden", "install", "commit"])
+
+ def test_started_row_replaces_pending(self):
+ b = self.planned(self.PLAN)
+ feed(b, step_start("pre", "Prechecks"), step_end("pre"), step_start("tests", "Tests"))
+ region = self.live().region(b, 79, 18)
+ self.assertNotIn("○ Tests", region)
+ self.assertTrue(any(line[2:].startswith("Tests") for line in region), region)
+
+ def test_passed_over_pending_not_drawn(self):
+ b = self.planned(self.PLAN)
+ feed(b, step_start("install", "Install")) # Prechecks and Tests were passed over
+ self.assertEqual([line for line in self.live().region(b, 79, 18) if line.startswith("○")], ["○ Commit"])
+
+ def test_width_includes_pending_labels(self):
+ b = self.planned([("a", "A"), ("long", "A much longer label")])
+ feed(b, step_start("a", "A"))
+ self.assertEqual(b.width(), len("A much longer label"))
+
+ def test_no_plan_unchanged(self):
+ b = self.board()
+ feed(b, step_start("a", "A"))
+ self.assertEqual(b.pending(), [])
+ self.assertEqual(len(self.live().region(b, 79, 18)), 1)
+
+
+class FoldTest(PendingCase):
+ def busy(self):
+ b = self.planned([("tests", "Tests")] + [(f"p{i}", f"Pending {i}") for i in range(6)])
+ feed(b, *start(size=4), *[mod_start(n) for n in "abcd"], *[cls(n) for n in "abcd"])
+ return b
+
+ def test_pending_folds_before_module_lines(self):
+ region = self.live().region(self.busy(), 79, 8)
+ self.assertEqual(len(region), 8)
+ self.assertEqual(sum(line.startswith(" Mod ") for line in region), 4) # every module line kept
+ self.assertEqual(region[-1], "○ +4 more pending")
+ self.assertEqual(region[-3:-1], ["○ Pending 0", "○ Pending 1"])
+
+ def test_more_pending_line_always_kept(self):
+ region = self.live().region(self.busy(), 79, 4)
+ self.assertEqual(region[-1], "○ +6 more pending")
+
+ def test_then_module_lines_fold(self):
+ region = self.live().region(self.busy(), 79, 5)
+ self.assertEqual(len(region), 5)
+ self.assertIn(" +2 more running", region)
+ self.assertEqual(region[-1], "○ +6 more pending")
+
+
+class ModuleLinesTest(RowCase):
+ def test_no_finished_module_lines_at_actionable(self):
+ for detail in ("summary", "actionable"):
+ with self.subTest(detail=detail):
+ b = self.board(detail)
+ self.assertEqual([blk for blk in feed(b, *maven_events()) if blk[0] == "module"], [])
+
+ def test_modules_detail_keeps_lines(self):
+ b = self.board("modules")
+ blocks = feed(b, *maven_events())
+ self.assertIn(" F Mod B 0:02 10 tests, 2 failed\n", plain(b, blocks, self.clock))
+
+ def test_inflight_sub_line_unchanged(self):
+ b = self.board()
+ feed(b, *start(), mod_start("a"), cls("a"), cls("a", "fail"))
+ live = jr.LiveRenderer(lambda t: None, size=lambda: os.terminal_size((60, 10)), clock=self.clock, color=False)
+ self.assertEqual(live.region(b, 59, 8)[1:], [" Mod A •F"])
+
+
+class SetupRowTest(RowCase):
+ def events(self, pom_status="ok"):
+ return [step_start("tests", "Tests"),
+ ("reactor", {"step": "tests", "size": 3, "poms": ["p0", "p1"], "tests": True}),
+ ("module_start", {"step": "tests", "module": "p0", "title": "Parent"}),
+ ("module_end", {"step": "tests", "module": "p0", "status": "ok", "ms": 1, "totals": None}),
+ ("module_start", {"step": "tests", "module": "p1", "title": "Bom"}),
+ ("module_end", {"step": "tests", "module": "p1", "status": pom_status, "ms": 1, "totals": None})]
+
+ def test_setup_finishes_when_poms_done(self):
+ b = self.board()
+ blocks = feed(b, *self.events()[:-2])
+ self.assertNotIn(("setup",), blocks)
+ blocks = feed(b, *self.events()[-2:]) # a pom's box is set when it starts: a pom has no work to wait for
+ self.assertEqual(blocks, [("setup",)])
+ self.assertTrue(b.setup.ended)
+ self.assertEqual(plain(b, blocks, self.clock), "✔ " + "Setup".ljust(10) + " ## 2 pom modules\n")
+
+ def test_setup_fails_on_pom_failure(self):
+ b = self.board()
+ out = []
+ renderer = jr.PlainRenderer(out.append, clock=self.clock)
+ for event in self.events("fail"):
+ renderer.render(b, feed(b, event))
+ # green at the last pom's start, then printed again red when that pom fails
+ self.assertEqual("".join(out), "✔ " + "Setup".ljust(10) + " ## 2 pom modules\n"
+ + "✗ " + "Setup".ljust(10) + " #F 1 failed: Bom\n")
+
+ def test_setup_ends_with_owner(self):
+ b = self.board()
+ feed(b, *self.events()[:4]) # p1 never reports
+ blocks = feed(b, step_end("tests", "fail", exit=1))
+ self.assertEqual(blocks[0], ("setup",))
+ self.assertEqual(blocks[1], ("row", b.rows["tests"]))
+ self.assertTrue(b.setup.ended)
+ self.assertEqual(feed(b, step_start("next")), []) # a later row does not reopen it
+
+
+class NestedPendingTest(PendingCase):
+ def test_handoff_exports_pending(self):
+ b = self.planned([("pre", "Prechecks"), ("tests", "Tests"), ("install", "Install")])
+ feed(b, step_start("pre", "Prechecks"), step_end("pre"), step_start("tests", "Tests"))
+ self.assertEqual(b.pending(), [("install", "Install")])
+ console = mock.Mock(mode="live", board=b)
+ with mock.patch.object(jr, "_console", console), jr.handoff() as env:
+ self.assertEqual(json.loads(env["JUNEAU_RUN_PENDING"]), [["install", "Install"]])
+
+ def test_child_draws_parent_pending_dedup(self):
+ pending = json.dumps([["install", "Install"], ["commit", "Commit"]])
+ with mock.patch.dict(os.environ, {"JUNEAU_RUN_PENDING": pending}):
+ b = self.planned([("install", "Install")], nested=True)
+ feed(b, step_start("compile", "Compile"))
+ self.assertEqual(b.pending(), [("install", "Install"), ("commit", "Commit")])
+ self.assertEqual([line for line in self.live().region(b, 79, 18) if line.startswith("○")],
+ ["○ Install", "○ Commit"])
+ feed(b, step_end("compile"), ("done", {"status": "ok", "commit": None}))
+ self.assertEqual(b.pending(), [])
+
+
+class LogTest(RowCase):
+ def run_log(self, *events):
+ with tempfile.TemporaryDirectory() as tmp:
+ log = Path(tmp) / "c.log"
+ c = jr.Console(jr.Sinks(console="none", condensed_log=str(log)), stream=io.StringIO(), clock=self.clock)
+ for kind, fields in events:
+ c(kind, fields)
+ c.close()
+ return log.read_text(encoding="utf-8")
+
+ def test_log_keeps_note_text_beneath_row(self):
+ text = self.run_log(step_start("a", "A"), note("warn", "first line\nsecond line", "a"), step_end("a"))
+ self.assertIn("⚠ " + "A".ljust(10) + " first line", text)
+ self.assertIn(" ⚠ first line\n second line\n", text)
+
+ def test_log_has_no_pending_rows(self):
+ text = self.run_log(("plan", {"ids": ["a", "b"], "labels": {"a": "A", "b": "B"}}), step_start("a", "A"),
+ step_end("a"))
+ self.assertNotIn("○", text)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/juneau-run/src/test/python/test_runner.py b/juneau-run/src/test/python/test_runner.py
index 5f0f6650ca..12ecab2d71 100644
--- a/juneau-run/src/test/python/test_runner.py
+++ b/juneau-run/src/test/python/test_runner.py
@@ -562,6 +562,7 @@ class ScriptApiTest(unittest.TestCase):
self.console = mock.Mock(mode="live")
self.console.board.width.return_value = 9
self.console.board.detail = "modules"
+ self.console.board.pending.return_value = []
patcher = mock.patch.object(jr, "_console", self.console)
patcher.start()
self.addCleanup(patcher.stop)
@@ -680,6 +681,11 @@ class ScriptApiTest(unittest.TestCase):
self.assertEqual(self.calls(), ["pause"])
self.assertEqual(self.calls(), ["pause", "resume"])
+ def test_handoff_exports_the_pending_rows(self):
+ self.console.board.pending.return_value = [("release", "Release")]
+ with jr.handoff() as env:
+ self.assertEqual(json.loads(env["JUNEAU_RUN_PENDING"]), [["release", "Release"]])
+
def test_console_active(self):
self.assertTrue(jr.console_active())
self.console.mode = None
diff --git a/juneau-run/src/test/python/test_scripts_checks.py b/juneau-run/src/test/python/test_scripts_checks.py
new file mode 100644
index 0000000000..50f98ceeee
--- /dev/null
+++ b/juneau-run/src/test/python/test_scripts_checks.py
@@ -0,0 +1,206 @@
+# ***************************************************************************************************************************
+# * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file
+# * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file
+# * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance
+# * with the License. You may obtain a copy of the License at
+# *
+# * http://www.apache.org/licenses/LICENSE-2.0
+# *
+# * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
+# * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# * specific language governing permissions and limitations under the License.
+# ***************************************************************************************************************************
+"""juneau_scripts.checks and the runners that drive it: run.checks, Session.checks and Registry.checks."""
+
+import argparse
+import io
+import os
+import sys
+import unittest
+from contextlib import redirect_stdout
+from unittest import mock
+
+import scripts_support
+from scripts_support import isolate_run_env, jr, markers, reset_run_state
+from test_scripts_session import SessionCase
+
+from juneau_scripts import checks, run, steps, testing
+from juneau_scripts.checks import Check, Verdict
+
+IDENTITY = Check("identity", lambda: Verdict("fail", "git user.email is not set",
+ ("set it:", " git config user.email a@b")))
+UPSTREAM = Check("upstream", lambda: True)
+UNREVIEWED = Check("unreviewed", lambda: Verdict("fail", "2 unreviewed paths", ("a.txt", "b.txt")))
+
+
+class ConsoleCase(unittest.TestCase):
+ """juneau_run loaded with a plain-screen console subscribed, and every bus event recorded in self.seen."""
+
+ def setUp(self):
+ isolate_run_env(self)
+ reset_run_state()
+ self.addCleanup(reset_run_state)
+ sys.modules["juneau_run"] = jr
+ os.environ.pop("RUN_MARKERS", None)
+ run._n = 0
+ self.seen = []
+ jr.subscribe(lambda kind, fields: self.seen.append((kind, dict(fields))))
+ self.out = io.StringIO()
+ jr._console = jr.subscribe(jr.Console(jr.Sinks(console="condensed"), stream=self.out))
+
+ def of(self, kind):
+ return [f for k, f in self.seen if k == kind]
+
+ def screen(self):
+ jr._console.close()
+ return self.out.getvalue()
+
+
+class VerdictTest(unittest.TestCase):
+ def test_status_is_validated_and_a_str_hint_is_one_line(self):
+ with self.assertRaises(ValueError):
+ Verdict("maybe")
+ self.assertEqual(Verdict("warn", "x", "one line").hint, ("one line",))
+
+ def test_evaluate_maps_bools_and_none(self):
+ self.assertEqual(checks.evaluate(Check("a", lambda: None)), checks.PASS)
+ self.assertEqual(checks.evaluate(Check("a", lambda: True)), checks.PASS)
+ self.assertEqual(checks.evaluate(Check("a", lambda: False)).status, "fail")
+ self.assertIn("not a Verdict", checks.evaluate(Check("a", lambda: "yes")).reason)
+
+
+class ChecksTest(ConsoleCase):
+ def test_one_cell_per_check(self):
+ run.checks("pre", "Prechecks", [IDENTITY, UPSTREAM, UNREVIEWED])
+ self.assertEqual(self.of("units"), [{"step": "pre", "total": 3, "kind": "checks"}])
+ self.assertEqual([u["outcome"] for u in self.of("unit")], ["fail", "pass", "fail"])
+ self.assertIn("F#F 3/3", self.screen())
+
+ def test_progress_text_is_check_name(self):
+ run.checks("pre", "Prechecks", [IDENTITY, UPSTREAM, UNREVIEWED])
+ self.assertEqual([p["text"] for p in self.of("step_progress")], ["identity", "upstream", "unreviewed"])
+
+ def test_fail_sets_reason_and_hint(self):
+ result = run.checks("pre", "Prechecks", [UPSTREAM, IDENTITY])
+ self.assertFalse(result.ok)
+ row = jr._console.board.rows["pre"]
+ self.assertEqual((row.status, row.reason, row.reason_level), ("fail", "git user.email is not set", "error"))
+ self.assertEqual(self.of("hint"), [{"step": "pre", "lines": ["identity: set it:",
+ " git config user.email a@b"]}])
+ screen = self.screen()
+ self.assertIn("✗ Prechecks", screen)
+ self.assertIn(" identity: set it:\n git config user.email a@b\n", screen)
+
+ def test_all_checks_run_reason_plus_n(self):
+ ran = []
+ listed = [Check(n, lambda n=n: ran.append(n) or Verdict("fail", f"{n} broke")) for n in ("a", "b", "c")]
+ result = run.checks("pre", "Prechecks", listed)
+ self.assertEqual(ran, ["a", "b", "c"])
+ self.assertEqual(list(result.verdicts), ["a", "b", "c"])
+ self.assertEqual(jr._console.board.rows["pre"].reason, "a broke +2")
+
+ def test_raise_fails_closed(self):
+ result = run.checks("pre", "Prechecks", [Check("java", lambda: 1 / 0)])
+ self.assertFalse(result.ok)
+ self.assertEqual(result.verdicts["java"].reason, "could not check java: division by zero")
+
+ def test_dry_run_fail_becomes_warn_with_hints(self):
+ result = run.checks("pre", "Prechecks", [UPSTREAM, UNREVIEWED], dry_run=True)
+ self.assertTrue(result.ok)
+ self.assertEqual(result.verdicts["unreviewed"], Verdict("warn", "would refuse: 2 unreviewed paths",
+ ("a.txt", "b.txt")))
+ self.assertEqual([u["outcome"] for u in self.of("unit")], ["pass", "warn"])
+ row = jr._console.board.rows["pre"]
+ self.assertEqual((row.status, jr.row_state(row)), ("ok", "warn"))
+ screen = self.screen()
+ self.assertIn("⚠ Prechecks", screen)
+ self.assertIn("#! 2/2 would refuse: 2 unreviewed paths", screen)
+ self.assertIn(" unreviewed: a.txt\n b.txt\n", screen)
+
+ def test_tick_called_between_checks(self):
+ calls = []
+ with mock.patch.object(jr, "tick", side_effect=lambda: calls.append("tick")):
+ listed = [Check(n, lambda n=n: calls.append(n)) for n in ("a", "b")]
+ run.checks("pre", "Prechecks", listed)
+ self.assertEqual(calls, ["tick", "a", "tick", "tick", "b", "tick"])
+
+
+class MarkersTest(unittest.TestCase):
+ def setUp(self):
+ isolate_run_env(self)
+ reset_run_state()
+ self.addCleanup(reset_run_state)
+ sys.modules["juneau_run"] = jr
+ os.environ["RUN_MARKERS"] = "1"
+ run._n = 0
+
+ def test_markers_one_step_with_notes(self):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ run.checks("pre", "Prechecks", [IDENTITY, UPSTREAM, UNREVIEWED])
+ found = markers(out.getvalue())
+ self.assertEqual([m["ev"] for m in found], ["step", "note", "note", "end"])
+ self.assertEqual([m["text"] for m in found if m["ev"] == "note"],
+ ["identity: git user.email is not set", "unreviewed: 2 unreviewed paths"])
+ self.assertEqual({m.get("level") for m in found if m["ev"] == "note"}, {"error"})
+
+
+class PlainRunTest(unittest.TestCase):
+ def setUp(self):
+ isolate_run_env(self)
+ cm = testing.clean_run_env()
+ cm.__enter__()
+ self.addCleanup(cm.__exit__, None, None, None)
+ patcher = mock.patch.dict(sys.modules)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ sys.modules.pop("juneau_run", None)
+ run._n = 0
+
+ def test_plain_run_prints_verdicts(self):
+ out = io.StringIO()
+ warned = Check("staged", lambda: Verdict("warn", "nothing staged"))
+ with redirect_stdout(out):
+ result = run.checks("pre", "Prechecks", [UPSTREAM, warned, IDENTITY])
+ self.assertFalse(result.ok)
+ self.assertEqual(out.getvalue(), "✔ upstream\n⚠ staged: nothing staged\n✗ identity: git user.email is not set\n"
+ " identity: set it:\n git config user.email a@b\n")
+
+
+class SessionChecksTest(SessionCase):
+ def test_session_checks_ends_session_silently(self):
+ ran = []
+
+ def body(s):
+ s.checks("pre", "Prechecks", [IDENTITY])
+ ran.append("after")
+ s, out = self.go(body)
+ self.assertEqual((s.code, ran), (1, []))
+ self.assertNotIn("ERROR", out)
+ self.assertIn("✗ identity: git user.email is not set", out)
+
+ def test_a_passing_session_checks_returns_the_result(self):
+ def body(s):
+ body.result = s.checks("pre", "Prechecks", [UPSTREAM])
+ s, _ = self.go(body)
+ self.assertEqual((s.code, body.result.ok), (0, True))
+
+
+class RegistryChecksTest(ConsoleCase):
+ def test_registry_checks_step(self):
+ reg = steps.Registry()
+ reg.checks("prechecks", "Prechecks", lambda ctx: [UPSTREAM, ctx])
+ reg.step("build", "Build")(lambda ctx: None)
+ parser = argparse.ArgumentParser(prog="r.py")
+ reg.add_arguments(parser)
+ result = reg.run(parser.parse_args([]), ctx=IDENTITY)
+ self.assertEqual((result.ok, result.code), (False, 1))
+ self.assertEqual([u["outcome"] for u in self.of("unit")], ["pass", "fail"])
+ self.assertNotIn("build", [f["id"] for f in self.of("step_start")])
+ row = jr._console.board.rows["prechecks"]
+ self.assertEqual((row.status, row.reason), ("fail", "git user.email is not set"))
+ self.assertIn("To resume: r.py --start-step prechecks", self.screen())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/juneau-run/src/test/python/test_scripts_git.py b/juneau-run/src/test/python/test_scripts_git.py
index 7d77d9aa86..edd74dbc20 100644
--- a/juneau-run/src/test/python/test_scripts_git.py
+++ b/juneau-run/src/test/python/test_scripts_git.py
@@ -11,6 +11,8 @@
# * specific language governing permissions and limitations under the License.
"""juneau_scripts.git against real temporary repositories with a bare origin; nothing is mocked except PATH."""
+import contextlib
+import io
import json
import os
import shlex
@@ -147,6 +149,13 @@ class UpstreamTest(GitCase):
def test_rebase_onto_upstream(self):
self.assertTrue(git.rebase_onto_upstream(self.work, capture=True).ok)
+ def test_rebase_onto_upstream_does_not_autostash_by_default(self):
+ with mock.patch.object(git, "_write", return_value=git.Result(True, 0)) as write:
+ git.rebase_onto_upstream(self.work)
+ git.rebase_onto_upstream(self.work, autostash=True)
+ self.assertEqual([c.args[0] for c in write.call_args_list],
+ [["git", "rebase", "@{upstream}"], ["git", "rebase", "--autostash", "@{upstream}"]])
+
class FailClosedTest(GitCase):
def test_not_a_repository_raises_from_every_reader(self):
@@ -315,8 +324,19 @@ class NeverAddOrForceTest(GitCase):
git.rebase_onto_upstream(self.work, capture=True)
git.tag("v1", self.work, message="v1", capture=True)
git.delete_tag("v1", self.work, remote="origin", capture=True)
+ git.unreviewed_paths(self.work)
+ git.upstream_verdict(self.work)
+ git.push_command(self.work)
+ self.g("reset", "-q", "--hard", "HEAD~1") # behind origin, so the rebase stashes and restores
+ self.write("c.txt")
+ self.g("add", "c.txt")
+ with mock.patch.dict(os.environ, {"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}):
+ self.assertEqual(git.rebase_keeping_index(self.work).status, "rebased")
+ git.commit_index("c", self.work, capture=True)
+ git.push_tracked(self.work, capture=True)
calls = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()]
self.assertTrue(calls)
+ autostash_rebases = []
for argv in calls:
with self.subTest(argv=argv):
self.assertNotEqual(argv[:1], ["add"])
@@ -326,6 +346,421 @@ class NeverAddOrForceTest(GitCase):
self.assertNotIn("-f", argv)
if argv[:1] == ["config"]:
self.assertIn("--get", argv)
+ if argv[:1] == ["rebase"] and "--autostash" in argv:
+ autostash_rebases.append(argv)
+ self.assertEqual(autostash_rebases, []) # only rebase_onto_upstream(autostash=True) passes it
+
+
+class UpstreamCase(GitCase):
+ """GitCase plus a second clone of origin that plays upstream."""
+
+ def other(self):
+ other = self.tmp / "other"
+ if not other.exists():
+ self.g("clone", "-q", str(self.origin), str(other), cwd=self.tmp)
+ for key, value in (("user.name", "O"), ("user.email", "o@example.com"), ("commit.gpgsign", "false")):
+ self.g("config", key, value, cwd=other)
+ return other
+
+ def upstream_commit(self, files, message="upstream", branch="main"):
+ """Commit files ({rel: bytes | str | None}; None deletes) in the other clone and push them to origin."""
+ other = self.other()
+ self.g("checkout", "-q", branch, cwd=other)
+ for rel, data in files.items():
+ path = other / rel
+ if data is None:
+ self.g("rm", "-q", "--", rel, cwd=other)
+ continue
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(data if isinstance(data, bytes) else data.encode())
+ self.g("add", "--", rel, cwd=other)
+ self.g("commit", "-q", "-m", message, cwd=other)
+ self.g("push", "-q", "origin", branch, cwd=other)
+
+ def commit(self, files, message="local"):
+ for rel, data in files.items():
+ path = self.work / rel
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(data if isinstance(data, bytes) else data.encode())
+ self.g("add", "--", rel)
+ self.g("commit", "-q", "-m", message)
+
+ def stash_count(self):
+ return len(self.g("stash", "list").splitlines())
+
+ def index_blob(self, rel):
+ return subprocess.run(["git", "--literal-pathspecs", "show", f":{rel}"], cwd=self.work, check=True,
+ capture_output=True).stdout
+
+
+class StagedPathsBytesTest(GitCase):
+ def test_rename_lists_both_sides(self):
+ self.g("mv", "README.md", "READ.md")
+ self.assertEqual(git.staged_paths(self.work), ["READ.md", "README.md"])
+
+ def test_name_with_a_cr_is_kept_exactly(self):
+ self.write("a\rb.txt")
+ self.g("add", "a\rb.txt")
+ self.assertEqual(git.staged_paths(self.work), ["a\rb.txt"])
+
+
+class UnreviewedTest(GitCase):
+ def test_sorted_union_minus_exempt(self):
+ self.write("README.md", "edited\n")
+ self.write("z.txt")
+ self.write("CHANGELOG.md")
+ self.write("staged.txt")
+ self.g("add", "staged.txt")
+ self.assertEqual(git.unreviewed_paths(self.work), ["CHANGELOG.md", "README.md", "z.txt"])
+ self.assertEqual(git.unreviewed_paths(self.work, exempt=[Path("CHANGELOG.md")]), ["README.md", "z.txt"])
+
+ def test_ignored_files_are_not_unreviewed(self):
+ self.write(".gitignore", "target/\n")
+ self.commit_ignore()
+ self.write("target/out.class")
+ self.assertEqual(git.unreviewed_paths(self.work), [])
+
+ def commit_ignore(self):
+ self.g("add", ".gitignore")
+ self.g("commit", "-q", "-m", "ignore")
+
+ def test_refusal_wording(self):
+ reason, hint = git.unreviewed_refusal(["a b.txt", "c.txt"], outcome="Nothing was pushed.",
+ git_cmd="git -C ../docs")
+ self.assertEqual(reason, "2 paths are not staged")
+ self.assertIn(" a b.txt", hint)
+ self.assertTrue(any(line.startswith("Why: ") for line in hint))
+ self.assertTrue(any(line.startswith("Nothing was pushed. ") for line in hint))
+ self.assertEqual(hint[-2:], (" git -C ../docs stash push -- 'a b.txt' c.txt", " git -C ../docs stash pop"))
+ self.assertEqual(git.unreviewed_refusal(["x"], outcome="")[0], "1 path is not staged")
+
+
+class UpstreamVerdictTest(UpstreamCase):
+ def test_level_and_ahead_are_ok(self):
+ self.assertEqual(git.upstream_verdict(self.work).status, "ok")
+ self.commit({"a.txt": "a\n"})
+ verdict = git.upstream_verdict(self.work)
+ self.assertEqual((verdict.status, verdict.ahead, verdict.behind, verdict.ok), ("ok", 1, 0, True))
+
+ def test_no_upstream_proceeds(self):
+ self.g("checkout", "-q", "-b", "feature")
+ verdict = git.upstream_verdict(self.work)
+ self.assertEqual(verdict.status, "no-upstream")
+ self.assertTrue(verdict.ok)
+
+ def test_fetch_error_fails_closed(self):
+ self.g("remote", "set-url", "origin", str(self.tmp / "missing.git"))
+ verdict = git.upstream_verdict(self.work)
+ self.assertEqual(verdict.status, "error")
+ self.assertFalse(verdict.ok)
+ self.assertTrue(verdict.reason.startswith("could not check upstream: "))
+
+ def test_not_a_repository_is_an_error_not_a_raise(self):
+ plain = self.tmp / "plain"
+ plain.mkdir()
+ self.assertEqual(git.upstream_verdict(plain).status, "error")
+
+ def test_behind_only_hints_ff_only(self):
+ self.upstream_commit({"b.txt": "b\n"})
+ self.upstream_commit({"c.txt": "c\n"})
+ verdict = git.upstream_verdict(self.work, git_cmd="git -C ../x")
+ self.assertEqual((verdict.status, verdict.ahead, verdict.behind), ("behind", 0, 2))
+ self.assertEqual(verdict.reason, "2 commits behind upstream")
+ self.assertEqual(verdict.hint[-1], " git -C ../x merge --ff-only @{upstream}")
+
+ def test_behind_and_ahead_hints_stash_and_rebase_never_autostash(self):
+ self.upstream_commit({"b.txt": "b\n"})
+ self.commit({"a.txt": "a\n"})
+ verdict = git.upstream_verdict(self.work)
+ self.assertEqual((verdict.status, verdict.ahead, verdict.behind), ("diverged", 1, 1))
+ self.assertEqual(verdict.reason, "1 commit behind upstream")
+ self.assertEqual(verdict.hint[-1], " git stash push && git pull --rebase && git stash pop --index")
+ self.assertNotIn("--autostash", " ".join(verdict.hint))
+
+
+class RebaseKeepingIndexTest(UpstreamCase):
+ def keep(self, **kw):
+ return git.rebase_keeping_index(self.work, **kw)
+
+ def test_up_to_date_does_nothing(self):
+ self.write("a.txt")
+ self.g("add", "a.txt")
+ result = self.keep()
+ self.assertEqual((result.ok, result.status), (True, "up-to-date"))
+ self.assertEqual(self.stash_count(), 0)
+ self.assertEqual(git.staged_paths(self.work), ["a.txt"])
+
+ def test_refuses_unreviewed_before_stashing(self):
+ self.upstream_commit({"b.txt": "b\n"})
+ self.write("loose.txt")
+ result = self.keep()
+ self.assertEqual((result.ok, result.status, result.reason), (False, "refused", "1 path is not staged"))
+ self.assertIn(" loose.txt", result.hint)
+ self.assertEqual(self.stash_count(), 0)
+ self.assertEqual(git.ahead_behind(self.work, fetch=True)[1], 1) # nothing was rebased
+
+ def test_exempt_path_is_not_refused(self):
+ self.upstream_commit({"b.txt": "b\n"})
+ self.write("CHANGELOG.md")
+ self.assertEqual(self.keep(exempt=["CHANGELOG.md"]).status, "rebased")
+
+ def test_staged_modify_delete_rename_and_new_come_back_staged(self):
+ self.commit({"mod.txt": "one\n", "del.txt": "gone\n", "old.txt": "moved\n"}, "seed")
+ self.g("push", "-q")
+ self.upstream_commit({"up.txt": "upstream\n"})
+ self.write("mod.txt", "two\n")
+ self.g("add", "mod.txt")
+ self.g("rm", "-q", "del.txt")
+ self.g("mv", "old.txt", "new-name.txt")
+ self.write("fresh.txt", "new\n")
+ self.g("add", "fresh.txt")
+ before = git.staged_paths(self.work)
+ result = self.keep()
+ self.assertEqual((result.ok, result.status), (True, "rebased"), result)
+ self.assertEqual(git.staged_paths(self.work), before)
+ self.assertEqual(self.index_blob("mod.txt"), b"two\n")
+ self.assertEqual(self.index_blob("new-name.txt"), b"moved\n")
+ self.assertTrue((self.work / "up.txt").exists())
+ self.assertFalse((self.work / "del.txt").exists())
+ self.assertEqual(git.unreviewed_paths(self.work), [])
+ self.assertEqual(self.stash_count(), 0)
+
+ def test_nothing_staged_rebases_without_a_stash(self):
+ self.upstream_commit({"b.txt": "b\n"})
+ self.commit({"a.txt": "a\n"})
+ result = self.keep()
+ self.assertEqual(result.status, "rebased")
+ self.assertIsNone(result.stash_sha)
+ self.assertEqual(git.ahead_behind(self.work, fetch=False), (1, 0, None))
+
+ def test_same_file_upstream_change_keeps_the_stash_and_hints(self):
+ self.upstream_commit({"README.md": "upstream readme\n"})
+ self.write("README.md", "my readme\n")
+ self.g("add", "README.md")
+ result = self.keep()
+ self.assertEqual((result.ok, result.status), (False, "restore-conflict"))
+ self.assertEqual(result.stash_ref, "stash@{0}")
+ self.assertEqual(self.g("rev-parse", "refs/stash"), result.stash_sha)
+ self.assertIn(git.STASH_MESSAGE, self.g("stash", "list"))
+ self.assertTrue(any("stash@{0}" in line for line in result.hint))
+ self.assertTrue(any("git stash pop --index stash@{0}" in line for line in result.hint))
+
+ def test_rebase_conflict_keeps_the_stash(self):
+ self.upstream_commit({"README.md": "upstream readme\n"})
+ self.commit({"README.md": "local readme\n"})
+ self.write("other.txt")
+ self.g("add", "other.txt")
+ result = self.keep()
+ self.assertEqual((result.ok, result.status), (False, "rebase-conflict"))
+ self.assertEqual(self.stash_count(), 1)
+ self.assertTrue(any("git rebase --abort" in line for line in result.hint))
+ self.assertTrue(any(result.stash_ref in line for line in result.hint))
+ self.g("rebase", "--abort")
+
+ def test_both_sides_delete_proceeds_with_the_deletion_left_out(self):
+ self.commit({"gone.txt": "x\n"}, "seed")
+ self.g("push", "-q")
+ self.upstream_commit({"gone.txt": None})
+ self.g("rm", "-q", "gone.txt")
+ self.write("keep.txt", "k\n")
+ self.g("add", "keep.txt")
+ result = self.keep()
+ self.assertEqual(result.status, "rebased", result)
+ self.assertEqual(git.staged_paths(self.work), ["keep.txt"])
+ self.assertFalse((self.work / "gone.txt").exists())
+ self.assertEqual(self.stash_count(), 0)
+
+ def test_binary_and_cr_paths_restore_byte_for_byte(self):
+ blob = bytes(range(256)) * 4
+ self.upstream_commit({"b.txt": "b\n"})
+ (self.work / "data.bin").write_bytes(blob)
+ (self.work / "cr\rname.txt").write_bytes(b"line\r\n")
+ self.g("add", "data.bin", "cr\rname.txt")
+ result = self.keep()
+ self.assertEqual(result.status, "rebased", result)
+ self.assertEqual(self.index_blob("data.bin"), blob)
+ self.assertEqual(self.index_blob("cr\rname.txt"), b"line\r\n")
+ self.assertEqual(sorted(git.staged_paths(self.work)), ["cr\rname.txt", "data.bin"])
+
+ def test_unicode_tab_space_and_glob_paths(self):
+ names = ["ünï cödé.txt", "tab\there.txt", "with space.txt", "[ab].txt", "*.txt", "a?.txt"]
+ self.commit({"a.txt": "decoy\n", "b.txt": "decoy\n"}, "decoys") # a glob, if expanded, would match these
+ self.g("push", "-q")
+ self.upstream_commit({"up.txt": "u\n"})
+ for name in names:
+ (self.work / name).write_text(name + "\n", encoding="utf-8")
+ self.g("add", "--", *names)
+ self.g("rm", "-q", "--cached", "--", "b.txt")
+ (self.work / "b.txt").unlink()
+ before = sorted(git.staged_paths(self.work))
+ result = self.keep()
+ self.assertEqual(result.status, "rebased", result)
+ self.assertEqual(sorted(git.staged_paths(self.work)), before)
+ for name in names:
+ self.assertEqual(self.index_blob(name), (name + "\n").encode())
+ self.assertEqual(self.index_blob("a.txt"), b"decoy\n")
+
+ def test_non_origin_remote(self):
+ self.g("remote", "rename", "origin", "upstream")
+ self.upstream_commit({"b.txt": "b\n"})
+ self.write("a.txt")
+ self.g("add", "a.txt")
+ result = self.keep()
+ self.assertEqual(result.status, "rebased", result)
+ self.assertTrue((self.work / "b.txt").exists())
+ self.assertEqual(git.staged_paths(self.work), ["a.txt"])
+
+ def test_branch_name_with_a_slash(self):
+ self.g("checkout", "-q", "-b", "feature/x")
+ self.g("push", "-q", "-u", "origin", "feature/x")
+ self.other()
+ self.g("fetch", "-q", cwd=self.other())
+ self.g("checkout", "-q", "-b", "feature/x", "origin/feature/x", cwd=self.other())
+ self.upstream_commit({"b.txt": "b\n"}, branch="feature/x")
+ self.write("a.txt")
+ self.g("add", "a.txt")
+ result = self.keep()
+ self.assertEqual(result.status, "rebased", result)
+ self.assertEqual(git.staged_paths(self.work), ["a.txt"])
+
+ def test_detached_head_is_an_error_and_stashes_nothing(self):
+ self.g("checkout", "-q", "--detach")
+ self.write("a.txt")
+ self.g("add", "a.txt")
+ result = self.keep()
+ self.assertEqual((result.ok, result.status), (False, "error"))
+ self.assertIn("detached", result.reason)
+ self.assertEqual(self.stash_count(), 0)
+
+ def test_no_upstream_is_ok(self):
+ self.g("checkout", "-q", "-b", "feature")
+ self.assertEqual((self.keep().ok, self.keep().status), (True, "no-upstream"))
+
+ def test_merges_keeps_a_merge_commit(self):
+ self.g("checkout", "-q", "-b", "side")
+ self.commit({"side.txt": "s\n"}, "side")
+ self.g("checkout", "-q", "main")
+ self.commit({"main.txt": "m\n"}, "main work")
+ self.g("merge", "-q", "--no-ff", "--no-edit", "side")
+ self.upstream_commit({"up.txt": "u\n"})
+ self.write("a.txt")
+ self.g("add", "a.txt")
+ result = self.keep(merges=True)
+ self.assertEqual(result.status, "rebased", result)
+ self.assertEqual(len(self.g("rev-list", "--parents", "-n", "1", "HEAD").split()), 3)
+ self.assertEqual(git.staged_paths(self.work), ["a.txt"])
+
+ def test_failed_stash_drop_only_warns(self):
+ self.upstream_commit({"b.txt": "b\n"})
+ self.write("a.txt")
+ self.g("add", "a.txt")
+ real = git._run
+
+ def failing_drop(cmd, **kw):
+ if "drop" in cmd:
+ return git.Result(False, 1, out=b"", err="cannot lock ref")
+ return real(cmd, **kw)
+
+ stderr = io.StringIO()
+ with mock.patch.object(git, "_run", failing_drop), contextlib.redirect_stderr(stderr):
+ result = self.keep()
+ self.assertEqual((result.ok, result.status), (True, "rebased"))
+ self.assertIn("could not be dropped: cannot lock ref", result.reason)
+ self.assertIn("could not be dropped", stderr.getvalue())
+ self.assertEqual(git.staged_paths(self.work), ["a.txt"])
+ self.assertEqual(self.stash_count(), 1)
+
+ def test_stash_that_did_not_move_is_an_error(self):
+ self.upstream_commit({"b.txt": "b\n"})
+ self.write("a.txt")
+ self.g("add", "a.txt")
+ real = git._run
+
+ def fake_stash(cmd, **kw):
+ if cmd[1:3] == ["stash", "push"]:
+ return git.Result(True, 0)
+ return real(cmd, **kw)
+
+ with mock.patch.object(git, "_run", fake_stash):
+ result = self.keep()
+ self.assertEqual((result.ok, result.status), (False, "error"))
+ self.assertIn("created no stash", result.reason)
+ self.assertEqual(git.ahead_behind(self.work, fetch=False)[1], 1)
+
+
+class PushCommandTest(UpstreamCase):
+ def test_tracked_branch_pushes_head_to_its_merge_ref(self):
+ self.assertEqual(git.push_command(self.work), ["git", "push", "origin", "HEAD:refs/heads/main"])
+
+ def test_differently_named_merge_ref_and_remote(self):
+ self.g("remote", "add", "mirror", str(self.origin))
+ self.g("checkout", "-q", "-b", "local/name")
+ self.g("config", "branch.local/name.remote", "mirror")
+ self.g("config", "branch.local/name.merge", "refs/heads/release")
+ self.assertEqual(git.push_command(self.work), ["git", "push", "mirror", "HEAD:refs/heads/release"])
+ self.commit({"a.txt": "a\n"})
+ self.assertTrue(git.push_tracked(self.work, capture=True).ok)
+ self.assertIn("refs/heads/release", self.g("ls-remote", "origin"))
+
+ def test_no_upstream_sets_one_on_origin(self):
+ self.g("checkout", "-q", "-b", "feature")
+ self.assertEqual(git.push_command(self.work), ["git", "push", "-u", "origin", "feature"])
+
+ def test_refusals(self):
+ def refused(setup):
+ setup()
+ with self.assertRaises(GitError) as caught:
+ git.push_command(self.work)
+ r = git.push_tracked(self.work, capture=True)
+ self.assertEqual((r.ok, r.code), (False, 1))
+ return str(caught.exception)
+
+ self.assertIn("detached", refused(lambda: self.g("checkout", "-q", "--detach")))
+ self.g("checkout", "-q", "main")
+ self.assertIn("local branch", refused(lambda: self.g("config", "branch.main.remote", ".")))
+ self.assertIn("could not resolve", refused(lambda: self.g("config", "--unset", "branch.main.remote")))
+ self.g("config", "branch.main.remote", "origin")
+ self.g("config", "branch.main.merge", "refs/tags/v1")
+ with self.assertRaises(GitError) as caught:
+ git.push_command(self.work)
+ self.assertIn("not a branch", str(caught.exception))
+
+
+class PolledTest(GitCase):
+ class TickingJr:
+ def __init__(self):
+ self.ticks = 0
+
+ def console_active(self):
+ return True
+
+ def tick(self):
+ self.ticks += 1
+
+ def say(self, text="", level="info", step=None):
+ pass
+
+ def test_ticks_while_a_command_runs(self):
+ fake = self.TickingJr()
+ with mock.patch.dict(sys.modules, {"juneau_run": fake}):
+ r = git._run([sys.executable, "-c", "import time, sys; time.sleep(0.35); print('x' * 200000)"])
+ self.assertTrue(r.ok)
+ self.assertEqual(len(r.out.strip()), 200000)
+ self.assertGreaterEqual(fake.ticks, 2)
+
+ def test_input_and_bytes_through_the_polled_path(self):
+ fake = self.TickingJr()
+ with mock.patch.dict(sys.modules, {"juneau_run": fake}):
+ r = git._run([sys.executable, "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"],
+ input=b"a\rb\0", binary=True)
+ self.assertEqual(r.out, b"a\rb\0")
+
+ def test_no_console_never_ticks(self):
+ fake = self.TickingJr()
+ fake.console_active = lambda: False
+ with mock.patch.dict(sys.modules, {"juneau_run": fake}):
+ git._run([sys.executable, "-c", "import time; time.sleep(0.25)"])
+ self.assertEqual(fake.ticks, 0)
if __name__ == "__main__":
diff --git a/juneau-run/src/test/python/test_scripts_run.py b/juneau-run/src/test/python/test_scripts_run.py
index 927c4a7dcc..17eedf3beb 100644
--- a/juneau-run/src/test/python/test_scripts_run.py
+++ b/juneau-run/src/test/python/test_scripts_run.py
@@ -102,6 +102,28 @@ class RoutedTest(MarkerCase):
self.assertTrue(run.console_requested())
+class ExportConsoleTest(PlainCase):
+ def test_export_console_precedence(self):
+ with mock.patch.dict(os.environ, {}):
+ os.environ.pop("JUNEAU_RUN_CONSOLE", None)
+ self.assertEqual(run.export_console(), "condensed") # the default
+ self.assertEqual(run.export_console(default="full"), "condensed") # now the env wins over the default
+ os.environ["JUNEAU_RUN_CONSOLE"] = "none"
+ self.assertEqual(run.export_console(), "none")
+ self.assertEqual(run.export_console("full"), "full") # the flag wins over the env
+ self.assertEqual(os.environ["JUNEAU_RUN_CONSOLE"], "full")
+ with self.assertRaises(ValueError):
+ run.export_console("loud")
+ self.assertEqual(os.environ["JUNEAU_RUN_CONSOLE"], "full")
+
+ def test_add_console_argument(self):
+ import argparse
+ parser = argparse.ArgumentParser()
+ run.add_console_argument(parser)
+ self.assertIsNone(parser.parse_args([]).console)
+ self.assertEqual(parser.parse_args(["--console", "none"]).console, "none")
+
+
class CmdMarkersTest(MarkerCase):
def test_runs_through_run_tool_with_the_step_number(self):
r, got, _ = self.capture(run.cmd, [PY, "-c", "print('x')"], title="Build it", n=4)
diff --git a/juneau-run/src/test/python/test_session_plan.py b/juneau-run/src/test/python/test_session_plan.py
index c8abc86f1f..90b9fb0eac 100644
--- a/juneau-run/src/test/python/test_session_plan.py
+++ b/juneau-run/src/test/python/test_session_plan.py
@@ -174,7 +174,13 @@ class PlanApiTest(unittest.TestCase):
seen = []
jr.subscribe(lambda kind, f: seen.append((kind, f)))
jr.plan(("a", "b"))
- self.assertEqual(seen, [("plan", {"ids": ["a", "b"]})])
+ self.assertEqual(seen, [("plan", {"ids": ["a", "b"], "labels": {}})])
+
+ def test_plan_publishes_labels_for_labelled_entries(self):
+ seen = []
+ jr.subscribe(lambda kind, f: seen.append((kind, f)))
+ jr.plan(["a", ("b", "Build")])
+ self.assertEqual(seen, [("plan", {"ids": ["a", "b"], "labels": {"b": "Build"}})])
def test_session_plan_duplicate_raises_in_plain_mode(self):
import sys
diff --git a/scripts/push.py b/scripts/push.py
index ad15c25152..6b76bb90ed 100755
--- a/scripts/push.py
+++ b/scripts/push.py
@@ -19,6 +19,9 @@
Build and Push Script for Juneau
This script automates the build, test, and deployment workflow:
+0. Prechecks: refuses up front unless git has the ASF committer identity, the tree has no unreviewed
+ (unstaged/untracked) changes and the branch isn't behind its upstream -- for juneau and, when it exists, the
+ sibling juneau-docs -- before any build or test work (commit_and_push() repeats the last two at the end)
1. Runs all tests
2. Builds and installs the project
3. Commits changes to Git
@@ -105,9 +108,12 @@ def run_gate(cmd, cwd, step_id, title, label):
label=label).exit
+PGP_PROMPT = "prompt-pgp-passphrase.py"
+
+
def prompt_pgp_passphrase(script_dir):
"""Run the dummy-sign prompt on the bare terminal: True or False for how it went, None if the script is absent."""
- prompt_script = script_dir / "prompt-pgp-passphrase.py"
+ prompt_script = script_dir / PGP_PROMPT
if not prompt_script.exists():
return None
try:
@@ -118,6 +124,35 @@ def prompt_pgp_passphrase(script_dir):
return False
+def pgp_row(script_dir):
+ """
+ The PGP row: the dummy-sign prompt, with the console paused so the prompt has the bare terminal. A prompt that
+ fails warns on the row and the push goes on (signing just prompts again later).
+ """
+ run = toolkit("run")
+ with run.step("pgp", "PGP passphrase", label="PGP") as step:
+ with run.paused():
+ ok = prompt_pgp_passphrase(script_dir)
+ if ok is False:
+ step.warn("passphrase not cached; signing will prompt again")
+
+
+def aborted(text, *hint):
+ """
+ Stop the push after a failed row: text and the hint lines as error lines, or under a console view (where the
+ failed row already says what stopped) only the hint lines, as a hint. Returns the exit code, 1.
+ """
+ run = _juneau.load()[0]
+ if run.console_active():
+ if hint:
+ run.hint(list(hint))
+ else:
+ say(text, "error")
+ for line in hint:
+ say(line, "error")
+ return 1
+
+
def next_step_number(count=1):
"""The next top-level step number, from the toolkit's session counter (the one run.step, run.gate and
run.handoff use); count > 1 reserves a block."""
@@ -207,17 +242,6 @@ def run_command(cmd, description, cwd=None, tool=None, step_id=None, title=None,
PUSH_NO_SOUND_ENV = "JUNEAU_PUSH_NO_SOUND"
-def play_sound(success=True):
- """
- The terminal bell and the success or failure sound (the toolkit's notify.bell and notify.sound), for a run that
- ends before its session opens; inside the session, run.session plays them once at the end. Silent in the Claude
- Code sandbox or when JUNEAU_PUSH_NO_SOUND=1.
- """
- notify = toolkit("notify")
- notify.bell(silence_env=(PUSH_NO_SOUND_ENV,))
- notify.sound(success, silence_env=(PUSH_NO_SOUND_ENV,))
-
-
def _python310():
"""
Locate a Python >= 3.10 interpreter, regardless of how push.py itself was launched.
@@ -383,6 +407,9 @@ def maybe_run_tracker_audit_gate(args, juneau_root, step_num):
# release-docs-stage.py) so the two repos enforce this check identically.
REQUIRED_GIT_EMAIL = "jamesbognar@apache.org"
+#: How the hints reach the juneau-docs sibling from the juneau checkout.
+DOCS_GIT = "git -C ../juneau-docs"
+
def resolve_commit_message(cli_message):
"""Use the CLI-supplied message, or prompt for one; abort if still empty."""
@@ -404,33 +431,6 @@ def resolve_commit_message(cli_message):
return message
-def verify_apache_identity(repo_dir):
- """Refuse to proceed unless git is configured with the ASF committer identity."""
- def report(text):
- # Mid-session (the docs follow-up) a raw print would tear the live region.
- if console_active():
- say(text, "error")
- else:
- print(text)
-
- try:
- email = toolkit("git").user_email(repo_dir) or ""
- except Exception as e:
- report(f"❌ ERROR: Could not read git user.email: {e}")
- return False
-
- if email != REQUIRED_GIT_EMAIL:
- report("❌ ERROR: Git identity is not the ASF committer identity.")
- report(f" Found: user.email = '{email or '(unset)'}'")
- report(f" Required: user.email = '{REQUIRED_GIT_EMAIL}'")
- report("")
- report(" Fix (this script cannot mutate git config):")
- report(f" git config user.email {REQUIRED_GIT_EMAIL}")
- report(' git config user.name "James Bognar"')
- return False
- return True
-
-
def get_staged_paths(repo_dir):
"""
List paths currently staged in the index.
@@ -478,32 +478,66 @@ def check_unreviewed_changes(repo_dir):
return [*git.unstaged_paths(repo_dir), *git.untracked_paths(repo_dir)]
-def check_upstream_changes(repo_dir):
+def report_unreviewed(unreviewed_paths, label, outcome, lead="ERROR"):
"""
- Fetch from the remote and compare the local branch to its upstream tracking branch.
-
- Returns:
- tuple: (ahead, behind, error_message)
- - ahead: commits on the local branch not yet on the upstream branch, or None if there
- is no upstream branch configured
- - behind: commits on the upstream branch not yet on the local branch, or None if there
- is no upstream branch configured
- - error_message: Error message if the check failed, None otherwise
-
- ahead/behind are None (not 0) when there's no upstream branch configured, so callers can
- tell "nothing to push because there's no remote to compare against" apart from "checked,
- and there's genuinely nothing ahead".
+ Print the unreviewed-changes refusal: the paths, why they matter, `outcome` (what was and wasn't done) and the
+ stash hint. Shared by the pre-flight and by commit_and_push()'s late check so the two read alike.
"""
+ say(f"\n❌ {lead}: The working tree{label} has unreviewed changes -- staging is how "
+ "this script knows something has been reviewed, and these paths are not staged:", "error")
+ for path in unreviewed_paths:
+ say(f" {path}", "error")
+ say(" Why: everything this pushes should be something you've read, not just", "error")
+ say(" something that happened to be in the tree.", "error")
+ say(f"\n {outcome} Stage each path above if you've reviewed", "error")
+ say(" it, or set it aside for this run and restore it after:", "error")
+ quoted_paths = " ".join(shlex.quote(path) for path in unreviewed_paths)
+ say(f" git stash push -- {quoted_paths}", "error")
+ say(" git stash pop", "error")
+
+
+def prechecks(juneau_root, *, dry_run=False, docs=True):
+ """
+ The Prechecks row's checks, which refuse before any build or test gate a push that commit_and_push() would
+ only refuse at the end: git's user.email isn't the ASF committer identity, the tree has unreviewed changes, or
+ the branch is behind its upstream (no upstream configured passes; a failed fetch or git error fails closed).
+ With docs and the juneau-docs sibling present and active -- something staged, or commits ahead of its upstream
+ (docs_active) -- the same three for juneau-docs follow (docs_prechecks); an idle juneau-docs is not checked, so
+ its dirt doesn't block a juneau push. The late checks stay (upstream can move during the long build); these
+ only save the wait.
+ """
+ checks = toolkit("checks")
+ outcome = "Nothing would be built, committed or pushed." if dry_run else "Nothing was built, committed or pushed."
+ listed = [checks.identity(juneau_root, required=REQUIRED_GIT_EMAIL),
+ checks.unreviewed(juneau_root, outcome=outcome),
+ checks.upstream(juneau_root)]
+ docs_root = juneau_root.parent / "juneau-docs"
+ if docs and docs_root.exists() and docs_active(docs_root):
+ listed += docs_prechecks(docs_root, dry_run=dry_run)
+ return listed
+
+
+def docs_active(docs_root):
+ """Whether the juneau-docs follow-up has anything to push: something staged, or commits ahead of its upstream
+ (as of the last fetch). A git error counts as active, so the docs Prechecks run and report it."""
git = toolkit("git")
try:
- ahead, behind, error = git.ahead_behind(repo_dir, fetch=True)
- except git.GitError as e:
- return (None, None, f"Git command failed: {e}")
- if error == "no upstream":
- return (None, None, None)
- if error:
- return (None, None, f"Git command failed: {error}")
- return (ahead, behind, None)
+ if git.staged_paths(docs_root):
+ return True
+ ahead, _, err = git.ahead_behind(docs_root, fetch=False)
+ except git.GitError:
+ return True
+ return bool(ahead) or (err is not None and err != "no upstream")
+
+
+def docs_prechecks(docs_root, *, dry_run=False):
+ """prechecks()' three checks for the juneau-docs sibling, named "docs identity", "docs unreviewed" and "docs
+ upstream", with hints that reach it from the juneau checkout."""
+ checks = toolkit("checks")
+ outcome = "Nothing would be built, committed or pushed." if dry_run else "Nothing was built, committed or pushed."
+ return [checks.identity(docs_root, required=REQUIRED_GIT_EMAIL, label="docs identity"),
+ checks.unreviewed(docs_root, git_cmd=DOCS_GIT, label="docs unreviewed", outcome=outcome),
+ checks.upstream(docs_root, git_cmd=DOCS_GIT, label="docs upstream")]
def current_branch(repo_dir):
@@ -704,7 +738,7 @@ def commit_and_push(
step_num,
*,
label="",
- pull_hint="git pull",
+ git_cmd="git",
pre_flight_hook=None,
pre_commit_hook=None,
step_prefix="",
@@ -755,17 +789,16 @@ def commit_and_push(
label: Optional text identifying which repo this is, appended to output messages (e.g.
" (juneau-docs)") so a caller running this against more than one repo per invocation
can tell them apart in the log. Empty by default.
- pull_hint: The `git pull` command shown in the behind-upstream error message. Callers
- operating outside the repo's own directory (e.g. main() running against the
- juneau-docs sibling while cwd is juneau) can pass a `-C <path>`-qualified form.
+ git_cmd: The git invocation shown in the behind-upstream hint. Callers operating outside
+ the repo's own directory (e.g. main() running against the juneau-docs sibling while cwd
+ is juneau) pass a `-C <path>`-qualified form.
pre_flight_hook: Optional zero-arg callable invoked once it's established that there is
something to commit and/or push (i.e. NOT the nothing_to_do case) and that the tree
has nothing unreviewed in it, before any git state is mutated. Must return True to
proceed or False to abort (and is responsible for printing its own error and playing
the failure sound before returning False). Used for checks that must run before ANY
- push, including a push-only run with nothing new to commit -- e.g. the juneau-docs
- Apache-identity gate in main()'s Step 6 follow-up. None (the default) means no such
- check is needed.
+ push, including a push-only run with nothing new to commit. None (the default) means no
+ such check is needed.
pre_commit_hook: Optional zero-arg callable invoked only when there ARE staged changes to
commit, immediately before `git commit`. Same True/False contract as pre_flight_hook.
Used for checks that have nothing new to validate when nothing changed -- e.g. the
@@ -780,30 +813,30 @@ def commit_and_push(
- step_num: the input step_num, advanced past whichever of the commit/push steps
actually ran (unchanged for "nothing_to_do"/"error").
"""
+ git = toolkit("git")
say(f"\n🔍 Checking for upstream changes{label}...")
- commits_ahead, commits_behind, error_msg = check_upstream_changes(repo_dir)
- if error_msg:
- say(f"\n⚠ Warning: Could not check upstream changes{label}: {error_msg}", "warn")
- say("Continuing anyway...", "warn")
- elif commits_behind:
- say(f"\n❌ ERROR: Local branch{label} is behind upstream/remote branch.", "error")
- say("Please pull/merge upstream changes before pushing.", "error")
- say(f"Run: {pull_hint}", "error")
+ upstream = git.upstream_verdict(repo_dir, git_cmd=git_cmd)
+ commits_ahead = upstream.ahead
+ if upstream.status == "error":
+ # Fails closed like the Prechecks: a branch nobody could compare with its upstream is not pushed.
+ say(f"\n❌ ERROR: {upstream.reason}{label}. Nothing was committed or pushed.", "error")
+ say(f" Re-run the push once `{git_cmd} fetch` works.", "error")
+ return ("error", step_num)
+ if not upstream.ok:
+ say(f"\n❌ ERROR: Local branch{label} is {upstream.reason}. Nothing was committed or pushed.", "error")
+ for line in upstream.hint:
+ say(f" {line}", "error")
return ("error", step_num)
try:
has_staged = bool(get_staged_paths(repo_dir))
- except (subprocess.CalledProcessError, OSError, toolkit("git").GitError) as e:
+ except (subprocess.CalledProcessError, OSError, git.GitError) as e:
say(f"\n❌ ERROR: Could not check the Git index{label}: {e}", "error")
return ("error", step_num)
# Nothing staged skips the commit, not the push -- see docstring above.
if not has_staged and not commits_ahead:
- if error_msg:
- say(f"\nℹ Nothing staged{label}, and the upstream comparison could not be "
- "verified (see warning above) -- assuming nothing to push.")
- else:
- say(f"\nℹ Nothing staged and no unpushed commits{label}.")
+ say(f"\nℹ Nothing staged and no unpushed commits{label}.")
say(f"🎉 Push{label} completed successfully (nothing to commit or push)!")
return ("nothing_to_do", step_num)
@@ -812,21 +845,11 @@ def commit_and_push(
# a push-only run.
try:
unreviewed_paths = check_unreviewed_changes(repo_dir)
- except (subprocess.CalledProcessError, OSError, toolkit("git").GitError) as e:
+ except (subprocess.CalledProcessError, OSError, git.GitError) as e:
say(f"\n❌ ERROR: Could not check for unreviewed changes{label}: {e}", "error")
return ("error", step_num)
if unreviewed_paths:
- say(f"\n❌ ERROR: The working tree{label} has unreviewed changes -- staging is how "
- "this script knows something has been reviewed, and these paths are not staged:", "error")
- for path in unreviewed_paths:
- say(f" {path}", "error")
- say(" Why: everything this pushes should be something you've read, not just", "error")
- say(" something that happened to be in the tree.", "error")
- say("\n Nothing was committed or pushed. Stage each path above if you've reviewed", "error")
- say(" it, or set it aside for this run and restore it after:", "error")
- quoted_paths = " ".join(shlex.quote(path) for path in unreviewed_paths)
- say(f" git stash push -- {quoted_paths}", "error")
- say(" git stash pop", "error")
+ report_unreviewed(unreviewed_paths, label, "Nothing was committed or pushed.")
return ("error", step_num)
if pre_flight_hook is not None and not pre_flight_hook():
@@ -845,7 +868,7 @@ def commit_and_push(
step_id=f"{step_prefix}commit", title=f"Commit{label}", label=row_labels[0],
capture=True, tty=True, summarize=commit_summary,
):
- say(f"\n❌ git commit failed{label} -- aborting.", "error")
+ aborted(f"\n❌ git commit failed{label} -- aborting.")
return ("error", step_num)
say(f"✅ Step {step_num}: Git commit completed{label}.")
step_num += 1
@@ -853,16 +876,22 @@ def commit_and_push(
say(f"\nℹ Nothing staged to commit{label} -- {commits_ahead} unpushed commit(s) "
"already on this branch will be pushed now.")
- # Step N (or N+1): Push to remote
+ # Step N (or N+1): Push to remote -- to the tracked upstream (git.push_command), never a guessed remote.
+ try:
+ push_cmd = git.push_command(repo_dir)
+ except git.GitError as e:
+ say(f"\n❌ ERROR: Could not work out where to push{label}: {e}", "error")
+ say(f"⚠ Local commits exist but were not pushed{label}.", "warn")
+ return ("error", step_num)
upstream = upstream_name(repo_dir) if console_active() else ""
if not run_command(
- ["git", "push"],
+ push_cmd,
f"🚀 Step {step_num}: Pushing changes to remote repository{label}...",
repo_dir,
step_id=f"{step_prefix}push", title=f"Push{label}", label=row_labels[1],
tty=True, summarize=lambda _text: f"{upstream} ✓" if upstream else None,
):
- say(f"\n❌ git push failed{label}.", "error")
+ aborted(f"\n❌ git push failed{label}.")
say(f"⚠ Local commits exist but were not pushed{label}.", "warn")
return ("error", step_num)
say(f"✅ Step {step_num}: Changes pushed to remote{label}.")
@@ -884,10 +913,9 @@ def run_docs_followup(juneau_root, message, step_num):
interrupted run, and this follow-up has to run for that case too or it silently never
pushes them -- the same false-success shape commit_and_push() exists to fix, one level up.
- Uses two commit_and_push() hooks: the Apache-identity gate must run before ANY push
- (including a push-only run with no new commit to make), so it's the pre_flight_hook; the
- Docusaurus smoke check only has anything new to validate when there IS a commit about to be
- made, so it's the pre_commit_hook.
+ The juneau-docs identity is checked by the Prechecks row (docs_prechecks) whenever juneau-docs has
+ something to push. The Docusaurus smoke check only has anything new to validate when there IS a
+ commit about to be made, so it's commit_and_push()'s pre_commit_hook.
Args:
juneau_root: The juneau repo root (used only to locate the juneau-docs sibling).
@@ -906,13 +934,6 @@ def run_docs_followup(juneau_root, message, step_num):
if not docs_root.exists():
return ("no_docs_root", step_num)
- def _verify_docs_identity():
- say("\n🔐 Verifying git identity (apache.org email) on juneau-docs...")
- if not verify_apache_identity(docs_root):
- return False
- say("✅ Git identity verified")
- return True
-
def _docs_smoke_check():
say("\n📚 juneau-docs has changes — running Docusaurus smoke check first...")
docs_build_script = docs_root / "scripts" / "build-docs.py"
@@ -924,7 +945,7 @@ def run_docs_followup(juneau_root, message, step_num):
)
docs_smoke_elapsed = time.time() - docs_smoke_start
if returncode != 0:
- say("\n❌ Docs smoke check failed — fix the Docusaurus build before pushing juneau-docs.", "error")
+ aborted("\n❌ Docs smoke check failed — fix the Docusaurus build before pushing juneau-docs.")
return False
say(f"✅ Docs smoke check passed ({docs_smoke_elapsed:.1f}s)")
return True
@@ -935,8 +956,7 @@ def run_docs_followup(juneau_root, message, step_num):
docs_status, step_num = commit_and_push(
docs_root, message, step_num,
label=" (juneau-docs)",
- pull_hint="git -C ../juneau-docs pull",
- pre_flight_hook=_verify_docs_identity,
+ git_cmd=DOCS_GIT,
pre_commit_hook=_docs_smoke_check,
step_prefix="docs-", row_labels=("Docs", "Docs push"),
)
@@ -981,41 +1001,39 @@ def run_docs_only(args, juneau_root):
print("🔍 DRY RUN MODE - No actual changes will be made")
print("=" * 70)
- if not docs_root.exists():
- print(f"\n❌ ERROR: juneau-docs repo not found at {docs_root}")
- print(" Expected it as a sibling of the juneau checkout.")
- play_sound(success=False)
- return 1
-
+ missing = (f"❌ ERROR: juneau-docs repo not found at {docs_root}",
+ " Expected it as a sibling of the juneau checkout.")
if args.dry_run:
+ if not docs_root.exists():
+ print("\n" + "\n".join(missing))
+ return 1
print("\nSteps that would be executed:")
- print(f" 1. Verify Apache git identity on juneau-docs (user.email == {REQUIRED_GIT_EMAIL})")
- print(" 2. Check for upstream changes on juneau-docs (git fetch + compare to upstream)")
- print(" 3. Check juneau-docs git status (exit 0 with a no-changes message if clean)")
- print(" 4. Run docs verification gate: python3 scripts/build-docs.py --skip-maven (runs verify-docs.py)")
- print(f" 5. Commit whatever is already staged: git commit -m \"{args.message}\" (in juneau-docs; "
+ print(f" 1. Prechecks on juneau-docs: refuse unless git user.email is {REQUIRED_GIT_EMAIL}, the tree has no")
+ print(" unreviewed changes and the branch isn't behind upstream (git fetch + compare), before any gate runs;")
+ print(" the last two are checked again just before the commit")
+ print(" 2. Check juneau-docs git status (exit 0 with a no-changes message if clean)")
+ print(" 3. Run docs verification gate: python3 scripts/build-docs.py --skip-maven (runs verify-docs.py)")
+ print(f" 4. Commit whatever is already staged: git commit -m \"{args.message}\" (in juneau-docs; "
"refuses if anything is unstaged/untracked-and-not-gitignored)")
- print(" 6. Push to remote: git push (in juneau-docs)")
+ print(" 5. Push to the tracked upstream (in juneau-docs)")
+ toolkit("run").checks("prechecks", "Prechecks", docs_prechecks(docs_root, dry_run=True), dry_run=True)
print("\nDry run complete. Use without --dry-run to execute.")
return 0
- # Step 1: Apache identity gate — must hold before any work begins.
- if not console:
- print("\n🔐 Step 1: Verifying git identity (apache.org email) on juneau-docs...")
- if not verify_apache_identity(docs_root):
- play_sound(success=False) # the session isn't open yet, so it can't sound the failure
- return 1
- if not console:
- print("✅ Step 1: Git identity verified")
- return _in_session(f"📚 Juneau docs push · {current_branch(docs_root)}",
- lambda: _docs_only_steps(args, console, docs_root),
- header=[f"{REQUIRED_GIT_EMAIL} ✓"] if console else [], mode="push", repo=docs_root,
- plan=["docs-smoke", "commit", "push"])
+ repo = docs_root if docs_root.exists() else juneau_root
+ return _in_session(f"📚 Juneau docs push · {current_branch(repo)}",
+ lambda s: _docs_only_steps(s, args, console, docs_root, missing),
+ mode="push", repo=repo,
+ plan=[("prechecks", "Prechecks"), ("docs-smoke", "Docs build"), ("commit", "Commit"),
+ ("push", "Push")])
-def _docs_only_steps(args, console, docs_root):
- """--docs-only's steps inside its session. Returns the exit code."""
- # Steps 2-6: upstream check, no-op detection, unreviewed-changes guard, docs smoke check,
+def _docs_only_steps(s, args, console, docs_root, missing):
+ """--docs-only's steps inside its session s. Returns the exit code."""
+ if not docs_root.exists():
+ s.fail(missing[0], hint=missing[1:])
+ s.checks("prechecks", "Prechecks", docs_prechecks(docs_root))
+ # After Prechecks: upstream check, no-op detection, unreviewed-changes guard, docs smoke check,
# commit, and push -- all delegated to commit_and_push() (shared with main()'s Step 6
# follow-up) so the nothing-staged-vs-nothing-to-push fix and the unreviewed-changes guard
# live in one place. The Docusaurus smoke build only has anything new to validate when there
@@ -1030,7 +1048,7 @@ def _docs_only_steps(args, console, docs_root):
docs_root, "docs-smoke", "Docs smoke check", "Docs build"
)
if returncode != 0:
- say("\n❌ Docs smoke check failed — fix the Docusaurus build before pushing juneau-docs.", "error")
+ aborted("\n❌ Docs smoke check failed — fix the Docusaurus build before pushing juneau-docs.")
return False
say("✅ Docs smoke check passed")
return True
@@ -1041,7 +1059,7 @@ def _docs_only_steps(args, console, docs_root):
status, _ = commit_and_push(
docs_root, args.message, 5,
label=" (juneau-docs)",
- pull_hint="git -C ../juneau-docs pull",
+ git_cmd=DOCS_GIT,
pre_commit_hook=_docs_smoke_check,
)
if status == "error":
@@ -1057,23 +1075,12 @@ def _docs_only_steps(args, console, docs_root):
return 0
-def _export_console(argv):
- """Export --console as JUNEAU_RUN_CONSOLE before juneau_run is loaded, so the flag and the variable behave alike
- (an argument beats a value already in the environment). main()'s parser validates the value again."""
- parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
- parser.add_argument("--console")
- args, _ = parser.parse_known_args(argv)
- if args.console in ("full", "condensed", "none"):
- os.environ["JUNEAU_RUN_CONSOLE"] = args.console
-
-
def main():
"""
- The push. _main() does the work up to the identity and PGP checks, then opens the run session, which owns the run
- and done markers, the console, the summary line, the bell and the sound. A Ctrl-C (or, with markers on, a
+ The push. _main() parses the arguments and resolves the commit message, then opens the run session, which owns
+ the run and done markers, the console, the summary line, the bell and the sound. A Ctrl-C (or, with markers on, a
SIGTERM) before the session opens exits 130 with "Cancelled." too.
"""
- _export_console(sys.argv[1:])
run = _juneau.load()[0]
previous_sigterm = None
if run.enabled():
@@ -1092,9 +1099,24 @@ def main():
signal.signal(signal.SIGTERM, previous_sigterm)
+#: Each step's row label, for the labelled plan (a pending row until the step starts). build, tests and js-tests
+#: are test.py's rows; the others are this script's.
+PLAN_LABELS = {
+ "prechecks": "Prechecks", "pgp": "PGP", "sonar": "Sonar", "tracker": "Tracker", "container-tags": "Tags",
+ "bom": "BOM", "build": "Compile", "tests": "Tests", "js-tests": "Browser tests", "timing": "Timing",
+ "install": "Install", "starters": "Starters", "commit": "Commit", "push": "Push", "docs-smoke": "Docs build",
+ "docs-commit": "Docs", "docs-push": "Docs push",
+}
+
+
def push_plan(args, script_dir, juneau_root, console):
- """The top-level step ids this run will take, in order, for the console's Overall figure."""
- ids = ["sonar"] if args.sonarqube else []
+ """The top-level steps this run will take, in order, as (id, label) pairs, for the console's pending rows and
+ Overall figure."""
+ ids = [] if args.test_only else ["prechecks"]
+ if (script_dir / PGP_PROMPT).exists():
+ ids.append("pgp")
+ if args.sonarqube:
+ ids.append("sonar")
if args.tracker_audit:
ids.append("tracker")
if args.skip_tests:
@@ -1113,20 +1135,22 @@ def push_plan(args, script_dir, juneau_root, console):
ids += ["commit", "push"]
if (juneau_root.parent / "juneau-docs").exists():
ids += ["docs-smoke", "docs-commit", "docs-push"]
- return ids
+ return [(step_id, PLAN_LABELS[step_id]) for step_id in ids]
-def _in_session(title, body, *, header, mode, repo, plan):
- """Run body() -- which returns an exit code -- inside the run session; returns the session's exit code."""
- with toolkit("run").session(title, header=header, bell=True, sound=True, silence_env=(PUSH_NO_SOUND_ENV,),
+def _in_session(title, body, *, mode, repo, plan):
+ """Run body(session) -- which returns an exit code -- inside the run session; returns the session's exit code."""
+ with toolkit("run").session(title, bell=True, sound=True, silence_env=(PUSH_NO_SOUND_ENV,),
mode=mode, project=repo.name, repo=repo, exit=False, plan=plan) as session:
- code = body()
+ code = body(session)
if code:
raise SystemExit(code)
return session.code
-def _main(): # NOSONAR python:S3776 -- Cognitive complexity is acceptable for this main function
+def parse_args(argv=None):
+ """The command line, validated, with --console and --detail exported (JUNEAU_RUN_CONSOLE, default condensed, and
+ JUNEAU_RUN_DETAIL) so the session and test.py use them. Exits 2 on a bad combination."""
parser = argparse.ArgumentParser(
description="Build, test, and push Juneau project to Git repository",
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -1233,7 +1257,7 @@ Examples:
parser.add_argument(
"--console",
choices=("full", "condensed", "none"),
- help="Maven output on the console: full (default), condensed or none (also JUNEAU_RUN_CONSOLE)."
+ help="Maven output on the console: full, condensed (default) or none (also JUNEAU_RUN_CONSOLE; the flag wins)."
)
parser.add_argument(
@@ -1241,19 +1265,25 @@ Examples:
help="Console detail: summary, actionable (default), modules or all (also JUNEAU_RUN_DETAIL)."
)
- args = parser.parse_args()
+ args = parser.parse_args(argv)
if args.js_tests and args.no_js_tests:
parser.error("--js-tests and --no-js-tests are mutually exclusive")
if args.test_only and (args.docs_only or args.sonarqube or args.tracker_audit or args.skip_tests):
parser.error("--test-only cannot be combined with --docs-only, --sonarqube, --tracker-audit or --skip-tests")
- run = _juneau.load()[0]
+ toolkit("run").export_console(args.console) # precedence: flag > environment > condensed
try:
- run.export_detail(args.detail)
+ _juneau.load()[0].export_detail(args.detail)
except ValueError as e:
parser.error(str(e))
+ return args
+
+
+def _main(): # NOSONAR python:S3776 -- Cognitive complexity is acceptable for this main function
+ args = parse_args()
+ run = _juneau.load()[0]
if not args.test_only:
args.message = resolve_commit_message(args.message)
-
+
# Get the Juneau root directory
script_dir = Path(__file__).parent
juneau_root = script_dir.parent
@@ -1291,6 +1321,11 @@ Examples:
if args.dry_run:
print("\nSteps that would be executed:")
step_num = 1
+ if not args.test_only:
+ print(f" {step_num}. Prechecks: refuse unless git user.email is {REQUIRED_GIT_EMAIL}, the tree has no unreviewed "
+ "(unstaged/untracked) changes and the branch isn't behind upstream (git fetch + compare) -- juneau, and "
+ "juneau-docs when present -- before any gate runs; the last two are checked again before the commit")
+ step_num += 1
print(f" {step_num}. Prompt for PGP passphrase (dummy call)")
step_num += 1
if args.sonarqube:
@@ -1321,42 +1356,27 @@ Examples:
print(f" {step_num}. Commit whatever is already staged: git commit -m \"{args.message}\" "
"(refuses if anything is unstaged/untracked-and-not-gitignored -- stage or discard it first)")
step_num += 1
- print(f" {step_num}. Push to remote: git push")
+ print(f" {step_num}. Push to the tracked upstream")
+ if not args.test_only:
+ toolkit("run").checks("prechecks", "Prechecks", prechecks(juneau_root, dry_run=True), dry_run=True)
print("\nDry run complete. Use without --dry-run to execute.")
return 0
- branch = current_branch(juneau_root)
-
- # Identity gate — must hold before the expensive build/test gate and any commit/push.
- # Extended (maintainer-approved) from the --docs-only path to the default flow too;
- # juneau-docs gets its own check further down, right before its Step 6 commit/push,
- # since the two repos can have independent git config user.email.
- # --test-only never commits or pushes, so it has no identity to verify.
- header = []
- if not args.test_only:
- if not console:
- print("\n🔐 Verifying git identity (apache.org email) on juneau...")
- if not verify_apache_identity(juneau_root):
- play_sound(success=False) # the session isn't open yet, so it can't sound the failure
- return 1
- if console:
- header.append(f"{REQUIRED_GIT_EMAIL} ✓")
- else:
- print("✅ Git identity verified")
-
- # The PGP prompt runs before the gates and before the session starts: it gets the bare terminal, and the
- # header can show how it went.
- pgp = prompt_pgp_passphrase(script_dir)
- if pgp is not None:
- header.append("PGP ✓" if pgp else "PGP ⚠")
- return _in_session(f"🚀 Juneau push · {branch}", lambda: _push_steps(args, run, console, script_dir, juneau_root),
- header=header, mode="test" if args.test_only else "push", repo=juneau_root,
+ return _in_session(f"🚀 Juneau push · {current_branch(juneau_root)}",
+ lambda s: _push_steps(s, args, run, console, script_dir, juneau_root),
+ mode="test" if args.test_only else "push", repo=juneau_root,
plan=push_plan(args, script_dir, juneau_root, console))
-def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:S3776 -- Cognitive complexity is acceptable for this function
- """The steps of the push inside its session. Returns the exit code."""
+def _push_steps(s, args, run, console, script_dir, juneau_root): # NOSONAR python:S3776 -- Cognitive complexity is acceptable for this function
+ """The steps of the push inside its session s. Returns the exit code."""
step_num = 1
+ # Prechecks refuse before any gate (the row says why); --test-only never commits or pushes, so it has none.
+ if not args.test_only:
+ s.checks("prechecks", "Prechecks", prechecks(juneau_root))
+ # The PGP prompt gets the bare terminal (the console pauses for it), before the long gates.
+ if (script_dir / PGP_PROMPT).exists():
+ pgp_row(script_dir)
if console and args.skip_tests:
say("⚠ Tests will be SKIPPED (--skip-tests)", "warn")
@@ -1365,15 +1385,13 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
if args.sonarqube:
gate_status = run_sonarqube_gate(juneau_root, step_num)
if gate_status == "fail":
- say("\n❌ Push aborted: SonarCloud currently reports open issues for this branch.", "error")
- say(" Note: this reflects SonarCloud's last CI-analyzed commit (a ratchet), not your local diff.", "error")
- say(" Resolve/triage the reported issues, or omit --sonarqube to push without this gate.", "error")
- return 1
+ return aborted("\n❌ Push aborted: SonarCloud currently reports open issues for this branch.",
+ " Note: this reflects SonarCloud's last CI-analyzed commit (a ratchet), not your local diff.",
+ " Resolve/triage the reported issues, or omit --sonarqube to push without this gate.")
if gate_status == "error":
- say("\n❌ Push aborted: the SonarQube gate could not be run.", "error")
- say(f" Fix the issue {'in the full log' if console else 'reported above'} "
- "(e.g. install a Python >= 3.10 interpreter), or omit --sonarqube to push without this gate.", "error")
- return 1
+ return aborted("\n❌ Push aborted: the SonarQube gate could not be run.",
+ f" Fix the issue {'in the full log' if console else 'reported above'} "
+ "(e.g. install a Python >= 3.10 interpreter), or omit --sonarqube to push without this gate.")
if gate_status == "skip":
say(f"⚠ Step {step_num}: SonarQube gate skipped — SonarCloud has no analysis for this "
"branch yet (a new branch CI hasn't scanned). Continuing without the Sonar gate.", "warn")
@@ -1386,16 +1404,14 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
tracker_gate_status = maybe_run_tracker_audit_gate(args, juneau_root, step_num)
if tracker_gate_status is not None:
if tracker_gate_status == "fail":
- say("\n❌ Push aborted: the tracker audit flagged at least one plan file "
- "under ~/Project Work/todos/juneau/.", "error")
- say(" Run `python3 ~/Project\\ Work/scripts/todo-status-audit.py --project juneau` for "
- "details, or omit --tracker-audit to push without this gate.", "error")
- return 1
+ return aborted("\n❌ Push aborted: the tracker audit flagged at least one plan file "
+ "under ~/Project Work/todos/juneau/.",
+ " Run `python3 ~/Project\\ Work/scripts/todo-status-audit.py --project juneau` for "
+ "details, or omit --tracker-audit to push without this gate.")
if tracker_gate_status == "error":
- say("\n❌ Push aborted: the tracker audit gate could not be run.", "error")
- say(f" Fix the issue {'in the full log' if console else 'reported above'}, "
- "or omit --tracker-audit to push without this gate.", "error")
- return 1
+ return aborted("\n❌ Push aborted: the tracker audit gate could not be run.",
+ f" Fix the issue {'in the full log' if console else 'reported above'}, "
+ "or omit --tracker-audit to push without this gate.")
say(f"✅ Step {step_num}: Tracker audit gate passed — no plan file flagged.")
step_num += 1
@@ -1412,8 +1428,7 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
label="Tags",
summarize=tags_summary
):
- say("\n❌ Build process aborted due to missing container test tags.", "error")
- return 1
+ return aborted("\n❌ Build process aborted due to missing container test tags.")
step_num += 1
check_bom = script_dir / "check-bom-completeness.py"
@@ -1426,8 +1441,7 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
label="BOM",
summarize=bom_summary
):
- say("\n❌ Build process aborted: juneau-bom is out of sync with the reactor.", "error")
- return 1
+ return aborted("\n❌ Build process aborted: juneau-bom is out of sync with the reactor.")
step_num += 1
test_script = script_dir / 'test.py'
@@ -1445,8 +1459,7 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
result = run_test_script(test_cmd, juneau_root, run, env={**os.environ, **extra})
_test_wall_sec = int(time.time() - _test_start)
if result.returncode != 0:
- say("\n❌ Build process aborted due to test failures.", "error")
- return 1
+ return aborted("\n❌ Build process aborted due to test failures.")
say(f"✅ Step {step_num}: Tests passed")
_append_test_run_history(juneau_root, _test_wall_sec)
except Exception as e:
@@ -1464,8 +1477,7 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
)
_test_wall_sec = int(time.time() - _test_start)
if not _mvn_ok:
- say("\n❌ Build process aborted due to test failures.", "error")
- return 1
+ return aborted("\n❌ Build process aborted due to test failures.")
_append_test_run_history(juneau_root, _test_wall_sec)
timing_report = script_dir / "push-timings.py"
if timing_report.exists():
@@ -1481,8 +1493,7 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
else:
say(f"\n⏭️ Step {step_num}: Skipping tests (--skip-tests flag)")
with run.row("tests", "Tests", label="Tests") as tests_row:
- tests_row.skip()
- tests_row.summary = "(--skip-tests)"
+ tests_row.skip("--skip-tests")
step_num += 1
# Step 2: Build and install -- unless test.py --full has just done `mvn clean install {PARALLELISM} -DskipTests`
@@ -1490,8 +1501,7 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
if installed_by_tests:
# Still the protocol step `install` (end status skip), so the release manager's step list doesn't change.
with marked_step("install", "Build and install", label="Install") as install_step:
- install_step.skip()
- install_step.summary = "(done by Tests)"
+ install_step.skip("done by Tests")
if not console:
say(f"\n⏭️ Step {step_num}: Build and install already done by test.py --full")
elif not run_marked(
@@ -1502,8 +1512,7 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
tool="maven",
label="Install",
):
- say("\n❌ Build process aborted due to build failure.", "error")
- return 1
+ return aborted("\n❌ Build process aborted due to build failure.")
step_num += 1
# Step 3 (TODO-158): Build external starter repos against the freshly-installed local SNAPSHOT (blocking gate)
@@ -1512,8 +1521,7 @@ def _push_steps(args, run, console, script_dir, juneau_root): # NOSONAR python:
if not starters_ok:
starters_step.fail()
if not starters_ok:
- say("\n❌ Build process aborted due to external starter repo verification failure.", "error")
- return 1
+ return aborted("\n❌ Build process aborted due to external starter repo verification failure.")
step_num += 1
if args.test_only:
diff --git a/scripts/tests/conftest.py b/scripts/tests/conftest.py
index 090fd45dbd..196c4916cf 100644
--- a/scripts/tests/conftest.py
+++ b/scripts/tests/conftest.py
@@ -46,6 +46,20 @@ def _no_real_run_history():
shutil.rmtree(tmp, ignore_errors=True)
+@pytest.fixture(autouse=True)
+def _full_console(monkeypatch):
+ """push.main() exports the condensed console default into os.environ; pin the full console for every test (set,
+ not unset, so teardown restores it) so that export cannot leak into later tests. Tests of the default or of
+ the flag/env precedence set JUNEAU_RUN_CONSOLE themselves."""
+ monkeypatch.setenv("JUNEAU_RUN_CONSOLE", "full")
+
+
+@pytest.fixture(autouse=True)
+def _no_push_sound(monkeypatch):
+ """Keep push.py's end-of-run bell and sound silent in every test, including subprocess runs (they inherit it)."""
+ monkeypatch.setenv("JUNEAU_PUSH_NO_SOUND", "1")
+
+
def load_juneau_run():
"""The juneau_run module the scripts share (sys.modules["juneau_run"]), loaded from the tree if not yet loaded."""
module = sys.modules.get("juneau_run")
diff --git a/scripts/tests/test_bell.py b/scripts/tests/test_bell.py
index dac5f33002..8384b71fde 100644
--- a/scripts/tests/test_bell.py
+++ b/scripts/tests/test_bell.py
@@ -62,6 +62,13 @@ def test_mod(monkeypatch):
return module
+def end_of_run(push_mod, success):
+ """What push.py's session does when it ends: the toolkit's bell and sound, silenced by push.py's variable."""
+ notify = push_mod.toolkit("notify")
+ notify.bell(silence_env=(push_mod.PUSH_NO_SOUND_ENV,))
+ notify.sound(success, silence_env=(push_mod.PUSH_NO_SOUND_ENV,))
+
+
def terminal(monkeypatch, tty=True):
out = Stdout(tty)
monkeypatch.setattr("sys.stdout", out)
@@ -71,27 +78,27 @@ def terminal(monkeypatch, tty=True):
@pytest.mark.parametrize("success", [True, False])
def test_push_rings_on_a_tty_whether_the_run_passed_or_failed(push_mod, monkeypatch, success):
out = terminal(monkeypatch)
- push_mod.play_sound(success=success)
+ end_of_run(push_mod, success)
assert out.getvalue() == "\a"
def test_push_is_silent_off_a_tty(push_mod, monkeypatch):
out = terminal(monkeypatch, tty=False)
- push_mod.play_sound(success=True)
+ end_of_run(push_mod, True)
assert out.getvalue() == ""
def test_push_is_silent_in_the_sandbox(push_mod, monkeypatch):
monkeypatch.setenv("SANDBOX_RUNTIME", "1")
out = terminal(monkeypatch)
- push_mod.play_sound(success=True)
+ end_of_run(push_mod, True)
assert out.getvalue() == ""
def test_push_is_silent_when_opted_out(push_mod, monkeypatch):
monkeypatch.setenv("JUNEAU_PUSH_NO_SOUND", "1")
out = terminal(monkeypatch)
- push_mod.play_sound(success=False)
+ end_of_run(push_mod, False)
assert out.getvalue() == ""
@@ -103,7 +110,7 @@ def test_push_no_sound_silences_the_toolkit_sound(push_mod, monkeypatch, silence
if silenced:
monkeypatch.setenv("JUNEAU_PUSH_NO_SOUND", "1")
terminal(monkeypatch, tty=False)
- push_mod.play_sound(success=False)
+ end_of_run(push_mod, False)
assert played == ([] if silenced else [False])
@@ -116,7 +123,7 @@ def test_push_never_raises_when_stdout_cannot_be_written(push_mod, monkeypatch):
raise OSError("closed")
monkeypatch.setattr("sys.stdout", Broken())
- push_mod.play_sound(success=True)
+ end_of_run(push_mod, True)
@pytest.mark.parametrize("code", [0, 1])
diff --git a/scripts/tests/test_push.py b/scripts/tests/test_push.py
index fafbdb4084..a89a5cd2bd 100644
--- a/scripts/tests/test_push.py
+++ b/scripts/tests/test_push.py
@@ -76,7 +76,7 @@ def _args(tracker_audit: bool) -> argparse.Namespace:
# ---------------------------------------------------------------------------------------
-# Real-git fixtures for commit_and_push() / check_upstream_changes() / get_staged_paths() /
+# Real-git fixtures for commit_and_push() / prechecks() / get_staged_paths() /
# check_unreviewed_changes(). Mirrors the board repo's reset-side-clones tests' hermetic_git_env and
# _run/_commit helpers.
# ---------------------------------------------------------------------------------------
@@ -156,9 +156,8 @@ def docs_repo(tmp_path: Path, docs_remote_repo: Path, push_module) -> Path:
A real clone of docs_remote_repo, seeded with a stub scripts/build-docs.py that exits 0 (so
tests can exercise the real pre_commit_hook smoke-check wiring without a real Docusaurus
site), living at <tmp_path>/juneau-docs so juneau_root.parent / "juneau-docs" resolves to
- it. Also configures the repo-local (never global) git identity commit_and_push()'s
- verify_apache_identity() check reads, since that check is real, pre-existing behavior this
- task doesn't touch and unrelated tests still need it satisfied.
+ it. Also configures the repo-local (never global) git identity the docs Prechecks' identity
+ check reads, so tests that run them see it satisfied.
"""
seed = tmp_path / "docs-seed"
seed.mkdir()
@@ -326,20 +325,23 @@ class TestRunTrackerAuditGateExitCodeMapping:
]
-class TestCheckUpstreamChangesAheadBehind:
+class TestUpstreamVerdictAheadBehind:
"""
- check_upstream_changes() against real git: the ahead count is the fix, so it needs to be
- right, not just the pre-existing behind count.
+ The upstream verdict commit_and_push() reads (the toolkit's git.upstream_verdict) against real
+ git: the ahead count is the fix, so it needs to be right, not just the pre-existing behind count.
"""
+ @staticmethod
+ def _verdict(push_module, repo_dir):
+ v = push_module.toolkit("git").upstream_verdict(repo_dir)
+ return (v.status, v.ahead, v.behind)
+
def test_clean_and_synced_is_zero_and_zero(self, push_module, repo):
- ahead, behind, error = push_module.check_upstream_changes(repo)
- assert (ahead, behind, error) == (0, 0, None)
+ assert self._verdict(push_module, repo) == ("ok", 0, 0)
def test_a_local_only_commit_is_one_ahead_zero_behind(self, push_module, repo):
_local_only_commit(repo)
- ahead, behind, error = push_module.check_upstream_changes(repo)
- assert (ahead, behind, error) == (1, 0, None)
+ assert self._verdict(push_module, repo) == ("ok", 1, 0)
def test_an_upstream_only_commit_is_zero_ahead_one_behind(self, push_module, tmp_path, repo, remote_repo):
other = tmp_path / "other-clone"
@@ -347,20 +349,16 @@ class TestCheckUpstreamChangesAheadBehind:
_local_only_commit(other, filename="from-elsewhere.txt", message="pushed by someone else")
_run(other, "git", "push", "origin", "master")
- ahead, behind, error = push_module.check_upstream_changes(repo)
-
- assert (ahead, behind, error) == (0, 1, None)
+ assert self._verdict(push_module, repo) == ("behind", 0, 1)
- def test_no_upstream_configured_is_none_none_none(self, push_module, tmp_path):
+ def test_no_upstream_configured_is_none_none(self, push_module, tmp_path):
lone = tmp_path / "lone"
lone.mkdir()
_run(lone, "git", "init", "-b", "master", ".")
(lone / "f.txt").write_text("x\n", encoding="utf-8")
_commit(lone, "no remote at all")
- ahead, behind, error = push_module.check_upstream_changes(lone)
-
- assert (ahead, behind, error) == (None, None, None)
+ assert self._verdict(push_module, lone) == ("no-upstream", None, None)
class TestGetStagedPaths:
@@ -829,22 +827,19 @@ class TestRunDocsFollowup:
assert remote_head == local_head, "the juneau-docs remote must actually have moved"
assert any(argv[:2] == ["git", "push"] for argv in git_spy)
- def test_identity_gate_still_runs_for_an_ahead_only_push(
- self, push_module, docs_repo, docs_remote_repo, juneau_root
+ def test_the_docs_identity_is_a_precheck_for_an_ahead_only_push(
+ self, push_module, docs_repo, juneau_root
):
"""
- The identity gate is the pre_flight_hook specifically because it must run even when
- there's nothing to commit -- only something to push. Breaking the repo-local identity
- AFTER the commit (env vars, not this config, are what let the commit itself succeed)
- proves the gate is consulted on this path, not bypassed because has_changes is False.
+ With nothing to commit -- only something to push -- juneau-docs is still active, so the
+ Prechecks row checks its identity (which replaced the follow-up's own identity hook).
"""
- local_head = _local_only_commit(docs_repo)
+ _local_only_commit(docs_repo)
_run(docs_repo, "git", "config", "--unset", "user.email")
- status, _ = push_module.run_docs_followup(juneau_root, "message", 6)
+ listed = {c.name: c for c in push_module.prechecks(juneau_root)}
- assert status == "error"
- assert _run(docs_remote_repo, "git", "rev-parse", "master") != local_head
+ assert push_module.toolkit("checks").evaluate(listed["docs identity"]).status == "fail"
def test_smoke_check_does_not_run_for_an_ahead_only_push(
self, push_module, docs_repo, docs_remote_repo, juneau_root, capsys
@@ -963,7 +958,7 @@ def test_build_test_command_has_no_timing_log(push_module):
def _stub_push_flow(push_module, monkeypatch, *, tests_pass, test_py=True):
"""Every gate and side effect of push.main() replaced; returns the _append_test_run_history calls."""
calls = []
- for name in ("play_sound", "run_timing_report", "verify_starter_repos", "verify_apache_identity"):
+ for name in ("run_timing_report", "verify_starter_repos"):
monkeypatch.setattr(push_module, name, lambda *a, **k: True)
monkeypatch.setattr(push_module, "prompt_pgp_passphrase", lambda *a, **k: None)
monkeypatch.setattr(push_module, "run_command",
@@ -1029,7 +1024,6 @@ def test_append_test_run_history_is_unchanged_and_the_only_tsv_writer(push_modul
def _said(push_module, monkeypatch):
lines = []
monkeypatch.setattr(push_module, "say", lambda text="", level="info": lines.append(text))
- monkeypatch.setattr(push_module, "play_sound", lambda success=True: None)
return lines
@@ -1113,22 +1107,35 @@ def test_unreviewed_changes_are_the_toolkits_unstaged_then_untracked(push_module
assert push_module.check_unreviewed_changes(Path("/r")) == ["m.txt", "new.txt"]
-def test_upstream_check_uses_ahead_behind_with_fetch(push_module, toolkit_git, monkeypatch):
+def test_the_late_upstream_check_uses_the_toolkit_verdict(push_module, toolkit_git, repo, monkeypatch):
calls = []
- answers = iter([(2, 1, None), (None, None, "no upstream"), (None, None, "fatal: unable to access")])
- monkeypatch.setattr(toolkit_git, "ahead_behind", lambda repo, **k: calls.append((repo, k)) or next(answers))
- assert push_module.check_upstream_changes(Path("/r")) == (2, 1, None)
- assert push_module.check_upstream_changes(Path("/r")) == (None, None, None)
- assert push_module.check_upstream_changes(Path("/r")) == (None, None, "Git command failed: fatal: unable to access")
- assert calls == [(Path("/r"), {"fetch": True})] * 3
+ def verdict(repo_dir, **k):
+ calls.append(k)
+ return toolkit_git.UpstreamVerdict("behind", 0, 1)
+ monkeypatch.setattr(toolkit_git, "upstream_verdict", verdict)
+ lines = _said(push_module, monkeypatch)
+ assert push_module.commit_and_push(repo, "message", 4, git_cmd="git -C x") == ("error", 4)
+ assert calls == [{"git_cmd": "git -C x"}]
+ assert any("Nothing was committed or pushed." in line for line in lines)
+
+
+def test_a_late_upstream_error_fails_closed_before_the_commit(push_module, toolkit_git, repo, git_spy,
+ monkeypatch):
+ (repo / "reviewed.txt").write_text("staged and reviewed\n", encoding="utf-8")
+ _run(repo, "git", "add", "reviewed.txt")
+ head_before = _run(repo, "git", "rev-parse", "HEAD")
+ monkeypatch.setattr(toolkit_git, "upstream_verdict",
+ lambda repo_dir, **k: toolkit_git.UpstreamVerdict("error", reason="could not check upstream: x"))
+ lines = _said(push_module, monkeypatch)
+ git_spy.clear()
+
+ assert push_module.commit_and_push(repo, "message", 4, git_cmd=push_module.DOCS_GIT) == ("error", 4)
-def test_upstream_check_reports_a_git_error_instead_of_raising(push_module, toolkit_git, monkeypatch):
- def broken(repo, **k):
- raise toolkit_git.GitError(["git", "rev-parse"], toolkit_git.Result(False, 128, err="not a repo"))
- monkeypatch.setattr(toolkit_git, "ahead_behind", broken)
- ahead, behind, error = push_module.check_upstream_changes(Path("/r"))
- assert (ahead, behind) == (None, None) and error.startswith("Git command failed: git rev-parse failed")
+ assert "\n❌ ERROR: could not check upstream: x. Nothing was committed or pushed." in lines
+ assert f" Re-run the push once `{push_module.DOCS_GIT} fetch` works." in lines
+ assert _run(repo, "git", "rev-parse", "HEAD") == head_before
+ assert not any(argv[:2] in (["git", "commit"], ["git", "push"]) for argv in git_spy)
@pytest.mark.parametrize(("branch", "head", "expected"), [
@@ -1144,14 +1151,6 @@ def test_current_branch_on_a_detached_head_is_still_head(push_module, repo):
assert push_module.current_branch(repo) == "HEAD"
-def test_the_identity_is_read_through_the_toolkit(push_module, toolkit_git, monkeypatch, capsys):
- monkeypatch.setattr(toolkit_git, "user_email", lambda repo: None)
- assert push_module.verify_apache_identity(Path("/r")) is False
- assert "user.email = '(unset)'" in capsys.readouterr().out
- monkeypatch.setattr(toolkit_git, "user_email", lambda repo: push_module.REQUIRED_GIT_EMAIL)
- assert push_module.verify_apache_identity(Path("/r")) is True
-
-
@pytest.mark.parametrize("reader", ["get_staged_paths", "check_unreviewed_changes"])
def test_refusal_guard_fails_closed_on_a_git_error(push_module, toolkit_git, repo, monkeypatch, reader):
(repo / "reviewed.txt").write_text("staged and reviewed\n", encoding="utf-8")
@@ -1209,3 +1208,233 @@ def test_the_gates_go_through_the_toolkit_gate(push_module, monkeypatch):
{"title": "Tracker audit", "id": "tracker", "label": "Tracker", "cwd": Path("/repo"),
"codes": {0: "pass", 1: "fail"}},
]
+
+
+# ---------------------------------------------------------------------------------------
+# Prechecks: unreviewed changes and a behind-upstream branch refuse before any gate runs.
+# ---------------------------------------------------------------------------------------
+class _PastPrechecks(Exception):
+ """Raised by the stubbed PGP row: the push got past its Prechecks to the first gate."""
+
+
+@pytest.fixture
+def flow(push_module, repo, monkeypatch):
+ """_main() pointed at `repo` (scripts/ beside it) with a stand-in session: the real Prechecks run, and the push
+ stops at the PGP row. `gates` records "pgp" and "session" when the push got that far."""
+ (repo / "scripts").mkdir()
+ (repo / "scripts" / push_module.PGP_PROMPT).write_text("", encoding="utf-8")
+ monkeypatch.setattr(push_module, "__file__", str(repo / "scripts" / "push.py"))
+ _run(repo, "git", "config", "user.email", push_module.REQUIRED_GIT_EMAIL)
+ gates = []
+ run = push_module.toolkit("run")
+
+ class Session:
+ def checks(self, id, title, listed):
+ result = run.checks(id, title, listed)
+ if not result.ok:
+ raise SystemExit(1)
+ return result
+
+ def in_session(title, body, **kw):
+ try:
+ return body(Session())
+ except SystemExit as e:
+ return e.code
+ except _PastPrechecks:
+ gates.append("session")
+ return 0
+
+ def pgp(script_dir):
+ gates.append("pgp")
+ raise _PastPrechecks()
+
+ monkeypatch.setattr(push_module, "pgp_row", pgp)
+ monkeypatch.setattr(push_module, "_in_session", in_session)
+ monkeypatch.setattr(push_module, "resolve_commit_message", lambda m: m)
+ monkeypatch.setattr(push_module, "current_branch", lambda root: "master")
+ # scripts/ is untracked in `repo`; ignore it so it isn't itself an unreviewed change
+ (repo / ".git" / "info" / "exclude").write_text("scripts/\n", encoding="utf-8")
+
+ def go(*argv):
+ monkeypatch.setattr(push_module.sys, "argv", ["push.py", *argv])
+ return push_module._main()
+
+ go.gates = gates
+ return go
+
+
+def _push_from_elsewhere(tmp_path: Path, remote_repo: Path) -> None:
+ other = tmp_path / "other-clone"
+ _run(tmp_path, "git", "clone", str(remote_repo), str(other))
+ _local_only_commit(other, filename="from-elsewhere.txt", message="pushed by someone else")
+ _run(other, "git", "push", "origin", "master")
+
+
+class TestPrechecks:
+ def test_up_to_date_and_clean_proceeds_to_the_gates(self, flow):
+ assert flow("message") == 0
+ assert flow.gates == ["pgp", "session"]
+
+ def test_behind_refuses_before_any_gate_with_ff_only_hint(self, flow, tmp_path, remote_repo, capsys):
+ _push_from_elsewhere(tmp_path, remote_repo)
+
+ assert flow("message") == 1
+ assert flow.gates == [] # no PGP prompt, no gate, mvn or test.py was reached
+ out = capsys.readouterr().out
+ assert "✗ upstream: 1 commit behind upstream" in out
+ assert "git merge --ff-only @{upstream}" in out
+ assert "--autostash" not in out
+
+ def test_behind_with_local_commits_gets_the_stash_rebase_hint(self, flow, tmp_path, repo, remote_repo, capsys):
+ _push_from_elsewhere(tmp_path, remote_repo)
+ _local_only_commit(repo)
+
+ assert flow("message") == 1
+ assert flow.gates == []
+ out = capsys.readouterr().out
+ assert "git stash push && git pull --rebase && git stash pop --index" in out
+ assert "--ff-only" not in out and "--autostash" not in out
+
+ def test_unreviewed_changes_refuse_before_any_gate(self, flow, repo, capsys):
+ (repo / "README.md").write_text("edited\n", encoding="utf-8")
+ (repo / "new.txt").write_text("new\n", encoding="utf-8")
+
+ assert flow("message") == 1
+ assert flow.gates == []
+ out = capsys.readouterr().out
+ assert "✗ unreviewed: 2 paths are not staged" in out
+ assert "README.md" in out and "new.txt" in out
+ assert "Nothing was built, committed or pushed." in out
+ assert "git stash push -- " in out
+
+ def test_the_wrong_identity_refuses_before_any_gate(self, push_module, flow, repo, capsys):
+ _run(repo, "git", "config", "user.email", "someone@example.invalid")
+
+ assert flow("message") == 1
+ assert flow.gates == []
+ out = capsys.readouterr().out
+ assert f"git user.email is someone@example.invalid, not {push_module.REQUIRED_GIT_EMAIL}" in out
+ assert f"git config user.email {push_module.REQUIRED_GIT_EMAIL}" in out
+
+ def test_staged_changes_do_not_refuse(self, flow, repo):
+ (repo / "README.md").write_text("edited\n", encoding="utf-8")
+ _run(repo, "git", "add", "README.md")
+ assert flow("message") == 0
+ assert "session" in flow.gates
+
+ def test_no_upstream_proceeds(self, flow, repo):
+ _run(repo, "git", "branch", "--unset-upstream")
+ assert flow("message") == 0
+ assert "session" in flow.gates
+
+ def test_fetch_error_fails_closed(self, flow, repo, remote_repo, capsys):
+ _run(repo, "git", "remote", "set-url", "origin", str(remote_repo) + "-missing")
+
+ assert flow("message") == 1
+ assert flow.gates == []
+ assert "✗ upstream: could not check upstream" in capsys.readouterr().out
+
+ def test_skip_tests_still_runs_the_prechecks(self, flow, tmp_path, remote_repo):
+ _push_from_elsewhere(tmp_path, remote_repo)
+
+ assert flow("message", "--skip-tests") == 1
+ assert flow.gates == []
+
+ def test_test_only_skips_the_prechecks(self, flow, tmp_path, remote_repo):
+ _push_from_elsewhere(tmp_path, remote_repo)
+
+ assert flow("--test-only") == 0 # nothing is committed or pushed, so behind-upstream is irrelevant
+ assert flow.gates == ["pgp", "session"]
+
+ def test_dry_run_reports_would_refuse_and_exits_zero(self, flow, tmp_path, repo, remote_repo, capsys):
+ _push_from_elsewhere(tmp_path, remote_repo)
+ (repo / "new.txt").write_text("new\n", encoding="utf-8")
+
+ assert flow("message", "--dry-run") == 0
+ out = capsys.readouterr().out
+ assert "⚠ unreviewed: would refuse: 1 path is not staged" in out
+ assert "⚠ upstream: would refuse: 1 commit behind upstream" in out
+ assert "Nothing would be built, committed or pushed." in out
+ assert "Prechecks" in out
+ assert flow.gates == []
+
+ def test_late_check_in_commit_and_push_is_kept(self, push_module, tmp_path, repo, remote_repo):
+ _push_from_elsewhere(tmp_path, remote_repo) # upstream moved after the Prechecks would have passed
+
+ status, _ = push_module.commit_and_push(repo, "message", 4)
+
+ assert status == "error"
+
+
+# ---------------------------------------------------------------------------------------
+# The juneau-docs Prechecks run only when juneau-docs has something to push.
+# ---------------------------------------------------------------------------------------
+DOCS_CHECKS = ["docs identity", "docs unreviewed", "docs upstream"]
+
+
+class TestDocsPrechecksWhenIdle:
+ @staticmethod
+ def _docs_checks(push_module, juneau_root):
+ return [c.name for c in push_module.prechecks(juneau_root) if c.name.startswith("docs ")]
+
+ def test_an_idle_docs_tree_is_not_checked_so_its_dirt_does_not_block(self, push_module, docs_repo,
+ juneau_root):
+ (docs_repo / "README.md").write_text("mid-edit\n", encoding="utf-8")
+ (docs_repo / "scratch.md").write_text("untracked\n", encoding="utf-8")
+ _run(docs_repo, "git", "config", "--unset", "user.email")
+
+ assert push_module.docs_active(docs_repo) is False
+ assert self._docs_checks(push_module, juneau_root) == []
+
+ def test_staged_docs_changes_are_checked_and_their_dirt_blocks(self, push_module, docs_repo, juneau_root):
+ (docs_repo / "reviewed.md").write_text("staged\n", encoding="utf-8")
+ _run(docs_repo, "git", "add", "reviewed.md")
+ (docs_repo / "scratch.md").write_text("untracked\n", encoding="utf-8")
+
+ listed = {c.name: c for c in push_module.prechecks(juneau_root)}
+
+ assert self._docs_checks(push_module, juneau_root) == DOCS_CHECKS
+ verdict = push_module.toolkit("checks").evaluate(listed["docs unreviewed"])
+ assert verdict.status == "fail" and "scratch.md" in " ".join(verdict.hint)
+
+ def test_unpushed_docs_commits_are_checked(self, push_module, docs_repo, juneau_root):
+ _local_only_commit(docs_repo)
+ (docs_repo / "scratch.md").write_text("untracked\n", encoding="utf-8")
+
+ assert push_module.docs_active(docs_repo) is True
+ assert self._docs_checks(push_module, juneau_root) == DOCS_CHECKS
+
+ def test_docs_only_always_checks_the_docs(self, push_module, docs_repo):
+ assert push_module.docs_active(docs_repo) is False
+ assert [c.name for c in push_module.docs_prechecks(docs_repo)] == DOCS_CHECKS
+
+
+# ---------------------------------------------------------------------------------------
+# Console default: flag > JUNEAU_RUN_CONSOLE > condensed.
+# ---------------------------------------------------------------------------------------
+class TestConsoleDefault:
+ @pytest.fixture(autouse=True)
+ def _clean_env(self, monkeypatch):
+ monkeypatch.setenv("JUNEAU_RUN_CONSOLE", "") # registers the restore; deleted next so the test starts unset
+ monkeypatch.delenv("JUNEAU_RUN_CONSOLE")
+
+ def test_defaults_to_condensed(self, push_module):
+ push_module.parse_args(["msg"])
+ assert os.environ["JUNEAU_RUN_CONSOLE"] == "condensed"
+
+ def test_environment_beats_the_default(self, push_module, monkeypatch):
+ monkeypatch.setenv("JUNEAU_RUN_CONSOLE", "full")
+ push_module.parse_args(["msg"])
+ assert os.environ["JUNEAU_RUN_CONSOLE"] == "full"
+
+ @pytest.mark.parametrize("mode", ["full", "condensed", "none"])
+ def test_flag_beats_the_environment(self, push_module, monkeypatch, mode):
+ monkeypatch.setenv("JUNEAU_RUN_CONSOLE", "full" if mode != "full" else "none")
+ push_module.parse_args(["msg", "--console", mode])
+ assert os.environ["JUNEAU_RUN_CONSOLE"] == mode
+
+ def test_help_says_condensed_is_the_default(self, push_module, monkeypatch, capsys):
+ monkeypatch.setattr(push_module.sys, "argv", ["push.py", "--help"])
+ with pytest.raises(SystemExit):
+ push_module._main()
+ assert "condensed (default)" in " ".join(capsys.readouterr().out.split())
diff --git a/scripts/tests/test_run_adoption.py b/scripts/tests/test_run_adoption.py
index ccb91c2b84..3b2810d4b0 100644
--- a/scripts/tests/test_run_adoption.py
+++ b/scripts/tests/test_run_adoption.py
@@ -54,6 +54,9 @@ def clean_env(monkeypatch, tmp_path):
monkeypatch.setenv("JUNEAU_RUN_LOG_DIR", str(tmp_path / "run-logs"))
for variable in RUN_VARIABLES:
monkeypatch.delenv(variable, raising=False)
+ # push.py now defaults to the condensed console; these tests assert on the full-console output. Set (not just
+ # unset) so teardown restores the variable and a main() that exports the default can't leak it to other tests.
+ monkeypatch.setenv("JUNEAU_RUN_CONSOLE", "full")
sigterm = signal.getsignal(signal.SIGTERM)
sys.modules.pop("juneau_run", None)
yield
@@ -234,11 +237,11 @@ class Recorder:
def gates(push_mod, monkeypatch):
"""Replace every gate and side effect of push.main() with a recorder; returns the recorder."""
rec = Recorder()
- monkeypatch.setattr(push_mod, "play_sound", rec("sound"))
monkeypatch.setattr(push_mod, "run_command", rec("run_command"))
monkeypatch.setattr(push_mod, "run_timing_report", rec("timing"))
monkeypatch.setattr(push_mod, "verify_starter_repos", rec("starters"))
- monkeypatch.setattr(push_mod, "verify_apache_identity", rec("identity"))
+ # The Prechecks row with no checks: test_push.TestPreflight covers them against real git.
+ monkeypatch.setattr(push_mod, "prechecks", lambda *a, **k: rec.calls.append("identity") or [])
monkeypatch.setattr(push_mod, "commit_and_push", lambda *a, **k: rec.calls.append("commit") or ("ok", 1))
monkeypatch.setattr(push_mod, "run_docs_followup", lambda *a, **k: rec.calls.append("docs") or ("ok", 1))
monkeypatch.setattr(push_mod, "_append_test_run_history", rec("history"))
@@ -257,7 +260,7 @@ def test_push_test_only_emits_mode_test_and_never_commits(push_mod, gates, monke
events = markers(capsys.readouterr().out)
project = Path(push_mod.__file__).resolve().parent.parent.name
assert events[0] == {"ev": "run", "v": 1, "mode": "test", "project": project}
- assert [e["id"] for e in events if e["ev"] == "step"] == ["container-tags", "bom", "install", "starters"]
+ assert [e["id"] for e in events if e["ev"] == "step"] == ["pgp", "container-tags", "bom", "install", "starters"]
assert events[-2] == {"ev": "note", "level": "info", "text": "Test-only run: nothing was committed or pushed"}
assert events[-1] == {"ev": "done", "status": "ok"}
assert not {"identity", "commit", "docs"} & set(gates.calls)
@@ -307,8 +310,9 @@ def test_push_failed_gate_ends_the_run_with_done_fail(push_mod, gates, monkeypat
monkeypatch.setattr(sys, "argv", ["push.py", "--test-only"])
assert push_mod.main() == 1
events = markers(capsys.readouterr().out)
- assert events[1] == {"ev": "step", "id": "container-tags", "n": 1, "title": "Container test tags"}
- assert events[2]["status"] == "fail"
+ assert events[1] == {"ev": "step", "id": "pgp", "n": 1, "title": "PGP passphrase"}
+ assert events[3] == {"ev": "step", "id": "container-tags", "n": 2, "title": "Container test tags"}
+ assert events[4]["status"] == "fail"
assert events[-1] == {"ev": "done", "status": "fail"}
@@ -355,7 +359,7 @@ def test_push_reserves_step_numbers_for_the_steps_test_py_contributes(push_mod,
env = _capture_child_env(push_mod, monkeypatch, gates)
monkeypatch.setattr(sys, "argv", ["push.py", "--test-only"])
assert push_mod.main() == 0
- assert env["JUNEAU_RUN_N_BASE"] == "2"
+ assert env["JUNEAU_RUN_N_BASE"] == "3" # after PGP, Tags and BOM
def test_push_test_only_fallback_runs_mvn_test_as_a_maven_step(push_mod, gates, monkeypatch, capsys):
@@ -575,7 +579,7 @@ def test_the_skipped_install_shows_done_by_tests(push_mod, gates, standalone, mo
monkeypatch.setattr(sys, "argv", ["push.py", "--test-only"])
assert push_mod.main() == 0
[install] = [f for f in events.of("step_end") if f["id"] == "install"]
- assert (install["status"], install["summary"]) == ("skip", "(done by Tests)")
+ assert (install["status"], install["summary"]) == ("skip", "done by Tests")
class FakeTimings:
@@ -661,14 +665,15 @@ def test_push_rejects_an_unknown_detail_level(push_mod, gates, monkeypatch, caps
assert "actionable" in capsys.readouterr().err
-def test_push_session_header_shows_identity_and_pgp(push_mod, gates, monkeypatch, capsys):
+def test_push_identity_and_pgp_are_rows_not_header_marks(push_mod, gates, monkeypatch, capsys):
monkeypatch.setenv("JUNEAU_RUN_CONSOLE", "condensed")
events = push_mod._juneau.load()[0].subscribe(Events())
monkeypatch.setattr(sys, "argv", ["push.py", "Quick fix", "--skip-tests"])
assert push_mod.main() == 0
[session] = events.of("session")
assert session["title"] == "🚀 Juneau push · main"
- assert session["header"][:2] == ["jamesbognar@apache.org ✓", "PGP ✓"]
+ assert not [line for line in session["header"] if "✓" in line or "PGP" in line]
+ assert [f["id"] for f in events.of("step_start")][:2] == ["prechecks", "pgp"]
out = capsys.readouterr().out
assert "=" * 70 not in out
assert "Juneau Build and Push Script" not in out
@@ -694,7 +699,8 @@ def test_push_hands_the_console_to_test_py(push_mod, gates, standalone, monkeypa
("test.py", env.get("JUNEAU_RUN_LABEL_WIDTH"))) or subprocess.CompletedProcess(cmd, 0))
monkeypatch.setattr(sys, "argv", ["push.py", "--test-only"])
assert push_mod.main() == 0
- assert calls[:3] == ["pause", ("test.py", "10"), "resume"] # the final line may pause again
+ # the PGP row pauses first; the final line may pause again
+ assert calls[:5] == ["pause", "resume", "pause", ("test.py", "10"), "resume"]
def test_push_ctrl_c_with_a_console_is_a_clean_cancel(push_mod, gates, standalone, monkeypatch):
@@ -718,13 +724,16 @@ def say_events(events):
def stub_push_flow(push_mod, monkeypatch, *, upstream="origin/master"):
- """commit_and_push's collaborators, stubbed so it reaches the commit and the push; returns the sound calls."""
- sounds = []
- monkeypatch.setattr(push_mod, "check_upstream_changes", lambda repo: (1, 0, None))
+ """commit_and_push's collaborators, stubbed so it reaches the commit and the push; returns the notify calls."""
+ git = push_mod.toolkit("git")
+ monkeypatch.setattr(git, "upstream_verdict", lambda repo, **k: git.UpstreamVerdict("ok", 1, 0))
+ monkeypatch.setattr(git, "push_command", lambda repo: ["git", "push"])
monkeypatch.setattr(push_mod, "get_staged_paths", lambda repo: ["a.txt"])
monkeypatch.setattr(push_mod, "check_unreviewed_changes", lambda repo: [])
monkeypatch.setattr(push_mod, "upstream_name", lambda repo: upstream)
- monkeypatch.setattr(push_mod, "play_sound", lambda success=True: sounds.append(success))
+ notify, sounds = push_mod.toolkit("notify"), []
+ monkeypatch.setattr(notify, "bell", lambda **k: sounds.append("bell"))
+ monkeypatch.setattr(notify, "sound", lambda ok, **k: sounds.append(ok))
return sounds
@@ -767,31 +776,16 @@ def test_a_test_history_failure_is_a_warning_through_say(push_mod, monkeypatch,
assert capsys.readouterr().out == ""
-def test_the_identity_failure_is_an_error_note_under_a_console(push_mod, console, monkeypatch):
- _, events = console
- monkeypatch.setattr(push_mod.subprocess, "run", lambda cmd, *a, **k: subprocess.CompletedProcess(cmd, 0, "x@y.org\n", ""))
- assert push_mod.verify_apache_identity(Path(".")) is False
- texts = [text for level, text in say_events(events) if level == "error"]
- assert texts[0].startswith("❌ ERROR: Git identity is not the ASF committer identity.")
- assert any("git config user.email" in text for text in texts)
- assert all(level == "error" for level, _ in say_events(events))
-
-
-def test_the_identity_failure_is_printed_without_a_console(push_mod, monkeypatch, capsys):
- monkeypatch.setattr(push_mod.subprocess, "run", lambda cmd, *a, **k: subprocess.CompletedProcess(cmd, 0, "x@y.org\n", ""))
- assert push_mod.verify_apache_identity(Path(".")) is False
- assert "❌ ERROR: Git identity is not the ASF committer identity." in capsys.readouterr().out
-
-
@pytest.fixture
def docs_tree(push_mod, monkeypatch, tmp_path):
"""A scratch juneau/juneau-docs pair that push.py believes it lives in."""
(tmp_path / "juneau" / "scripts").mkdir(parents=True)
(tmp_path / "juneau-docs").mkdir()
monkeypatch.setattr(push_mod, "__file__", str(tmp_path / "juneau" / "scripts" / "push.py"))
- monkeypatch.setattr(push_mod, "verify_apache_identity", lambda repo: True)
+ # the scratch juneau-docs isn't a repo: Prechecks has nothing to check
+ monkeypatch.setattr(push_mod, "prechecks", lambda *a, **k: [])
+ monkeypatch.setattr(push_mod, "docs_prechecks", lambda *a, **k: [])
monkeypatch.setattr(push_mod, "current_branch", lambda root: "main")
- monkeypatch.setattr(push_mod, "play_sound", lambda success=True: None)
monkeypatch.setenv("JUNEAU_SCRIPTS_QUIET", "1")
return tmp_path
@@ -805,7 +799,7 @@ def test_docs_only_under_a_console_has_one_session_and_one_done(push_mod, docs_t
[session] = events.of("session")
assert session["title"] == "📚 Juneau docs push · main"
assert [f["status"] for f in events.of("done")] == ["ok"]
- assert [p["ids"] for p in events.of("plan")] == [["docs-smoke", "commit", "push"]]
+ assert [p["ids"] for p in events.of("plan")] == [["prechecks", "docs-smoke", "commit", "push"]]
def test_docs_only_with_markers_on_emits_run_and_done_markers(push_mod, docs_tree, monkeypatch, capsys):
@@ -903,7 +897,6 @@ def test_the_docs_followup_keeps_its_rows_apart_from_juneaus(push_mod, console,
run, _ = console
(tmp_path / "juneau-docs").mkdir()
stub_push_flow(push_mod, monkeypatch)
- monkeypatch.setattr(push_mod, "verify_apache_identity", lambda repo: True)
calls = routed_calls(run, monkeypatch)
assert push_mod.run_docs_followup(tmp_path / "juneau", "msg", 5) == ("ok", 7)
assert [(c["step_id"], c["label"]) for c in calls] == [
@@ -972,9 +965,11 @@ def test_a_gate_that_could_not_run_points_at_the_full_log_under_a_console(push_m
monkeypatch.setattr(push_mod, "run_tracker_audit_gate", lambda root, n: "error")
monkeypatch.setattr(sys, "argv", ["push.py", "msg", "--sonarqube" if gate == "sonar" else "--tracker-audit"])
assert push_mod.main() == 1
- texts = [text for _, text in say_events(events)]
+ # the failed row says what stopped; the fix goes out as a hint
+ texts = [text for _, text in say_events(events)] + [line for f in events.of("hint") for line in f["lines"]]
assert any("full log" in text for text in texts)
assert not any("reported above" in text for text in texts)
+ assert not any("Push aborted" in text for text in texts)
def test_a_gate_that_could_not_run_still_points_above_without_a_console(push_mod, gates, monkeypatch, capsys):
@@ -1005,7 +1000,7 @@ def test_push_child_env_carries_push_active_and_n_base_under_markers(push_mod, g
monkeypatch.setattr(sys, "argv", ["push.py", "--test-only"])
assert push_mod.main() == 0
assert env["JUNEAU_PUSH_ACTIVE"] == "1"
- assert env["JUNEAU_RUN_N_BASE"] == "2"
+ assert env["JUNEAU_RUN_N_BASE"] == "3" # after PGP, Tags and BOM
def test_push_child_env_has_push_active_but_no_n_base_without_markers(push_mod, gates, monkeypatch):
@@ -1065,8 +1060,9 @@ def test_push_plain_ctrl_c_in_the_session_exits_130(push_mod, gates, monkeypatch
@pytest.mark.parametrize("argv, expected", [
- (["--test-only"], ["container-tags", "bom", "build", "tests", "timing", "install", "starters"]),
- (["Quick fix", "--skip-tests", "--tracker-audit"], ["tracker", "tests", "install", "starters", "commit", "push"]),
+ (["--test-only"], ["pgp", "container-tags", "bom", "build", "tests", "timing", "install", "starters"]),
+ (["Quick fix", "--skip-tests", "--tracker-audit"],
+ ["prechecks", "pgp", "tracker", "tests", "install", "starters", "commit", "push"]),
])
def test_push_session_plan_lists_the_steps_this_run_will_take(push_mod, gates, standalone, monkeypatch, argv, expected):
_, events, _ = standalone
@@ -1077,6 +1073,7 @@ def test_push_session_plan_lists_the_steps_this_run_will_take(push_mod, gates, s
docs = ["docs-smoke", "docs-commit", "docs-push"] if (root.parent / "juneau-docs").exists() else []
[plan] = events.of("plan")
assert plan["ids"] == expected + (docs if "commit" in expected else [])
+ assert all(plan["labels"].get(step_id) for step_id in plan["ids"]) # every planned row has a label
def test_push_session_rings_the_bell_and_plays_the_sound_once_when_it_ends(push_mod, gates, monkeypatch):
@@ -1097,9 +1094,9 @@ def test_push_plan_puts_the_opt_in_gates_first_and_forced_browser_tests_after_te
(script_dir / name).touch()
(tmp_path / "juneau-docs").mkdir()
args = argparse.Namespace(sonarqube=True, tracker_audit=True, skip_tests=False, js_tests=True, test_only=False)
- assert push_mod.push_plan(args, script_dir, script_dir.parent, False) == [
- "sonar", "tracker", "bom", "build", "tests", "js-tests", "install", "starters", "commit", "push",
- "docs-smoke", "docs-commit", "docs-push"]
+ assert [step_id for step_id, _ in push_mod.push_plan(args, script_dir, script_dir.parent, False)] == [
+ "prechecks", "sonar", "tracker", "bom", "build", "tests", "js-tests", "install", "starters", "commit", "push",
+ "docs-smoke", "docs-commit", "docs-push"] # no prompt-pgp-passphrase.py here, so no PGP row
# The one loader push.py, test.py and release.py share (scripts/_juneau.py).