Skip to content
Fran Gonzalez
← Back to blog
(updated Aug 11, 2026)·Clanker·4 min read

How I split a shared Bash and Zsh configuration without changing startup behavior

I reorganized a shared shell configuration into XDG-aligned domain modules while preserving startup order, behavior, and warm-shell performance.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

A shell configuration can become difficult to change long before it becomes slow or broken.

The Problem

I shared one configuration file between Bash and Zsh. Over time it accumulated environment setup, aliases, Git helpers, secret wrappers, fuzzy finders, and functions for tools that had nothing else in common. Zsh-specific widgets and prompt hooks also made .zshrc harder to navigate.

The shared file had grown to more than 900 lines. Moving every function into one functions.sh would only relocate the same problem. Splitting it into arbitrary chunks would make startup order harder to understand.

I wanted smaller files without changing what a new shell did.

The Approach

I kept .bashrc, .zshrc, and .commonrc as entrypoints. They still establish shell options, environment variables, and initialization order. The larger function bodies now live under the default XDG configuration location:

~/.config/shell/
├── common.d/   # sourced by Bash and Zsh
├── zsh.d/      # Zsh widgets, hooks, and prompt functions
└── legacy/     # retained reference code, never sourced automatically

The XDG Base Directory Specification defines where user configuration belongs. It does not standardize shell/common.d or numbered module names. Those are local conventions built on top of the standard base directory.

I grouped modules by responsibility rather than size. A Git module owns Git helpers. Interactive-tool integrations stay together. Zsh widgets remain separate from functions shared with Bash. Standalone commands continue to live in ~/.local/bin instead of being sourced into every shell.

The distinction between a sourced function and an executable matters. A function that changes the caller’s working directory must run inside the current shell. A command that only reads data or prints a preview can be an independent script.

Preserve phases, not just file order

The first version used one loop to source every shared module. That looked tidy, but it subtly changed when conditional functions and Zsh hooks were registered.

I replaced the batch loader with a small helper called at deliberate points in .commonrc:

# ~/.commonrc
_source_common_module() {
  _source_if "$XDG_CONFIG_HOME/shell/common.d/$1"
}

_source_common_module 10-git.sh

# PATH and platform setup happen here.

_source_common_module 40-proton-pass.sh
_source_common_module 50-cli-wrappers.sh

The numeric prefixes document the overall dependency order. The explicit calls preserve initialization phases.

I used the same approach in .zshrc. Clipboard widgets load after vi-mode bindings, prompt hooks load before completion initialization, and terminal integration remains last. That follows Zsh’s startup model rather than treating every sourced file as interchangeable. Bash and Zsh also keep their shell-specific settings in the startup files documented by the Bash manual and Zsh manual.

Retire code without deleting its history

The audit also found a legacy environment-file and Claude secret workflow. The active setup now injects secrets into child processes through Proton Pass, so the old functions no longer needed to load in every shell.

I moved that workflow into legacy/ and stopped sourcing it. Keeping retired code outside the active module directories makes its status explicit. It can still serve as a reference without expanding the normal shell environment.

Verification

A configuration refactor is not behavior-preserving just because both shells parse it. I compared the old and new setups in isolated homes and checked:

  • shared function definitions;
  • aliases and exported environment values;
  • Zsh widget and hook ordering;
  • Bash and Zsh runtime loading;
  • an interactive worktree workflow;
  • partial installation without startup errors.

I also ran Bash and Zsh syntax checks, ShellCheck, and whitespace validation.

The module split did not create a measurable warm-start regression. Five warm starts before the refactor took roughly 0.23–0.25 seconds. The modular setup took roughly 0.23–0.24 seconds. Sourcing the shared modules accounted for about two milliseconds in profiling; completion initialization remained the larger cost.

Results

.commonrc dropped from 937 lines to about 300, and .zshrc dropped from 244 lines to about 120. The total configuration stayed roughly the same size, but it became easier to locate, review, and retire one responsibility at a time.

The layout also fits a future mise dotfiles bootstrap. The current bare Git repository can track .config/shell today, while mise can later place the same tree through its directory mapping. The organization does not depend on adopting mise first.

The tradeoff is more files and an explicit loading map. I prefer that cost to a clever automatic loader because startup order is part of the shell’s behavior. The entrypoints now show that order, while the implementation details live in focused modules.

References

This post was written with AI assistance.