Commits

Oben Sonne committed 6547461

Initial commit

Comments (0)

Files changed (4)

+syntax: glob
+
+*~
+*.pyc
+.settings
+.project
+.pydevproject
+
+tests/tenv
+
+#!/usr/bin/python
+
+# =============================================================================
+#
+#    hg-sync - Mercurial sync extension
+#    Copyright (C) 2009 Oben Sonne <obensonne@googlemail.com>
+#
+#    This file is part of hg-sync.
+#
+#    hg-sync is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    hg-sync is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with hg-sync. If not, see <http://www.gnu.org/licenses/>.
+#
+# =============================================================================
+
+"""commit, fetch and push at regular intervals"""
+
+# read the sync() function documentation for detailed information
+
+from datetime import datetime
+import os.path
+import subprocess
+import time
+
+from mercurial import hg
+from mercurial import commands
+from mercurial import cmdutil
+from mercurial import util
+from hgext.fetch import fetch
+from hgext.fetch import cmdtable as fetch_ct
+
+# =============================================================================
+# constants
+# =============================================================================
+
+ALERTER = ".hgalert" # alert tool to run on errors (rel. to repo root)
+LOGFILE = os.path.join(".hg", "autosync.log") # daemon log (rel. to repo root)
+
+# =============================================================================
+# utility functions
+# =============================================================================
+
+def _cmdopts(cte, ouropts):
+    """Build options dictionary for a command.
+    
+    Option names are read from the given command table entry. Option values are
+    those set in `ouropts` or the defaults set in the command table entry.
+    
+    """
+    cmdopts = {}
+    for optdesc in cte[1]:
+        key = optdesc[1].replace("-", "_")
+        cmdopts[key] = ouropts.get(key, optdesc[2])
+    return cmdopts
+
+def _cycle(ui, root, commitopts, fetchopts, pushopts):
+    """Run a single 'commit, fetch, push' cycle"""
+
+    repo = hg.repository(ui, path=root)
+    ui.status("sync: commit working copy changes\n")
+    commands.commit(ui, repo, **commitopts)
+    ui.status("sync: fetch changes from other repository\n")
+    fetch(ui, repo, **fetchopts)
+    ui.status("sync: push local changes to other repository\n")
+    commands.push(ui, repo, **pushopts)
+
+def _sync(ui, repo, other, **opts):
+    """Synchronize once or continuously, depending on `opts`."""
+    
+    # check options
+    
+    if opts["interval"] < 1:
+        raise util.Abort("interval must be a positive number")
+    
+    if not repo.changelog:
+        raise util.Abort("initial repository, first change should get pulled "
+                         "or committed manually")
+        
+    # set up options for sub-commands
+    
+    commitopts = _cmdopts(commands.table["^commit|ci"], opts)
+    commitopts["message"] = "Automated commit"
+    fetchopts = _cmdopts(fetch_ct["fetch"], opts)
+    fetchopts["message"] = "Automated merge"
+    fetchopts["switch_parent"] = True
+    fetchopts["source"] = other
+    pushopts = _cmdopts(commands.table["^push"], opts)
+    pushopts["dest"] = other
+    
+    # force non-interactive merge (unless set explicitly in repo-hgrc)
+
+    os.environ["HGMERGE"] = "internal:merge"
+    if repo.ui.config("ui", "merge"):
+        source = repo.ui.configsource("ui", "merge").split(":")[0]
+        if source == os.path.join(repo.root, ".hg", "hgrc"):
+            os.environ["HGMERGE"] = repo.ui.config("ui", "merge")
+    
+    # run one synchronization cycle only ?
+    
+    if opts["once"]:
+        _cycle(ui, repo.root, commitopts, fetchopts, pushopts)
+        return
+    
+    # detect alerter tool
+    
+    if opts["alerter"]:
+        alerter = opts["alerter"]
+    elif os.path.exists(os.path.join(repo.root, ALERTER)):
+        alerter = os.path.join(repo.root, ALERTER)
+    else:
+        alerter = repo.ui.config("autosync", "alerter")
+            
+    # loop synchronization cycles !
+    
+    while True:
+        ts = datetime.strftime(datetime.now(), "%x %X")
+        ui.write("%s\n" % (" %s " % ts).center(79, "-"))
+        try:
+            _cycle(ui, repo.root, commitopts, fetchopts, pushopts)
+        except util.Abort, e:
+            ui.warn("error: %s\n" % e)
+            ui.warn("sync: an error occurred, will retry at next interval\n")
+            if alerter and os.path.exists(alerter):
+                try:
+                    subprocess.call([alerter, repo.root, e])
+                except OSError, e:
+                    ui.warn("sync: failed to run %s (%s)" % (alerter, e))
+        finally:
+            ui.flush()
+        time.sleep(opts["interval"])
+
+# =============================================================================
+# extension interface
+# =============================================================================
+
+def autosync(ui, repo, other="default", **opts):
+    """commit, fetch and push at regular intervals 
+    
+    Commit changes in the working copy, fetch (pull, merge and commit) changes
+    from the other repository and push local changes back to the other
+    repository - either continuously (default) or once only.
+    
+    The idea of this command is to use Mercurial as a back-end to synchronize
+    a set of files across different machines. Think of configuration files or
+    to-do lists as examples for things to synchronize. On a higher level one
+    can say this command synchronizes not only repositories but also working
+    directories. A central repository (usually without a working copy) must be
+    used as synchronization hub:
+    
+    repo1 <--sync--> hub <--sync--> repo2
+    
+    Running this command in repo1 and repo2 ensures the working copies (!) of
+    both repositories stay in sync (as long as they are no conflicting
+    changes).
+
+    Errors and merge conflicts which cannot be resolved automatically are
+    highlighted in the output. Additionally an alerter tool can be specified
+    to run on errors and conflicts. This tool can be set (1) by using option
+    --alerter, (2) by placing it in the repository root as a file called
+    `.hgalert` or (3) in an HGRC file using options `alerter` in section
+    `autosync` (locations are evaluated in that order). The alerter is supposed
+    to notify errors to a human. The repository path is given as first
+    argument, an error message as the second one. Independent of this, on erros
+    and conflicts the command keeps running and retries after the next
+    interval, hoping things get fixed externally.
+        
+    When running in daemon mode, any output gets logged into the file
+    `autosync.log` within the repository's `.hg` directory (use --daemon-log
+    to set a different file).
+    
+    This command denies to run in a virgin repository as this may unrelate
+    repositories which were supposed to get synchronized. Before running
+    autosync, pull or commit something first manually.
+    
+    """
+    runfn = lambda: _sync(ui, repo, other, **opts)
+    if not opts["daemon"]:
+        logfile = None
+    elif opts["daemon_log"] == LOGFILE:
+        logfile = os.path.join(repo.root, LOGFILE)
+    else:
+        logfile = opts["daemon_log"]
+    cmdutil.service(opts, runfn=runfn, logfile=logfile)
+    
+# =============================================================================
+# command table
+# =============================================================================
+
+cmdtable = {
+    "autosync": (autosync,
+        [("A", "addremove", False,
+          "automatically synchronize new/missing files"),
+         ("i", "interval", 600, "synchronization interval in seconds"),
+         ("o", "once", False, "synchronize once only, don't loop"),
+         ("", "alerter", "", "program to run to alert errors"),
+         # daemon options
+         ("D", "daemon", False, "run in background"),
+         ("", "daemon-log", LOGFILE, "log file for daemon mode"),
+         ("", "daemon-pipefds", "", "used internally by daemon mode"),
+         ("", "pid-file", "", "name of file to write process ID to"),
+        ]  + commands.commitopts2 + commands.remoteopts,
+        "[-A] [-i] [-D] ... [OTHER]   (sync continuously)\n"
+        "hg autosync [-A] -o ... [OTHER]          (sync once)")
+}
+

tests/test-autosync

+#!/bin/sh
+
+# enable autosync extension when using Mercurial's run-tests.py
+[ -n "$HGRCPATH" ] && cat >> $HGRCPATH <<EOF
+[extensions]
+hgext.autosync=
+EOF
+
+# -----------------------------------------------------------------------------
+# constants
+# -----------------------------------------------------------------------------
+
+SYNCIVAL=4
+
+# -----------------------------------------------------------------------------
+# utilities
+# -----------------------------------------------------------------------------
+
+wait() {
+	IVAL=$((2 * $SYNCIVAL))
+    echo "-> waiting for synchronization ($IVAL seconds)"
+    sleep $IVAL
+}
+
+# -----------------------------------------------------------------------------
+# init
+# -----------------------------------------------------------------------------
+
+rm -rf tenv
+mkdir tenv
+cd tenv
+
+echo "-> set up test repos"
+hg init hub     # sync hub
+echo h > hub/f0
+hg -R hub commit -Am "Initial manual commit" -d "0 0" -u hub
+hg clone hub ra # a local clone
+hg clone hub rb # a local clone
+
+echo "-> start sync daemons"
+hg -R ra -v autosync -A -i $SYNCIVAL -D --pid-file=sd-ra.pid -d "0 0" -u ra
+sleep $(($SYNCIVAL / 2))
+hg -R rb -v autosync -A -i $SYNCIVAL -D --pid-file=sd-rb.pid -d "0 0" -u rb
+sleep $(($SYNCIVAL / 4))
+
+# -----------------------------------------------------------------------------
+# test
+# -----------------------------------------------------------------------------
+
+NREV=0 # expected number of revisions
+
+echo "-> ra only change"
+echo a > ra/f1
+wait # ra commits and pushes | rb pulls
+NREV=$((NREV+1))
+
+echo "-> rb only change"
+echo b > rb/f2
+wait # rb commits and pushes | ra pulls
+NREV=$((NREV+1))
+
+echo "-> identical changes in ra and rb"
+echo ab > ra/f3
+echo ab > rb/f3
+wait # ra commits and pushes | rb commits, pulls, merge and pushes | ra pulls
+NREV=$((NREV+3))
+
+echo "-> non-conflicting changes in ra and rb"
+echo a > ra/f4
+echo b > rb/f5
+wait # ra commits and pushes | rb commits, pulls, merge and pushes | ra pulls
+NREV=$((NREV+3))
+
+echo "-> conflicting change in ra and rb"
+echo a > ra/f6
+echo b > rb/f6
+wait # ra commits and pushes | rb commits, pulls, fails in merging 
+NREV=$((NREV+1))
+
+echo "-> manually resolve merge conflict"
+rm rb/f6.orig
+echo "ab" > rb/f6
+hg -R rb resolve -a -m
+wait # rb commits and pushes | ra pulls 
+NREV=$((NREV+2))
+
+echo "-> stop sync dameons"
+kill `cat sd-ra.pid` || echo "failed: sync daemon for ra crashed"
+kill `cat sd-rb.pid` || echo "failed: sync daemon for rb crashed"
+
+echo "-> configure repos to use internal:local for merge"
+for repo in ra rb ; do
+    echo "[ui]" >> $repo/.hg/hgrc
+    echo "merge = internal:local" >> $repo/.hg/hgrc
+done
+
+echo "-> start sync daemons"
+hg -R ra -v autosync -A -i $SYNCIVAL -D --pid-file=sd-ra.pid -d "0 0" -u ra
+sleep $(($SYNCIVAL / 2))
+hg -R rb -v autosync -A -i $SYNCIVAL -D --pid-file=sd-rb.pid -d "0 0" -u rb
+sleep $(($SYNCIVAL / 4))
+
+echo "-> conflicting change in ra and rb"
+echo a > ra/f7
+echo b > rb/f7
+wait # ra commits and pushes | rb commits, pulls, merges and pushes | ra pulls 
+NREV=$((NREV+3))
+
+echo "-> stop sync dameons"
+kill `cat sd-ra.pid` || echo "failed: sync daemon for ra crashed"
+kill `cat sd-rb.pid` || echo "failed: sync daemon for rb crashed"
+
+echo "-> conflicting change in ra and rb"
+echo a > ra/f8
+echo b > rb/f8
+echo "-> run autosync in both repos, non-looping and non-daemon"
+echo "-------- non-looping, no-daemon mode --------" >> ra/.hg/autosync.log
+hg -R ra -v autosync -A -o -d "0 0" -u ra >> ra/.hg/autosync.log 2>&1
+echo "-------- non-looping, no-daemon mode --------" >> rb/.hg/autosync.log
+hg -R rb -v autosync -A -o -d "0 0" -u rb >> rb/.hg/autosync.log 2>&1
+# wait # ra commits and pushes | rb commits, pulls, merges and pushes
+NREV=$((NREV+3))
+
+echo "-> conflicting change in ra and rb while not completely synchronized"
+echo a > ra/f9
+echo b > rb/f9
+echo "-> run autosync in both repos, non-looping and non-daemon"
+hg -R ra -v autosync -A -o -d "0 0" -u ra >> ra/.hg/autosync.log 2>&1
+hg -R rb -v autosync -A -o -d "0 0" -u rb >> rb/.hg/autosync.log 2>&1
+# wait # ra commits, pulls, merges and pushes | rb commits, pulls, merges and pushes
+hg -R ra -v autosync -A -o -d "0 0" -u ra >> ra/.hg/autosync.log 2>&1
+# wait # ra pulls
+NREV=$((NREV+4))
+
+# -----------------------------------------------------------------------------
+# check result
+# -----------------------------------------------------------------------------
+
+echo "-> check if repos equal"
+
+ID_RA=`hg -R ra id -i --debug -r tip`
+ID_RB=`hg -R rb id -i --debug -r tip`
+
+echo "-> id ra: $ID_RA"
+echo "-> id rb: $ID_RB"
+
+[ "$ID_RA" = "$ID_RB" ] || echo "failed: repo tip ids differ"
+
+NREV_RA=`hg -R ra id -n -r tip`
+NREV_RB=`hg -R rb id -n -r tip`
+
+echo "-> nrev expected: $NREV"
+echo "-> nrev ra      : $NREV_RA"
+echo "-> nrev rb      : $NREV_RB"
+
+[ "$NREV" = "$NREV_RA" ] || echo "failed: ra has wrong number of revisions"
+[ "$NREV" = "$NREV_RB" ] || echo "failed: rb has wrong number of revisions"
+
+# -----------------------------------------------------------------------------
+# clean up
+# -----------------------------------------------------------------------------
+
+#kill `cat sd-ra.pid` || echo "failed: sync daemon for ra crashed"
+#kill `cat sd-rb.pid` || echo "failed: sync daemon for rb crashed"
+

tests/test-autosync.out

+-> set up test repos
+adding f0
+updating to branch default
+1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+updating to branch default
+1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+-> start sync daemons
+-> ra only change
+-> waiting for synchronization (8 seconds)
+-> rb only change
+-> waiting for synchronization (8 seconds)
+-> identical changes in ra and rb
+-> waiting for synchronization (8 seconds)
+-> non-conflicting changes in ra and rb
+-> waiting for synchronization (8 seconds)
+-> conflicting change in ra and rb
+-> waiting for synchronization (8 seconds)
+-> manually resolve merge conflict
+-> waiting for synchronization (8 seconds)
+-> stop sync dameons
+-> configure repos to use internal:local for merge
+-> start sync daemons
+-> conflicting change in ra and rb
+-> waiting for synchronization (8 seconds)
+-> stop sync dameons
+-> conflicting change in ra and rb
+-> run autosync in both repos, non-looping and non-daemon
+-> conflicting change in ra and rb while not completely synchronized
+-> run autosync in both repos, non-looping and non-daemon
+-> check if repos equal
+-> id ra: f989311c4ac9efb36d14dede64baf6873df9d017
+-> id rb: f989311c4ac9efb36d14dede64baf6873df9d017
+-> nrev expected: 21
+-> nrev ra      : 21
+-> nrev rb      : 21