From 06665d05582f29acc84cb63cfeb1f719a7f904fd Mon Sep 17 00:00:00 2001
From: Kris Austin
Date: Sun, 18 Oct 2026 13:33:53 -0500
Subject: [PATCH] Post a readable diff of changed custom G-code on profile PRs
(#16605)
A profile stores each custom G-code setting as one JSON string, so GitHub
shows a G-code edit as one long changed line and reviewers cannot see
what changed.
Add a gcode-diff command to orca_profile_tool.py. For each changed
profile file it decodes, on both sides, every *_gcode and multiline
string option PrintConfig.cpp defines, the options Tab.cpp edits as
code, and any string value that spans several lines. It prints one
collapsed unified diff per setting as markdown. Files with the same diff
for a setting share one section. Trailing spaces are removed, file names
cannot mention GitHub users, and the output is cut to whole sections to
fit a GitHub comment.
Check profiles runs it between the PR's merge commit and its first
parent and adds the result to its artifact, with a line naming the PR
commit. Post profile check comment posts it under its own marker. It
keeps the comment while the diff is unchanged, edits it in place when
only the commit changed, replaces it when the diff changes, and deletes
it when the PR no longer changes any G-code.
The posting job still runs no PR code. It now posts only when the run
checked the head of the PR the artifact names, which covers the
validation comment too.
---
.github/workflows/check_profiles.yml | 25 ++
.github/workflows/check_profiles_comment.yml | 61 +++-
scripts/orca_profile_tool.py | 255 ++++++++++++++-
scripts/tests/test_profile_tool.py | 312 ++++++++++++++++++-
4 files changed, 643 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/check_profiles.yml b/.github/workflows/check_profiles.yml
index 4167cc705b..0b29732fc8 100644
--- a/.github/workflows/check_profiles.yml
+++ b/.github/workflows/check_profiles.yml
@@ -232,6 +232,31 @@ jobs:
mkdir -p ${{ runner.temp }}/profile-check-results
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
+ # Builds a readable diff of the custom G-code the PR changes, which GitHub shows as
+ # one long line per setting, for check_profiles_comment.yml to post.
+ - name: Diff custom G-code
+ if: ${{ always() && github.event_name == 'pull_request' }}
+ continue-on-error: true
+ env:
+ PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)"
+ results=${{ runner.temp }}/profile-check-results
+ diff=${{ runner.temp }}/gcode_diff.md
+ # HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto.
+ python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff"
+ if [ -s "$diff" ]; then
+ {
+ echo ""
+ cat "$diff"
+ echo ""
+ # The comment workflow finds this line as the only one starting with .
+ echo "For commit ${PR_HEAD_SHA} of this PR."
+ } > "$results/gcode_diff.md"
+ fi
+ # Tells the comment workflow that a missing gcode_diff.md means no G-code changed.
+ touch "$results/gcode_diff_done.txt"
+
- name: Prepare comment artifact
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
run: |
diff --git a/.github/workflows/check_profiles_comment.yml b/.github/workflows/check_profiles_comment.yml
index 21098cc467..bd42b8622d 100644
--- a/.github/workflows/check_profiles_comment.yml
+++ b/.github/workflows/check_profiles_comment.yml
@@ -38,6 +38,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
if [ ! -f pr_number.txt ]; then
echo "No pr_number.txt in artifact, skipping."
@@ -49,18 +50,72 @@ jobs:
echo "Invalid PR number: $PR_NUMBER"
exit 1
fi
+ # PR code wrote the artifact, so only a run for the named PR's current head may post.
+ if [ -z "$HEAD_SHA" ]; then
+ echo "The triggering run names no head commit."
+ exit 1
+ fi
+ if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then
+ echo "Cannot look up PR #${PR_NUMBER}."
+ exit 1
+ fi
+ if [ "$HEAD_SHA" != "$PR_HEAD" ]; then
+ echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping."
+ exit 0
+ fi
+
+ # Lists every bot comment on the PR, one JSON object per line.
+ COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
+ --jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json')
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
- OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
- --jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
+ OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
for comment_id in $OLD_IDS; do
echo "Deleting outdated profile-validation comment ${comment_id}"
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
done
# Post a new comment only when validation failed (pr_comment.md present).
+ # A failed post fails the job only after the G-code diff is handled.
+ failed=0
if [ -f pr_comment.md ]; then
- gh pr comment "$PR_NUMBER" --body-file pr_comment.md
+ gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1
else
echo "Validation succeeded; cleaned up prior comments without posting."
fi
+
+ # Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none.
+ update_gcode_diff_comment() {
+ if [ ! -f gcode_diff_done.txt ]; then
+ echo "The G-code diff step did not finish; leaving any G-code diff comment as it is."
+ return 0
+ fi
+ GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("")) | .id')
+ if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then
+ echo "gcode_diff.md is over GitHub's comment limit; not posting it."
+ rm gcode_diff.md
+ fi
+ if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then
+ OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r')
+ NEW_BODY=$(tr -d '\r' < gcode_diff.md)
+ if [ "$OLD_BODY" = "$NEW_BODY" ]; then
+ echo "G-code diff unchanged; keeping comment ${GCODE_IDS}."
+ return 0
+ fi
+ # Edits in place when only the line differs, since edits notify nobody.
+ if [ "$(grep -v '^' <<< "$OLD_BODY")" = "$(grep -v '^' <<< "$NEW_BODY")" ]; then
+ echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}."
+ gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null
+ return 0
+ fi
+ fi
+ for comment_id in $GCODE_IDS; do
+ echo "Deleting outdated G-code diff comment ${comment_id}"
+ gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
+ done
+ if [ -f gcode_diff.md ]; then
+ gh pr comment "$PR_NUMBER" --body-file gcode_diff.md
+ fi
+ }
+ update_gcode_diff_comment
+ exit "$failed"
diff --git a/scripts/orca_profile_tool.py b/scripts/orca_profile_tool.py
index f1a428cdfe..2519ce9e43 100755
--- a/scripts/orca_profile_tool.py
+++ b/scripts/orca_profile_tool.py
@@ -12,6 +12,7 @@ commands:
trim delete profile files no .json list references
update-index regenerate the *_list sections of .json
legacy-compat write the printers user presets can be migrated back to
+ gcode-diff print a readable markdown diff of changed custom G-code
options shared by several commands:
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
@@ -94,11 +95,14 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way.
"""
import argparse
+import difflib
import functools
+import html
import json
import os
import posixpath
import re
+import subprocess
import sys
import uuid
from collections import Counter, defaultdict
@@ -192,6 +196,7 @@ VECTOR_KEYS = {
PRINT_CONFIG_CPP = os.path.normpath(
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
+TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp"))
@functools.lru_cache(maxsize=None)
@@ -313,11 +318,11 @@ REMEDY_HINTS = {
}
-def print_error(msg):
- print(f"\033[91m[ERROR]\033[0m {msg}") # Red
+def print_error(msg, file=None):
+ print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red
-def print_warning(msg):
- print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
+def print_warning(msg, file=None):
+ print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow
def print_info(msg):
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
@@ -3209,6 +3214,223 @@ def run_legacy_compat(base_dir, profiles_dir, output, check):
return 0
+# ---------------------------------------------------------------------------
+# gcode-diff
+# ---------------------------------------------------------------------------
+
+# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit.
+GCODE_DIFF_BUDGET = 60000
+# The file names a section's summary shows before "and N more".
+GCODE_DIFF_SUMMARY_FILES = 3
+
+GCODE_DIFF_HEADER = (
+ "## Custom G-code changes\n"
+ "\n"
+ "GitHub shows custom G-code in a profile as one long line, so below is a line by line "
+ "diff of each changed G-code or other text setting.\n")
+
+
+@functools.lru_cache(maxsize=None)
+def _text_option_keys():
+ """The string options PrintConfig.cpp defines as G-code or other multi-line text
+ (every *_gcode option, and every one it marks multiline, such as notes), plus the
+ ones Tab.cpp edits as code, such as filename_format."""
+ with open(PRINT_CONFIG_CPP, encoding="utf-8") as f:
+ source = f.read()
+ keys = set()
+ for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]:
+ match = re.match(r'"(\w+)",\s*coStrings?\b', body)
+ if match and (match.group(1).endswith("_gcode")
+ or re.search(r"def->multiline\s*=\s*true", body)):
+ keys.add(match.group(1))
+ if "machine_start_gcode" not in keys:
+ raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode")
+ # Tab.cpp only adds keys, so a missing file is not an error.
+ try:
+ with open(TAB_CPP, encoding="utf-8") as f:
+ gui = f.read()
+ except OSError:
+ gui = ""
+ keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true',
+ gui, re.DOTALL))
+ return frozenset(keys)
+
+
+def _git(cwd, *args, stdin=None):
+ return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True,
+ capture_output=True).stdout
+
+
+def _blobs_at(profiles_dir, specs):
+ """{(ref, path): bytes, or None if not a file there} for (ref, path relative to
+ profiles_dir) pairs, all read by one git cat-file process."""
+ prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip()
+ out = _git(profiles_dir, "cat-file", "--batch",
+ stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8"))
+ blobs, pos = {}, 0
+ for spec in specs:
+ end = out.index(b"\n", pos)
+ header = out[pos:end]
+ pos = end + 1
+ # " missing" and " ambiguous" have no content; " " does.
+ if header.endswith((b" missing", b" ambiguous")):
+ blobs[spec] = None
+ continue
+ _oid, kind, size = header.split()
+ blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None
+ pos += int(size) + 1
+ return blobs
+
+
+def _parse_profile(raw, rel, where):
+ """The JSON in raw, or None with a warning when it is absent or not JSON."""
+ if raw is None:
+ print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr)
+ return None
+ try:
+ return json.loads(raw.decode("utf-8-sig"))
+ except ValueError as e:
+ print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr)
+ return None
+
+
+def _changed_profiles(profiles_dir, base, head):
+ """(old path, new path) of each JSON file under profiles_dir that differs between
+ base and head (the working tree, untracked files included, when None); the absent
+ side of an added or deleted file is None, and so is a side that is not JSON. Renames
+ are paired."""
+ out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base,
+ *([head] if head else []), "--", ".")
+ fields = out.decode("utf-8").split("\0")
+ pairs, i = [], 0
+ while i + 1 < len(fields):
+ status = fields[i]
+ old = new = fields[i + 1]
+ if status[0] in "RC":
+ new = fields[i + 2]
+ i += 3
+ else:
+ i += 2
+ old = None if status[0] == "A" or not old.endswith(".json") else old
+ new = None if status[0] == "D" or not new.endswith(".json") else new
+ if old or new:
+ pairs.append((old, new))
+ if head is None:
+ out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".")
+ pairs += [(None, rel) for rel in out.decode("utf-8").split("\0")
+ if rel.endswith(".json") and rel.split("/")[0] != USER_DIR]
+ return pairs
+
+
+def _text_lines(value):
+ return [line.rstrip() for line in value.split("\n")] if value else []
+
+
+def _text_changes(old, new):
+ """(setting, unified diff) for every G-code or other text value that differs
+ between two profiles; a vector option with several values gives one per value."""
+ changes = []
+ for key in list(new) + [k for k in old if k not in new]:
+ a, b = _as_list(old.get(key, [])), _as_list(new.get(key, []))
+ if a == b or not all(isinstance(v, str) for v in a + b):
+ continue
+ if key not in _text_option_keys() and not any("\n" in v for v in a + b):
+ continue
+ width = max(len(a), len(b))
+ for i in range(width):
+ diff = list(difflib.unified_diff(
+ _text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""),
+ lineterm=""))[2:] # drops the ---/+++ file header
+ if diff:
+ changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff)))
+ return changes
+
+
+def _gcode_diff_section(files, setting, diff):
+ # A fence longer than any backtick run in the G-code cannot be closed by it.
+ fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)])
+ # In , the "@BBL" of a filament file name does not mention a GitHub user.
+ def code(text):
+ return f"{html.escape(text, quote=False)}"
+ names, listing = ", ".join(map(code, files)), ""
+ if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more"
+ listing = f"{names}
\n\n" # an HTML block, so markdown in names stays literal
+ names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES]))
+ + f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more")
+ return (f"\n\n{names} - {code(setting)} (click to expand)"
+ f"
\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n \n")
+
+
+def gcode_diff(profiles_dir, base, head=None, vendors=None):
+ """Markdown with one collapsed diff per changed G-code setting between base and
+ head, or "" when none changed. Files whose diff of a setting is identical share
+ one section. Cut to whole sections under GCODE_DIFF_BUDGET."""
+ pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head)
+ if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))]
+ specs = ([(base, old) for old, _ in pairs if old]
+ + [(head, new) for _, new in pairs if new and head])
+ blobs = _blobs_at(profiles_dir, specs) if specs else {}
+
+ def load(ref, rel):
+ if rel is None:
+ return {}
+ if ref:
+ return _parse_profile(blobs[(ref, rel)], rel, ref)
+ try:
+ return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree")
+ except OSError as e:
+ print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr)
+ return None
+
+ groups = {} # (setting, diff) -> files, in the order git lists them
+ for old_rel, new_rel in pairs:
+ old, new = load(base, old_rel), load(head, new_rel)
+ if not isinstance(old, dict) or not isinstance(new, dict):
+ continue
+ for setting, diff in _text_changes(old, new):
+ groups.setdefault((setting, diff), []).append(new_rel or old_rel)
+ if not groups:
+ return ""
+
+ sections = [_gcode_diff_section(files, setting, diff)
+ for (setting, diff), files in groups.items()]
+ budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8"))
+ shown = 0
+ for section in sections:
+ budget -= len(section.encode("utf-8"))
+ if budget < 0:
+ break
+ shown += 1
+ note, hidden = "", len(sections) - shown
+ if hidden:
+ note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not "
+ "shown because the comment would be too long for GitHub. See the Files "
+ "changed tab for the rest.\n")
+ return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note
+
+
+def _default_gcode_diff_base(profiles_dir, head):
+ for main in ("upstream/main", "origin/main"):
+ try:
+ return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip()
+ except subprocess.CalledProcessError:
+ continue
+ raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists")
+
+
+def run_gcode_diff(profiles_dir, vendors, base, head):
+ try:
+ markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head),
+ head, vendors)
+ # UnicodeDecodeError means git listed a file name that is not UTF-8.
+ except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e:
+ stderr = getattr(e, "stderr", None)
+ print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr)
+ return 1
+ sys.stdout.write(markdown)
+ return 0
+
+
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@@ -3233,6 +3455,9 @@ examples:
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
+ orca_profile_tool.py gcode-diff > gcode.md
+ the changed custom G-code of the working tree since it left upstream/main,
+ as markdown with one collapsed diff per setting
after adding, renaming or deleting profile files, run in this order:
normalize -> update-index -> generate-id -> check
@@ -3387,6 +3612,25 @@ def build_parser():
legacy_cmd.add_argument("--check", action="store_true",
help="fail if the map differs from what --base implies; write nothing")
+ gcode_cmd = add(
+ "gcode-diff", [vendor_opt, profiles_opt],
+ "print a readable markdown diff of changed custom G-code",
+ "Print, as markdown, a unified diff of every G-code or other multi-line\n"
+ "setting that differs between --base and --head in each changed profile\n"
+ "file, one collapsed section per setting. Files whose diff of a setting is\n"
+ "identical share a section. Trailing spaces are removed from every line, and\n"
+ "the output is cut to whole sections to fit a GitHub comment. Prints nothing\n"
+ "when no such setting changed. CI posts it on profile PRs.\n"
+ "\n"
+ "The settings are the *_gcode and multiline string options PrintConfig.cpp\n"
+ "defines, the options Tab.cpp edits as code (such as filename_format), and any\n"
+ "string value that spans several lines.")
+ gcode_cmd.add_argument("--base", metavar="REF",
+ help="compare against this git ref (default: the merge base "
+ "with upstream/main, else origin/main)")
+ gcode_cmd.add_argument("--head", metavar="REF",
+ help="compare this git ref (default: the working tree)")
+
return parser
@@ -3423,6 +3667,9 @@ def main(argv=None):
if args.command == "legacy-compat":
return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
+ if args.command == "gcode-diff":
+ return run_gcode_diff(profiles_dir, vendors, args.base, args.head)
+
if args.command == "fix-variant":
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
strict=args.strict)
diff --git a/scripts/tests/test_profile_tool.py b/scripts/tests/test_profile_tool.py
index 167951d609..ed329b478c 100644
--- a/scripts/tests/test_profile_tool.py
+++ b/scripts/tests/test_profile_tool.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
-normalize, trim, update-index, fix-variant and check commands, and the subcommand
-dispatch that reaches them (stdlib unittest, no external deps).
+normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the
+subcommand dispatch that reaches them (stdlib unittest, no external deps).
The id halves are covered by test_filament_id.py and test_setting_id.py.
@@ -14,9 +14,12 @@ import json
import os
import re
import shutil
+import stat
+import subprocess
import sys
import tempfile
import unittest
+from unittest import mock
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
@@ -41,7 +44,24 @@ class Tree:
os.makedirs(self.profiles)
def cleanup(self):
- shutil.rmtree(self.dir, ignore_errors=True)
+ root = os.path.abspath(self.dir)
+
+ def writable_retry(func, path, _exc):
+ # Windows refuses to delete read-only files such as git's objects, and POSIX
+ # refuses to delete the entries of a read-only directory.
+ if not os.path.lexists(path): # already removed, as on a second cleanup
+ return
+ parent = os.path.dirname(os.path.abspath(path))
+ try:
+ if os.path.commonpath([parent, root]) == root: # never the temp dir itself
+ os.chmod(parent, stat.S_IRWXU)
+ if not os.path.islink(path): # chmod would follow it out of the tree
+ os.chmod(path, stat.S_IRWXU)
+ func(path)
+ except (OSError, ValueError): # commonpath raises ValueError across drives
+ pass
+ hook = "onexc" if sys.version_info >= (3, 12) else "onerror"
+ shutil.rmtree(self.dir, **{hook: writable_retry})
def index_path(self, vendor):
return os.path.join(self.profiles, vendor + ".json")
@@ -1409,6 +1429,292 @@ class TestDispatch(TreeCase):
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
+# ---------------------------------------------------------------------------
+# gcode-diff
+# ---------------------------------------------------------------------------
+
+@unittest.skipUnless(shutil.which("git"), "git not installed")
+class TestGcodeDiff(TreeCase):
+ START = "G28\nM104 S200\nG1 Z5"
+
+ def git(self, *args):
+ subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t",
+ "-c", "commit.gpgsign=false", *args], check=True, capture_output=True)
+
+ def printer(self, name, **extra):
+ self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra})
+
+ def commit(self):
+ self.git("add", "-A")
+ self.git("commit", "-q", "--allow-empty", "-m", "x")
+
+ @classmethod
+ def setUpClass(cls):
+ # git's list of the variables that tie a process to a repository, asked with no
+ # GIT_* set since a stray GIT_DIR fails the query.
+ clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
+ cls.repo_env_vars = set(subprocess.run(
+ ["git", "rev-parse", "--local-env-vars"], env=clean, check=True,
+ capture_output=True, text=True).stdout.split())
+
+ def setUp(self):
+ super().setUp()
+ # A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the
+ # tool's included, at the real repository instead of the temp tree.
+ env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars}
+ patcher = mock.patch.dict(os.environ, env, clear=True)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ self.git("init", "-q")
+ for name in ("A", "B", "C"):
+ self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
+ self.printer("Gone", machine_end_gcode="M84")
+ self.commit()
+
+ def diff(self):
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD")
+ self.assertEqual(rc, 0, out)
+ return out
+
+ def test_identical_edits_share_one_section(self):
+ for name in ("A", "B"):
+ self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5")
+ self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5")
+ out = self.diff()
+ self.assertEqual(out.count(""), 2)
+ self.assertIn("V/machine/A.json, V/machine/B.json - "
+ "machine_start_gcode", out)
+ self.assertIn("V/machine/C.json - machine_start_gcode", out)
+ # Trailing spaces are removed.
+ self.assertIn("\n-M104 S200\n+M104 S210\n", out)
+ self.assertIn("\n\n```diff\n@@ ", out)
+ self.assertIn("\n```\n\n ", out)
+
+ def test_nothing_is_printed_without_a_text_change(self):
+ self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"])
+ self.assertEqual(self.diff(), "")
+
+ def test_added_and_deleted_files_show_the_whole_value(self):
+ # An untracked file counts as added; the validator's user dir is not a profile.
+ self.printer("New", machine_start_gcode="G28\nG29")
+ self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"})
+ os.remove(self.t.path("V", "machine/Gone.json"))
+ out = self.diff()
+ self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out)
+ self.assertIn("V/machine/Gone.json - machine_end_gcode", out)
+ self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
+ self.assertNotIn("U.json", out)
+
+ def test_a_multi_line_value_counts_whatever_its_key(self):
+ self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo")
+ self.assertIn("V/machine/A.json - custom_note", self.diff())
+
+ def test_a_file_name_cannot_mention_a_user(self):
+ self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]})
+ self.assertIn("V/filament/PLA @BBL A1.json", self.diff())
+
+ def test_head_compares_two_commits(self):
+ self.printer("A", machine_start_gcode="G28")
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("V/machine/A.json - machine_start_gcode", out)
+
+ def test_a_renamed_file_diffs_against_its_old_self(self):
+ os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json"))
+ self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"])
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("V/machine/A2.json - machine_start_gcode", out)
+ self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out)
+
+ def test_a_file_renamed_off_json_loses_its_values(self):
+ os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak"))
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("V/machine/Gone.json - machine_end_gcode", out)
+ self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
+
+ def test_a_file_renamed_onto_json_is_new(self):
+ with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f:
+ json.dump({"type": "machine", "machine_start_gcode": self.START}, f)
+ self.commit()
+ os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json"))
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out)
+
+ def test_a_batch_read_survives_paths_that_are_not_files(self):
+ blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"),
+ ("HEAD", "V/machine/Gone.json")])
+ self.assertIsNone(blobs[("HEAD", "V/machine")])
+ self.assertIsNone(blobs[("HEAD", "V/no such.json")])
+ self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"],
+ "M84")
+
+ def test_each_value_of_a_vector_option_is_its_own_setting(self):
+ self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]})
+ self.commit()
+ self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]})
+ out = self.diff()
+ self.assertEqual(out.count(""), 1)
+ self.assertIn("filament_start_gcode[1]", out)
+
+ def test_vendor_limits_the_files(self):
+ self.printer("A", machine_start_gcode="G28")
+ self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"})
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("V/machine/A.json", out)
+ self.assertNotIn("W/machine/X.json", out)
+
+ def test_vendor_includes_files_moved_out_of_it(self):
+ self.t.add_vendor("W")
+ os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json"))
+ data = self.t.read("W", "machine/A.json")
+ self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"})
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD",
+ "--vendor", "V")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("W/machine/A.json - machine_start_gcode", out)
+
+ def same_edit(self, names):
+ """The diff after one start G-code edit to each of names; returns (summary, output)."""
+ for name in names:
+ self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
+ self.commit()
+ for name in names:
+ self.printer(name, machine_start_gcode="G28")
+ out = self.diff()
+ return re.search(r"(.*)
", out).group(1), out
+
+ def test_a_long_file_list_moves_into_the_section(self):
+ summary, out = self.same_edit("ABCDE")
+ self.assertIn("V/machine/C.json and 2 more - ", summary)
+ self.assertNotIn("D.json", summary)
+ self.assertIn("V/machine/D.json, V/machine/E.json
\n\n```diff",
+ out)
+
+ def test_four_files_are_all_named_in_the_summary(self):
+ summary, _out = self.same_edit("ABCD")
+ self.assertIn("V/machine/D.json - ", summary)
+ self.assertNotIn("more", summary)
+
+ def test_an_error_goes_to_stderr_only(self):
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err):
+ rc, out = self.run_command("gcode-diff", "--base", "no-such-ref")
+ self.assertEqual(rc, 1)
+ self.assertEqual(out, "")
+ self.assertIn("no-such-ref", err.getvalue())
+
+ def test_a_file_name_that_is_not_utf8_is_an_error(self):
+ bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte")
+ err = io.StringIO()
+ with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \
+ contextlib.redirect_stderr(err):
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD")
+ self.assertEqual((rc, out), (1, ""))
+ self.assertIn("invalid continuation byte", err.getvalue())
+
+ def test_output_is_cut_to_whole_sections(self):
+ for name in ("A", "B", "C"):
+ self.printer(name, machine_start_gcode=name * 300)
+ size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8"))
+ with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1):
+ out = self.diff()
+ self.assertEqual(out.count(""), 2)
+ self.assertEqual(out.count(" "), 2)
+ self.assertIn("\n1 of 3 sections is not shown", out)
+ with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1):
+ self.assertIn("\n3 of 3 sections are not shown", self.diff())
+
+ def test_cleanup_removes_the_repo(self):
+ # setUp's commit leaves read-only git objects, and a read-only directory blocks
+ # deletion on POSIX.
+ os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR)
+ self.t.cleanup()
+ self.assertFalse(os.path.exists(self.t.dir))
+ with mock.patch.object(os, "chmod") as chmod:
+ self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir
+ chmod.assert_not_called()
+
+ def test_only_repository_variables_are_stripped(self):
+ self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars)
+ self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars)
+
+ def test_cleanup_leaves_symlink_targets_alone(self):
+ outside = tempfile.mkdtemp(prefix="profile_tool_outside_")
+ self.addCleanup(shutil.rmtree, outside, True)
+ target = os.path.join(outside, "f")
+ open(target, "w").close()
+ os.chmod(target, stat.S_IRUSR)
+ self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above
+ machine = self.t.path("V", "machine")
+ try:
+ os.symlink(target, os.path.join(machine, "link"))
+ except (OSError, NotImplementedError):
+ self.skipTest("cannot create symlinks here")
+ os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first
+ self.t.cleanup()
+ self.assertFalse(os.path.exists(self.t.dir))
+ self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0)
+
+ def test_a_git_hook_environment_is_ignored(self):
+ scripts = os.path.join(REPO_ROOT, "scripts")
+ result = subprocess.run(
+ [sys.executable, "-m", "unittest",
+ "tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"],
+ cwd=scripts, capture_output=True, text=True,
+ env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")})
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_the_options_are_read_from_the_engine(self):
+ keys = apt._text_option_keys()
+ self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode",
+ "notes", "post_process"} <= keys)
+ self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool
+
+ START_DEF = 'def = this->add("machine_start_gcode", coString);\n'
+
+ def keys_from(self, print_config, tab=None):
+ """_text_option_keys read from these sources; no tab means no Tab.cpp."""
+ paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")]
+ for path, text in zip(paths, (print_config, tab)):
+ if text is not None:
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(text)
+ apt._text_option_keys.cache_clear()
+ self.addCleanup(apt._text_option_keys.cache_clear)
+ with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \
+ mock.patch.object(apt, "TAB_CPP", paths[1]):
+ return apt._text_option_keys()
+
+ def test_multiline_is_read_whatever_its_spacing(self):
+ keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n'
+ ' def->multiline = true;\n')
+ self.assertIn("printer_notes", keys)
+
+ def test_the_code_options_of_tab_cpp_are_read(self):
+ keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n'
+ 'option.opt.is_code = true;\n'
+ 'option = optgroup->get_option("layer_height");\n')
+ self.assertIn("filename_format", keys)
+ self.assertNotIn("layer_height", keys)
+
+ def test_a_missing_tab_cpp_is_not_an_error(self):
+ self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF))
+
+ def test_markdown_in_a_file_name_stays_literal(self):
+ section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json",
+ "V/f.json"], "machine_start_gcode", "@@ -1 +1 @@")
+ self.assertIn("\nV/a *b*.json, ", section)
+
+
# ---------------------------------------------------------------------------
# the real tree
# ---------------------------------------------------------------------------