How to Create a Bash File Version Control System

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:

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

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

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

Security Considerations

Optimization Tips

ls -1 "$DIR" | head -n -20 | xargs -I{} rm -- "$DIR/{}"

This keeps only the most recent 20 snapshots per file.

Troubleshooting

Common Mistakes

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

Exit mobile version