]> thyrsus.com Git - src.git/commitdiff
Modified check is now by content, not by date. Regression test passes.
authorEric S. Raymond <esr@thyrsus.com>
Wed, 19 Nov 2014 12:17:04 +0000 (07:17 -0500)
committerEric S. Raymond <esr@thyrsus.com>
Wed, 19 Nov 2014 12:17:04 +0000 (07:17 -0500)
NEWS
TODO
src

diff --git a/NEWS b/NEWS
index c45847ce464cb649bb57e128cfd926677878c704..b8f073e8494cf5c64d6428bb614f27996ed50714 100644 (file)
--- a/NEWS
+++ b/NEWS
@@ -1,5 +1,8 @@
                        src project news
 
+Repository head:
+   Modified check is now done by content, not modification date.
+
 0.9: 2014-11-16
    SRC is now feature-complete as planned.
    Branch deletion is implemented and tested.
diff --git a/TODO b/TODO
index c9c8582b3b652d702be19689176d862084179a9a..01f1ac502f3f7a909c352256b8da3aad8f213301 100644 (file)
--- a/TODO
+++ b/TODO
@@ -14,9 +14,3 @@
 
 * An SCCS back end, just for the yucks.  
 
-                       KNOWN BUGS
-
-* chungy reports timestamp issues messing up status on ZFS.  Produces
-  false 'M' status when the file is actually unmodified.  Consider
-  checking content when M is returned.
-
diff --git a/src b/src
index 75716c9b99d017e865cb8a6a345184a735f6008a..47e52884cd0326721a62dfac80071aea42a4c09e 100755 (executable)
--- a/src
+++ b/src
@@ -54,7 +54,7 @@
 # the majority except for absolutely *not* using a space as a status code;
 # this makes the reports too hard to machine-parse.
 
-import sys, os, subprocess, time, calendar, stat, glob, shutil
+import sys, os, subprocess, time, calendar, stat, glob, shutil, hashlib
 
 version=0.9
 
@@ -89,6 +89,85 @@ pseudotime = False   # option -T: artificial clock for regression testing
 # Repo directory
 repodir = ".src"
 
+def stamp(arg):
+    "Name the stamp file used to track revisions."
+    if os.sep not in arg:
+        return os.path.join(repodir, arg + ".srcstamp")
+    else:
+        return os.path.join(os.path.dirname(arg),
+                            repodir,
+                            os.path.basename(arg) + ".stamp")
+
+def is_stamp(arg):
+    "Is this the name of a stamp file?"
+    return arg.endswith(".srcstamp")
+
+def make_stamp(workfile, length=None, hashval=None):
+    "Create a stamp file for a workfile."
+    stampfile = stamp(workfile)
+    if length is None:
+        length = os.path.getsize(workfile)
+    with open(stampfile, "wb") as wfp:
+        if hashval is None:
+            with open(workfile) as rfp:
+                hashobj = hashlib.sha1()
+                hashobj.update(rfp.read())
+                hashval = hashobj.hexdigest()
+        wfp.write("%d:%s\n" % (length, hashval))
+
+def is_history(arg):
+    "Are we looking at a history file?"
+    return arg.endswith(",v") 
+
+def modified(workfile):
+    "Has the workfile been modified since it was checked out?"
+    # Alas, we can't rely on modification times; it was tried, and
+    # os.utime() is flaky from Python - sometimes has no effect.
+    # Where the bug is - Python, glibc, kernel - is unknown.
+    # Even if we could, it's nice to catch the case where an edit
+    # was undone.
+    #
+    # Try getting the last stored length and hash from the stamp file to
+    # compare the workfile length and hash with.
+    workfile_content = workfile_length = workfile_hash = None
+    try:
+        stampfile = stamp(workfile)
+        with open(stampfile, "rb") as rfp:
+            stampline = rfp.read()
+        try:
+            (length, sha1) = stampline.strip().split(":")
+            try:
+                workfile_length = os.path.getsize(workfile)
+                # This is the fast path
+                if int(length) != workfile_length:
+                    return True
+                with open(workfile) as rfp:
+                    workhash = hashlib.sha1()
+                    workfile_content = rfp.read()
+                    workhash.update(workfile_content)
+                workfile_hash = workhash.hexdigest()
+                return workfile_hash != sha1
+            except OSError:
+                announce("in %s, internal error - workfile is missing!" % workfile)
+                return None
+        except ValueError:
+            announce("in %s, internal error - bad format of stamp file." % arg)
+    except IOError:
+        pass
+    # Stamp file absent or garbled, do it the hard way
+    history = History(workfile)
+    with backend.lifter(workfile):
+        tmp = os.path.join(repodir, "tmp" + str(os.getpid()))
+        backend.cat(workfile, history.lockrevs[0], tmp)
+        base_content = open(tmp).read()
+        os.remove(tmp)
+    if workfile_content is None:
+        workfile_content = open(workfile).read()
+    basehash = hashlib.sha1()
+    basehash.update(base_content)
+    make_stamp(workfile, length=len(base_content), hashval=basehash.hexdigest())
+    return base_content != workfile_content
+
 def do_or_die(dcmd, legend=b"", loud=False):
     "Either execute a command or die."
     if legend:
@@ -172,9 +251,9 @@ class HistoryEntry:
     def __str__(self):
         return "<%s = %s>" % (self.revno, self.native)
 
-def registered(fn):
+def registered(workfile):
     "Is this a workfile for a registered history?"
-    return os.path.exists(backend.history(fn))
+    return os.path.exists(backend.history(workfile))
 
 class History:
     "Encapsulate a revision list and some methods on it"
@@ -779,7 +858,7 @@ class CommandContext:
             croak("%s doesn't take a revision spec" % cmd)
         if not self.args:
             try:
-                masters = os.listdir(repodir)
+                masters = [fn for fn in os.listdir(repodir) if is_history(fn)]
             except OSError:
                 croak("repo directory %s does not exist" % repodir)
             if len(masters) == 1:
@@ -1040,19 +1119,14 @@ def checkout_method(*args):
             revision = metadata.revno_to_native(ctx.start)
         with backend.lifter(arg):
             backend.checkout(arg, revision)
-            # We change access and modification times of
-            # both history and workfile to avoid a race
-            # condition if src status is done within a second.
-            st = os.stat(backend.history(arg))
-            os.utime(backend.history(arg), (st.st_atime, st.st_mtime))
-            os.utime(arg, (st.st_atime-1, st.st_mtime-1))
+        make_stamp(arg)
         if not quiet and len(args) > 1:
             announce("%s <- %d" % (arg, ctx.start))
 
 def status_method(*args):
     "Get status of some or all files."
     try:
-        registered = os.listdir(repodir)
+        registered = [fn for fn in os.listdir(repodir) if is_history(fn)]
     except OSError:
         croak("repo directory %s does not exist" % repodir)
     args = list(args)
@@ -1079,9 +1153,7 @@ def status_method(*args):
                 pairs.append((fn, "?"))
         elif not os.path.exists(fn):
             pairs.append((fn, "!"))
-        # Relies on the fact that we touch the file with utime(2)
-        # each time it's checked out.
-        elif os.path.getmtime(fn) > os.path.getmtime(backend.history(fn)):
+        elif modified(fn):
             pairs.append((fn, "M"))
         elif not backend.has_revisions(fn):
             pairs.append((fn, "A"))
@@ -1305,7 +1377,7 @@ def ls_method(*args):
     if args:
         croak("ls cannot accept arguments")
     try:
-        masters = os.listdir(repodir)
+        masters = [fn for fn in os.listdir(repodir) if is_history(fn)]
     except OSError:
         croak("repo directory %s does not exist" % repodir)
     masters.sort()