Skip to content

adopt-project.py: the plan writer escapes every character that is not printable from the one table repo_shape's reader resolves, so a source holding the macOS Icon\r no longer writes a plan check refuses, and a non-UTF-8 name no longer empties the plan (#199) - #247

Merged
brettheap merged 4 commits into
mainfrom
fix/adopt-plan-linebreak-names
Oct 11, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #199 (comment)

What

The adoption plan's writer and its reader now share one escape table, so
a plan adopt-project.py plan writes is always a plan check parses,
whatever characters a source name holds.

  • scripts/repo_shape.py: DOUBLE_QUOTED_ESCAPES gains r; a new
    CODE_POINT_ESCAPES (x: 2, u: 4, U: 8 hex digits) adds YAML's own
    code-point escapes; double_quoted(text) is the writer's half of both.
    It writes \\, \", \n, \r, \t by name, and every other character
    str.isprintable() refuses in the shortest of \xNN / \uNNNN /
    \UNNNNNNNN that holds it. _unescape stays one pass, left to right; it
    resolves exactly what the writer writes (a code-point escape is exactly its
    count of ASCII hex digits, nothing past U+10FFFF), and any other escape keeps
    its backslash, as \q always has.
  • adopt-project.py: y() returns double_quoted(text) for whatever it
    does not write plain. The plain decision (the if line adopt-project.py plan writes null, true and other YAML keywords unquoted, so plan --spec-path null silently mounts the leg at the default spec #181 changes) is
    untouched. _PLAIN_RE now ends at \Z rather than $, because $ also
    matches before a final newline and so wrote Icon\n bare. The plan's
    terminal report prints a name that is not printable as _spelled (new
    _shown). Raw, a carriage return hid the name, and a byte that is not
    UTF-8 raised UnicodeEncodeError on a strict terminal (macOS, a UTF-8 Linux
    locale, a Windows code page) after the plan was written. Printable names
    print exactly as before. _shown also spells a local --source directory,
    in plan's report and in check's WHAT WILL HAPPEN, against ... line.
  • scripts/shape_materialize.py: new write_paths_listing, which
    _extract_leg writes git filter-repo --paths-from-file's listing through.
    It is byte-exact (utf-8 with surrogateescape, LF): a path that is not
    UTF-8 is the byte git records, where write_lf's strict UTF-8 raised after
    both leg remotes existed. A UTF-8 listing is byte-identical to what
    write_lf wrote.
  • tests/test_adopt_plan_escapes.py (new, 59 tests).

Why

#199: y() escaped only \ and ". The reader splits a file with
str.splitlines, which ends a line at \n, \r, \x0b, \x0c,
\x1c-\x1e, \x85, U+2028 and U+2029. A source tracking the macOS
folder-icon file Icon\r therefore got a plan written with exit 0, which
check then refused as REFUSED yaml-unparsable, with no entry to edit.

A name that is not UTF-8 gets a faithful round trip, not a refusal.
Source.tree keeps such a byte as a lone surrogate. A surrogate is not
printable, so the same rule writes it \udce9, and the reader's \u turns
it back into that same surrogate. The escape is ASCII and the plan file stays
valid UTF-8, so #192's yaml-unreadable never fires, and check finds the
entry's name in the tree.
This adds no code beyond the rule every other non-printable character already
follows. A refusal in plan would have added a new refusal whose only remedy
for a top-level file is renaming it in the source. (On main such a name made
plan raise in write_lf and leave the plan empty.)

Evidence

Reproduced on main e4decfb with a source holding Icon\r at the top level:
plan exit 0, check exit 2 REFUSED yaml-unparsable ... line 129. The
same happens with a\x85b / l\u2028s. With caf\xe9.txt: plan exit 1,
UnicodeEncodeError, 0-byte plan.

On this branch:

Targeted runs (git-filter-repo 2.47.0 on PATH), all with /usr/bin/python3 -m pytest -q -rs.

On the merge head a02240a (main 54aa69a merged in):

  • Before the push: tests/test_adopt_plan_escapes.py gave 59 passed;
    tests/test_adopt_verify.py tests/test_adopt_mount_ignored.py tests/test_adopt_leg_path_collisions.py gave 137 passed;
    git diff --check HEAD^1 HEAD was clean.
  • tests/test_adopt_plan.py tests/test_adopt_submodules.py tests/test_adopt_execute_refusals.py tests/test_adopt_spec_only.py tests/test_adopt_e2e.py tests/test_shape_doctor.py tests/test_manifest_validator.py tests/test_repo_hygiene.py tests/test_cli_reference.py tests/test_adopt_plan_escapes.py gave 600
    passed, 1 skipped
    (9 min 3 s). The only skip is the MedxEHR clone test
    (SHAPE_MEDXEHR_CLONE unset). The one extra test over the 599 below is
    main's.
  • tests/test_adopt_leg_paths.py tests/test_adopt_submodules.py tests/test_scaffold_e2e.py gave 525 passed, no skips (2 min 56 s).

On d5fdc22, before main was merged in:

No full-suite run, and no CI run yet: tests, tests-macos and tests-windows
have not seen this branch.

Fix round 1

The adversarial review of d3023fb asked for three should-fix items and seven
nits; the round is one commit, d5fdc22, on the #198 merge;
main 54aa69a is merged in on top of it (a02240a).

Left out

Closes #199

🤖 Generated with Claude Code

brettheap and others added 4 commits October 11, 2026 00:53
…r character that is not printable from the one table `repo_shape`'s reader resolves, so a source holding the macOS `Icon\r` no longer writes a plan `check` refuses as `yaml-unparsable`, and a name that is not UTF-8 no longer empties the plan (#199)

`y()` quoted a string with double quotes but escaped only a backslash and
a double quote, and `repo_shape`'s reader takes a file line by line with
`str.splitlines`, which ends a line at `\n`, `\r`, `\x0b`, `\x0c`,
`\x1c`-`\x1e`, `\x85`, `
` and `
`. A source tracking the file
macOS writes for a folder icon, `Icon\r`, got a plan `plan` wrote with exit
0 and `check` refused as `REFUSED yaml-unparsable`, with no entry to edit.
A top-level name that is not UTF-8 (kept by `Source.tree` as a surrogate)
made `plan` raise `UnicodeEncodeError` in `write_lf` and leave the plan
empty. `_PLAIN_RE` ended at `$`, which matches before a final newline, so
`Icon\n` was even written bare.

WHAT CHANGED
- `repo_shape.py`: ONE escape table the reader and the writer share.
  `DOUBLE_QUOTED_ESCAPES` gains `r`; `CODE_POINT_ESCAPES` adds YAML's
  `\xNN`, `\uNNNN`, `\UNNNNNNNN` (code points, exactly that many ASCII hex
  digits, nothing past U+10FFFF). `double_quoted` is the writer: `\\`, `\"`,
  `\n`, `\r`, `\t` by name, every other character `str.isprintable`
  refuses in the shortest code-point form that holds it. `_unescape` stays
  one pass, left to right; an escape it does not resolve keeps its
  backslash, as `\q` always has.
- `adopt-project.py`: `y()` returns `double_quoted(text)` for anything it
  does not write plain (the plain decision itself is untouched, #181 owns
  it); `_PLAIN_RE` ends at `\Z`; the plan's terminal report prints a name
  that is not printable as `_spelled` (`_shown`), because raw it hid the
  name behind a carriage return and raised `UnicodeEncodeError` on a
  strict terminal (macOS, a UTF-8 Linux locale, a Windows code page) after
  the plan was written. Every printable name prints as it did.

THE NON-UTF-8 NAME: a faithful round trip, not a refusal. A lone surrogate
is not printable, so the same rule writes it `\udce9`, and the reader's
`\u` turns it back into that surrogate; the plan file stays ASCII, so
#192's `yaml-unreadable` never fires, and `check` compares the entry with
`Source.tree`'s name and finds it. It costs no code beyond the rule every
other character already takes, where refusing would add a refusal whose
only remedy for a top-level file is renaming it in the source. `execute`
then refuses the plan path as `unsafe-value` (SAFE_ARG_RE) before any
repository exists, as it does `Icon\r` -- #196's question, not this one.

EVIDENCE
- Every code point U+0000-U+10FFFF, alone and as `a<c>b`, round-trips
  through `y()` after `k: ` and as `- ` (0 failures; a one-off probe, not a
  test). Every `str.splitlines` break character is not printable.
- The 14 tracked YAML files read identically with the old and the new
  reader (7 parse, 7 are refused identically by both).
- `tests/test_adopt_plan_escapes.py`: the pinned spelling of each class,
  a seeded fuzz (every code point to U+02FF, a sample above, 5,000 strings
  of up to 8) for `y` and `double_quoted` (0.33 s), the reader's refusals
  of near-escapes, and `plan` then `check` on a source holding `Icon\r`,
  `caf\xe9.txt`, `n\u0085el` and `l
s` at the top level and under
  `docs/`, built in git's index without a checkout so it runs on Windows.
  Each part of the fix reverted on its own fails at least one test.

LEFT OUT: `check` printing an entry's path raw in a FINDING (a carriage
return hides it on a terminal); SAFE_ARG_RE on plan paths (#196);
`_split_flow` ignoring a backslash in a quoted flow item.

Closes #199

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eak-names (#199)

Brings #198's leg mount-path checks under the #199 branch before fix
round 1, so the round's tests run against what main now is. The merge is
clean (no conflicting hunks); the review of d3023fb found the same.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sted (S1), `_extract_leg` writes filter-repo's path listing byte-exact so a path that is not UTF-8 no longer raises after both legs exist (S2), and `plan` and `check` show a `--source` directory that is not UTF-8 spelled instead of raising on a strict stdout (S3)

The adversarial review of d3023fb asked for three should-fix items and
six nits; this commit is all of them except N6, which is a PR-body line.

S1. The `_shown()` call in `_print_plan_report`'s unresolved list had no
test: reverting it (mutation A3a) left the file green, because the
entries table prints the same spelling and `run_script`'s text mode
turns a raw `\r` into a line break. `plan` is now read in BYTES
(`planned`, beside `checked`, both through one `adopted` runner), the
raw name must be absent from its stdout, and when `plan` asks about the
name the question-list line itself must hold the spelled name.

S2. `_extract_leg` wrote the `--paths-from-file` listing through
`write_lf`, strict UTF-8, so a plan path holding a surrogate (a byte that
is not UTF-8) raised `UnicodeEncodeError` once both leg remotes existed.
On this branch alone `SAFE_ARG_RE` refuses such an entry first; merged
with #196's `entry_path_problem` (30badde) it does not. New
`shape_materialize.write_paths_listing` writes the same text encoded
`utf-8`/`surrogateescape`, LF, through `write_bytes`; filter-repo reads
the file as bytes. No `literal:` prefix here: that is #196's, and the
helper takes whatever lines it is given. Tested on the helper (a
surrogate is the byte 0xE9; a UTF-8 listing is byte-identical to what
`write_lf` wrote) and, beyond the brief, on `_extract_leg` itself with
`caf\xe9.txt` (skipped without filter-repo or where no file can carry
the byte), so the call site is guarded and not only the helper.

S3. The report's `source     <path> @` and `check`'s `WHAT WILL HAPPEN,
against <path> @` printed a local source directory raw; one holding a
byte that is not UTF-8 crashed both on a strict UTF-8 stdout after the
plan was written. Both use `_shown(str(source.path))` now;
`source.repository` (a URL) is unchanged. The test builds the source
under a directory named b"caf\xe9" and runs `plan` and `check` with
`PYTHONIOENCODING=utf-8:strict`; it skips where no such directory can
exist (Windows, APFS) or git will not work in one.

Nits. N1: `double_quoted`'s docstring says which part of the escaped
class the reader cannot take raw (the ten `splitlines` breakers and a
lone surrogate) and that the rest is escaped so a person can see it.
N2: the table comment says libyaml (`CSafeLoader`) refuses `\udce9`.
N3: the `y()` comment says what the test covers (every code point to
U+02FF, a sample above, a seeded sample of strings) and that the bare
keywords are #181's, worded so the three lines #181 (76a6153) edits are
left as they are and that merge stays clean. N4: the `_BLOCK_SENTINEL`
comment says a block scalar key holding a NUL, raw or escaped, collides
with it and is misread, and that no writer here emits one; the reader is
not changed.
N5: `_spelled`'s docstring says why the plan writes `\udce9` where the
report says `\xe9`. N7: the test line asserting the raw `\r` in a
FINDING says it pins #212's defect.

U1. `odd_source` passes `-c core.protectNTFS=false` to `update-index`
and `commit`: on Windows that guard makes `update-index` ignore a path
holding a byte below 0x20 and exit 0, so the headline `Icon\r` case
would skip there. Accepted on Linux, and the case still runs; CI says
whether Windows now runs it.

Left out: N6 (the reader change reaches every YAML file every tool
reads) is for the PR body; the `_parse_mapping` refactor N4 suggests is
for the issue the coordinator files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ebreak-names (#199)

One hunk conflicted, the `from repo_shape import (...)` list in
adopt-project.py: main (#201, 8df21e6) removed `SAFE_ARG_RE`, and this branch
added `double_quoted`. Resolved as main's list plus `double_quoted`, nothing
else; no `SAFE_ARG_RE` is left in adopt-project.py. Every other file
(scaffold-project.py, scripts/shape_materialize.py, the tests) merged
without a conflict.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 16 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR makes adoption plan serialization and parsing share a complete, validated escape scheme, preventing plans from becoming unparsable or empty when names contain line-breaking controls or non-UTF-8 bytes. It also safely spells problematic names in reports and preserves raw path bytes when generating git filter-repo listings, with broad regression and end-to-end coverage.

Sequence diagram for safe adoption plan round-trip

sequenceDiagram
    participant Source
    participant Plan as adopt-project.py plan
    participant Writer as repo_shape.double_quoted
    participant File as Adoption plan
    participant Check as adopt-project.py check
    participant Reader as repo_shape._unescape

    Source->>Plan: read source tree names
    Plan->>Writer: y(value)
    Writer->>File: write named or code-point escapes
    Check->>File: load plan
    File->>Reader: parse double-quoted scalar
    Reader-->>Check: restore original name
    Check->>Source: find entry in tree
Loading

File-Level Changes

Change Details Files
Synchronize YAML double-quoted escaping between plan generation and parsing.
  • Added shared named and code-point escape tables, including carriage returns and \x/\u/\U escapes.
  • Made the reader resolve only valid, bounded escapes in a single left-to-right pass while preserving unknown or malformed escapes.
  • Added double_quoted() and routed plan scalar quoting through it.
  • Changed plain-name matching to use \Z so a trailing newline cannot be emitted as an unquoted multi-line value.
scripts/repo_shape.py
adopt-project.py
Make adoption plans and terminal reports safe for control characters and non-UTF-8 names.
  • Spelled non-printable and surrogate-containing paths in plan questions, entry tables, and source-location reports.
  • Applied the same safe source-directory display to plan and check reports while preserving repository URL output.
  • Added extensive round-trip and end-to-end coverage for line separators, controls, Unicode, surrogate escapes, and unusual names.
adopt-project.py
tests/test_adopt_plan_escapes.py
Write git filter-repo path listings byte-exactly for surrogateescaped names.
  • Added a UTF-8-plus-surrogateescape LF writer that preserves the bytes recorded by git.
  • Replaced strict UTF-8 listing output during leg extraction with the byte-exact writer.
  • Tested both non-UTF-8 paths and byte-identical UTF-8 listings.
scripts/shape_materialize.py
adopt-project.py
tests/test_adopt_plan_escapes.py

Assessment against linked issues

Issue Objective Addressed Explanation
#199 Ensure adoption plans containing source names with line breaks or other problematic characters are written in a form that the repository's YAML loader can parse and round-trip. ✅
#199 Allow adopt-project.py check to load such plans successfully and expose the affected source entry for review or editing instead of refusing the entire plan as yaml-unparsable. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-11T20:59Z) @codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-11T21:00Z) Review trail for lanes openRepoShape-2 and openRepoShape-4, who each offered a review of #199.

  • d3023fb: adversarial review, CHANGES NEEDED, 0 blockers (review199-report.md).
  • d5fdc22: fix round 1, items S1, S2, S3, N1-N5, N7 and U1. Independent re-verification: VERIFIED WITH NITS, 10 of 10 items done. Three independent mutants are each caught. Targeted runs: 59 passed; 599 passed + 1 skip; 441 passed.
  • a02240a: merge of main 54aa69a. One import hunk was resolved: SAFE_ARG_RE was dropped and double_quoted kept. Targeted runs: 59 + 137 passed; the ten-file set 600 passed + 1 skip; 525 passed.

The composition notes for #181, #223, #231 and #246 are in the body. Every report is in opensoft/brett-wip under handoffs/openRepoShape/attachments/lane-openRepoShape-5/ (commit f64bb587c). The reviewed head is a02240a; a further review there is welcome.

@brettheap
brettheap merged commit 4835fb4 into main Oct 11, 2026
5 checks passed
@brettheap
brettheap deleted the fix/adopt-plan-linebreak-names branch October 11, 2026 21:25
brettheap added a commit that referenced this pull request Oct 11, 2026
…fix/plan-report-stdout-encoding (#214)

One conflict, scripts/shape_materialize.py, the single add/add hunk right
after write_lf: #199's write_paths_listing (from main) and this branch's
escape_what_stdout_cannot_encode with SURROGATEESCAPE_THEN_BACKSLASHREPLACE,
_registered_surrogateescape_then_backslashreplace,
_surrogateescape_then_backslashreplace and _is_escaped_byte. Resolved by
keeping both sides whole, this branch's block first and write_paths_listing
after it, two blank lines between. Every other file merged without a conflict
and is taken as main has it. No code was written for the merge.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-11T21:41:33Z)

This lane reviewed PR #247 at a02240a, read-only, in its own detached worktrees. Nothing was authored and no claim was taken. This is the second, independent review that lane 5 invited. It covers what lane 5's trail did not: the full character space, cross-version plans, hand-edited plans, non-path plan values, and check/execute end to end under git-filter-repo.

CHANGES NEEDED (one P2). #247 merged as 4835fb4 at 21:25Z while this ran. Its tree is a02240a merged with #244, and the P2 reproduces there, so it needs a follow-up issue rather than a reopen: filed as #255, not claimed.

  • P2. A plan value holding a byte that is not UTF-8 now passes plan and check, and execute raises only after both legs are pushed. It can come from --elected-by b'Jos\xe9', from --reference, or from a --source directory named with such a byte (local remotes). execute pushes both legs, then raises UnicodeEncodeError in write_lf (scripts/shape_materialize.py:83). The value is recorded unchecked at adopt-project.py:1256-1257; the templates are built at :2821, after _create_leg_remotes at :2814. On main, plan raised and nothing was created. Hand-edited \ud800 in elected_by, elected_on or reference does the same. Fix: apply one_printable_line (shape_materialize.py:686) in plan, and in execute before any leg exists.
  • P3. On head, a hand-edited \x00 or surrogate in local_path makes check and execute end in a traceback (:407, shape_materialize.py:498); main refused. Nothing is created.
  • P3. A plan saved through PyYAML's default safe_dump respells NEL, U+2028, U+2029, NBSP and VT as \N \L \P \_ \v. The reader keeps the backslash, so check reports coverage findings. This fails closed.
  • Nits. No test pins "printable names print as before": a mutant that always spells survives. _spelled (:704-707) writes U+0085 and the byte 0x85 alike. N6 misses templates/assembly-root/project.yaml:42,46.

Round trip. 2,240,964 strings (every code point, all surrogates, special combinations) were written into a plan-shaped document. Reading them back gave 0 misreads beyond #181's keywords. Main-written values read identically on head.

Tests.

  • head: 358 passed.
  • main, with a shim: 43 failed, 16 passed.
  • 4835fb4: 143 passed, 1 skipped; test_shape_doctor.py 181 passed.
  • End to end: 138 scenarios, 0 tracebacks.

Merges. One hunk each conflicts:

Clean merges:

The full report is handoffs/xFactory/attachments/lane-openRepoShape-2/review247-a02240a/final-report.md in opensoft/brett-wip.

brettheap added a commit that referenced this pull request Oct 11, 2026
…ng-paths

main moved to 4835fb4: #224 (e7d1720, update-shape.py), #244 (404760f,
repo_shape.py `_split_flow`) and #247 (4835fb4, the plan writer escapes
every character that is not printable). The textual merge is clean. The
next commit takes #247's one assertion that this branch's spelling
changes.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 11, 2026
…est expects the spelled FINDING `Icon\r`, as every path a FINDING names is since this branch

#247 (4835fb4) landed first. Its
test_plan_writes_the_name_as_one_entry_check_parses[carriage-return]
asserted that `check`'s `plan-unresolved` FINDING names the macOS
`Icon<CR>` raw, and its comment said #212's fix "edits this line". On the
merge of main into this branch the case fails (1 failed, 58 passed in
tests/test_adopt_plan_escapes.py), because `_leg_findings` now writes the
path through `_spelled`.

Whichever of the two PRs landed second had to take the assertion; this is
the second, so the assertion now expects
``FINDING plan-unresolved: `Icon\r` still has `leg: null` `` with a
backslash and an `r`, and the comment says what is on main: the FINDING is
spelled, as `plan` spells a question, and before #212 it was raw. No other
assertion of the file changes; the file passes 59 of 59.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 11, 2026
… that #199's listing carries it, any other surrogate as a path list that cannot be written, and a name of carriage returns alone is told its line would be `literal:` alone, which matches every path

The merge of main before this commit brings #199's PR #247, whose
`write_paths_listing` writes filter-repo's path list byte-exact
(`surrogateescape`). On that merge the Cs reason this branch gave ("the
path list `git filter-repo` reads is written as UTF-8 text, which cannot
carry it, and the write would fail after both leg repositories exist")
was no longer true. The refused set is unchanged; each reason is now
true on the merged tree, measured there:
- A byte that is not UTF-8, which `Source.tree` keeps as one of U+DC80
  to U+DCFF, is refused as POLICY, in the same form as the other policy
  findings: "which `execute` refuses as policy, though git and `git
  filter-repo` would take it literally". With the refusal disabled, a
  source holding `caf\xe9.md` checked `plan ok`, `execute` said
  `adoption verified`, the spec leg held the byte, and the split
  commit's message listed it as `caf?.md`. It is held until adopting
  such a name end to end is measured on macOS and Windows (the PR's
  Left out).
- Any other surrogate, such as a hand-edited `\ud800`, stays a
  misreading: `surrogateescape` writes only U+DC80 to U+DCFF, so
  `write_paths_listing` raised `UnicodeEncodeError` on it, and no
  source path holds one (such an entry is also `plan-empty-entry`).
- `NOT_UTF8_BYTES` names the 128 surrogates; the test lists move the
  byte cases to POLICY and add U+DC7F, U+DD00 and U+DC80 at the bounds.

A name made only of carriage returns (`\r`, `\r\r`) was told "its line
would be blank, which the filter skips". filter-repo strips the
carriage returns from `literal:\r`, which leaves `literal:` alone; that
matches every path, and one such line kept the whole tree (measured
with git-filter-repo 2.47.0). The finding now says so, as the empty
path's does. (The independent reviewer's nit on round 1.)

The module docstring's `check` paragraph now names the entry-path test
(lane 2's review, N4). The docstring of #247's
`test_a_leg_extracts_a_path_that_is_not_utf8` said `execute` refuses
such an entry through `SAFE_ARG_RE`; it now says `plan-unsafe-path`.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 11, 2026
…, #213 and #199 land beside #190, and the `repo_shape` import list carries #199's `double_quoted` with #190's `redacted`, `remote_is_a_path`, `remote_key` and `remote_local_path`

Main moved from 54aa69a to 4835fb4: e7d1720 (#208 via #224), 404760f
(#213 via #244) and 4835fb4 (#199 via #247). One conflict hunk, in
adopt-project.py, and it takes BOTH sides:

1. The `repo_shape` import list. #199 adds `double_quoted`, which `y`
   now spells every quoted plan scalar with; #190 adds `redacted`,
   `remote_is_a_path`, `remote_key` and `remote_local_path`, which its
   relative-url check calls (`_Resolution.same_as` and `__str__`,
   `_default_remote`, `_relative_url_findings`). Main's line is kept as
   main wrote it, and #190's four names follow `parse_yaml` in the
   list's order. Either side's list alone leaves a name the merged code
   calls undefined.

Merged with no conflict:

- adopt-project.py, #199's other hunks: `_PLAIN_RE` ending at `\Z`,
  `y` through `double_quoted`, `_spelled`'s docstring, `_shown` and the
  four report lines that print through it, and `write_paths_listing`
  in the `shape_materialize` import and in `_extract_leg`. None touches
  a #190 line. #190's findings and NOTE already print every path and
  url through `_spelled`, which is what `_shown` falls back to for a
  name that is not printable.
- scripts/repo_shape.py: #199's escape table (`DOUBLE_QUOTED_ESCAPES`
  with `\r`, `CODE_POINT_ESCAPES`, `double_quoted`, `_escaped`,
  `_escape_at`, the one-pass `_unescape`), with its module-docstring
  line and its `_BLOCK_SENTINEL` comment, and #213's `_split_key_flow`
  and `_split_flow`, are all in the YAML reader at the top of the
  file. #190's `REMOTE_PORT_RE`, `REMOTE_HOST_ALIASES` and
  `remote_key` are in the remote helpers some 1,400 lines below, and
  call nothing main changed. Each side's added and removed lines are
  the same before and after the merge, byte for byte.
- update-shape.py (#208), scripts/shape_materialize.py (#199) and
  main's three test files are main's blobs; #190's three test files
  are this branch's.

#190's added and removed lines against 4835fb4 are its lines against
54aa69a, but for the import hunk.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 11, 2026
…) into fix/adopt-record-submodule-placement

Brings in e7d1720 (#208), 404760f (#213) and 4835fb4 (#199) on top of
54aa69a. No rebase.

#199 landed first, so this branch is the one that lands second, and it
takes #199's side of the double-quoted escape section, as cd2a622's message
and lane openRepoShape-2's review of e167d8b both said it would.

Conflicts, and how each was resolved:

- scripts/repo_shape.py, two hunks (textual): the escape section and
  `_unescape`'s docstring. Both sides added `CODE_POINT_ESCAPES`,
  `_HEX_DIGITS` and an `_escape_at` whose body is the same line for line,
  and both reworded the docstring; #199 also added `r` to
  `DOUBLE_QUOTED_ESCAPES` and the writer, `double_quoted` with `_escaped`
  and `_ESCAPE_OF`. The auto-merge had also left this branch's own copies
  of `CODE_POINT_ESCAPES`, `_HEX_DIGITS` and `_escape_at` beside main's.
  Resolved as main's file, byte for byte, plus this branch's one change to
  it that is not about escapes: 379763c's `path_spelling_fault` section and
  its line in the module docstring, applied with `git apply`. `git diff
  origin/main -- scripts/repo_shape.py` is that 51-line addition alone.

Merged without a conflict, and checked:

- adopt-project.py: main's `y` now quotes with `double_quoted`, and this
  branch's `submodule_record` still quotes with `_quoted`, which reads
  main's table now, so it writes a carriage return as `\r`, where it wrote
  `\x0d`. The two writers give the same text for every character to U+2FFF,
  every surrogate and seven more (14,343 compared, 0 different). The next
  commit removes `_quoted`.
- scripts/shape_materialize.py: main's `write_paths_listing` and this
  branch's `path_spelling_fault` import and `LEG_SEGMENT_REASONS`.

Every merged module compiles. On this merge, tests/test_windows_paths.py,
test_adopt_plan_escapes.py, test_yaml_flow_quotes.py,
test_manifest_validator.py and the record's two name tests in
test_adopt_submodules.py: 218 passed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…test, `entry_path_problem`, so a source holding `My Notes.md` no longer checks `plan ok` and is then refused by `execute` as `unsafe-value`, and a path `git filter-repo` would misread, or one refused as policy, is a `check` finding that says which, refused before any leg exists (#196) (#231)

On main at 4835fb4 `check` asked nothing of an entry's path, and
`execute` asked `checked_value`, whose alphabet (`SAFE_ARG_RE`) is meant
for the values that are options. A source holding `My Notes.md`,
`#notes.md` or `[ID].tsx` checked `plan ok`, and `execute` refused the
same plan as `unsafe-value`. So did #166's split instruction for a
`docs/User Guide.md` beside a `docs/theme` submodule.

DECISION: accept what a source tree holds. Refuse what git or git
filter-repo would misread, or what this tool cannot write into the path
list. Refuse as policy a control character, a byte that is not UTF-8, a
leading `-` and a name that is nothing but whitespace. An entry's path
is a fact of the source, not an option: `plan` writes it from the tree,
and every command is argv with `shell=False`. The issue's other choice
was to state the restriction in the question. That leaves a top-level
`My Notes.md` with a rename in the source as its only remedy. The policy
refusals are names git and filter-repo take literally, measured, and
each finding says it is policy. A byte that is not UTF-8 is written
byte-exact since #247, and is held until adopting such a name end to
end is measured on macOS and Windows.

WHAT CHANGED
- `entry_path_problem(path)`, ONE test, is `_misread(path) or
  _refused_by_policy(path)`, so a name with a reason of each kind is
  told the misreading.
  - `check` reports what it finds as the new finding `plan-unsafe-path`:
    "`<path>` has leg: <leg>, and `execute` will not extract it into a
    leg: <reason>".
  - It is listed FIRST in `_entry_findings`, so a path that is a `git`
    option is refused as that and not only as the uncovered file it also
    leaves. Its remediation is said once.
  - `execute` refuses the same finding in `check`'s words, through
    `_refuse_what_check_finds`, before any leg exists.
  - An entry answered `root` or `drop` is not judged: it is never a line
    of the filter's list.
- `_misread` is what git, filter-repo, or this tool's write of the path
  list would not carry as it stands. Each case is measured with
  git-filter-repo 2.47.0, whose `get_paths_from_file` strips `\r` and
  `\n` from each line's end and looks for `==>` first:
  - an empty path: a `literal:` line alone kept every path in the leg;
  - a line feed anywhere: the rest of the name is read as a line of its
    own, so `docs\nregex:.*` kept every path;
  - a carriage return at the END: it is stripped, so `Icon\r`, the macOS
    folder-icon file, extracted `Icon`, another file. A name of carriage
    returns alone leaves `literal:` alone, which kept every path;
  - a NUL, which no command line carries;
  - a surrogate outside U+DC80 to U+DCFF, such as a hand-edited
    `\ud800`: no source path holds one, and `write_paths_listing`
    raised `UnicodeEncodeError` on it;
  - `==>`, which filter-repo reads as a rename anywhere on a line.
- `_refused_by_policy` covers names git and filter-repo take literally.
  Samples of each kind were measured: a tab, `\x01`, ESC, DEL, `\x85`,
  `\x0b`, `\x0c`, `\x1c`, U+2028, a carriage return inside a name,
  `-n.md`, and one and three spaces. Each, as one `literal:` line and as
  a literal `git rm`, extracted and removed exactly that path:
  - a name that is nothing but whitespace;
  - a control character (C0, DEL, C1), or U+2028 or U+2029, a carriage
    return that does not end the name included;
  - a byte that is not UTF-8, which `Source.tree` keeps as one of
    U+DC80 to U+DCFF (`NOT_UTF8_BYTES`). With the refusal disabled, a
    source holding `caf\xe9.md` was extracted and verified on Linux,
    the byte in the leg, and the split message listed `caf?.md`;
  - a leading `-`, the threat model's first rule.
- `_leg_paths` collects the paths without `checked_value`. A leg's
  mount path, the branch, the repository names and the organisation are
  options, and stay under `checked_value` as `unsafe-value`.
- `_extract_leg` writes each `--paths-from-file` line as
  `literal:<path>`, through #247's byte-exact `write_paths_listing`.
  Written bare, `#notes.md` is a comment, and `glob:y.md`, `regex:x.md`
  and `literal:z.md` are directives.
- `_mount_the_legs` runs `git --literal-pathspecs rm -r -q -- <path>`.
  After `--` a path is still a pathspec: `[ID].tsx` also removed
  `D.tsx`, and `:!keep.md` removed every path but `keep.md`.
- `shape_materialize.env_commit` hands `git commit -F -` the message as
  UTF-8 (`encoding="utf-8", errors="replace"`).
  - The split message names every entry path. Under a cp1252 or POSIX
    `C` locale without UTF-8 mode, `文書.md` raised `UnicodeEncodeError`
    after both legs were pushed.
  - The change reaches every message `env_commit` writes:
    scaffold-project.py's assembly commit, scripts/family.py's holder
    commit, and `git_init_commit`'s first commits.
  - `errors="replace"` also applies when encoding. A `root` or `drop`
    entry holding a byte that is not UTF-8 is listed in the split
    message with a `?` in its place (`caf?.md`), where a strict encode
    would have raised.
- The module docstring's `check` paragraph names the entry-path test.

MEASURED on this machine (git 2.43.0, git-filter-repo 2.47.0). Each
source was planned, its unanswered entries answered `root`, then
`check` and `execute` were run with local remotes. Main 4835fb4
(a5b09c3 printed the same, but `plan` raised there on `caf\xe9.md`,
which is #199) -> this change:
- `My Notes.md`, `docs/User Guide.md`, `Übersicht.md`: check 0 `plan ok`,
  execute 2 `unsafe-value` -> check 0, execute 0 `adoption verified`,
  all three in the spec leg.
- `#notes.md`: check 0, execute 2 `unsafe-value` -> execute 0, in the
  spec leg.
- `[ID].tsx` (code) beside `D.tsx` (root): check 0, execute 2
  `unsafe-value` -> execute 0. `[ID].tsx` is in the code leg, and
  `D.tsx` is still in the root.
- `-notes.md`, `notes\t.md`, `caf\xe9.md` (byte 0xE9): check 0
  `plan ok`, execute 2 `unsafe-value` -> check 1 `FINDING
  plan-unsafe-path`, which says the refusal is policy; execute 2
  `REFUSED plan-unsafe-path`; no leg remote created.
- `a==>b.md`: the same exits, with the finding naming the rename
  misreading.
- Before #247, merged with #199's first head (d566e12) and without the
  rule, `caf\xe9.md` checked `plan ok`, and `execute` raised
  `UnicodeEncodeError` in `_extract_leg` after both leg remotes existed.
- The set of refused names is the same as at fix round 1, compared on
  5,679,428 names.

UNCHANGED:
- What `plan` writes and prints.
- What `check` prints for a plan none of whose entry paths it refuses
  (byte-identical to main for three sources).
- Every option value's `unsafe-value` refusal.

TESTS:
- tests/test_adopt_entry_paths.py:
  - the admitted names, and the refused ones as MISREAD and POLICY, each
    asserting its own words. A policy finding names no misreading;
  - `check` and `execute` on each hand-edit class, on a control
    character in the source, and on a byte that is not UTF-8 put
    straight into git's index;
  - `root`/`drop` not judged;
  - the split message under a foreign locale;
  - an end-to-end adoption of every admitted kind of name. It skips
    without git filter-repo, as on the Windows job.
- The assertions in test_adopt_execute_refusals.py, test_adopt_e2e.py and
  test_adopt_submodules.py that expected `unsafe-value` for an entry
  path now expect `plan-unsafe-path`, with every path a finding names
  spelled as #212's change (PR #223) spells it.

LEFT OUT:
- Adopting a name that is not UTF-8 end to end: lifting the policy
  refusal, and measuring macOS and Windows (#247's listing is on main).
- `plan` asking `entry_path_problem` of each entry it writes, and
  offering `root` or `drop` where it refuses.
- `cross_leg_references` handing entry paths to `git grep` (#216).

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…e-notice

It merged with no conflict. Main brings six commits since 54aa69a:

- e7d1720, update-shape.py (#208 via #224);
- 404760f, scripts/repo_shape.py's double-quoted flow items (#213 via
  #244), with tests/test_yaml_flow_quotes.py;
- 4835fb4, adopt-project.py's plan writer escapes (#199 via #247), with
  tests/test_adopt_plan_escapes.py;
- b538015, scaffold-project.py's leg paths against the shape's own
  paths (#197 via #234);
- 150c41d, update-shape.py's `apply --add` (#202 via #233);
- 39ac59c, scaffold-project.py's leg mount `--force` (#229 via #235).

Two of them touch adopt-project.py, and no hunk of either meets this
branch's:

- 4835fb4 changes the plan writer (`y`, `_PLAIN_RE`), adds `_shown` to
  the plan and check reports, and writes the `--paths-from-file` listing
  in `_extract_leg` through `write_paths_listing`. This branch changes
  other lines of `_extract_leg`: it reads the tree digest before the
  push and pushes through `_push_the_leg`. The signature is the same on
  both sides, so main's new direct `_extract_leg` test calls it as
  written.
- b538015 moves `_overlap`, `_first_and_more`, `MOVE_THE_LEG` and
  `CASE_ASIDE` into scripts/shape_materialize.py, and imports the first
  three back under the same names. This branch adds no use of any of
  them. A `symtable` check finds no name that the merged adopt-project.py,
  scaffold-project.py or scripts/shape_materialize.py reads and nothing
  binds.

`git diff -U0` of this merge against 39ac59c equals the branch's against
54aa69a, apart from the hunk headers. On the merged tree,
tests/test_adopt_plan_escapes.py, tests/test_yaml_flow_quotes.py,
tests/test_scaffold_leg_path_collisions.py and
tests/test_adopt_leg_path_collisions.py give 275 passed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…er a file can hold it, in `plan`, in `check` and in `execute` before any leg exists, so a byte that is not UTF-8 in `--elected-by`, `--reference` or a local `--source` directory no longer pushes both legs before `write_lf` raises (#255) (#270)

Since #247 the plan writer keeps a lone surrogate (a byte that is not
UTF-8, as Python decodes argv and paths) as `\udce9`, and the reader gives
it back. `plan --elected-by b'Jos\xe9'`, `--reference b'ref-\xe9'` and a
source under a directory `caf\xe9` with local remotes therefore passed
`plan` and `check`, and `execute` created and pushed both leg remotes
before `write_lf` raised `UnicodeEncodeError` (exit 1). Before #247 `plan`
raised and nothing was created. A hand-edited `\ud800` in `elected_by`,
`elected_on`, `reference`, `visibility`, a pin or a follow-up ended the
same way; a NUL or a line break there went into the manifest; and a bidi
override or isolate went into the README raw, which then showed the rest
of its line reversed.

`shape_materialize.unwritable_reason` asks the question, beside
`one_printable_line`: one line, no lone surrogate, no bidi embedding,
override or isolate control (U+202A..U+202E, U+2066..U+2069, by code
point in `BIDI_CONTROLS`), no other control character. It is
`one_printable_line` less `Cf` other than those nine, and it takes the
empty value: a Persian name joins with U+200C, which a file holds and
`execute` adopted before #247 and since, and an empty `elected_by` is
what `plan` records when `git config user.name` names nobody. The joiners,
U+00AD, U+FEFF and the marks U+200E, U+200F and U+061C stay writable.

- `plan` refuses `--elected-by` and `--reference` as
  `adopt-unwritable-value` and writes no plan. An elector it read from
  `git config user.name` is named `--elected-by (not given, so
  `git config user.name`)`, as #181 names a value `plan` filled in.
- `check` reports `elected_by`, `elected_on`, `reference`, `visibility`,
  each pin and each follow-up as `plan-unwritable-value`, through
  `_entry_findings`, so `execute` refuses the same findings in `check`'s
  words through `_refuse_what_check_finds`, before `_create_leg_remotes`.
- `execute` refuses a local clone URL (the source's own path under
  `--local-remote-dir`) as `adopt-unwritable-value`, right after
  `_repository_urls`, and says to rename or move the source and run
  `plan --source <its new path>`, `check` and `execute` again. A clone
  passed with --source would not do: its `origin` is the old path, which
  `_remote_repository` reads as UTF-8 and raises on.
- `Source.open` refuses a `local_path` that does not exist and is not text
  as `source-unresolvable`, where a NUL or a surrogate reached `print` or
  `subprocess` as a traceback.
- `Source.open` also refuses a `--source`, or a plan's `local_path:`, that
  begins with `-` as `source-unresolvable`, before `gh repo clone` can
  read it as one of gh's own options: `-u/x` was read as
  `--upstream-remote-name /x`. No GitHub owner name begins with `-`.
- `check`'s WHAT WILL HAPPEN shows the plan's visibility, adopt branch,
  leg paths and follow-ups through `_shown`, so a surrogate there no
  longer raises on a strict stdout before the finding is printed.

tests/test_adopt_template_values.py covers each argv route, an elector
from `git config user.name`, the local source directory (a byte that is
not UTF-8, and a bidi override, each followed by its remediation to
`adoption verified`), each hand-edited value, `local_path`, the printed
values, a `--source` that begins with `-` (a stub `gh` records no call,
and a control shows it records one), and controls (`José`, U+200C, a
`café` source directory) that still adopt. Every invisible character in
it is spelled as an escape.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…\udcNN` and a character as `\xNN`, as the plan's own `path:` does, so the byte 0x85 and the character U+0085 no longer both read `\x85`, and 34 pairs of different names no longer read as one in a question or a finding (#329) (#378)

`_spelled_character` wrote a lone surrogate U+DC80 to U+DCFF, which is how
`Source.tree` keeps a byte that is not UTF-8, as `\xNN`, and any other
character that is not printable and lies below U+0100 as `\xNN` too. The 32
C1 controls U+0080 to U+009F, U+00A0 and U+00AD therefore read exactly as the
bytes 0x80 to 0x9F, 0xA0 and 0xAD: 34 pairs of different names. In YAML `\x85`
is the character, so a person who copied the byte's spelling out of a finding
into the plan wrote the character's entry; the plan's own writer spells the
byte `\udc85` (#199, #247).

Now a character that is not printable is spelled by `repo_shape.double_quoted`,
the one table of escapes the plan's writer uses and its reader resolves, and
no table of this file's own: a finding and the written plan spell one name the
same way, and a byte and a character never share an escape because each escape
names a code point.

- The byte 0x85 is `\udc85`; the character U+0085 is `\x85`. Every byte
  0x80 to 0xFF is `\udcNN`, the 94 whose character prints as itself included,
  because `\xe9` for the byte 0xE9 was the character U+00E9 in the plan.
- A backslash and a backtick are escaped with a backslash, as before: the
  backticks delimit the name. A line feed, carriage return and tab are
  `\n`, `\r` and `\t`; any other character below U+0100 is `\xNN`, below
  U+10000 `\uNNNN`, above it `\UNNNNNNNN`, as the plan writes them.
- The messages #255 added name a value, not a path, with `repr`, which
  escapes by code point already: a byte is `\udcNN` and a character `\xNN`
  there too. `repr` and `_spelled` agree on every character but the backtick,
  which only `_spelled` escapes, so no message of either spells a byte as
  `\xNN` and no table is added for them.
- Tests that pinned a byte as `\xNN` (the policy refusal, `plan-unsafe-path`,
  the mount refusal, a `.gitmodules` value, a submodule question, the source
  directory in `plan`'s report, a hand-edited `code_path` that `check` prints
  under WHAT WILL HAPPEN (#255), and the follow-up `git grep` names #228 spells
  through the same function) pin `\udcNN` now.
- `tests/test_adopt_spelled_unambiguous.py` is new: the 34 pairs one by one,
  every byte and every code point of the BMP spelled apart, the spelling equal
  to `double_quoted`'s and read back by the plan's reader as the same name, a
  finding and a question naming a byte and its character differently, the
  messages of #255 doing the same, and `repr` and `_spelled` agreeing, and
  `plan` and `check` run on a source holding both.

Unchanged, diffed over all 1,114,112 code points: only the 128 characters
U+DC80 to U+DCFF are spelled differently; every other character, a backslash,
a backtick and the whole of `_spelled`'s framing are byte for byte what they
were. A spelling is only printed: in a finding, a refusal or the report, and
in a question the plan carries as prose that nothing parses. Of the 28
functions that name `_spelled`, `_spelled_character` or `_shown`, one runs
git, `cross_leg_references`'s `git grep`, and it is given the source's own
directory name, never the spelling, which only the follow-up's text holds; none
opens a file or builds a command line from one. Nothing here changes what git
is asked.

Left out: a `\` + backtick in a finding is still the finding's own delimiter
escape and is not a YAML escape, so a name holding a backtick is not
copy-and-paste into the plan as it stands.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant