Import patchsync version 2.3
[utils/utils.git] / patchsync
index 8c20a1d..e63d6da 100755 (executable)
--- a/patchsync
+++ b/patchsync
 #!/bin/bash
 #!/bin/bash
-# patchsync: Synchronize a branch with a patch (e.g., acls.diff and rsync-acl).
-# usage: patchsync <staging> {'' | --dry-run} [branch | patch]
-# <staging>: the staging directory, containing a shell script "settings" and an optional rsync filter file "filter"
-#     "settings" must contain:
-#         - $trunk, $patch, $branch paths interpreted relative to the staging dir
-#         - shell function do_diff: trunk, branch => patch
-#             e.g., for rsync: diff -uprN trunk branch | sed -re 's/^(\+\+\+|---) ([^\t]+).*$/\1 \2/' | grep -v '^diff' >patch
-#         - shell function do_patch: branch copied from trunk, patch => branch
-#             e.g., for rsync: patch --no-backup-if-mismatch -d branch/ -p1 <patch
-# --dry-run: don't note anything
-# branch | patch: overwrite the specified thing in case of a conflict (e.g., on the first run)
+# patchsync: Synchronizes a trunk, a branch, and a patch containing the
+# differences between them.
+# -- Matt McCutchen
+
+# If I had to update the version in the --version message separately, I would forget.
+PATCHSYNC_VERSION=2.3
+
+# usage: patchsync [--dry-run] <staging> [branch | patch]
+#
+# Patchsync is invoked on a "staging directory", which holds some configuration
+# (including the locations of the trunk, patch, and branch it is to synchronize)
+# and some synchronization state.  It determines whether each of the trunk,
+# patch, and branch has changed since the last successful synchronization and
+# updates the patch or branch as appropriate:
+# 
+# Changed since last sync   Patchsync's behavior
+# -------------------------------------------------
+# Nothing                   Do nothing
+# Trunk only                Update branch
+# Patch but not branch      Update branch
+# Branch but not patch      Update patch
+# Branch and patch          Complain about conflict
+#
+# <staging>: path to the staging directory
+#
+# --dry-run: show what would happen without actually modifying the trunk, patch,
+#   branch, or synchronization state
+#
+# {branch | patch}: force patchsync to update the specified thing from the
+#   others instead of deciding automatically; you can use this argument to
+#   revert or to resolve a conflict
+#
+# CAVEAT: Patchsync might make a mess if the trunk, patch, or branch is
+# modified in a way not hidden by the filters while patchsync is running!
+#
+# CAVEAT: Patchsync only notices creations, deletions, and modifications of
+# regular files in the trunk and branch, not other changes like empty directory
+# creations.  If you make a change like that to the trunk, you can force
+# patchsync to update the branch.
+#
+# Staging directory format: A staging directory contains the following items:
+#   "trunk", trunk directory or symlink to it
+#   "patch", patch regular file or symlink to it
+#   "branch", branch directory or symlink to it
+#     [Why symlinks?  Expose as much as possible to tools like symlinks(8).]
+#   "settings", shell script defining the following shell functions:
+#     - do_diff <trunk> <branch> <write-patch>: diff the specified trunk and
+#         branch and write the patch to the specified file; define it to use
+#         your favorite diff format
+#       - example: exitoneok diff -urN $1 $2 \
+#                    | sed -re 's/^(\+\+\+|---) ([^\t]+).*$/\1 \2/' \
+#                    | exitoneok grep -v '^diff' >$3
+#     - do_patch <patch> <convert-trunk-to-branch>: apply the patch to the
+#         specified trunk; define it to understand your favorite diff format
+#       - example: patch --no-backup-if-mismatch -d $2/ -p1 <$1
+#     - Note: patchsync runs these functions under "pipefail", but the
+#         "set -e" it uses does not propagate into the functions.  Patchsync
+#         provides an "exitoneok" function you can use to treat an exit code of
+#         1 as 0.  You might want to && successive commands together.
+#     - There are several possible ways to handle failed hunks.  The simplest
+#         and safest is to make do_patch fail, but that's inconvenient for the
+#         user, who must investigate the *.rej files in the staging directory
+#         and either fix the patch or fix the branch and force updating the
+#         patch.  One could make do_patch succeed, but if the user then modifies
+#         the branch, the failed hunks will merely be dropped from the patch,
+#         which is probably unacceptable.  The clever way is to let do_patch
+#         succeed but make do_diff fail if any *.rej files exist in the branch.
+#   "filters" (optional): rsync filters to use when accessing the trunk and
+#     branch; hide filters apply to reading, protect filters to writing;
+#     hint: you probably want to hide and protect build outputs
+#
+# Other usage: patchsync --new <trunk> <patch> <branch> <staging>
+# Mostly sets up a new staging directory for the given trunk, branch, and patch
+# at the given location.  You still have to provide settings, and filters if
+# you want them.
+# - If one of the patch or branch exists, the other will be calculated when
+#   you first synchronize.
+# - If both exist, you will get a conflict when you first synchronize and you
+#   will need to specify which to update.
+# - If neither exists, you get an empty patch and a branch identical to the trunk.
+
+# Disable branch/.patchsync support because it's a bad idea in general, and the
+# cyclic symlink confuses Eclipse in particular. -- Matt 2006.11.30
 
 set -e
 
 set -e
+trap "echo 'Patchsync encountered an unexpected error!  ABORTING!' 1>&2; exit 2;" ERR
+set -o errtrace
+set -o pipefail
+
+# Make sure we have rsync.
+type rsync >/dev/null 2>&1 || \
+       { echo "Patchsync requires rsync, but there's no rsync on your path!" 1>&2; exit 1; }
+# If a cp2 is available, use it; otherwise define our own.
+type cp2 >/dev/null 2>&1 || function cp2 { exec rsync -rltE --chmod=ugo=rwx "$@"; }
+
+function exitoneok {
+       "$@" || [ $? == 1 ]
+}
+
+# wdpp_from <B> ==> the shortest relative prefix-path from directory B to the current directory
+# (prefix-path means it ends in a slash unless it's `' which means '.')
+# "patchsync" uses this to link-dest when copying the branch out.
+# "patchsync --new" uses it to reverse the staging dir path when creating symlinks.
+function wdpp_from {
+       AtoB="$1"
+       # Start with symlink-followed absolute prefix-paths without the initial slash.
+       # NOT bash builtin pwd; it tells us how we got here, not where we are
+       pA="$(/bin/pwd)/"
+       pA="${pA#/}"
+       pB="$( (cd "$AtoB" && /bin/pwd) )/"
+       pB="${pB#/}"
+       # Lop off the longest common prefix of components that we can.
+       # While first components are equal...
+       # (Empty correctly doesn't equal remaining)
+       while { [ -n "$pA" ] || [ -n "$pB" ]; } && [ "${pA%%/*}" == "${pB%%/*}" ]; do
+               # Remove them.
+               pA="${pA#*/}"
+               pB="${pB#*/}"
+       done
+       ans="$pA"
+       # Translate remaining components of $pB to ../s
+       while [ -n "$pB" ]; do
+               ans="$ans../"
+               pB="${pB#*/}"
+       done
+       # Double check; add dot to the end to enforce ending in a slash and handle empty ans
+       (cd "$AtoB" && [ "$ans." -ef /proc/self/fd/3 ]) 3<.
+       # Yay
+       echo "$ans"
+}
+
+function hash_file {
+       # Lop off the filename and binary indicator
+       sha1sum -b "$1" | sed -re 's/^([^ ]*).*$/\1/'
+}
+
+function patchsync_sync {
+
+if [ "$1" == --dry-run ]; then
+       echo "Dry run mode."
+       dryrun=1
+       shift
+fi
 
 staging="$1"
 
 staging="$1"
-if ! [ -r "$staging/settings" ]; then
+if [ -r "$staging/settings" ]; then
+       echo "Using staging dir $staging"
+else
        echo "Specify a staging directory containing a settings file!" 1>&2
        exit 1
 fi
        echo "Specify a staging directory containing a settings file!" 1>&2
        exit 1
 fi
-cd "$staging"
-
-dryrun="$2"
-whichtochange="$3"
-CP2t=(cp2 --del)
-CP2in=("${CP2t[@]}" --filter='. filter' --delete-excluded)
-CP2out=("${CP2t[@]}" --filter='. filter' --no-t --checksum) # be nice to mtimes
-export LC_COLLATE="C"
-
-. settings
-
-echo "Copying in, checking for changes..."
-"${CP2in[@]}" --link-dest=../trunk-save -i "$trunk/" trunk/
-diff -r trunk{-save,} >/dev/null || trunkch=ch
-"${CP2in[@]}" --link-dest=../branch-save -i "$branch/" branch/
-diff -r branch{-save,} >/dev/null || branchch=ch
-cp2 --link-dest=../patch-save -i "$patch" patch
-diff -r patch{-save,} >/dev/null || patchch=ch
-echo "Done."
-
-if [ $trunkch ] || [ $branchch ] || [ $patchch ]; then
-       # Something changed.
-       # Update either branch or patch, whichever didn't change.
-       # If trunk changes, update branch, not patch.
-       if [ "$whichtochange" == 'branch' ] || ! [ $branchch ]; then
-               echo "Updating branch..."
-               "${CP2t[@]}" trunk/ branch/
-               do_patch
-               echo "Done."
-               copyout=1
-       elif [ "$whichtochange" == 'patch' ] || ! [ $patchch ]; then
-               echo "Updating patch..."
-               do_diff
-               echo "Done."
-               copyout=1
+cd "$staging" || { echo "Failed to enter staging dir!" 1>&2; exit 1; }
+shift
+
+. ./settings
+type do_diff >/dev/null 2>&1 || { echo "do_diff is not defined!" 1>&2; exit 1; }
+type do_patch >/dev/null 2>&1 || { echo "do_patch is not defined!" 1>&2; exit 1; }
+
+whichtoupdate="$1"
+# patchsync --new doesn't need this any more except for identical-branch
+#if [ -z "$whichtoupdate" ] && [ -s whichtoupdate ]; then
+#      # Hook for patchsync --new
+#      whichtoupdate="$(< whichtoupdate)"
+#      echo "Updating $whichtoupdate according to staging dir."
+#el
+if [ -n "$whichtoupdate" ]; then
+       echo "Updating $whichtoupdate according to command line argument."
+else
+       echo "Synchronizing."
+fi
+
+filteropts=()
+! [ -e filters ] || filteropts=("${filteropts[@]}" --filter='. filters')
+# 'R *' or 'S *' disables filtering on the staging dir side.
+
+COPYIN=(cp2 --del --filter='R *' "${filteropts[@]}")
+COPYOUT=(cp2 --del --filter='S *' "${filteropts[@]}" --no-t --checksum) # be nice to mtimes
+
+# hash_dir foo/ ==> a hash code covering all of the shown files in foo/
+function hash_dir {
+       # Itemize the dir, extract filenames, hash the files, and hash the list of
+       # hashes.
+       "${COPYIN[@]}" -i -n $1 nonexistent/ \
+               | sed -n -e '/^>f/{ s/^[^ ]* //; p }' \
+               | (cd $1 && xargs --no-run-if-empty --delimiter='\n' sha1sum -b) \
+               | hash_file /dev/stdin
+}
+
+echo "Checking for changes..."
+hash_dir trunk/ >trunk-new-hash
+cmp trunk-{save,new}-hash &>/dev/null || { trunkch=1; echo "Trunk has changed"; }
+hash_file patch >patch-new-hash
+cmp patch-{save,new}-hash &>/dev/null || { patchch=1; echo "Patch has changed"; }
+hash_dir branch/ >branch-new-hash
+cmp branch-{save,new}-hash &>/dev/null || { branchch=1; echo "Branch has changed"; }
+
+# If we're in synchronization mode, decide what to update.
+if [ -z "$whichtoupdate" ] && [[ -n $trunkch || -n $branchch || -n $patchch ]]; then
+       if [ -e identical-branch-flag ] && ! [ $patchch ] && ! [ $branchch ]; then
+               # We still want to create an identical branch.
+               whichtoupdate=identical-branch
+       elif ! [ $branchch ]; then
+               # Trunk, patch, or both changed.  Update branch.
+               whichtoupdate=branch
+       elif ! [ $patchch ]; then
+               # Branch changed, and trunk may have also changed.  Update patch.
+               whichtoupdate=patch
        else
        else
-               echo "Conflict: both branch and patch changed!"
-               echo "Run patchsync <staging> <dry-run> [branch | patch] to"
-               echo "update the specified area from the others."
-               exit 1
+               # Branch and patch both changed.  A message appears later.
+               whichtoupdate=conflict
        fi
        fi
-else
-       # Easy case
-       echo "Nothing changed."
+       #echo "Synchronization will update $whichtoupdate."
 fi
 
 fi
 
-if [ $copyout ]; then
-if ! [ $dryrun ]; then
-       ! [ -e lock ] || { echo "Locked!  Please fix!"; exit 1; }
-       echo "patchsync lock file pid $$ date $(date)" >lock
+# Remove old copy-out files to be clean and to make sure we don't
+# mistakenly copy them out this time.
+rm -rf patch-new branch-new
+
+if [ -n "$whichtoupdate" ]; then
+
+# Always show what would happen if patch-new and branch-new were copied out.
+# (If there was a problem creating one of them, patchsync would have just
+# deleted it.)  But only actually copy them out and update synchronization
+# state if no error.
+error=
+
+function prepare_branch {
+       echo "Preparing updated branch..."
+       # No link-dest because we will modify and then link-dest when copying out
+       "${COPYIN[@]}" trunk/ branch-new/
+       do_patch patch branch-new || \
+               { error=1; echo "Failed to prepare updated branch!" 1>&2; rm -rf branch-new; }
+}
+
+function prepare_patch {
+       echo "Preparing updated patch..."
+       # Link-dest is fine because these are temporary read-only copies
+       "${COPYIN[@]}" --link-dest=../trunk/ trunk/ trunk-tmp/
+       "${COPYIN[@]}" --link-dest=../branch/ branch/ branch-tmp/
+       do_diff trunk-tmp branch-tmp patch-new || \
+               { error=1; echo "Failed to prepare updated patch!" 1>&2; rm -rf patch-new; }
+       rm -rf trunk-tmp branch-tmp
+}
+
+case $whichtoupdate in
+(identical-branch)
+       echo "Creating identical branch..."
+       # No link-dest because we will link-dest when copying out
+       "${COPYIN[@]}" trunk/ branch-new/
+       echo "Creating empty patch..."
+       do_diff branch-new branch-new patch-new || \
+               { error=1; echo "Failed to create empty patch!" 1>&2; rm -rf patch-new; }
+       ;;
+(branch)
+       prepare_branch
+       ;;
+(patch)
+       prepare_patch
+       ;;
+(conflict)
+       error=1
+       cat <<EOF 1>&2
+CONFLICT: both branch and patch changed!
+Run patchsync <staging> {branch | patch} to
+update the specified thing from the others.
+I'll leave updated copies of both branch
+and patch in the staging directory to help
+you decide which way you want to update.
+EOF
+       prepare_branch
+       prepare_patch
+       ;;
+(*)
+       echo "Internal error, whichtoupdate should not be $whichtoupdate!" 1>&2
+       exit 1
+       ;;
+esac
+
+if ! [ $error ] && ! [ $dryrun ]; then
+       # Disable locking for now...
+       # ! [ -e lock ] || { echo "Staging dir is locked!  Delete the file \`lock' if the other instance of patchsync is gone." 1>&2; exit 1; }
+       # echo "patchsync lock file pid $$ date $(date)" >lock
        
        echo "Copying out..."
        
        echo "Copying out..."
-       "${CP2out[@]}" -i branch/ "$branch/"
-       cp2 -i --checksum patch "$patch"
-       echo "Done."
-       echo "Noting..."
-       for i in trunk branch patch; do
-               rm -rf $i-save
-               mv $i{,-save}
+       ! [ -e branch-new ] || {
+               hash_dir branch-new/ >branch-new-hash
+               "${COPYOUT[@]}" -i --link-dest="$(wdpp_from branch/)branch-new/" branch-new/ branch/
+               rm -rf branch-new
+       }
+       ! [ -e patch-new ] || cmp -s patch patch-new || {
+               hash_file patch-new >patch-new-hash
+               # Don't use rsync because we might have to write through a symlink.
+               echo "> patch"
+               cp --preserve=timestamps patch-new patch
+               rm -f patch-new
+       }
+       
+       echo "Remembering synchronized state for next time..."
+       for i in trunk patch branch; do
+               mv $i-new-hash $i-save-hash
        done
        done
-       echo "Done."
        
        
-       rm lock
+       rm lock
 else
 else
-       echo "Dry run; no action.  You can inspect the results if you want."
-       echo "Fake copying out..."
-       "${CP2out[@]}" -in branch/ "$branch/"
-       cp2 -in --checksum patch "$patch"
-       echo "Done."
+       echo "Would copy out as follows:"
+       ! [ -e branch-new ] || {
+               hash_dir branch-new/ >branch-new-hash
+               "${COPYOUT[@]}" -n -i --link-dest="$(wdpp_from branch/)branch-new/" branch-new/ branch/
+               #rm -rf branch-new
+       }
+       ! [ -e patch-new ] || cmp -s patch patch-new || {
+               hash_file patch-new >patch-new-hash
+               # Don't use rsync because we might have to write through a symlink.
+               echo "> patch"
+               #cp --preserve=timestamps patch-new patch
+               #rm -f patch-new
+       }
+       echo "Would remember synchronized state for next time."
+       echo "I'm leaving \"new\" files in the staging dir so you can inspect them."
+fi
+
+else # whichtoupdate
+       # Easy case
+       echo "Nothing changed."
+       rm -f {trunk,patch,branch}-new-hash
 fi
 fi
+
+if [ $error ]; then
+       echo "Synchronization failed." 1>&2
+       exit 1
+else
+       echo "Synchronization finished."
+       if [ -e identical-branch-flag ]; then
+               if ! [ $dryrun ]; then
+                       rm identical-branch-flag
+                       echo "Removed identical-branch-flag."
+               else
+                       echo "Would remove identical-branch-flag."
+               fi
+       fi
+       # Yay!  Done patchsync_sync!
 fi
 fi
+}
+
+function patchsync_new {
+       if [ $# != 4 ]; then
+               echo "Expected 4 arguments after --new, got $#." 1>&2
+               echo "usage: patchsync --new <trunk> <patch> <branch> <staging>" 1>&2
+               exit 1
+       fi
+       
+       # Set up arguments.
+       trunk="$1"
+       patch="$2"
+       branch="$3"
+       staging="$4"
+       
+       # What exists?
+       ! [ -e "$staging" ] || { echo "Staging dir already exists!" 1>&2; exit 1; }
+       [ -d "$trunk" ] || { echo "Trunk does not exist!" 1>&2; exit 1; }
+       
+       # Create staging dir.
+       mkdir "$staging"
+       wdpp="$(wdpp_from "$staging")"
+       cd "$staging"
+       echo "Created staging dir at $staging."
+       
+       # Adjust paths appropriately.
+       trunk="$wdpp$trunk"
+       patch="$wdpp$patch"
+       branch="$wdpp$branch"
+       
+       # Create links to areas
+       ln -s "$trunk" trunk
+       ln -s "$patch" patch
+       ln -s "$branch" branch
+       echo "Created links to areas."
+       
+       # This approach is better than setting whichtochange because we'll notice
+       # if the user puts something into one of the areas we created before first
+       # sync.
+       function create_patch {
+               touch "$patch"
+               hash_file patch >patch-save-hash
+               echo "Created empty patch."
+       }
+       function create_branch {
+               mkdir "$branch"
+               # Can't do hash_dir because ${COPYIN[@]} hasn't been set <== no filters
+               hash_file /dev/null >branch-save-hash
+               echo "Created empty branch."
+       }
+       
+       if [ -e "$patch" ] && ! [ -e "$branch" ]; then
+               create_branch
+               echo "Patch exists; branch will be calculated when you first synchronize."
+       elif [ -e "$branch" ] && ! [ -e "$patch" ]; then
+               create_patch
+               echo "Branch exists; patch will be calculated when you first synchronize."
+       elif ! [ -e "$patch" ] && ! [ -e "$branch" ]; then
+               create_patch
+               create_branch
+               echo "Neither branch nor patch exists;"
+               echo "a branch identical to the trunk will be created when you first synchronize."
+               echo flag >identical-branch-flag
+               echo "Created identical-branch-flag to tell first run of patchsync about this."
+       else
+               echo "Both patch and branch exist."
+               echo "You will need to specify whether to overwrite the"
+               echo "patch or the branch when you first synchronize!"
+       fi
+       
+       # Write settings file.
+       cat >settings <<END
+# Define do_diff and do_patch here!
+END
+       echo "Wrote settings file placeholder."
+       
+       echo ""
+       echo "Patchsync initialized."
+       echo "Now add your definitions of do_diff and do_patch to the settings file,"
+       echo "add a filter file if you wish, and perform the first sync."
+}
+
+function patchsync_help {
+       cat <<EOF
+Patchsync version $PATCHSYNC_VERSION by Matt McCutchen
+usage: patchsync [--dry-run] <staging> [branch | patch]
+       patchsync --new <trunk> <patch> <branch> <staging>
+Please read the top of the script for complete documentation.
+EOF
+}
 
 
-exit 0
+case "$1" in
+(--help|--version)
+       patchsync_help ;;
+(--dry-run)
+       patchsync_sync "$@" ;;
+(--new)
+       shift
+       patchsync_new "$@" ;;
+(''|--*)
+       patchsync_help 1>&2
+       exit 1 ;;
+(*)
+       patchsync_sync "$@" ;;
+esac