Last active
June 17, 2026 18:48
-
-
Save dereckmezquita/1e02490d47816046cb4bf5cf7fcd7fe6 to your computer and use it in GitHub Desktop.
Used for merging a bunch of code from a project together into one file with context; useful for feeding LLMs.
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
| function merge_files() { | |
| # Default values | |
| local output_file="" # empty => write to stdout (default) | |
| local to_clipboard=false # -c => copy to clipboard instead of stdout | |
| local extensions=() | |
| local extra_excluded_dirs=() | |
| local extra_excluded_files=() | |
| # Robust default ignore list. These are matched by NAME at any depth | |
| # (recursively), so e.g. a "test" or "node_modules" folder is dropped no | |
| # matter how deep it sits in a monorepo. | |
| local default_excluded_dirs=( | |
| # version control | |
| .git .svn .hg | |
| # JavaScript / Node / Bun / Yarn / pnpm + bundler output | |
| node_modules bower_components .yarn .pnp .npm | |
| .next .nuxt .svelte-kit .turbo .parcel-cache .cache | |
| dist build out coverage | |
| # Python / Poetry / virtualenvs | |
| .venv venv env __pycache__ .pytest_cache .mypy_cache .ruff_cache | |
| .tox .eggs "*.egg-info" .ipynb_checkpoints site-packages | |
| # R / renv / packrat | |
| renv packrat .Rproj.user | |
| # Swift / Xcode | |
| .build .swiftpm DerivedData "*.xcodeproj" "*.xcworkspace" Pods Carthage | |
| # other language build/output dirs | |
| target vendor .gradle .terraform | |
| # editors / OS | |
| .idea .vscode | |
| ) | |
| # File basenames (globs allowed) to skip even when their extension matches. | |
| # Mostly lockfiles and minified bundles that add noise, not signal. | |
| local default_excluded_files=( | |
| package-lock.json yarn.lock pnpm-lock.yaml bun.lockb | |
| poetry.lock Pipfile.lock composer.lock Cargo.lock renv.lock | |
| "*.min.js" "*.min.css" | |
| ) | |
| # Help message | |
| local usage="Usage: merge_files -e EXTENSIONS [OPTIONS] | |
| Required: | |
| -e, --extensions EXT Comma-separated list of extensions (e.g., py,js,ts) | |
| Options: | |
| -o, --output FILE Write to FILE instead of stdout (default: stdout) | |
| -c, --clipboard Copy merged output to the clipboard (via pbcopy) | |
| -x, --exclude DIRS Comma-separated dir NAMES to ignore recursively | |
| (added to the built-in defaults) | |
| -X, --exclude-files F Comma-separated file names/globs to ignore | |
| (added to the built-in defaults) | |
| -h, --help Show this help message | |
| By default output goes to stdout (pipe it, or use -o to save a file). | |
| Examples: | |
| merge_files -e py,js # print merged code to stdout | |
| merge_files -e py -o all.txt # save to a file | |
| merge_files -e py,js,ts -c # copy straight to the clipboard | |
| merge_files -e R,r -x test,fixtures # ignore every test/ dir recursively | |
| merge_files -e py,js,ts | pbcopy # straight to clipboard" | |
| # Helper: split a comma-separated value into an array, appending to $1. | |
| _mf_split_into() { | |
| local __name="$1" __val="$2" | |
| if [[ -n "$ZSH_VERSION" ]]; then | |
| eval "${__name}+=(\"\${(@s:,:)__val}\")" | |
| else | |
| local __oldIFS=$IFS part | |
| IFS=',' | |
| for part in $__val; do | |
| eval "${__name}+=(\"\$part\")" | |
| done | |
| IFS=$__oldIFS | |
| fi | |
| } | |
| # Check if no arguments provided | |
| if [[ $# -eq 0 ]]; then | |
| echo "Error: No arguments provided" >&2 | |
| echo "$usage" >&2 | |
| return 1 | |
| fi | |
| # Parse arguments | |
| local extensions_provided=false | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -o|--output) | |
| output_file="$2" | |
| shift 2 | |
| ;; | |
| -c|--clipboard) | |
| to_clipboard=true | |
| shift | |
| ;; | |
| -e|--extensions) | |
| _mf_split_into extensions "$2" | |
| extensions_provided=true | |
| shift 2 | |
| ;; | |
| -x|--exclude) | |
| _mf_split_into extra_excluded_dirs "$2" | |
| shift 2 | |
| ;; | |
| -X|--exclude-files) | |
| _mf_split_into extra_excluded_files "$2" | |
| shift 2 | |
| ;; | |
| -h|--help) | |
| echo "$usage" | |
| return 0 | |
| ;; | |
| *) | |
| echo "Error: Unknown option: $1" >&2 | |
| echo "$usage" >&2 | |
| return 1 | |
| ;; | |
| esac | |
| done | |
| # Check if extensions were provided | |
| if ! $extensions_provided || [[ ${#extensions[@]} -eq 0 ]]; then | |
| echo "Error: No extensions provided. Use -e or --extensions option." >&2 | |
| echo "$usage" >&2 | |
| return 1 | |
| fi | |
| # -o and -c are mutually exclusive destinations. | |
| if $to_clipboard && [[ -n "$output_file" ]]; then | |
| echo "Error: -c/--clipboard cannot be combined with -o/--output." >&2 | |
| return 1 | |
| fi | |
| if $to_clipboard && ! command -v pbcopy >/dev/null 2>&1; then | |
| echo "Error: -c/--clipboard requires pbcopy, which was not found." >&2 | |
| return 1 | |
| fi | |
| # Merge user-supplied excludes onto the defaults (additive, not replacing). | |
| local excluded_dirs=("${default_excluded_dirs[@]}" "${extra_excluded_dirs[@]}") | |
| local excluded_files=("${default_excluded_files[@]}" "${extra_excluded_files[@]}") | |
| # Comment map | |
| declare -A comment_map=( | |
| [py]="#" | |
| [js]="//" | |
| [ts]="//" | |
| [sh]="#" | |
| [cpp]="//" | |
| [java]="//" | |
| [rb]="#" | |
| [php]="#" | |
| [pl]="#" | |
| [R]="#" | |
| [r]="#" | |
| [swift]="//" | |
| [go]="//" | |
| [rs]="//" | |
| [c]="//" | |
| [h]="//" | |
| [yaml]="#" | |
| [yml]="#" | |
| [ini]="#" | |
| [conf]="#" | |
| [md]="<!--" | |
| [html]="<!--" | |
| [css]="/*" | |
| ) | |
| local DEFAULT_COMMENT="#" | |
| # Build the find expression. | |
| # 1) Prune directories matched by NAME (recursive at any depth). | |
| local prune_expr=("(" "-type" "d" "(") | |
| local first=true dir | |
| for dir in "${excluded_dirs[@]}"; do | |
| $first || prune_expr+=("-o") | |
| prune_expr+=("-name" "$dir") | |
| first=false | |
| done | |
| prune_expr+=(")" "-prune" ")") | |
| # 2) Match files by extension. | |
| local name_expr=("(") | |
| first=true | |
| local ext | |
| for ext in "${extensions[@]}"; do | |
| $first || name_expr+=("-o") | |
| name_expr+=("-name" "*.$ext") | |
| first=false | |
| done | |
| name_expr+=(")") | |
| # 3) Exclude noisy files by basename/glob, plus the output file itself so | |
| # it never gets merged into itself. | |
| local notfile_expr=() | |
| local f | |
| for f in "${excluded_files[@]}"; do | |
| notfile_expr+=("!" "-name" "$f") | |
| done | |
| [[ -n "$output_file" ]] && notfile_expr+=("!" "-name" "${output_file##*/}") | |
| # Emit merged content to stdout; caller redirects to a file if requested. | |
| _mf_emit() { | |
| find . "${prune_expr[@]}" -o \( -type f "${name_expr[@]}" "${notfile_expr[@]}" -print \) \ | |
| | sort | while IFS= read -r file; do | |
| local extension="${file##*.}" | |
| local comment_prefix="${comment_map[$extension]}" | |
| [[ -z "$comment_prefix" ]] && comment_prefix="$DEFAULT_COMMENT" | |
| printf '%s %s\n\n' "$comment_prefix" "$file" | |
| cat "$file" | |
| printf '\n' | |
| done | |
| } | |
| if [[ -n "$output_file" ]]; then | |
| _mf_emit > "$output_file" | |
| if [[ ! -s "$output_file" ]]; then | |
| rm -f "$output_file" | |
| echo "Warning: No files found matching extensions: ${extensions[*]}" >&2 | |
| return 0 | |
| fi | |
| echo "Merged [${extensions[*]}] into $output_file" >&2 | |
| elif $to_clipboard; then | |
| local merged | |
| merged="$(_mf_emit)" | |
| if [[ -z "$merged" ]]; then | |
| echo "Warning: No files found matching extensions: ${extensions[*]}" >&2 | |
| return 0 | |
| fi | |
| printf '%s' "$merged" | pbcopy | |
| echo "Merged [${extensions[*]}] into the clipboard" >&2 | |
| else | |
| _mf_emit | |
| fi | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment