fix(worktree,bash): propagate the repo's git identity into worktrees and bash commits — never invent one - #1825
Merged
Merged
Conversation
wqymi
force-pushed
the
fix/worktree-git-identity
branch
from
July 27, 2026 13:23
1a86ee2 to
0e44410
Compare
… hostname-fallback authorship A separate worktree checkout shares the object/ref store but has its own config, so it does NOT inherit the parent repo's LOCAL git identity. When global identity is also empty, git commit autodetects user@hostname (e.g. MI <mi@host.local>), leaking the machine hostname and wrong authorship into pushed commits. setup() now resolves the parent repo's identity (git -C <ctx.worktree> config user.name/email, which walks local->global->system) and pins it into the new worktree's own local config, right after the HEAD-attach assertion and inside the existing per-repo lock. If the parent has no identity at all, it falls back to a stable mimocode identity (mimocode <mimocode@users.noreply.github.com>) so the worktree is never left without one.
Layer-1 (worktree/index.ts setup) only covers worktrees mimocode creates in code. It does NOT cover worktrees/commits an agent makes via the bash tool (git worktree add / git clone / committing in an ad-hoc dir) — those still fall back to MI <mi@hostname.local>. Layer-2 adds a floor in BashTool.shellEnv(): resolve the repo identity once per worktree (git config user.name/email at Instance.worktree via the Git service) and inject GIT_AUTHOR_NAME/EMAIL + GIT_COMMITTER_NAME/EMAIL into every bash env. Fall back to a stable mimocode-agent[bot] identity when the repo has none, and guard the non-git case (Instance.worktree === '/') so we never read git config at root. Layering / git precedence: explicit -c / repo-or-worktree LOCAL config (layer 1) > GIT_AUTHOR_*/COMMITTER_* env (layer 2 floor) > global > autodetect user@hostname. The floor is placed below process.env (an operator-set GIT_AUTHOR_* still wins) and above plugin extra.env (a plugin can still override), and only fills vars not already present in process.env. Complementary, not conflicting. Also aligns layer-1's fallback identity to mimocode-agent[bot] for consistency. Adds bash-env floor tests (inherit from repo config, bot fallback for a non-git project, operator-override wins) alongside the existing worktree identity tests.
The fallback identity email was fabricated by analogy to the real opencode-agent[bot] GitHub App address. Replace it with the intended mimo@xiaomi.com / "MiMo Code" pair in both layers (worktree setup local config + bash shellEnv floor) and their tests.
The fallback git-identity name for agent-authored commits is now "MiMo" instead of "MiMo Code"; the fallback email stays mimo@xiaomi.com.
…or contract Review follow-up on the two-layer git-identity fix. - Extract the fallback identity into Git.FALLBACK_IDENTITY (src/git/index.ts), the module both layers already import. The literals "MiMo"/"mimo@xiaomi.com" were duplicated in src/worktree/index.ts and src/tool/bash.ts; that constant had already been renamed once across several files, so the drift risk was demonstrated rather than hypothetical. Both tests now assert against the shared constant too, so a future rename cannot pass in one layer while silently failing in the other. - Document the floor's behavioral contract on gitIdentityCache: its only job is to stop `user@hostname` authorship; it is delivered as env, and git gives GIT_AUTHOR_*/GIT_COMMITTER_* precedence OVER user.name/user.email config; the per-worktree memoization means a mid-session `git config` change is not picked up until the process restarts; operator-set vars still win per-variable. - Correct the previous shellEnv comment, which claimed the env floor sat "below repo/worktree local config, which still wins". Verified empirically that git env vars override config, so the claim was backwards. - Note why resolveGitIdentity/gitIdentityCache sit in the tool's outer setup block rather than inside shellEnv (memoization across bash invocations). - Extend the operator-override test to assert per-variable precedence: with only GIT_AUTHOR_NAME operator-set, the other three vars must still receive the floor. It previously asserted names only, leaving the email path uncovered.
wqymi
force-pushed
the
fix/worktree-git-identity
branch
from
July 27, 2026 14:43
0e44410 to
ae18dec
Compare
FALLBACK_IDENTITY ("MiMo <mimo@xiaomi.com>") did not rescue a git failure —
it pre-empted git's own resolution. Measured on git 2.50.1:
- with no identity configured anywhere, `git commit` exits 0 and autodetects
`user@hostname`; there is no fatal to rescue;
- `git config user.email` cannot see `EMAIL`, but `git commit` honours it, so
the substitution silently replaced a user-chosen address;
- GIT_AUTHOR_*/GIT_COMMITTER_* env outrank `user.email`, so the injected
placeholder also overrode the correct config of any OTHER repo a bash
command committed in — and for a non-git project (`worktree === "/"`) it was
injected without probing any config at all.
Both layers now propagate-or-abstain: they copy an identity the repo itself
resolves, and inject/pin nothing when it resolves none, leaving the fallback to
git. Unresolved fields are logged instead of being silently substituted.
The two assertions that pinned the substitution encoded the defect, so they are
replaced rather than kept: the worktree now asserts an EMPTY local identity, and
bash asserts NO GIT_* vars for a non-git project. A new test commits in a
worktree whose parent has no identity and asserts the author email comes from
`EMAIL`, not from a substituted constant.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A linked git worktree does not inherit its parent repo's local config, so commits made in a mimocode-created worktree could be authored
MI <mi@MIdeMacBook-Pro.local>— the machine hostname, in pushed history. This propagates the identity the repo itself resolves into the worktree, and injects it for bash-tool commits.It deliberately does NOT invent an identity when the repo has none. An earlier revision of this PR did (a hardcoded
MiMo <mimo@xiaomi.com>fallback); that was wrong and is removed — see the follow-up section at the bottom for the measurements that settled it. The contract now is propagate or abstain: copy what git can resolve, otherwise set nothing and leave git's own chain (local → global → system →EMAIL→ autodetect → git's actionable error) fully intact.Root cause
A separate worktree checkout shares the object/ref store with its parent repo but has its own config, so it does not inherit the parent's LOCAL git identity. With no global identity either,
git commitautodetectsuser@hostname. That differential — the parent repo commits correctly, its worktree does not — is the actual defect, and the only thing this PR is entitled to fix.Layer 1 — worktree local config (
src/worktree/index.tssetup())After
git worktree add+ the HEAD-attach assertion, inside the existing per-repo lock:git -C <ctx.worktree> config user.name/user.email(walks local → global → system).log.warn, so the worktree behaves exactly like its parent instead of carrying an identity nobody chose.Covers worktrees mimocode creates in code, including commits made there later by the user's own terminal — which is precisely why an invented identity was harmful and an abstain is not.
Layer 2 — bash-env floor (
src/tool/bash.tsshellEnv())Layer 1 does not cover commits an agent makes through the bash tool in an ad-hoc directory, so
shellEnv()injectsGIT_AUTHOR_NAME/EMAIL+GIT_COMMITTER_NAME/EMAILas a floor:Instance.worktree.Instance.worktree === "/") now injects nothing — previously it short-circuited straight to the hardcoded identity without consulting any config.process.env, so an operator-setGIT_AUTHOR_*still wins — per variable, not all-or-nothing. Pluginextra.envis spread last and can still override.GIT_AUTHOR_*/GIT_COMMITTER_*override the config of whatever repo the command runs in, not just the session's repo. A placeholder floor therefore rewrote authorship in unrelated repositories reached withcd.Corrected: git precedence (env beats config)
An earlier revision of this description — and a code comment — claimed the layer-2 env floor sat below repo/worktree local config. That was backwards. Verified empirically:
GIT_AUTHOR_*/GIT_COMMITTER_*env outranksuser.name/user.emailconfig, including an explicit-c. Actual precedence:GIT_AUTHOR_*/COMMITTER_* env> worktree/repo LOCAL config > global >EMAIL> autodetectuser@hostname.The two layers don't conflict: layer 2 seeds itself from the same config resolution, so they normally agree. Layer 1 remains necessary for commits made in the worktree outside the bash tool, where no env floor is present. The comment in
shellEnv()has been corrected to match the code and git's real behavior.Documented cache contract
gitIdentityCacheis keyed by worktree path and memoized for the process lifetime. Its behavioral contract is written down at the definition site:cd.git config user.name ...performed mid-session is not picked up until the process restarts.GIT_AUTHOR_*/GIT_COMMITTER_*still win, per variable.Also noted why
resolveGitIdentity/gitIdentityCachelive in the tool's outer setup block rather than insideshellEnv— so the cache persists across bash invocations instead of re-spawning twogit configsubprocesses per call.Tests (real git, no mocks)
test/worktree/index.test.ts:--localidentity, and a commit there resolves through git's own chain (asserted viaEMAIL).test/tool/bash.test.ts:GIT_*vars inherited from repo config;worktree=/) gets noGIT_*var injected;GIT_AUTHOR_NAMEoperator-set, that one var is preserved while the other three each still receive the floor.Reviewer note —
.text.trim()vs.text().trim()These are two different runners, both correct, not a silent bug:
worktree/index.tsuses a file-localgit()helper returning{ code, text, stderr }wheretextis a plain string fromStream.mkString— hence the property form.text.trim(). The same file already uses that form at:264,:272,:425,:430,:448.bash.tsusesgitSvc.run(...), whoseResult.textis a method — hence.text().trim().Verification
bun typecheck→ exit 0bun test test/worktree/index.test.ts test/tool/bash.test.ts --timeout 120000→ 31 pass / 0 fail (84expect()calls); pristine baseline atae18decb7, identical command → 30 pass / 0 failbun test test/git→ 7 pass / 0 failorigin/main.Follow-up (
8c965e96b): the fallback identity is removed — git decidesA review objection was raised against
Git.FALLBACK_IDENTITY(MiMo <mimo@xiaomi.com>): ahardcoded fallback attributes commits to an identity the user never chose, and git already has its
own fallback chain. Investigated against git's actual behaviour, and the objection holds.
Measured on
git 2.50.1:EMAILgit commitexit 0, authorMI <mi@host.local>— there is no fatal to rescueEMAIL=real@person.dev, no configgit config user.emailprints nothing, exit 1 — the probe is blind toEMAILgit commitMI <real@person.dev>— git would have used the user's chosen addressReal Person <real@person.dev>, commit run with the 4 injected varsauthor=MiMo <mimo@xiaomi.com>— env outranks that repo's own correct configSo the constant was not a rescue for a hard failure; it pre-empted a resolution git could still
make, silently, and with no log or surfaced message. Worst case: for a non-git project
(
Instance.worktree === "/")bash.tsinjected the placeholder without probing any config atall, so a
cd ~/anyrepo && git committhrough the bash tool overrode that repo's real identity.Layer 1's write also lands in
<worktree>/.git/config, so it governed every later commit there —including ones the human makes in their own terminal.
Both layers are now propagate-or-abstain. They copy an identity the repo itself resolves, and
pin/inject nothing when it resolves none — leaving the fallback to git (config →
EMAIL→user@hostname→ git's own actionable*** Please tell me who you are.). Unresolved fields arelog.warned rather than silently substituted, so the abstain is discoverable.This keeps the PR's actual fix intact: the reported bug was that a linked worktree does not
inherit the parent's LOCAL identity, and propagation still fixes exactly that. When the parent has
no identity, the worktree now resolves authorship exactly as the parent repo would — mimocode
introduces no attribution differential, which is the honest contract.
The two assertions that pinned the substitution encoded the defect
They are replaced, not quietly edited to match:
test/worktree/index.test.ts— wasexpect(email).toBe(Git.FALLBACK_IDENTITY.email); now assertsthe worktree's
--localidentity is empty.test/tool/bash.test.ts— wastoContain('GIT_AUTHOR_EMAIL=' + Git.FALLBACK_IDENTITY.email); nowasserts no
GIT_*var is injected for a non-git project.New regression test + revert-probe
New: "does not override an identity git itself would resolve from EMAIL" — commits in a worktree
whose parent has no identity and asserts the author email comes from
EMAIL.Revert-probe (new tests,
src/reverted toae18decb7) → 28 pass / 3 fail:Verification
bun typecheck→ exit 0bun test test/worktree/index.test.ts test/tool/bash.test.ts --timeout 120000→ 31 pass / 0 fail(84
expect()); pristine baseline of the identical command atae18decb7→ 30 pass / 0 fail (85).mimo@xiaomi.comno longer appears anywhere inpackages/opencode.