Skip to content

Instantly share code, notes, and snippets.

@sbolel
Last active June 18, 2026 21:52
Show Gist options
  • Select an option

  • Save sbolel/0f9104a5359483bf2ef13d6ef9ac2e37 to your computer and use it in GitHub Desktop.

Select an option

Save sbolel/0f9104a5359483bf2ef13d6ef9ac2e37 to your computer and use it in GitHub Desktop.
Flatten directory trees with zsh: copy nested files into one folder using collision-resistant, path-encoded filenames

flatten-dir

Flatten a nested directory tree into one folder without losing path context.

Shell Platform Safety Encoding

src/components/Button.tsx.txt
⬇️
flat/components%2FButton.tsx.txt


Versions

What it does

flatten-dir copies every file from a nested source directory into a single destination directory, while encoding each file’s original relative path into the new filename.

That gives you a flat folder that still preserves where each file came from.

Before:

src-txt/
├── components/
│   └── Button.tsx.txt
├── pages/
│   └── index.tsx.txt
└── package.json.txt

After:

src-flat/
├── components%2FButton.tsx.txt
├── pages%2Findex.tsx.txt
└── package.json.txt

Why this exists

This is useful when you need to feed many files into tools that prefer or require a flat file list, while still keeping file origin obvious.

Great for:

  • Preparing source files for LLM/code-review workflows
  • Flattening exported .txt versions of a codebase
  • Creating searchable single-directory snapshots
  • Archiving nested files without losing path context
  • Avoiding vague filenames like index.txt, index-1.txt, index-final.txt

Features

Feature Why it matters
POSIX sh compatible Works without Bash or zsh-specific syntax
Path-preserving filenames Original directory context is encoded into each filename
Collision detection Refuses to silently overwrite files
Safe destination handling Rejects destinations inside the source tree
Percent encoding Avoids lossy _ replacement schemes
Metadata preservation Uses cp -p to preserve timestamps and modes where supported

Quick start

Save the script as:

flatten_dir_posix.sh

Make it executable:

chmod +x flatten_dir_posix.sh

Run it:

./flatten_dir_posix.sh ./src-txt ./src-flat

Example

Given this source tree:

src-txt/
├── components/
│   └── Button.tsx.txt
├── components/
│   └── Modal.tsx.txt
├── lib/
│   └── api/client.ts.txt
└── README.md.txt

Run:

./flatten_dir_posix.sh ./src-txt ./src-flat

Output:

src-flat/
├── components%2FButton.tsx.txt
├── components%2FModal.tsx.txt
├── lib%2Fapi%2Fclient.ts.txt
└── README.md.txt

Encoding rules

The script percent-encodes path separators and awkward filename characters.

Original Encoded
% %25
/ %2F
: %3A
space %20
tab %09
carriage return %0D
newline %0A

This is safer than replacing everything with underscores because underscore-based flattening can create collisions.

These could collide with naive underscore replacement:

a:b.txt
a b.txt
a_b.txt

Percent encoding keeps them distinct.


Safety behavior

The script refuses to run when:

  • The source directory does not exist
  • The destination already exists
  • The destination is inside the source directory
  • A flattened filename would overwrite an existing file
  • A copy operation fails

This is intentional. A flattening tool should be boring, predictable, and hard to misuse.


Visual flow

flowchart TD
    A[Source directory tree] --> B[Find files recursively]
    B --> C[Compute relative path]
    C --> D[Percent-encode path]
    D --> E[Copy into destination folder]
    E --> F[Flat directory with path-safe filenames]
Loading

Requirements

Uses standard Unix tooling:

  • sh
  • find
  • awk
  • cp
  • mkdir
  • dirname
  • basename
  • pwd
  • wc
  • tr
  • mktemp

Works best on macOS and Linux.


Script

See flatten_dir_posix.sh.


Naming tip

If your gist includes both the zsh and POSIX versions, suggested file names:

flatten_dir.zsh
flatten_dir_posix.sh
README.md

Suggested gist description:

Flatten directory trees safely: POSIX sh and zsh scripts that copy nested files into one folder using collision-resistant, path-encoded filenames

License

Use freely, modify freely, and ship it wherever it helps.

#!/bin/zsh
# Flatten a directory by encoding each file's relative path into its filename.
#
# Usage:
# flatten_dir <source_dir> <dest_dir>
#
# Example:
# flatten_dir ./src ./src-flat
#
# Converts:
# ./src/components/Button.tsx
# to:
# ./src-flat/components%2FButton.tsx.txt
#
# Notes:
# - zsh-native implementation.
# - Destination must not already exist.
# - Destination must not be inside the source directory.
# - Encodes path separators so nested files can live in one flat folder.
# - Appends .txt to every output file.
# - Refuses to overwrite files if a flattened name collision occurs.
flatten_dir() {
emulate -L zsh
local src_dir="${1:-}"
local dest_dir="${2:-}"
if [[ -z "$src_dir" || -z "$dest_dir" ]]; then
echo "Usage: flatten_dir <source_dir> <dest_dir>" >&2
return 1
fi
if [[ ! -d "$src_dir" ]]; then
echo "Error: source directory not found: $src_dir" >&2
return 1
fi
if [[ -e "$dest_dir" ]]; then
echo "Error: destination already exists: $dest_dir" >&2
return 1
fi
# Normalize paths to avoid trailing-slash bugs and make containment checks reliable.
local src_abs="${src_dir:A}"
local dest_abs="${dest_dir:a}"
if [[ "$dest_abs" == "$src_abs" || "$dest_abs" == "$src_abs"/* ]]; then
echo "Error: destination must not be inside the source directory: $dest_dir" >&2
return 1
fi
if ! mkdir -p "$dest_abs"; then
echo "Error: failed to create destination directory: $dest_abs" >&2
return 1
fi
echo "Flattening $src_abs to $dest_abs..."
# zsh glob qualifiers:
# N = null glob; do not error if no files match
# D = include dotfiles
# . = regular files only
local -a files
files=("$src_abs"/**/*(ND.))
local file rel_path flat_name dest_file
local copied=0
local failed=0
for file in "${files[@]}"; do
if [[ "$src_abs" == "/" ]]; then
rel_path="${file#/}"
else
rel_path="${file#$src_abs/}"
fi
# Encode characters that are ambiguous or awkward in flattened names.
#
# Important:
# - In zsh, bare % is special in substitution patterns.
# - Use [%] to match a literal percent sign.
# - Append .txt after encoding so every output file is text-friendly.
flat_name="$rel_path"
flat_name="${flat_name//[%]/%25}"
flat_name="${flat_name//\//%2F}"
flat_name="${flat_name//:/%3A}"
flat_name="${flat_name// /%20}"
flat_name="${flat_name//$'\t'/%09}"
flat_name="${flat_name//$'\n'/%0A}"
flat_name="${flat_name}.txt"
dest_file="$dest_abs/$flat_name"
if [[ -e "$dest_file" ]]; then
echo "Error: collision detected, refusing to overwrite: $dest_file" >&2
(( failed++ ))
continue
fi
if cp -p "$file" "$dest_file"; then
(( copied++ ))
else
echo "Error: failed to copy: $file" >&2
(( failed++ ))
fi
done
if (( failed > 0 )); then
echo "Completed with failures: $copied copied, $failed failed" >&2
return 1
fi
echo "Done! $copied files copied to $dest_abs"
}
#!/bin/sh
# Flatten a directory by encoding each file's relative path into its filename.
#
# Usage:
# flatten_dir.sh <source_dir> <dest_dir>
#
# Example:
# ./flatten_dir.sh ./src-txt ./src-flat
#
# Converts:
# ./src-txt/components/Button.tsx.txt
# to:
# ./src-flat/components%2FButton.tsx.txt
#
# Notes:
# - Requires POSIX sh, find, awk, cp, mkdir, dirname, basename, pwd, wc, tr.
# - Destination must not already exist.
# - Destination must not be inside the source directory.
# - Uses percent-encoding to avoid lossy filename flattening.
usage() {
printf '%s\n' "Usage: $0 <source_dir> <dest_dir>" >&2
}
die() {
printf '%s\n' "Error: $*" >&2
exit 1
}
make_abs_existing_dir() {
# Resolve an existing directory to a physical absolute path.
# Prints the absolute path on success.
# Returns non-zero on failure.
cd "$1" 2>/dev/null && pwd -P
}
make_abs_new_path() {
# Resolve a not-yet-existing path to an absolute path by resolving its parent.
# The parent directory must already exist.
path=$1
parent=$(dirname "$path") || return 1
base=$(basename "$path") || return 1
parent_abs=$(cd "$parent" 2>/dev/null && pwd -P) || return 1
printf '%s/%s\n' "$parent_abs" "$base"
}
count_files() {
# Count files without relying on newline-separated file names.
find "$1" -type f -exec printf x \; | wc -c | tr -d ' '
}
encode_flat_name() {
# Percent-encode path separators and awkward filename characters.
#
# Important:
# - Encode % first so the transformation stays unambiguous.
# - Encode / so nested paths become flat filenames.
awk -v s="$1" '
BEGIN {
tab = sprintf("%c", 9)
cr = sprintf("%c", 13)
gsub(/%/, "%25", s)
gsub(/\//, "%2F", s)
gsub(/:/, "%3A", s)
gsub(/ /, "%20", s)
gsub(tab, "%09", s)
gsub(cr, "%0D", s)
gsub(/\n/, "%0A", s)
printf "%s", s
}
'
}
flatten_dir() {
src_dir=$1
dest_dir=$2
[ -n "$src_dir" ] || {
usage
return 1
}
[ -n "$dest_dir" ] || {
usage
return 1
}
[ -d "$src_dir" ] || {
printf '%s\n' "Error: source directory not found: $src_dir" >&2
return 1
}
[ ! -e "$dest_dir" ] || {
printf '%s\n' "Error: destination already exists: $dest_dir" >&2
return 1
}
src_abs=$(make_abs_existing_dir "$src_dir") || {
printf '%s\n' "Error: failed to resolve source directory: $src_dir" >&2
return 1
}
dest_abs=$(make_abs_new_path "$dest_dir") || {
printf '%s\n' "Error: failed to resolve destination path. Ensure the destination parent directory exists: $dest_dir" >&2
return 1
}
case "$dest_abs" in
"$src_abs"|"$src_abs"/*)
printf '%s\n' "Error: destination must not be inside the source directory: $dest_dir" >&2
return 1
;;
esac
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/flatten_dir.XXXXXX") || {
printf '%s\n' "Error: failed to create temporary directory" >&2
return 1
}
status_file=$tmp_dir/failures
# POSIX-compatible cleanup.
trap 'rm -rf "$tmp_dir"' 0 1 2 15
: > "$status_file" || {
printf '%s\n' "Error: failed to create status file" >&2
return 1
}
mkdir -p "$dest_abs" || {
printf '%s\n' "Error: failed to create destination directory: $dest_abs" >&2
return 1
}
printf '%s\n' "Flattening $src_abs to $dest_abs..."
(
cd "$src_abs" || exit 1
find . -type f -exec sh -c '
dest_abs=$1
status_file=$2
shift 2
for rel_file do
rel_path=${rel_file#./}
flat_name=$(
awk -v s="$rel_path" '"'"'
BEGIN {
tab = sprintf("%c", 9)
cr = sprintf("%c", 13)
gsub(/%/, "%25", s)
gsub(/\//, "%2F", s)
gsub(/:/, "%3A", s)
gsub(/ /, "%20", s)
gsub(tab, "%09", s)
gsub(cr, "%0D", s)
gsub(/\n/, "%0A", s)
printf "%s", s
}
'"'"'
)
src_file=./$rel_path
dest_file=$dest_abs/$flat_name
if [ -e "$dest_file" ]; then
printf "%s\n" "Error: collision detected, refusing to overwrite: $dest_file" >&2
printf "x\n" >> "$status_file"
continue
fi
if ! cp -p "$src_file" "$dest_file"; then
printf "%s\n" "Error: failed to copy: $src_file" >&2
printf "x\n" >> "$status_file"
fi
done
' sh "$dest_abs" "$status_file" {} +
)
find_status=$?
failed_count=$(wc -l < "$status_file" | tr -d ' ')
copied_count=$(count_files "$dest_abs")
if [ "$find_status" -ne 0 ]; then
printf '%s\n' "Error: failed while scanning source directory" >&2
return 1
fi
if [ "$failed_count" -gt 0 ]; then
printf '%s\n' "Completed with failures: $copied_count copied, $failed_count failed" >&2
return 1
fi
printf '%s\n' "Done! $copied_count files copied to $dest_abs"
return 0
}
if [ "$#" -ne 2 ]; then
usage
exit 1
fi
flatten_dir "$1" "$2"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment