paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

July 15, 2025

Writing Idempotent Bulk-Rename Scripts in Bash: A Deep Dive into Safe Directory Refactoring

How a 40-line prefix-stripping script teaches the core disciplines of safe filesystem automation

Introduction

Every codebase eventually accumulates a naming convention that outlives its usefulness. Someone decides that every project folder should carry a prj-- prefix so it sorts cleanly in a file browser, or so a build tool can glob it unambiguously. Two years later, the tooling that depended on the prefix is gone, the prefix is now just noise, and forty directories need to be renamed without breaking git history, without colliding with existing folders, and without anyone needing to babysit the process by hand.

This is the exact problem solved by the script under discussion: a small, self-contained bash utility that walks a projects/ directory, strips a configurable prefix from matching subdirectories, and leaves everything else untouched. It is unglamorous work, but the way the script handles it - dry-run by default, explicit opt-in for destructive action, careful collision detection - is a compact lesson in how to write filesystem automation that a team can trust. This article uses the script as a case study to explore the broader discipline of safe bulk-rename tooling in bash, including where the pattern holds up and where it starts to strain.

Context: Why Bulk Renames Are Riskier Than They Look

Renaming a single file is a low-stakes operation that most engineers do without a second thought. Renaming dozens of directories in a single pass is a different category of risk entirely, because the operation is no longer atomic from the perspective of the person running it. If the script dies halfway through - because of a permissions error, a full disk, or a stray Ctrl-C - the repository is left in a partially-renamed state that is often harder to reason about than either the fully-prefixed or fully-stripped version would have been.

There is also the question of what "renaming a directory" actually means in a version-controlled repository. A plain mv changes the path on disk but, from git's perspective, looks like a deletion of every tracked file under the old path followed by the addition of the same files under the new path. Git's rename detection is a heuristic based on content similarity, not an explicit record, so a plain mv followed by git add -A usually still produces a clean rename in git status, but only if content didn't change enough to fall below git's similarity threshold. Using git mv sidesteps this ambiguity by staging the operation explicitly, which is precisely why the script tries git mv first and only falls back to a bare mv when the directory is outside a git working tree or the command fails for some other reason.

Finally, bulk renames are risky because they are frequently run against directories the operator has not personally audited in detail. Nobody reads the full listing of forty project folders before running a rename script; they trust the tool to make the right decision for each one. That trust is only warranted if the tool defaults to showing its work before doing anything irreversible, which is the central design decision this script gets right and the one worth examining most closely.

Deep Technical Explanation: Anatomy of the Script

The script opens with set -euo pipefail, sometimes called bash's "unofficial strict mode." Each flag addresses a distinct failure class. -e causes the script to exit immediately if any command returns a non-zero status, which prevents the classic failure mode of a script silently continuing after a command fails partway through a multi-step operation. -u treats references to unset variables as errors rather than silently expanding them to empty strings, which catches typos in variable names before they cause a rename to target the wrong path. -o pipefail ensures that a pipeline's exit status reflects the first failing command in the chain rather than only the last one, which matters the moment this script grows a | grep or | sort somewhere in its logic. None of these flags make bash behave like a fully safe language - there are well-documented edge cases where -e doesn't fire, such as inside conditionals or command substitutions used in certain contexts - but together they close off a large fraction of the silent-failure surface that makes ad hoc shell scripts dangerous in production use.

The path resolution line, ROOT="$(cd "$(dirname "$0")" && pwd)/projects", is doing more work than it looks like at first glance. dirname "$0" extracts the directory containing the script itself, and the cd ... && pwd pattern converts that into an absolute, canonicalized path regardless of whether the script was invoked with a relative path, an absolute path, or via a symlink from a different working directory. This is a standard idiom for making a script location-independent: the operator can cd anywhere and run ./strip-prj-prefix.sh, or invoke it via an absolute path from a cron job, and ROOT will always resolve to the projects/ directory sitting next to the script rather than next to the operator's current working directory.

The glob loop itself, for dir in "$ROOT"/"$PREFIX"*/;, relies on shopt -s nullglob being set beforehand. This is a small but important detail: without nullglob, a glob pattern that matches nothing expands to the literal, unexpanded pattern string, which would cause the loop to execute once with a nonsensical directory name and likely throw a "no such file or directory" error deep inside the loop body instead of simply iterating zero times. With nullglob enabled, a directory with no matching prefixed subfolders correctly results in zero loop iterations, which the script then reports cleanly via the moved == 0 && skipped == 0 branch at the end.

The rename decision itself, new_name="${base#"$PREFIX"}", uses bash's parameter expansion for prefix removal rather than shelling out to sed or cut. The # operator strips the shortest match of the pattern from the front of the string, and quoting "$PREFIX" inside the expansion prevents the prefix from being interpreted as a glob pattern if it happens to contain characters like * or ?. This is meaningfully faster and more robust than spawning an external process per directory, and it is the kind of detail that separates a script written by someone fluent in bash from one that works by trial and error.

Implementation Walkthrough: Extending the Pattern

The dry-run-by-default behavior is worth implementing explicitly if you are porting this pattern to a different language, because it is easy to accidentally lose the safety property in translation. Below is a Python port that preserves the same guarantees: no filesystem mutation unless the operator passes an explicit flag, and a clear separation between planning the renames and executing them.

import argparse
import shutil
import subprocess
from pathlib import Path

def plan_renames(root: Path, prefix: str) -> list[tuple[Path, Path]]:
    """Return (source, destination) pairs for directories to rename.
    Performs no filesystem mutation."""
    planned = []
    for entry in sorted(root.iterdir()):
        if not entry.is_dir() or not entry.name.startswith(prefix):
            continue
        new_name = entry.name[len(prefix):]
        if not new_name:
            print(f"skip (empty name): {entry}")
            continue
        dest = root / new_name
        if dest.exists():
            print(f"skip (target exists): {dest}")
            continue
        planned.append((entry, dest))
    return planned

def apply_renames(planned: list[tuple[Path, Path]]) -> None:
    for src, dest in planned:
        try:
            subprocess.run(
                ["git", "mv", str(src), str(dest)],
                check=True, capture_output=True,
            )
        except (subprocess.CalledProcessError, FileNotFoundError):
            shutil.move(str(src), str(dest))

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--apply", action="store_true")
    parser.add_argument("--prefix", default="prj--")
    args = parser.parse_args()

    root = Path(__file__).resolve().parent / "projects"
    planned = plan_renames(root, args.prefix)

    for src, dest in planned:
        print(f"{src.name}  ->  {dest.name}")

    if args.apply:
        apply_renames(planned)
        print(f"\nRenamed {len(planned)}.")
    else:
        print(f"\nDry-run: {len(planned)} would be renamed. Re-run with --apply to rename.")

if __name__ == "__main__":
    main()

The structural mirroring is deliberate: plan_renames is a pure function that only reads the filesystem and returns a data structure describing intended changes, while apply_renames is the only function permitted to mutate state. This separation is the same idea behind the bash script's moved/skipped counters and its single if [[ "$APPLY" -eq 1 ]] gate - the planning logic and the execution logic are kept apart so that the planning path can be exercised, tested, and trusted independently of whether anything is actually renamed. In a CI context, you could run the planning function on every pull request that touches projects/ and fail the build if it reports any skip (target exists) conflicts, catching naming collisions before they reach a human running the script manually.

A TypeScript equivalent, useful if the rename logic needs to live alongside a Node-based build toolchain, follows the same shape using fs.promises and node:path:

import { readdir, rename } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";

interface PlannedRename {
  from: string;
  to: string;
}

async function planRenames(root: string, prefix: string): Promise<PlannedRename[]> {
  const entries = await readdir(root, { withFileTypes: true });
  const planned: PlannedRename[] = [];

  for (const entry of entries) {
    if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
    const newName = entry.name.slice(prefix.length);
    if (!newName) {
      console.warn(`skip (empty name): ${entry.name}`);
      continue;
    }
    const dest = path.join(root, newName);
    if (existsSync(dest)) {
      console.warn(`skip (target exists): ${dest}`);
      continue;
    }
    planned.push({ from: path.join(root, entry.name), to: dest });
  }
  return planned;
}

async function applyRenames(planned: PlannedRename[]): Promise<void> {
  for (const { from, to } of planned) {
    await rename(from, to); // git mv handled separately via child_process if needed
  }
}

Trade-offs and Pitfalls

The most consequential trade-off in the original script is its use of a directory-level glob rather than a recursive search. "$ROOT"/"$PREFIX"*/ only matches immediate children of projects/, which is almost certainly the intended scope, but it is worth being explicit about that boundary because a reader skimming the script might assume it recurses. If your repository ever grows nested project directories - projects/prj--platform/prj--billing/ - this script will happily rename the outer folder and leave the inner one alone, which could produce a structure that no longer matches whatever convention the outer rename was meant to establish. This is not a bug so much as an implicit scope decision that should be documented in the script's usage comment rather than left to be discovered.

A second, more subtle pitfall is the collision check's timing. The script checks [[ -e "$dest" ]] at the moment it processes each directory, but between the dry-run and the --apply run - which are two separate invocations - the state of projects/ could change. Someone could create a new folder with the destination name in the interim, or another process could be modifying the same tree concurrently. This is a classic time-of-check-to-time-of-use (TOCTOU) race condition. It is a low-probability issue for a script an engineer runs manually against their own repository, but it becomes a real concern the moment this pattern is wired into an automated pipeline where multiple jobs might touch the same filesystem tree, or where the dry-run output is reviewed by a human hours before the apply step actually runs.

The fallback from git mv to plain mv also deserves scrutiny. git mv fails, among other reasons, when the target repository has uncommitted changes to the source path, when the path isn't tracked by git at all, or when the destination already exists - the last of which the script has already ruled out. Because the script suppresses git mv's stderr with 2>/dev/null and silently falls through to mv, an operator running this inside a git repository with a dirty working tree will get a successful rename but lose the benefit of git's explicit rename tracking, without any indication that the fallback path was taken. Surfacing which path was used - even just a one-word annotation in the output - would make the tool's behavior easier to audit after the fact.

Finally, the script has no undo mechanism beyond the safety net of running it in reverse with a hypothetical --prefix swap, which doesn't actually exist in the current implementation since PREFIX is hardcoded. If fifty directories get renamed and someone later decides the prefix should have been kept after all, there is no recorded mapping to reverse the operation cleanly outside of git history (git log --follow and git mv in the other direction) or shell history. This is a reasonable trade-off for a script this size, but it is the kind of gap that tends to get discovered at the worst possible time - during an incident, not during a planning meeting.

Best Practices for Bulk Filesystem Automation

The single most transferable lesson from this script is that destructive operations should require an explicit, hard-to-typo flag, and the default invocation should never mutate state. --apply is a good choice here specifically because it reads as an affirmative statement rather than a toggle - compare it to a hypothetical --dry-run=false, which is far easier to get backwards under time pressure. This pattern generalizes well beyond renaming: database migration tools, infrastructure-as-code applies, and bulk data-cleanup scripts all benefit from the same "plan first, confirm explicitly, then execute" shape, and teams that standardize on a consistent flag name (--apply, --execute, --yes-i-am-sure) across their internal tooling reduce the cognitive load of remembering which script defaults to which behavior.

Equally important is treating the planning phase as something that produces reviewable output, not just a side effect on the way to execution. The script's echo "$base -> $new_name" line, printed unconditionally regardless of the APPLY flag, means the exact same code path generates both the dry-run report and the apply-time log - there is no separate "preview" logic to fall out of sync with the "real" logic. Where possible, structure automation this way: one function computes the plan, and both the review path and the execution path consume that same plan, rather than maintaining two parallel implementations that can drift apart over time.

Key Takeaways

For engineers writing or reviewing similar filesystem automation, a few concrete habits carry most of the value:

Analogies and Mental Models

A useful way to think about this class of script is as a compiler with two phases: analysis and code generation. A compiler's front end parses source code and builds an intermediate representation without touching the filesystem beyond reading input files; only the back end, invoked separately or gated by a flag, actually emits binaries or writes output. The rename script follows the same shape - the loop body that decides what should happen (new_name, collision checks, skip conditions) is the analysis phase, and the single if [[ "$APPLY" -eq 1 ]] block is the code-generation phase. Keeping these phases textually and logically separate, even in a forty-line bash script, is what makes the tool trustworthy enough to run against a real repository without a rehearsal.

Another helpful frame is the surgical "time-out" checklist used in operating rooms: before an incision is made, the team verifies the patient, the procedure, and the site out loud, even though everyone in the room is already confident they have the right information. The dry-run output serves the same function. It is not there because the script's logic is likely to be wrong - it is there because the cost of an unreviewed mistake (a botched rename across dozens of directories) is disproportionate to the cost of a thirty-second read-through, and good automation is designed around that asymmetry rather than around the average case.

Conclusion

The strip-prj-prefix script is a small piece of tooling, but the discipline it encodes - dry-run by default, explicit collision detection, git-aware renaming with a documented fallback, and strict-mode error handling - scales far beyond stripping a naming prefix. The same shape applies to any script that mutates a filesystem or a database on behalf of a team that isn't reading every line of its source before running it. What makes bulk automation safe is rarely clever logic; it is almost always the boring, explicit guardrails: show the plan, require confirmation, check before you act, and fail loudly rather than silently.

If you're maintaining scripts like this one, the return on investment for adding a few lines of defensive logging - which path was taken, how many operations were skipped and why, whether the operation ran inside a git repository at all - tends to be far higher than the return on adding new features. The next engineer who runs this script six months from now, possibly at 6 p.m. on a Friday, will be grateful for output that tells them exactly what happened and why, without having to read the source to find out.

References