refactor(manifest): key git config by host
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 42s

This commit is contained in:
2026-05-28 00:49:34 -04:00
parent 85104742ca
commit 59ee32cc8d
17 changed files with 356 additions and 159 deletions
+8 -6
View File
@@ -269,12 +269,14 @@ cred_proxy:
token_ref: GITEA_TOKEN
role: [git-insteadof, tea-login]
git:
- Name: claude-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
IdentityFile: ~/.ssh/gitea-delos-2.pem
ExtraHosts:
gitea.dideric.is: 100.78.141.42
KnownHostKey: ssh-rsa AAAAB3...
remotes:
gitea.dideric.is:
Name: claude-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
IdentityFile: ~/.ssh/gitea-delos-2.pem
ExtraHosts:
gitea.dideric.is: 100.78.141.42
KnownHostKey: ssh-rsa AAAAB3...
egress:
allowlist:
- example.com
@@ -191,9 +191,11 @@ egress:
- host: api.anthropic.com
git:
- Name: throwaway
Upstream: ssh://git@127.0.0.1:22/throwaway.git
IdentityFile: ~/.ssh/cb-test-key # fixture key
remotes:
127.0.0.1:
Name: throwaway
Upstream: ssh://git@127.0.0.1:22/throwaway.git
IdentityFile: ~/.ssh/cb-test-key # fixture key
---
```
+13 -13
View File
@@ -105,23 +105,17 @@ overlay it. For each field on `Bottle`:
| Field | Type | Merge |
|--------------|-----------------------|---------------------------------------------|
| `env` | `Mapping[str, str]` | dict merge, child wins on key collision |
| `git` | `tuple[GitEntry,…]` | full replace if child declares `git:` |
| `git_user` | `GitUser` | child overlay: child's non-empty fields win |
| `git.user` | `GitUser` | child overlay: child's non-empty fields win |
| `git.remotes`| `tuple[GitEntry,…]` | dict merge by host, child wins |
| `egress` | `EgressConfig` | full replace if child declares `egress:` |
| `supervise` | `bool` | full replace if child declares `supervise:` |
Why full-replace for the list-valued fields (`git[]`,
`egress.routes[]`):
Why full-replace for `egress.routes[]`:
- **Ordering matters.** Egress route ordering is part of the
match semantics (first matching host wins). Merging two
ordered lists by name introduces "where does the child's route
go?" ambiguity.
- **Name collisions are ambiguous.** If parent has
`git: [{Name: foo, Upstream: A}]` and child has `git:
[{Name: foo, Upstream: B}]`, "merge" could mean override-B or
error-on-collision. Full-replace makes the operator's
intent explicit.
- **Simpler precedence.** "Child declares X → X wins, full
stop" is one sentence; partial merges need a table per list.
@@ -129,9 +123,15 @@ The `env` dict is the one exception because dict-merge has no
ordering concern and dict-keyed overrides are the obvious user
expectation. (Same model as shell `export` precedence.)
The `git_user` dataclass-overlay (each non-empty field wins
individually) is so a parent can declare `git_user.name` and a
child can add just `git_user.email`. The default `GitUser()`
`git.remotes` is also keyed, so it follows dict-style inheritance:
children can override one host without restating every remote. The
remote entry is replaced as a whole on host collision because
`Upstream`, `IdentityFile`, `KnownHostKey`, and `ExtraHosts` are
tightly coupled.
The `git.user` dataclass-overlay (each non-empty field wins
individually) is so a parent can declare `git.user.name` and a
child can add just `git.user.email`. The default `GitUser()`
fields are empty strings, which are treated as "not set" for
overlay purposes — same `is_empty()` predicate the provisioner
uses.
@@ -191,7 +191,7 @@ moot because there's only one bottle source.
- Implement `_merge(parent: Bottle, child_raw: dict, name: str)
-> Bottle` with the rules table above.
- Unit tests: simple two-bottle extends, env merge with
collision, list-replace for git + egress, git_user overlay,
collision, host-keyed git remote merge, egress list-replace, git.user overlay,
supervise override, missing parent dies, cycle dies, deeper
chains (A extends B extends C).
3. **Docs.** Add an `extends:` example to the README's manifest