How to Create a Bash File Version Control System

How to Create a Bash File Version Control System

Version control isn’t just for source code sitting in Git repositories. Sometimes the need is smaller and more immediate: tracking changes to a handful of configuration files, keeping snapshots of a document as it evolves, or maintaining a rollback point before a risky edit. For situations like that, a full-blown Git setup can feel like overkill. A lightweight, purpose-built Bash version control system fills that gap nicely.

This guide walks through building one from scratch, starting with the simplest possible snapshot mechanism and ending with a script that handles versioning, diffing, restoring, and cleanup — all using nothing but standard Unix tools and Bash.

Why Build a Custom Version Control Script?

Git is powerful, but it comes with overhead: a .git directory, staging areas, commit objects, and a learning curve. There are plenty of scenarios where that overhead isn’t justified:

  • Tracking changes to a single config file on a server where installing Git isn’t practical.
  • Keeping timestamped backups of a script before each edit, without a full repository.
  • Teaching how version control concepts work under the hood — hashing, diffing, storage.
  • Embedding lightweight versioning inside a larger automation pipeline where a dependency on Git is undesirable.

A Bash-based system can be as simple or as sophisticated as needed, and because it’s just shell and standard utilities (cp, diff, sha256sum, tar), it runs anywhere Bash runs.

Core Concept: What “Version Control” Means Here

At its heart, a minimal version control system needs to do four things:

  1. Save a snapshot of a file’s current state.
  2. List the available snapshots.
  3. Diff between two snapshots (or a snapshot and the current file).
  4. Restore a file to a previous snapshot.

Everything else — compression, hashing, metadata — is built on top of these four operations.

Step 1: A Minimal Snapshot Script

Let’s start with the simplest version: copying a file into a versioned directory with a timestamp.

#!/usr/bin/env bash
set -euo pipefail

FILE="$1"
VCS_DIR=".fvc"

if [[ -z "$FILE" ]]; then
    echo "Usage: $0 <file>"
    exit 1
fi

mkdir -p "$VCS_DIR/$(basename "$FILE")"

TIMESTAMP=$(date +%Y%m%d-%H%M%S)
cp "$FILE" "$VCS_DIR/$(basename "$FILE")/$TIMESTAMP"

echo "Saved snapshot: $VCS_DIR/$(basename "$FILE")/$TIMESTAMP"

Save this as fvc-save.sh and run it:

chmod +x fvc-save.sh
./fvc-save.sh notes.txt

Output:

Saved snapshot: .fvc/notes.txt/20260728-143201

How It Works Internally

  • set -euo pipefail makes the script exit on errors, treat unset variables as errors, and fail if any command in a pipeline fails. This is a defensive habit worth adopting in every serious Bash script.
  • mkdir -p creates a nested directory structure without complaining if it already exists — one folder per tracked file, holding all its snapshots.
  • date +%Y%m%d-%H%M%S generates a sortable, unique timestamp so snapshots never collide and naturally order chronologically when listed.
  • cp performs the actual copy, which is the entire “commit” operation at this stage.

Step 2: Listing Snapshots

Add a companion script to list what’s been saved:

#!/usr/bin/env bash
set -euo pipefail

FILE="$1"
VCS_DIR=".fvc/$(basename "$FILE")"

if [[ ! -d "$VCS_DIR" ]]; then
    echo "No history found for $FILE"
    exit 1
fi

echo "History for $FILE:"
ls -1 "$VCS_DIR" | nl

Example output:

History for notes.txt:
     1  20260728-143201
     2  20260728-151022
     3  20260728-163915

nl numbers each line, which makes it easy to reference a specific version by index in later commands.

Step 3: Diffing Against a Snapshot

Now the useful part — seeing what changed:

#!/usr/bin/env bash
set -euo pipefail

FILE="$1"
VERSION="$2"
VCS_DIR=".fvc/$(basename "$FILE")"

SNAPSHOT="$VCS_DIR/$VERSION"

if [[ ! -f "$SNAPSHOT" ]]; then
    echo "Snapshot $VERSION not found for $FILE"
    exit 1
fi

diff -u "$SNAPSHOT" "$FILE" || true

Running ./fvc-diff.sh notes.txt 20260728-143201 produces a standard unified diff:

--- .fvc/notes.txt/20260728-143201
+++ notes.txt
@@ -1,3 +1,4 @@
 Meeting notes
 - Discuss budget
+- Follow up with vendor

The || true at the end matters: diff returns a non-zero exit code when files differ, which would otherwise trigger the script’s set -e and exit unexpectedly.

Step 4: Restoring a Previous Version

#!/usr/bin/env bash
set -euo pipefail

FILE="$1"
VERSION="$2"
VCS_DIR=".fvc/$(basename "$FILE")"
SNAPSHOT="$VCS_DIR/$VERSION"

if [[ ! -f "$SNAPSHOT" ]]; then
    echo "Snapshot $VERSION not found"
    exit 1
fi

cp "$FILE" "$VCS_DIR/pre-restore-$(date +%Y%m%d-%H%M%S)"
cp "$SNAPSHOT" "$FILE"

echo "Restored $FILE to version $VERSION"

Notice the script saves a backup of the current state before overwriting — a small habit that prevents irreversible mistakes.

Step 5: Adding Content Hashing to Avoid Duplicate Snapshots

A common refinement is skipping the snapshot entirely if nothing changed since the last save:

LAST=$(ls -1 "$VCS_DIR" 2>/dev/null | tail -1)

if [[ -n "$LAST" ]]; then
    OLD_HASH=$(sha256sum "$VCS_DIR/$LAST" | awk '{print $1}')
    NEW_HASH=$(sha256sum "$FILE" | awk '{print $1}')
    if [[ "$OLD_HASH" == "$NEW_HASH" ]]; then
        echo "No changes since last snapshot."
        exit 0
    fi
fi

sha256sum produces a cryptographic hash of the file contents; comparing hashes is far more reliable than comparing timestamps or sizes, since it detects even a single-byte change.

Step 6: Combining Everything Into One Tool

Here’s a consolidated version that supports subcommands, similar to how Git itself is structured:

#!/usr/bin/env bash
set -euo pipefail

VCS_ROOT=".fvc"
CMD="${1:-}"
FILE="${2:-}"
ARG3="${3:-}"

usage() {
    echo "Usage: fvc.sh {save|list|diff|restore} <file> [version]"
    exit 1
}

[[ -z "$CMD" || -z "$FILE" ]] && usage

BASENAME="$(basename "$FILE")"
DIR="$VCS_ROOT/$BASENAME"
mkdir -p "$DIR"

case "$CMD" in
  save)
    LAST=$(ls -1 "$DIR" 2>/dev/null | tail -1)
    if [[ -n "$LAST" ]] && cmp -s "$DIR/$LAST" "$FILE"; then
        echo "No changes since last snapshot ($LAST)."
        exit 0
    fi
    TS=$(date +%Y%m%d-%H%M%S)
    cp "$FILE" "$DIR/$TS"
    echo "Saved snapshot: $TS"
    ;;
  list)
    ls -1 "$DIR" | nl
    ;;
  diff)
    [[ -z "$ARG3" ]] && usage
    diff -u "$DIR/$ARG3" "$FILE" || true
    ;;
  restore)
    [[ -z "$ARG3" ]] && usage
    cp "$FILE" "$DIR/pre-restore-$(date +%Y%m%d-%H%M%S)"
    cp "$DIR/$ARG3" "$FILE"
    echo "Restored $FILE to $ARG3"
    ;;
  *)
    usage
    ;;
esac

This uses cmp -s (silent compare) instead of hashing for a quicker equality check — functionally similar but slightly faster for small files.

Real-World Use Cases

  • Server config safety net: Wrap fvc.sh save /etc/nginx/nginx.conf into a pre-deploy hook so every configuration change is automatically snapshotted before a reload.
  • Cron-based document backups: Schedule fvc.sh save /home/user/journal.md every hour via cron to build an automatic history of a frequently edited file.
  • Pre-edit safety in scripts: Any automation script that modifies a file in place can call save first, guaranteeing a rollback point.
  • Lightweight audit trail: Combine with diff to generate a changelog of exactly what changed and when, useful for compliance-adjacent tasks without needing a full VCS.

Automation Example: Auto-Snapshot on Save via inotify

Pairing the script with inotifywait creates an automatic “save on change” system:

#!/usr/bin/env bash
FILE="$1"
while inotifywait -e close_write "$FILE"; do
    ./fvc.sh save "$FILE"
done

This watches the file and triggers a snapshot every time it’s saved by an editor, similar to how IDEs implement local history.

Best Practices

  • Always quote variables ("$FILE" not $FILE) to avoid word-splitting issues with filenames containing spaces.
  • Use set -euo pipefail in every script to catch errors early rather than silently continuing with bad state.
  • Store snapshots outside version-controlled directories if the project already uses Git, to avoid polluting commits with .fvc noise (add .fvc/ to .gitignore).
  • Prefer hashing over timestamps for change detection — timestamps can be misleading if a file is touched without content changes.
  • Cap snapshot retention to avoid unbounded disk growth (see cleanup below).

Security Considerations

  • Snapshot directories should have restrictive permissions (chmod 700 .fvc) if the tracked files contain sensitive data, since anyone with read access to .fvc can see historical file content, including secrets that may have since been removed.
  • Never snapshot files containing credentials without encrypting the snapshot directory or excluding secret files entirely.
  • Be cautious with restore operations in automated pipelines — a bug that restores the wrong version could silently reintroduce a vulnerability that was patched.

Optimization Tips

  • For large files, compress snapshots with gzip to save disk space: cp "$FILE" - | gzip > "$DIR/$TS.gz".
  • Limit history size by pruning old snapshots:
ls -1 "$DIR" | head -n -20 | xargs -I{} rm -- "$DIR/{}"

This keeps only the most recent 20 snapshots per file.

Troubleshooting

  • “No such file or directory” errors: Usually means the .fvc directory hasn’t been created yet for that file — run save at least once first.
  • Diff shows no output for a changed file: Confirm the correct snapshot version string was used; a typo in the timestamp silently matches nothing under set -e unless checked explicitly.
  • Permission denied on restore: Check that the script has write access to the target file’s directory, not just the .fvc folder.

Common Mistakes

  • Forgetting to quote $FILE, which breaks on filenames with spaces.
  • Not handling the case where no snapshots exist yet before running diff or restore.
  • Relying on mtime instead of content hashes to detect “no changes,” which produces false positives after a touch.

FAQs

Does this replace Git for real projects? No. It’s meant for single-file tracking or small-scale needs, not multi-file, multi-branch collaborative development.

Can this handle binary files? Yes, cp and diff both work on binary files, though diff output for binaries is less useful (it’ll just report “binary files differ” unless run with --text).

How do I track an entire directory instead of one file? Wrap tar around the directory before snapshotting, or loop the save logic over find . -type f.

Summary

Building a Bash file version control system is a great exercise in understanding what real version control systems do under the hood: content hashing, timestamped storage, diffing, and restore logic. The script built here — fvc.sh — is small enough to audit in a few minutes, yet capable enough to serve as genuine protection against accidental data loss in scripts, configs, and personal files.

References

Total
1
Shares

Leave a Reply

Previous Post
How to Create a Bash File Unarchiving Tool

How to Create a Bash File Unarchiving Tool

Next Post
Introduction to C Programming

Introduction to C Programming: History, Features, and Why Learn C

Related Posts