diff options
| -rw-r--r-- | Readme.md | 81 | ||||
| -rwxr-xr-x | sv | 211 |
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 @@ -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" |
