Skip to content

fix(worktree,bash): propagate the repo's git identity into worktrees and bash commits — never invent one - #1825

Merged
wqymi merged 6 commits into
mainfrom
fix/worktree-git-identity
Jul 29, 2026
Merged

fix(worktree,bash): propagate the repo's git identity into worktrees and bash commits — never invent one#1825
wqymi merged 6 commits into
mainfrom
fix/worktree-git-identity

Conversation

@wqymi

@wqymi wqymi commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

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 commit autodetects user@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.ts setup())

After git worktree add + the HEAD-attach assertion, inside the existing per-repo lock:

  1. Resolve the parent repo's identity via git -C <ctx.worktree> config user.name/user.email (walks local → global → system).
  2. Pin each resolved field into the new worktree's own local config.
  3. If a field does not resolve, write nothing for it and 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.ts shellEnv())

Layer 1 does not cover commits an agent makes through the bash tool in an ad-hoc directory, so shellEnv() injects GIT_AUTHOR_NAME/EMAIL + GIT_COMMITTER_NAME/EMAIL as a floor:

  • Resolve the repo identity once per worktree (memoized) via the Git service at Instance.worktree.
  • Inject only the fields that resolved; inject nothing at all when nothing resolved.
  • The non-git case (Instance.worktree === "/") now injects nothing — previously it short-circuited straight to the hardcoded identity without consulting any config.
  • Fill only the vars absent from process.env, so an operator-set GIT_AUTHOR_* still wins — per variable, not all-or-nothing. Plugin extra.env is spread last and can still override.

⚠️ Why "inject nothing" matters and is not merely tidier: 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 with cd.

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:

# repo config user.name=LocalCfg, plus explicit -c user.name=DashC
GIT_AUTHOR_NAME=EnvName ... git -c user.name=DashC commit
→ author=EnvName <env@x.com>   committer=EnvName <env@x.com>

GIT_AUTHOR_*/GIT_COMMITTER_* env outranks user.name/user.email config, including an explicit -c. Actual precedence:

GIT_AUTHOR_*/COMMITTER_* env > worktree/repo LOCAL config > global > EMAIL > autodetect user@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

gitIdentityCache is keyed by worktree path and memoized for the process lifetime. Its behavioral contract is written down at the definition site:

  • Its only job is to stop the worktree/parent attribution differential. It is not a general identity-configuration feature, and it never substitutes an identity of its own.
  • Because it is delivered as env, it takes precedence over the repo's config (see above) — including a repo reached by cd.
  • Because the resolved value is memoized per worktree path, a git config user.name ... performed mid-session is not picked up until the process restarts.
  • Operator-set GIT_AUTHOR_*/GIT_COMMITTER_* still win, per variable.

Also noted why resolveGitIdentity/gitIdentityCache live in the tool's outer setup block rather than inside shellEnv — so the cache persists across bash invocations instead of re-spawning two git config subprocesses per call.

Tests (real git, no mocks)

test/worktree/index.test.ts:

  • worktree inherits the parent repo's identity;
  • worktree whose parent has no identity is left with an empty --local identity, and a commit there resolves through git's own chain (asserted via EMAIL).

test/tool/bash.test.ts:

  • bash env injects the 4 GIT_* vars inherited from repo config;
  • a non-git project (worktree=/) gets no GIT_* var injected;
  • per-variable precedence: with only GIT_AUTHOR_NAME operator-set, that one var is preserved while the other three each still receive the floor.

⚠️ The two assertions that previously pinned the invented fallback were replaced, not adjusted — they encoded the defect, so quietly editing them to match new behaviour would have hidden the change.

Reviewer note — .text.trim() vs .text().trim()

These are two different runners, both correct, not a silent bug:

  • worktree/index.ts uses a file-local git() helper returning { code, text, stderr } where text is a plain string from Stream.mkString — hence the property form .text.trim(). The same file already uses that form at :264, :272, :425, :430, :448.
  • bash.ts uses gitSvc.run(...), whose Result.text is a method — hence .text().trim().

Verification

  • bun typecheck → exit 0
  • bun test test/worktree/index.test.ts test/tool/bash.test.ts --timeout 12000031 pass / 0 fail (84 expect() calls); pristine baseline at ae18decb7, identical command → 30 pass / 0 fail
  • bun test test/git7 pass / 0 fail
  • Rebased onto latest origin/main.

Follow-up (8c965e96b): the fallback identity is removed — git decides

A review objection was raised against Git.FALLBACK_IDENTITY (MiMo <mimo@xiaomi.com>): a
hardcoded 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:

probe result
no identity configured anywhere, no EMAIL git commit exit 0, author MI <mi@host.local>there is no fatal to rescue
EMAIL=real@person.dev, no config git config user.email prints nothing, exit 1 — the probe is blind to EMAIL
same, git commit author MI <real@person.dev>git would have used the user's chosen address
repo with LOCAL Real Person <real@person.dev>, commit run with the 4 injected vars author=MiMo <mimo@xiaomi.com>env outranks that repo's own correct config

So 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.ts injected the placeholder without probing any config at
all
, so a cd ~/anyrepo && git commit through 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 are
log.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 — was expect(email).toBe(Git.FALLBACK_IDENTITY.email); now asserts
    the worktree's --local identity is empty.
  • test/tool/bash.test.ts — was toContain('GIT_AUTHOR_EMAIL=' + Git.FALLBACK_IDENTITY.email); now
    asserts 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 to ae18decb7) → 28 pass / 3 fail:

error: expect(received).toBe(expected)
Expected: "chosen@example.test"
Received: "mimo@xiaomi.com"
(fail) Worktree.setup git identity > does not override an identity git itself would resolve from EMAIL
Expected: ""              Received: "MiMo"
Expected: "GIT_AUTHOR_NAME="   Received: "GIT_AUTHOR_NAME=MiMo"

Verification

  • bun typecheckexit 0
  • bun test test/worktree/index.test.ts test/tool/bash.test.ts --timeout 12000031 pass / 0 fail
    (84 expect()); pristine baseline of the identical command at ae18decb730 pass / 0 fail (85).
  • mimo@xiaomi.com no longer appears anywhere in packages/opencode.

@wqymi wqymi changed the title fix(worktree): set git identity on isolated worktree creation to stop hostname-fallback authorship fix(worktree+bash): set git identity on worktree creation AND floor it in the bash env to stop hostname-fallback authorship Jul 20, 2026
@wqymi wqymi changed the title fix(worktree+bash): set git identity on worktree creation AND floor it in the bash env to stop hostname-fallback authorship fix(worktree,bash): set git identity on worktree creation + bash-env floor to stop hostname-fallback authorship Jul 20, 2026
@wqymi
wqymi force-pushed the fix/worktree-git-identity branch from 1a86ee2 to 0e44410 Compare July 27, 2026 13:23
wqymi added 5 commits July 27, 2026 22:36
… 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
wqymi force-pushed the fix/worktree-git-identity branch from 0e44410 to ae18dec Compare July 27, 2026 14:43
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.
@wqymi wqymi changed the title fix(worktree,bash): set git identity on worktree creation + bash-env floor to stop hostname-fallback authorship fix(worktree,bash): propagate the repo's git identity into worktrees and bash commits — never invent one Jul 29, 2026
@wqymi
wqymi merged commit c028e0e into main Jul 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant