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:
- Save a snapshot of a file’s current state.
- List the available snapshots.
- Diff between two snapshots (or a snapshot and the current file).
- 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 pipefailmakes 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 -pcreates 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%Sgenerates a sortable, unique timestamp so snapshots never collide and naturally order chronologically when listed.cpperforms 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.confinto 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.mdevery hour viacronto 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
savefirst, guaranteeing a rollback point. - Lightweight audit trail: Combine with
diffto 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 pipefailin 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
.fvcnoise (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.fvccan 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
restoreoperations 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
gzipto 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
.fvcdirectory hasn’t been created yet for that file — runsaveat 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 -eunless checked explicitly. - Permission denied on restore: Check that the script has write access to the target file’s directory, not just the
.fvcfolder.
Common Mistakes
- Forgetting to quote
$FILE, which breaks on filenames with spaces. - Not handling the case where no snapshots exist yet before running
difforrestore. - Relying on
mtimeinstead of content hashes to detect “no changes,” which produces false positives after atouch.
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.
