Skip to content

scaffold-project.py: the clone git submodule add makes of each leg names its remote origin, whatever clone.defaultRemoteName says, so --local-remote-dir no longer ends REFUSED a git or gh command failed ("No such remote 'origin'") after both legs are pushed (#1275) - #1277

Draft
brettheap wants to merge 1 commit into
mainfrom
l3-1275-scaffold-default-remote-name

Conversation

@brettheap

@brettheap brettheap commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

What changed

A v1.2.2 follow-up from lane 3's independent review of #1268 (#1275). It lands after the v1.2.1 tag, under Brett Heap's merge word, once reviewed and green.

With clone.defaultRemoteName set in the global git config, scaffold-project.py --local-remote-dir made both legs, pushed them, and ended REFUSED a git or gh command failed (exit 2) at git remote set-url origin in the first mount, with the root's bare repository still empty. _build_assembly_root mounts each leg with git submodule add and then addresses the clone it made as origin; git clone names that remote after the setting, so there was no origin. It is the failure #1261 removed from adopt-project.py.

REMOTE_NAMED_ORIGIN = ["-c", "clone.defaultRemoteName=origin"] now goes ahead of the verb on that git submodule add (scaffold-project.py, the mount in _build_assembly_root). The -c reaches the clone submodule add makes, as #1268's reviewer measured and as the runs below show. The constant is the same spelling as adopt-project.py's; no shared helper exists (grep defaultRemoteName finds none outside tests), so it stays in this file. No new command or flag. The mount is the one clone this tool makes (grep of every "git" call in scaffold-project.py, scripts/shape_materialize.py and scripts/repo_shape.py finds no other clone); the table "Every other repository" says why the rest need nothing.

Two files: scaffold-project.py (+22 -3) and the new tests/test_scaffold_clone_default_remote_name.py.

Measured, before and after

git 2.43.0, Python 3.12.3, a stub gh (exit 97) first on PATH, GH_TOKEN=bogus, GIT_SSH_COMMAND=false. scaffold-project.py --org testorg --project Northwind --elected-by "Test Human" --elected-on 2026-09-02 --local-remote-dir <D>/remotes --work-dir <D>/work, the way scaffolded in tests/conftest.py drives it. Each run had a GIT_CONFIG_GLOBAL of its own: the two maintenance-off lines, then the setting. Every run was made at ONE path (each moved aside before the next) with the commit dates pinned, because the leg templates carry the root's clone URL: another path is another set of bytes. The dumps are symbolic-ref HEAD, for-each-ref and the sorted cat-file --batch-all-objects --batch-check of the two legs and the root, 50 lines.

global config main 27397a4b5 this change
the two maintenance-off lines only (the control the rows use) exit 0 exit 0
an empty file exit 0 exit 0
[clone] defaultRemoteName = upstream exit 2 exit 0
[clone] defaultRemoteName = a..b exit 2 exit 0
GIT_CONFIG_COUNT=1 with clone.defaultRemoteName=fromcount, control global exit 2 exit 0
GIT_CONFIG_PARAMETERS='clone.defaultRemoteName'='fromparams', control global exit 2 exit 0

The last lines on main:

upstream (and fromcount, fromparams):
REFUSED a git or gh command failed. THE EXACT COMMAND WAS:
    git remote set-url origin <D>/remotes/Northwind-spec.git
    (in <D>/work/Northwind/spec)
    exit 2
--- output ---
error: No such remote 'origin'
--- end output ---

a..b:
REFUSED a git or gh command failed. THE EXACT COMMAND WAS:
    git -c protocol.file.allow=always submodule add --force -q <D>/work/Northwind-spec spec
    (in <D>/work/Northwind)
    exit 128
--- output ---
fatal: 'a..b' is not a valid remote name
fatal: clone of '<D>/work/Northwind-spec' into submodule path '<D>/work/Northwind/spec' failed
--- end output ---

With the change, all six runs print Working trees are at <D>/work and nothing on stderr.

The legs and root against the control (cmp of the dumps):

  • Main, upstream and a..b: the legs (the first 24 lines: HEAD, refs, objects of Northwind-spec and Northwind-code) are cmp-identical to the control's, since both were pushed before the mount refused. The root's bare repository holds HEAD and no ref, no object. The dump is 24 lines, the control's 50.
  • This change, upstream, a..b, the empty file, fromcount and fromparams: the whole dump, legs AND root, is cmp-identical to main's control (50 lines each). Those six ran with the change uncommitted over 27397a4b5, so the shape commit the messages record is main's. On the committed head d07b6fc51 the control, the empty file, upstream and a..b were run again: the four dumps and the four remotes lists are cmp-identical to one another, with nothing on stderr.
  • The remotes of the five repositories in <D>/work (the root, its two mounts, the two legs' own work trees):
    • main, control: origin in each;
    • main, upstream: the root has none (its git remote add origin comes after the mounts), the spec mount upstream, the code mount never made, the two legs' work trees origin;
    • this change, every config: origin in each, cmp-identical to the control. No work tree's config names defaultRemoteName (grep -rl over work/ and remotes/: none); the root's, both mounts' and .git/modules/*/config are diff-equal to the control's.

Every other repository the scaffold makes

where what reads the setting?
scaffold-project.py _build_assembly_root, git submodule add the clone of each leg into the root (the only clone) yes: now -c clone.defaultRemoteName=origin
seed_tree, git_init_commit (scripts/shape_materialize.py), _seed_legs, _build_assembly_root's root git init no: init makes no remote; each work tree is then given git remote add origin <url> by name
_create_remotes git init --bare no
only_commit a throwaway git init --bare --template=, then git fetch --depth=1 <url> <commit> no: fetches by URL, names no remote
_take_held_seed (--reuse-empty-repo, a leg already holding the seed) git init, git remote add origin, git fetch origin <seed>, git reset no: not a clone

The held-seed path was run, not only read: on this change under upstream, a first run exits 0; the root is made empty again and the run repeated with --reuse-empty-repo: exit 0, reused testorg/Northwind-spec (already there, holding this scaffold's seed ...) for each leg, and all five repositories name origin.

Rows

tests/test_scaffold_clone_default_remote_name.py, 9 rows (5 test functions; three are parametrized). One module fixture scaffolds the project three times at one path: without the setting, with upstream, and with a..b.

  • test_the_premise_a_plain_clone_names_its_remote_after_the_setting: asked of git. A plain git clone names its remote upstream under the upstream config and origin under the control's; under a..b it refuses. Robust to a developer whose own global config holds the setting (RV1268's N3 on adopt-project.py: each clone execute makes names its remote origin, whatever clone.defaultRemoteName says, so execute no longer ends in REFUSED mounting the legs ("No such remote 'origin'") after both legs are pushed (#1261) #1268, whose premise row failed ['upstream'] == ['origin'] for such a person): the control's file holds the two maintenance lines and NOTHING of the developer's config, and GIT_CONFIG_NOSYSTEM=1.
  • test_the_scaffold_exits_0_with_or_without_the_setting[without|upstream|refused]: exit 0, no REFUSED, no No such remote, Working trees are at.
  • test_the_legs_and_root_are_the_bytes_a_run_without_the_setting_pushes[upstream|refused]: the dumps above, three refs/heads/main in the control's.
  • test_each_repository_the_scaffold_made_has_the_remotes_it_has_without_it[upstream|refused]: the five remotes lists equal the control's, each ["origin"].
  • test_the_setting_goes_to_the_row_s_own_child_only: every GIT_CONFIG* variable of the session, the bytes of the file GIT_CONFIG_GLOBAL names, and git's own answer to config --get clone.defaultRemoteName are what they were before the three scaffolds (a developer's own value included); each child's file is its own, and only the upstream and a..b files name the setting.

The setting goes to each run's own child, through its GIT_CONFIG_GLOBAL. Nothing is exported, nothing writes the session's file.

On main (origin/main's scaffold-project.py in this tree): 6 failed, 3 passed: the upstream and a..b cases of the exit, bytes and remotes rows fail (No such remote 'origin'; 'a..b' is not a valid remote name); the premise, the control's exit and the own-child row pass, by design. Restored with cmp and git diff --quiet HEAD.

Mutants

The change is one -c on the one clone call, which runs once for each leg. Each mutant was applied to the committed scaffold-project.py, the 9 rows run, and the file restored with cmp against a copy and git diff --quiet HEAD (each restore clean):

mutant result killed by
D1 drop the -c from the mount's submodule add 6 failed, 3 passed exit, bytes and remotes rows (upstream, a..b)
D2 drop it for the code leg only 6 failed, 3 passed the same six
D3 drop it for the spec leg only 6 failed, 3 passed the same six
V1 value =orign 7 failed, 2 passed the six, and the exit row without
V2 key clone.defaultRemoteNam 6 failed, 3 passed the six
V3 constant [] 6 failed, 3 passed the six
A1 -c after submodule 7 failed, 2 passed the six and the exit row without (git refuses it)
A2 -c after add 7 failed, 2 passed the same seven
E1 -c before protocol.file.allow (order only) 9 passed an equivalent mutant: the order of two -c is not a behaviour

Gates

Head d07b6fc51, clean tree. git 2.43.0, Python 3.12.3, git-filter-repo 2.47.0 first on PATH after a stub gh (exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null, EMAIL=tests@openreposhape.invalid. One pytest at a time, -rs, a basetemp per group on /tmp. The full suite was not run.

group modules result
rows tests/test_scaffold_clone_default_remote_name.py 9 passed in 4.34s
scaffold modules that run --local-remote-dir, one pytest test_scaffold_clone_default_remote_name, test_scaffold_pin_and_reuse, test_scaffold_e2e, test_scaffold_reuse_seeded_legs with test_ruleset_hint_tracking_branch (it imports that module's fixture), test_scaffold_manifest_values, test_scaffold_init_empty_values, test_scaffold_mount_ignored with test_scaffold_ignored_shape_files, test_scaffold_device_names with test_dry_run_parity, test_scaffold_referent_chain_links, test_scaffold_pin_collisions, test_scaffold_leg_paths, test_scaffold_free_text_values, test_scaffold_partial_pin_source 921 passed in 320.07s (0:05:20), 0 skipped
leg path collisions tests/test_scaffold_leg_path_collisions.py 104 passed in 4.58s
guards test_repo_hygiene, test_agents_md_codes, test_git_output_decoding, test_stdout_encoding, test_cli_reference, test_no_auto_maintenance 343 passed, 3 skipped in 73.78s (0:01:13)
rows, the developer's own global config holding the setting the rows, run with GIT_CONFIG_GLOBAL naming a file that holds defaultRemoteName = upstream, then a..b 9 passed in 3.83s, 9 passed in 4.08s

The 3 skips: test_stdout_encoding.py:285 (the default it is about is Windows' own), and 2 at test_no_auto_maintenance.py:222 (the session began with GIT_CONFIG_GLOBAL set, which the lane sets to /dev/null; CI leaves it unset).

Merges

One gh pr list -R opensoft/openRepoShape --state open --limit 200 --json number,headRefOid,files: 46 open PRs, none with 100 files, so no list is cut. None of the 46 touches scaffold-project.py or tests/test_scaffold_clone_default_remote_name.py, so no PR needs a merge check for these files.

merge (git merge-tree --write-tree) result
head d07b6fc51 x origin/main 27397a4b5 (not moved since the branch was cut) clean, tree a2e4c81e8d49, the head's own tree
head x #1268 faa29987a (the model PR; touches adopt-project.py and tests/test_adopt_*, so not required) clean, tree f4630d20b4ed

Out of scope

Read, not run, at main 27397a4b5. Nothing here is changed by this PR, and nobody has measured these under the setting. Each is for a further issue.

setup-project.py

  • :3178 runs scaffold-project.py ... --no-next-steps, so step (5) of a setup run gets this change with no edit of its own.
  • :3345-3350 (step 7) git [-c ...] clone -q [--no-local] --recurse-submodules <root url> <clone>: a clone of the new root, with its two mounts. Under the setting the root's remote and the mounts' remotes are named after it. Nothing in the file addresses them as origin right after, but bump-leg.py and family.py (below) do later, and what_stands_at does at :2491.
  • :2417-2438 origin_recorded_in (git config --file <clone>/.git/config --get remote.origin.url), called at :2491: it reads the clone a rehearsal left behind. Under the setting the clone of :3345 records no remote.origin.url, so the call returns "" and the answer at :2493 is "It is a directory holding a .git." in place of the rehearsal sentence at :2497-2500. The path is refused either way; only the advice sentence changes.
  • :962 (self-bootstrap) git clone --quiet [--depth 1] -- <repo> <temp checkout>: no origin is addressed in it.
  • :1779-1802 remote_url(invocation_dir, "origin") reads the PERSON'S OWN checkout, not a clone this tool made. Under the setting their remote has another name, and _org_without_origin (:1826-1841) asks for --org.

scripts/family.py

  • :7290 family.py add: git [-c protocol.file.allow=always] submodule add -q --force -- <url> <path>: a clone of the member, remote named after the setting. Nothing follows it that addresses origin (no set-url in the file), so add is not refused. bump later does: _has_origin :8885 (git remote get-url origin), :8936 (git ls-remote --tags origin), :9468-9469 (git fetch -q --prune ... origin), :8786 (for-each-ref --contains <commit> refs/remotes/origin/). By :9455-9475 a mount with no origin is not fetched and the line says it has no remote named origin.
  • :6637, :7391 git clone -q --bare -- <url> <scratch>/member.git: throwaway clones read by commit id; no origin is addressed in them.
  • :3324, :3337 the holder: git remote add origin <url>, then push -u origin: by name, not a clone.
  • :8208, :8227 submodule update --init --no-fetch, in the rollback of a mount: restores from the store that is there.

scripts/bump-leg.py makes no clone. It addresses origin of a leg's mount, which a clone made under the setting names otherwise (the clone of setup-project.py:3345, a member family.py add mounted, or the person's own git clone --recurse-submodules from the NEXT STEPS): :1246 (git remote get-url origin, _has_origin), :1101 (for-each-ref --contains ... refs/remotes/origin/), :1503 and :1520 (remote = "origin" and git fetch -q --prune ... <remote>). On a failed fetch :1528 says has no remote named origin, and the refusals at :1353, :1384 and :1407 say it too. :2560 prints push -u origin for the root.

scripts/bootstrap.py (run at setup-project.py:3367) was not read.

Closes #1275

Lane: openreposhape-3 (openRepoShape-3)

🤖 Generated with Claude Code

Summary by Sourcery

Make local scaffold creation resilient to custom Git remote-name configuration.

Bug Fixes:

  • Ensure scaffolded submodule mounts consistently use the origin remote even when Git is configured with a different default remote name.

Tests:

  • Add coverage for scaffolding with custom, invalid, and absent clone.defaultRemoteName settings, including repository contents, remotes, exit status, and configuration isolation.

…names its remote `origin`, whatever `clone.defaultRemoteName` says, so `--local-remote-dir` no longer ends `REFUSED a git or gh command failed` ("No such remote 'origin'") after both legs are pushed (#1275)

`_build_assembly_root` mounts each leg with `git submodule add` and then runs
`git remote set-url origin` in the mount. `git clone` names the remote it
makes after `clone.defaultRemoteName`, so with `upstream` in the global git
config the mount had no `origin`: exit 2, `error: No such remote 'origin'`,
the root's bare repository still empty. A setting git refuses, `a..b`, ended
the run at `git submodule add` itself, exit 128.

`REMOTE_NAMED_ORIGIN = ["-c", "clone.defaultRemoteName=origin"]` goes ahead
of the verb on that call, as `adopt-project.py` does (#1261). It is the one
clone the scaffold makes. tests/test_scaffold_clone_default_remote_name.py
scaffolds with and without the setting, at one path with the commit dates
pinned, and compares every ref and object of both legs and the root, and the
remotes of the five repositories in the work directory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Nov 8, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implementation

Force the git submodule add clone to name its remote origin, matching the subsequent remote set-url call regardless of clone.defaultRemoteName.

  • scaffold-project.py L515-533, L2657-2659

Tests

Verify the scaffold succeeds with default, alternate, and invalid clone remote-name settings, while preserving repository objects and expected remotes.

  • tests/test_scaffold_clone_default_remote_name.py L1-234

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

@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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