summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarc Vertes <mvertes@free.fr>2025-05-12 15:27:08 +0200
committerMarc Vertes <mvertes@free.fr>2025-05-12 15:27:08 +0200
commit9911921af76733aab593088296aba03d34cd11ea (patch)
tree4b530ffe0e2e6fd8289c21e1f4a8d2e8b32002f5
initial commitHEADmain
-rw-r--r--Readme.md81
-rwxr-xr-xsv211
2 files changed, 292 insertions, 0 deletions
diff --git a/Readme.md b/Readme.md
new file mode 100644
index 0000000..ef81afd
--- /dev/null
+++ b/Readme.md
@@ -0,0 +1,81 @@
+# sv
+
+sv is a source versioning tool.
+
+## Why sv ?
+
+Well git exists for almost 20 years now, and is now the de-facto
+standard. Its design is sound, it works pretty well and is definitely
+better than the previous generation of tools (cvs, subversion,
+clearcase, etc.).
+
+But to me, git is too large and complex in its format, usage, and
+overall code. According to a [security audit on git], git has a
+quarter of millions lines of code, C, Bash and Perl. This is not a
+complaint. Git is extremely useful, and covers succesfully an
+incredibly large scope of uses. But for my own simple usage, I
+feel it is too large and complex, still too opaque.
+
+I would prefer the storage of versions to be transparent: previous
+versions stored directly as trees, not opaque archives. I would
+prefer to see real version names instead of cryptographic sums,
+which are useful but not in the face of users.
+
+Patches are already in the right format: unified diff.
+
+Storing the full history in place (under .git) is also very nice.
+
+I think that it should be possible to create a small and minimalist
+tool in a shell layer just on top of existing standard (POSIX) unix
+commands to manipulate text files to implement all basic requirements
+for a versioning system.
+
+## Wanted features and constraints
+
+- transactional: changes should be atomic, replayable, reversible,
+ work correctly in distributed mode (client-server architecture).
+
+- centralized: having one source of truth. Do not forbid replicas.
+
+- transparent: every thing stored as pure text files, no tools
+ needed to extract, browse, understand files. Use the file system
+ as database. This is the condition to remain usable over decades
+ and decades, even after long unused periods.
+
+- simple and minimal, thus secure. Unix philosophy: compose simple
+ tools which are fast and proven, glued by shell script. Ideally,
+ only one shell script necessary, no other dependencies than core
+ system such as busybox+ssh or openBSD.
+
+- fully self contained: each repo or clone contains the whole
+ history, but also the minimal tooling to operate (at least the
+ sv script itself).
+
+- robust in presence of incomplete or degraded tooling: for example,
+ still operate if no checksuming available (give up warranty on
+ integrity).
+
+- robust in presence of missing versions or patches.
+
+- comply to standards whenever applicable: dates, mbox format, posix
+ tools (sh, diff, patch).
+
+## Low priority features and constraints
+
+- performance: if you think it should be faster or more efficient,
+ use git instead.
+
+- support of binary or large files. Outside scope. Small binary
+ files can be uuencoded.
+
+- support of symbolic links.
+
+- support of file modes and permissions.
+
+## Todo
+
+- branch command: create a named branch from a version
+- checkout command
+
+
+[security audit on git]: https://www.x41-dsec.de/static/reports/X41-OSTIF-Gitlab-Git-Security-Audit-20230117-public.pdf
diff --git a/sv b/sv
new file mode 100755
index 0000000..7acf7fa
--- /dev/null
+++ b/sv
@@ -0,0 +1,211 @@
+#!/bin/sh
+
+# - Usage: sv command [options] [args]
+# - sv is a simple versioning tool.
+# - Options:
+# - -b starts a new branch
+# - -m msg uses msg as save message
+# - -n sets dry-run mode
+# - -v sets verbose mode
+# - Commands:
+
+# anc - prints clone ancestor version.
+anc() { root "$1" && [ -f "$root"/.sv/anc ] && read -r R1 <"$root"/.sv/anc; }
+
+ancs() { root && cd "$root"/.sv/delta && R1=' ' && anc1 "$1"; }
+
+anc1() {
+ local a b
+ for a in *^"$1"; do
+ b="${a%^*}"
+ case $R1 in (*\ $b\ *) continue ;; esac
+ R1="$R1$b " && anc1 "$b"
+ done
+}
+
+cache() {
+ anc && cd "$root/.sv/cache" || return
+ [ -d "$1" ] && return
+ [ -d "$R1" ] && a="$R1" && cp -a "$R1" "$1.$$" || a=0
+ path "$a" "$1" && cd "$root/.sv/cache" && mkdir -p "$1.$$" && cd "$1.$$" &&
+ mpatch ../../delta && cd .. && mv "$1.$$" "$1"
+}
+
+common_anc() {
+ ancs "$1" && R2=" $1$R1" && ancs "$2" && R3=" $2$R1" &&
+ for v in $R2; do
+ case $R3 in (*\ $v\ *) R1="$v" && return ;; esac
+ done && return 1
+}
+
+clean() {
+ anc && cd "$root/.sv/cache" &&
+ for v in *; do
+ [ "$v" != '*' ] && [ "$v" != "$R1" ] && rm -rf "$v"
+ done
+}
+
+die() { echo "$@" >&2; exit 1; }
+
+# diff - prints differences between versions.
+diff() {
+ anc && old="$R1" && cd "$root" &&
+ command diff --color=always -ruNx .sv .sv/cache/"$old" . | ${PAGER:-more}
+}
+
+# dir returns the absolute path of input directory.
+dir() {
+ [ "$1" = . ] && R1="$PWD" && return
+ cd "$1" && R1="$PWD" && cd "$owd"
+}
+
+# dot - prints the directed graph of versions.
+dot() {
+ root && cd "$root"/.sv/delta && echo "digraph sv {
+$(for f in *; do echo " \"${f#*^}\" -> \"${f%^*}\""; done)
+}" | ${DOT:-cat}
+}
+
+changed() {
+ root && cache "$1" && cache "$2" && cd "$root/.sv/cache" &&
+ command diff -Nqr "$1" "$2" | while read -r line; do
+ line="${line#Files $1/}" && printf ' %s' "${line%\ and\ $2/*}"
+ done && echo ' '
+}
+
+# help - prints this help text.
+help() {
+ while read -r line; do
+ case $line in
+ (\#*\ -\ *) ;;
+ (*) continue ;;
+ esac
+ set -- "${line% - *}" "${line#* - }"
+ [ "$1" = '#' ] || printf " %-26s" "${1#\# }"
+ echo "$2"
+ done <"$script"
+}
+
+# init - creates an empty sv repository.
+init() { root || mkdir -p .sv/cache/0 .sv/delta && echo 0 >.sv/anc; }
+
+merge() {
+ [ "$1" ] && b="$1" || die 'missing parameter'
+ anc && a=$R1 && common_anc "$a" "$b" && c="$R1" && R1='' &&
+ a_chg="$(changed "$c" "$a")" && b_chg="$(changed "$c" "$b")" && cd "$root" &&
+ for f in $a_chg; do
+ [ -d .sv/cache/"$b/$f" ] && echo "rm -f $b/$f"
+ done
+ for f in $b_chg; do
+ [ -d .sv/cache/"$a/$f" ] && continue # directory creation > file creation
+ case $a_chg in (*\ $f\ *) # f changed in both sides: potential conflict
+ [ -f .sv/cache/"$b/$f" ] || continue # file change > file suppression
+ [ -f .sv/cache/"$a/$f" ] && echo "diff3 -m $a/$f $c/$f $b/$f" && continue ;;
+ esac
+ echo "diff -Nu $c/$f $b/$f | patch -p 1"
+ done
+}
+
+# mpatch applies multiples patches, backward in R1, forward in R2.
+mpatch() {
+ for p in $R1; do
+ patch -fsTER -p 1 <"$1/$p" || return
+ done &&
+ for p in $R2; do
+ patch -fsTE -p 1 <"$1/$p" || return
+ done && rmd
+}
+
+# msg - edits the commit message.
+msg() { root && ${EDITOR:-vi} "$root/.sv/msg"; }
+
+new_version() {
+ [ "$2" ] && R1="${bopt:+$1.}$2" && return || set -- "$1$bopt"
+ set -- "$1" "${1##*[^0-9]}"
+ set -- "${1%"$2"}" "$2"
+ R1="$1$(($2 + 1))"
+}
+
+# path returns the list of deltas from version A to B. Given C the
+# common ancestor of A and B, deltas must be applied backward from
+# A to C, then forward from C to B.
+path() {
+ local c ac bc p v
+ common_anc "$@" && c=$R1 ac=$R2 bc=$R3 && R1='' R2='' p='' &&
+ for v in $ac; do
+ [ "$p" ] && R1="$R1 $v^$p"
+ p=$v
+ [ "$v" = "$c" ] && break
+ done && p='' &&
+ for v in $bc; do
+ [ "$p" ] && R2="$v^$p $R2"
+ p=$v
+ [ "$v" = "$c" ] && break
+ done
+}
+
+# rmd recursively removes empty directories.
+rmd() {
+ local d
+ for d in "${1:-.}"/*; do
+ [ -d "$d" ] && cd "$d" && rmd && cd .. && rmdir "$d" 2>/dev/null || true
+ done
+}
+
+# root returns the directory containing .sv/ in . or parent directories.
+# The result is also set in root global var.
+root() {
+ [ "$root" ] && R1="$root" && return
+ dir "${1:-.}" &&
+ while [ "$R1" ]; do
+ [ -d "$R1/.sv" ] && root="$R1" && return || R1="${R1%/*}"
+ done && return 1
+}
+
+# save [-bnv] [-m msg] [v1] - creates a new version from clone
+save() {
+ anc && old="$R1" && new_version "$old" "$1" && new="$R1" && cd "$root" || return
+ ls .sv/delta/*^"$new" >/dev/null 2>&1 && die "version $new already exists"
+ [ "$nopt" ] && return
+ echo "From ${EMAIL:-$USER@$(hostname)} $(date +'%a %b %d %T %Y')
+
+$([ -s .sv/msg ] && cat .sv/msg || echo "$mopt")
+" >.sv/delta/"$old^$new.$$" &&
+ command diff -rNux .sv .sv/cache/"$old" . >>.sv/delta/"$old^$new.$$" &&
+ trap "rm -f \"$PWD/.sv/delta/$old^$new.$$\"" EXIT && die "no change"
+ chmod -w .sv/delta/"$old^$new.$$" &&
+ mv .sv/delta/"$old^$new.$$" .sv/delta/"$old^$new" &&
+ mv .sv/cache/"$old" .sv/cache/"$new.$$" && cd .sv/cache/"$new.$$" &&
+ patch -fsTE -p 1 <../../delta/"$old^$new" && rmd && cd ../.. &&
+ mv cache/"$new.$$" cache/"$new" && echo "$new" >anc && rm -f msg
+}
+
+# switch v1 - changes ancestor to v1. Clone is not changed.
+switch() {
+ anc && a="$R1" && path "$a" "$1" && cd "$root" &&
+ mv .sv/cache/"$a" .sv/cache/"$1" && cd .sv/cache/"$1" && mpatch ../../delta &&
+ cd "$root" && mpatch .sv/delta && echo "$1" >.sv/anc
+}
+
+unset CDPATH
+export LC_ALL=C IFS='
+'
+script="$(cd "$(dirname "$0")" && pwd)/${0##*/}"
+owd="$PWD"
+
+case $1 in
+(-*) ;;
+(*) cmd=$1; shift ;;
+esac
+
+while getopts :bm:nv opt; do
+ case $opt in
+ (b) bopt="$bopt.0" ;;
+ (m) mopt="$OPTARG" ;;
+ (n) nopt=1 ;;
+ (v) vopt=1 ;;
+ (*) help; exit 1;;
+ esac
+done
+shift $((OPTIND - 1))
+$cmd "$@" && printf "%s${R1:+\n}" "$R1"