Skip to content

fix: detect sourced shell integration configs#1312

Open
yash27-lab wants to merge 2 commits intomax-sixty:mainfrom
yash27-lab:codex/fix-zsh-sourced-config-detection
Open

fix: detect sourced shell integration configs#1312
yash27-lab wants to merge 2 commits intomax-sixty:mainfrom
yash27-lab:codex/fix-zsh-sourced-config-detection

Conversation

@yash27-lab
Copy link
Copy Markdown

Follow simple source/dot directives when checking shell integration so wt config show and shell setup checks work when the init line lives in a sourced file instead of directly in .zshrc or .bashrc.\n\nAlso return the actual sourced file path in config output and add unit and integration coverage for the sourced-file case.

Follow simple source/dot directives when checking shell integration so wt config show and shell setup checks work when the init line lives in a sourced file instead of directly in .zshrc or .bashrc.\n\nAlso return the actual sourced file path in config output and add unit and integration coverage for the sourced-file case.
Copy link
Copy Markdown
Collaborator

@worktrunk-bot worktrunk-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work — the recursive source-following approach is well-structured with cycle detection and clean separation of concerns.

Two observations:

  1. file_tree_has_exact_line is unused outside its own test. It's exported publicly and re-exported in mod.rs, but no production code calls it — only find_exact_line_in_file_tree and file_tree_has_integration are used. See inline suggestion.

  2. Unit test coverage for parsing helpers is thin. extract_shell_word, split_shell_segments, expand_shell_path, and parse_source_segment contain non-trivial parsing logic. The integration test covers the happy path ($HOME expansion + source following), but edge cases like quoted source paths (source "dir/file.zsh"), multi-statement lines ([[ -f x ]] && source x), and skip conditions (source <(cmd)) aren't directly tested. A handful of unit tests for these would catch regressions if the parsing is later modified.

Remove the unused public exact-line wrapper and add focused unit tests for shell source parsing, path expansion, multi-statement splitting, and dynamic-source skip cases.
@yash27-lab
Copy link
Copy Markdown
Author

Good catch. I pushed a follow-up that removes the unused public helper and adds targeted unit coverage for the parsing helpers and skip cases.

@worktrunk-bot
Copy link
Copy Markdown
Collaborator

Looks great — the follow-up commit covers exactly the cases I had in mind. The test matrix for parse_source_segment_skips_dynamic_sources is particularly nice. Thanks for the quick turnaround!

@max-sixty
Copy link
Copy Markdown
Owner

thanks @yash27-lab

the existing state seems not great, admittedly

but I worry this is fairly finicky — am I right that it's parsing the shell to find files that have been sourced, and then reading those files? presumably there is lots that could still slip through?

@worktrunk-bot could you a survey of what will get caught vs missed in the existing vs proposed approach?

@worktrunk-bot
Copy link
Copy Markdown
Collaborator

You're right that this is parsing shell syntax to follow source/. directives, then reading those files. Here's a concrete breakdown of what each approach catches:

Before this PR (main)

Detection only looks at the literal lines of ~/.zshrc, ~/.bashrc, etc. — never follows sourced files.

Setup Detected?
eval "$(wt config shell init zsh)" directly in .zshrc Yes
source ~/.zsh/config.d/init.zsh in .zshrc, init line in init.zsh No
source $HOME/.config/shell/init.zsh No
source ~/shell-extras.zsh No
Any level of indirection via sourced files No

This is the gap the PR addresses — users who organize their shell config into sourced files see wt config show report "Not configured" even though everything works at runtime.

After this PR

The parser follows one level (recursively) of static source/. directives. Here's what it handles:

Caught (source path is resolvable at parse time):

Pattern Example
Relative path source .zsh/init.zsh
~ expansion source ~/init.zsh
$HOME expansion source $HOME/.config/init.zsh
${HOME} expansion source ${HOME}/.config/init.zsh
Quoted paths (with spaces) source "dir name/init.zsh"
Absolute paths source /etc/profile.d/wt.sh
Multi-statement lines [[ -f x ]] && source x
. (dot) syntax . ~/.zsh/init.zsh
Chained sourcing (A→B→C) Followed recursively with cycle detection
Any env var in path source $XDG_CONFIG_HOME/init.zsh (resolved from env)

Intentionally skipped (dynamic/unparseable — correctly returns "not found"):

Pattern Why skipped
Process substitution source <(wt config shell init zsh) — no file to read
Command substitution in path source $(some_cmd)/init.zsh — path unknown at parse time
Backtick substitution source `some_cmd`/init.zsh — same
Glob patterns source ~/.zsh/*.zsh — would need glob expansion
=(...) substitution source =(cmd) — zsh-specific, no file

Still missed (not handled by the parser):

Pattern Why missed
Conditional variable paths source ${ZDOTDIR:-$HOME}/.zshrc — would need shell default-value expansion
$ZDOTDIR (zsh-specific) Not in env at detection time unless user exports it
Programmatic sourcing for f in ~/.zsh/*.zsh; do source $f; done — loop + glob
Function-wrapped sourcing load_config() { source $1; }; load_config init.zsh
autoload (zsh) Not a source directive
source behind if/test guards checking something other than file existence e.g., if [[ $TERM != dumb ]]; then source init.zsh; fi — the parser finds this (it splits on &&/;) but only if the source keyword is visible in a segment

Practical assessment

The "caught" cases cover the most common real-world pattern: users who split their .zshrc/.bashrc into sourced files with literal paths using ~, $HOME, or relative references. The "still missed" cases are increasingly niche — $ZDOTDIR and default-value expansion (${VAR:-default}) are probably the most realistic gap, but they're uncommon enough that fixing the straightforward cases is a clear improvement over the status quo.

The finickiness risk is real but bounded: the parser is conservative (skips anything dynamic rather than guessing), uses cycle detection, and only reads files that already exist. It won't produce false positives — the worst case is a false negative, which is the same as the current behavior on main.

@withakay
Copy link
Copy Markdown

withakay commented Mar 7, 2026

I would appreciate the 'niche' $ZDOTDIR support here. I have my zsh config managed by GNU stow and installed to ~./config/zsh which becomes my $ZDOTDIR via a ~/.zshenv.

Repository owner deleted a comment from worktrunk-bot Mar 7, 2026
@max-sixty
Copy link
Copy Markdown
Owner

@worktrunk-bot are there alternative simpler approaches here? or dependencies which can help?

I'm quite skeptical about maintaining a few hundred lines of code which attempt to implement the equivalent of a mini-zsh-parser-slash-walker...

We could do something like:

  • only warn if we don't find the shell integration line and we're not running under shell integration (which we should know without having to run any zsh commands?)
  • we could even run actual zsh in wt config show --full, though possibly that's unnecessary.

@worktrunk-bot
Copy link
Copy Markdown
Collaborator

Good question — there are a couple of simpler alternatives worth considering:

Alternative 1: Suppress the warning when running under shell integration

The shell wrapper sets WORKTRUNK_DIRECTIVE_FILE when it invokes wt, so is_shell_integration_active() already tells us "shell integration is working right now." The simplest fix for the original problem (users seeing false "Not configured" warnings) would be:

  • In wt config show: if is_shell_integration_active() is true, skip the file-scanning detection for the current shell and just report "configured" — because it demonstrably is working.
  • Only fall back to file scanning when shell integration is not active (i.e., the user ran wt config show outside the wrapper, or from a different shell).

This eliminates the false negative that motivated this PR without any shell parsing. The downside: wt config show run outside shell integration (e.g., from a script or a different shell) would still report "Not configured" even if a sourced file has the init line. But that's arguably acceptable — the warning that matters is the one users actually see in their daily shell.

Alternative 2: Run the actual shell

For wt config show, we could do something like:

zsh -ic 'type wt' 2>/dev/null

If the shell function wt exists (created by the eval "$(wt config shell init zsh)"), integration is configured — regardless of how it got sourced. This handles every sourcing pattern (including $ZDOTDIR, loops, conditionals, autoload, etc.) with zero parsing. The cost is spawning a shell subprocess, but config show is not performance-sensitive.

Downsides: adds ~100-200ms latency per shell, won't work if the user's .zshrc has interactive-only guards that skip init in non-interactive mode (though -i handles most of those), and can't report which file contains the init line.

Assessment

Alternative 1 is the lowest-risk option — it's a few lines of code, no new parsing, and fixes the user-facing symptom. Alternative 2 is the most accurate but has edge cases with shell startup. The current PR's approach (mini shell parser) sits in between: more accurate than Alt 1 for the config show case, but carries ongoing maintenance cost for a few hundred lines of source-following logic, as you noted.

If the main goal is "don't show a misleading warning to users whose shell integration works," Alt 1 seems like the clear winner. If showing the exact file path in config show is important, Alt 2 gets there without maintaining a parser.

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.

4 participants