fix(env): search every referenced candidate, respect || conditionality (review)

Two more P1s from the bot's round-10 review of 75f3eac:

- The traversal committed to the first referenced candidate in
  SHELL_PROFILE_CANDIDATE_NAMES's fixed priority order and gave up if
  that branch was a dead end, instead of trying every candidate the
  current file actually references. Git for Windows' own generated
  .bash_profile sources both .bashrc and .profile in one file — if the
  real block sits in .profile but .bashrc (sorting earlier) has none,
  the walk stopped at .bashrc without ever trying .profile. Reworked
  into a breadth-first search over the whole reference graph.

- Splitting statements on `||` treated its right side as unconditionally
  reached, but `||`'s right side only runs if the left side fails,
  which isn't something this code can establish. `source ~/.profile ||
  source ~/.bashrc` would mark .bashrc reachable even when .profile
  succeeds. Statements no longer split on `||`; a `source`/`.` sitting
  only after it is folded into its left side's statement and never
  recognized as its own reference, so it's never preferred over a
  block the left side already reaches. Conservative by construction:
  worst case is falling back to the order-based pick (the pre-#693-fix
  behavior), never a false "reachable".

Verified the exact branching scenario end-to-end on a real Windows
host: .bash_profile with the literal Git-for-Windows-generated content
(sources both .bashrc and .profile), .bashrc empty, real block in
.profile — resolves to .profile, no duplicate, doctor fully clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
STiFLeR7
2026-09-23 09:31:39 +05:30
co-authored by Claude Sonnet 5
parent 75f3eac35d
commit aaa1142729
2 changed files with 72 additions and 20 deletions
+37
View File
@@ -211,6 +211,43 @@ describe('resolveActiveShellProfile', () => {
await fse.writeFile(path.join(homeDir, '.profile'), 'source ~/.bash_profile\n');
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.bash_profile'));
});
// Regression (#693 review round 10): the resolver committed to the first
// referenced candidate in SHELL_PROFILE_CANDIDATE_NAMES's fixed order and
// gave up if that branch was a dead end, instead of trying every candidate
// the active pick actually references. .bash_profile sourcing both
// .bashrc and .profile is exactly Git for Windows' own generated content
// — .bashrc sorts earlier in the candidate list, so a dead .bashrc branch
// would previously stop the search before it ever reached .profile.
it('tries every referenced candidate, not just the first in priority order', async () => {
await fse.writeFile(
path.join(homeDir, '.bash_profile'),
'test -f ~/.bashrc && . ~/.bashrc\ntest -f ~/.profile && . ~/.profile\n',
);
await fse.writeFile(path.join(homeDir, '.bashrc'), '# no block here\n');
await fse.writeFile(path.join(homeDir, '.profile'), teamaiBlock());
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.profile'));
});
// Regression (#693 review round 10): splitting on `||` treated its
// right-hand side as unconditionally reached, but it only runs if the left
// side fails — undetermined here. A stale block behind `||` must not win
// over a working one the left side already reaches.
it('does not treat the right side of || as reachable', async () => {
// Both .profile and .bashrc carry a valid block for this scope; the
// point is which one the resolver *reaches* through the || line, not
// which one has a well-formed block. .bashrc sorts earlier than
// .profile in SHELL_PROFILE_CANDIDATE_NAMES, so a naive "any referenced
// candidate in priority order" search would wrongly land on .bashrc even
// though it only runs if the left side (.profile) fails.
await fse.writeFile(
path.join(homeDir, '.bash_profile'),
'source ~/.profile || source ~/.bashrc\n',
);
await fse.writeFile(path.join(homeDir, '.profile'), teamaiBlock());
await fse.writeFile(path.join(homeDir, '.bashrc'), teamaiBlock());
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.profile'));
});
});
describe('envBlockSourcesPath', () => {
+35 -20
View File
@@ -222,7 +222,14 @@ function referencesCandidate(content: string, name: string): boolean {
const target = new RegExp(`^["']?(?:~|\\$\\{?HOME\\}?)/${escaped}(?![\\w.-])["']?$`);
for (const rawLine of content.split('\n')) {
if (rawLine.trimStart().startsWith('#')) continue;
for (const statement of rawLine.split(/&&|\|\||;/)) {
// Only `&&`/`;` split into statements that are still unconditionally
// attempted (or gated on the referenced candidate's own existence, which
// is independently re-checked by reading that candidate). `||`'s right
// side runs only if its left side fails — something not established
// here — so it is left folded into the same statement as its left side:
// that statement's first `.`/`source` command (the unconditional one)
// still matches, but a `source` sitting only after `||` never does.
for (const statement of rawLine.split(/&&|;/)) {
const tokens = statement.trim().split(/\s+/);
if (tokens.length >= 2 && (tokens[0] === '.' || tokens[0] === 'source') && target.test(tokens[1])) {
return true;
@@ -236,17 +243,22 @@ function referencesCandidate(content: string, name: string): boolean {
* Resolve which shell profile file this scope's env block belongs in.
*
* Starts from `detectShellProfile`'s order-based pick — the file the current
* environment actually reads — and follows the chain of files it actually
* `source`s (transitively, with cycle protection) looking for one that
* already carries this scope's block. A candidate the chain never reaches is
* never preferred, regardless of what it contains: an earlier version
* matched any candidate with a block anywhere (#693 review round 8: a stale
* pre-#682 block in `.bashrc` then outranked a genuinely unwritten,
* currently-read `.profile`, reintroducing #682 for exactly the installs
* upgrading through this fix) and checked only one hop of sourcing (#693
* review round 9: `.bash_profile` sourcing `.profile` sourcing `.bashrc` —
* the common Debian `.profile` pattern — would miss a block sitting in
* `.bashrc` two hops away and inject a duplicate into `.bash_profile`).
* environment actually reads — and searches every file it actually `source`s
* (transitively, breadth-first, with cycle protection) for one that already
* carries this scope's block. A candidate the search never reaches is never
* preferred, regardless of what it contains: earlier versions matched any
* candidate with a block anywhere (#693 review round 8: a stale pre-#682
* block in `.bashrc` then outranked a genuinely unwritten, currently-read
* `.profile`, reintroducing #682 for exactly the installs upgrading through
* this fix), checked only one hop of sourcing (#693 review round 9:
* `.bash_profile` sourcing `.profile` sourcing `.bashrc` — the common Debian
* `.profile` pattern — would miss a block sitting in `.bashrc` two hops away
* and inject a duplicate into `.bash_profile`), and followed only the first
* referenced candidate in a fixed priority order rather than every one
* (#693 review round 10: `.bash_profile` sourcing both `.bashrc` and
* `.profile`, with the block actually sitting in `.profile`, would commit to
* the dead-end `.bashrc` branch first — earlier in `SHELL_PROFILE_CANDIDATE_
* NAMES` — and give up without ever trying `.profile`).
*
* The common real case this exists for: Git for Windows'
* `/etc/profile.d/bash_profile.sh` auto-generates `~/.bash_profile`
@@ -266,20 +278,23 @@ export async function resolveActiveShellProfile(
const activePick = await detectShellProfile(platform);
const visited = new Set<string>();
let current = activePick;
while (!visited.has(current)) {
const queue: string[] = [activePick];
while (queue.length > 0) {
const current = queue.shift() as string;
if (visited.has(current)) continue;
visited.add(current);
const content = await readFileSafe(current);
const block = content ? extractEnvBlock(content) : null;
if (block && envBlockReferencesDataHome(block, envShPath)) return current;
if (!content) break;
if (!content) continue;
const next = SHELL_PROFILE_CANDIDATE_NAMES.find((name) => {
for (const name of SHELL_PROFILE_CANDIDATE_NAMES) {
const candidate = path.join(home, name);
return candidate !== current && !visited.has(candidate) && referencesCandidate(content, name);
});
if (!next) break;
current = path.join(home, next);
if (candidate !== current && !visited.has(candidate) && referencesCandidate(content, name)) {
queue.push(candidate);
}
}
}
return activePick;