diff options
author | Dmitry Bogatov <KAction@debian.org> | 2018-11-10 03:09:43 +0000 |
---|---|---|
committer | Dmitry Bogatov <KAction@debian.org> | 2018-11-10 03:09:43 +0000 |
commit | 486f4254b69321ca468f4349c8f8384a651c03ae (patch) | |
tree | 0518772cc17a0754d7b22ba16486dd64b2419fae /src |
New upstream version 1.20
Diffstat (limited to 'src')
-rwxr-xr-x | src | 2757 |
1 files changed, 2757 insertions, 0 deletions
@@ -0,0 +1,2757 @@ +#!/usr/bin/env python +# +# SRC - simple revision control. +# +# Things to know before hacking this: +# +# All the code outside the RCS and SCCS classes (and the RevisionMixin +# helper) is intended to be generic to any file-oriented VCS. +# +# SRC and RCS/SCCS have different goals in managing locks. RCS/SCCS +# wants to keep the workfile read-only except when it's explicitly +# checked out, SRC wants to leave it writeable all the time. Thus, +# the checkin sequence is "release lock; check in; assert lock". If +# this seems confusing, it's because in RCS/SCCS terminology, locked is +# writeable and unlocked is read-only. +# +# Despite appearances, this code does not actually use RCS locks (and +# sets locking to non-strict). That just happens to be a handy way to +# record which revision the user last checked out, which is significant +# for future checkouts and for branching. +# +# With sufficient cleverness it would be possible to go in a different +# direction - leave the master unlocked at all times except during during +# the commit sequence, intervening with a chmod to turn off write protection +# on the workfile when RCS/SCCS would normally turn it on. I had this as +# a to-do for a long time but have abandoned the concept; fighting RCS/SCCS's +# notion of when the workfile ought to be write-locked seems too likely to +# lead to weird bugs in unexpected situations. +# +# In an ideal world, we'd get rid of A status. The SCCS back end doesn't +# have it, because the only way to register new content is by "sccs add" +# on an already existing file and that creates a new SCCS master with +# the content checked in as the first commit. RCS ci works that way as +# well. On the other hand, if you use rcs -i foo it creates a master foo,v +# but does *not* stuff it with the content of any corresponding foo. This +# is what A status means. +# +# This code uses magic tags with a 0 in the second-to-last slot to +# designate branches. It's the same format as CVS sticky tags, for +# the same reason. They need to be distinguishable from regular tags +# pointing at revisions, and this way the code to transform the sticky +# tag into the branch name is as simple as possible. +# +# Top of the list of things that people will bikeshed about is the +# letter codes returned by 'src status'. Different VCSes have +# conflicting ideas about this. The universal ones are 'A' = Added, +# 'M' = Modified, and '?' = Untracked. Here's a table of the ones +# in dispute. Entries with '-' mean the VCS does not have a closely +# corresponding status. +# +# git hg svn src +# Unmodified ' ' '=' ' ' '=' +# Renamed 'R' - - - +# Deleted 'D' 'R' 'D' - +# Copied 'C' - - - +# Ignored '!' 'I' 'I' 'I' +# Updated/unmerged 'U' - - - +# Missing - '!' '!' '!' +# Locked - - 'L' 'L' +# +# (hg used to use 'C' as the code for unmodified status.) +# +# This is a bit oversimplified; it is meant not as a technical +# comparison but rather to illustrate how bad the letter collisions are. +# SRC follows the majority except for absolutely *not* using a space as +# a status code; this makes the reports too hard to machine-parse. +# +# SPDX-License-Identifier: BSD-2-Clause + +# This code runs under both Python 2 and Python 3. +# Preserve this property! + +from __future__ import print_function + +import sys, os, subprocess, datetime, time, calendar, stat, glob +import shutil, difflib, cgi, json, io, re, signal +import tempfile, email.utils +try: + import curses +except ImportError: + pass + +version="1.20" + +CENTURY = "20" # A Y2.1K problem, but only for SCCS. + +# General notes on Python 2/3 compatibility: +# +# SRC uses the following strategy to allow it to run on both Python 2 +# and Python 3: +# +# * Use binary I/O to read/write data from/to files and subprocesses; +# where the exact bytes are important (such as in checking for +# modified files), use the binary data directly. +# +# * Use latin-1 encoding to transform binary data to/from Unicode when +# necessary for operations where Python 3 expects Unicode; this will +# ensure that bytes 0x80..0xff are passed through and not clobbered. +# The polystr and polybytes functions are used to do this so that when +# running on Python 2, the byte string data is used unchanged. +# +# * Construct custom stdin, stdout, and stderr streams when running +# on Python 3 that force ASCII encoding, and wrap them around the +# underlying binary buffers (in Python 2, the streams are binary and +# are used unchanged); this ensures that the same transformation is +# done on data from/to the standard streams, as is done on binary data +# from/to files and subprocesses; the make_std_wrapper function does +# this. Without this change, 0x80..0xff written to stdout will be +# garbled in unpredictable ways. + +master_encoding = 'latin-1' + +if str is bytes: # Python 2 + + polystr = str + polybytes = bytes + +else: # Python 3 + + def polystr(obj): + "Polymorphic string factory function" + # This is something of a hack: on Python 2, bytes is an alias + # for str, so this ends up just giving a str back for all + # inputs; but on Python 3, if fed a byte string, it decodes it + # to Unicode using the specified master encoding, which should + # be either 'ascii' if you're sure all data being handled will + # be ASCII data, or 'latin-1' otherwise; this ensures that the + # original bytes can be recovered by re-encoding. + if isinstance(obj, str): + return obj + if not isinstance(obj, bytes): + return str(obj) + return str(obj, encoding=master_encoding) + + def polybytes(s): + "Polymorphic string encoding function" + # This is the reverse of the above hack; on Python 2 it returns + # all strings unchanged, but on Python 3 it encodes Unicode + # strings back to bytes using the specified master encoding. + if isinstance(s, bytes): + return s + if not isinstance(s, str): + return bytes(s) + return bytes(s, encoding=master_encoding) + + def make_std_wrapper(stream): + "Standard input/output wrapper factory function" + # This ensures that the encoding of standard output and standard + # error on Python 3 matches the master encoding we use to turn + # bytes to Unicode in polystr above. + return io.TextIOWrapper(stream.buffer, encoding=master_encoding, newline="\n") + + sys.stdin = make_std_wrapper(sys.stdin) + sys.stdout = make_std_wrapper(sys.stdout) + sys.stderr = make_std_wrapper(sys.stderr) + +# Note: Avoid using unbolded blue (poor luminance contrast with black +# terminal-emulator background) or bolded yellow (poor contrast with +# white background.) +RESET = BOLD = '' +CBLACK = CBLUE = CGREEN = CCYAN = CRED = CMAGENTA = CYELLOW = CWHITE = '' + +def init_colors(): + curses.setupterm() + global RESET, BOLD + RESET = polystr(curses.tigetstr('sgr0')) or '' + BOLD = polystr(curses.tigetstr('bold')) or '' + colors = {'setaf': [0, 4, 2, 6, 1, 5, 3, 7], # ANSI colors + 'setf': [0, 1, 2, 3, 4, 5, 6, 7]} # legacy colors + for (k, v) in colors.items(): + x = curses.tigetstr(k) + if x: + for (i, c) in enumerate(['black', 'blue', 'green', 'cyan', 'red', + 'magenta', 'yellow', 'white']): + globals()['C' + c.upper()] = polystr(curses.tparm(x, v[i])) + break + +if 'curses' in sys.modules and sys.stdout.isatty(): + try: + init_colors() + except (curses.error, AttributeError): + pass + +def rfc3339(t): + "RFC3339 string from Unix time." + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t)) + +def announce(msg): + sys.stdout.write("src: " + msg + "\n") + +def croak(msg): + sys.stdout.flush() + sys.stderr.write("src: " + msg + "\n") + sys.exit(1) + +debug = 0 +DEBUG_COMMANDS = 1 # Show commands as they are executed +DEBUG_SEQUENCE = 2 # Sequence debugging +DEBUG_PARSE = 3 # Debug logfile parse + +quiet = False # option -q: run more quietly +pseudotime = False # option -T: artificial clock for regression testing + +class popen_or_die: + "Read or write from a subordinate process." + def __init__(self, command, legend="", mode="r"): + assert mode in ("r", "w") + self.command = command + self.legend = legend + self.mode = mode + # Pipe to the correct streams depending on the chosen mode + self.stdin = (subprocess.PIPE if mode == "w" else None) + self.stdout = (subprocess.PIPE if mode == "r" else None) + self.stderr = (subprocess.STDOUT if mode == "r" else None) + if self.legend: + self.legend = " " + self.legend + self.fp = None + def __enter__(self): + if debug >= DEBUG_COMMANDS: + if self.mode == "r": + sys.stderr.write("%s: reading from '%s'%s\n" % (rfc3339(time.time()), self.command, self.legend)) + else: + sys.stderr.write("%s: writing to '%s'%s\n" % (rfc3339(time.time()), self.command, self.legend)) + try: + # The I/O streams for the subprocess are always bytes; this + # is what we want for some operations, but we will need + # to decode to Unicode for others to work in Python 3, as + # explained in the general notes. + self.fp = subprocess.Popen(self.command, shell=True, + stdin=self.stdin, stdout=self.stdout, stderr=self.stderr) + # The Python documentation recommends using communicate() to + # avoid deadlocks, but this doesn't allow fine control over + # reading the data; since we are not trying to both read + # from and write to the same process, this should be OK. + return self.fp.stdout if self.mode == "r" else self.fp.stdin + except (OSError, IOError) as oe: + croak("execution of %s%s failed: %s" \ + % (self.command, self.legend, oe)) + def __exit__(self, extype, value, traceback_unused): + if extype: + if debug > 0: + raise extype(value) + croak("fatal exception in popen_or_die.") + if self.fp.stdout is not None: + # This avoids a deadlock in wait() below if the OS pipe + # buffer was filled because we didn't read all of the data + # before exiting the context mgr (shouldn't happen but this + # makes sure). + self.fp.stdout.read() + self.fp.wait() + if self.fp.returncode != 0: + croak("%s%s returned error." % (self.command, self.legend)) + return False + +def screenwidth(): + "Return the current width of the terminal window." + width = 73 + if "COLUMNS" in os.environ: + return int(os.environ["COLUMNS"]) + if sys.stdin.isatty(): + with popen_or_die('stty size', "rb") as tp: + # stty returns 0,0 inside Emacs + width = int(tp.read().split()[1]) or width + return width + +WIDTH = screenwidth() - 1 + +def is_history(arg): + "Are we looking at a history file?" + for vcsb in backends: + if vcsb.is_history(arg): + return True + return False + +def modified(workfile, history=None): + "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. + # + # If no revisions, bail out + if not backend.has_revisions(workfile): + return False + if history is None: + history = History(workfile) + with backend.lifter(workfile): + with backend.cat(workfile, history.current().native) as bstream: + base_content = bstream.read() # this data will be binary + with open(workfile, "rb") as fp: + workfile_content = fp.read() + # This comparison uses the binary data for maximum accuracy + return base_content != workfile_content + +def do_or_die(dcmd, legend="", mute=True, missing=None): + "Either execute a command or die." + if legend: + legend = " " + legend + if debug == 0 and mute: + muteme = " >/dev/null 2>&1" + else: + muteme = "" + if debug >= DEBUG_COMMANDS: + sys.stderr.write("executing '%s'%s\n" % (dcmd, legend)) + try: + retcode = subprocess.call("(" + dcmd + ")" + muteme, shell=True) + if retcode < 0: + croak("%s was terminated by signal %d." % (repr(dcmd), -retcode)) + elif retcode != 0: + errmsg = "%s returned %d." % (repr(dcmd), retcode) + if retcode == 127: + if missing is None: + missing = backend.__class__.__name__ + errmsg += "\nYou probably need to install %s." % missing + croak(errmsg) + except (OSError, IOError) as e: + croak("execution of %s%s failed: %s" % (repr(dcmd), legend, e)) + +def capture_or_die(command): + "Run a specified command, capturing the output." + if debug >= DEBUG_COMMANDS: + sys.stderr.write("%s: capturing %s\n" % (rfc3339(time.time()), command)) + try: + # This will return binary data + content = subprocess.check_output(command, shell=True) + except (subprocess.CalledProcessError, OSError) as oe: + croak("execution of %s failed: %s" % (repr(command), oe)) + if debug >= DEBUG_COMMANDS: + sys.stderr.write(polystr(content)) + return content + +class HistoryEntry: + "Capture the state of a native revision item in the log." + def __init__(self, history): + self.history = history + self.revno = None + self.native = None # magic cookie only interpreted by back end + self.headers = None + self.log = "" + self.date = None + self.parent = None # Another HistoryEntry + self.child = None # Another HistoryEntry + self.branches = set([]) + self.branch = None + def selected(self): + return self == self.history.current() + def getdate(self, who): + if self.headers and not pseudotime: + return self.headers.get(who + "-date") or self.date + return self.date + def unixtime(self, who): + date = self.getdate(who) + try: + t = calendar.timegm(time.strptime(date, "%Y-%m-%dT%H:%M:%SZ")) + offset = 0 + if self.headers and not pseudotime: + offset = self.headers.get(who + "-date-offset") or 0 + return t, offset + except (TypeError, ValueError): + croak("garbled date %s" % date) + def __str__(self): + return "<%s = %s>" % (self.revno, self.native) + +def registered(workfile): + "Is this a workfile for a registered history?" + return os.path.exists(backend.history(workfile)) + +class History: + "Encapsulate a revision list and some methods on it" + def __init__(self, name): + self.name = name + self.revlist = [] + self.symbols = {} + self.branch = "trunk" + self.lockrevs = [] + self.description = "" + self.annotations = {} + if not registered(self.name): + croak("%s is not registered" % self.name) + self.by_revno_d = {} + self.by_native_d = {} + self.branches = set([]) + backend.parse(self) + self.lift_headers() + self.normalize_header_dates() + def build_indices(self): + for item in self.revlist: + self.by_revno_d[item.revno] = item + self.by_native_d[item.native] = item + for item in self.revlist: + item.parent = self.by_native_d.get(backend.pred(item.native)) + item.child = self.by_native_d.get(backend.succ(item.native)) + if item.parent and item.parent.child != item: + item.parent.branches.add(item) + #sys.stderr.write("Symbols: %s\n" % self.symbols) + #for item in self.revlist: + # sys.stderr.write("Item %s\n" % item) + #sys.stderr.write("By revision: %s\n" % self.by_revno_d.keys()) + if self.revlist: + for (name, rev) in list(self.symbols.items()): + if backend.isbranch(rev): + base = backend.branch_to_base(rev, self) + tip = backend.branch_to_tip(rev, self) + while True: + self.by_native_d[base].branch = name + if base == tip: + break + base = backend.succ(base) + # Digest JSON from the description field + if not self.description.strip(): + self.annotations = {} + else: + try: + # The JSON loader returns Unicode, so this is one place + # where internal data will be Unicode even in Python 2; + # since ASCII data will be inter-converted between byte + # string and Unicode whenever needed in Python 2, this + # is not an issue. + self.annotations = json.loads(self.description.strip()) + except ValueError as _e: + croak("legacy data %s in description field" % self.description) + def lift_headers(self): + valid = ('author', 'author-date', 'committer', 'committer-date', + 'mark', 'parents') + for item in self.revlist: + headers = {} + i = 0 + while True: + n = item.log.find('\n', i) + if n < 0: + break + header = item.log[i:n].split(':', 1) + if len(header) != 2: + break + key = header[0].lower() + if key not in valid: + break + headers[key] = header[1].strip() + i = n + 1 + # eat blank line between headers and body + while item.log[i] == '\n': + i += 1 + item.log = item.log[i:] + if headers: + item.headers = headers + def normalize_header_dates(self): + for item in self.revlist: + if item.headers: + for k in tuple(item.headers): + if k.endswith("-date"): + d = email.utils.parsedate_tz(item.headers[k]) + if d: + u = email.utils.mktime_tz(d) + u = datetime.datetime.utcfromtimestamp(u) + item.headers[k] = u.isoformat() + "Z" + item.headers[k + "-offset"] = d[9] if d[9] else 0 + def __len__(self): + return len(self.revlist) + def current(self): + "Return the revision currently checked out." + # Yes, this looks weird. The idea is: Try to return the locked + # revision. If that blows up, try to return the tip revision of + # the current branch. If that blows up, return None. + try: + return self.by_native_d[self.lockrevs[0]] + except (IndexError, KeyError): + try: + return self.by_native_d[backend.branch_to_tip(self.symbols[self.branch], self)] + except (IndexError, KeyError): + return None + def current_branch(self, backwards=False): + "Return a list of items that are descendants or ancestors of current." + if debug >= DEBUG_SEQUENCE: + sys.stdout.write(("current_branch(%s)" % self.current())) + selection = [] + p = self.current() + if p is not None: + selection = [p] + while True: + if p.parent is None: + break + else: + p = p.parent + selection = [p] + selection + s = self.current() + while True: + s = s.child + if s is None: + break + selection.append(s) + if backwards: + selection.reverse() + return selection + def tip(self, rev=None): + "Return the tip revision of the branch of the given native revision." + if rev is None: + rev = self.current().native + s = self.by_native_d[rev] + while True: + if s.child is None: + return s + else: + s = s.child + def native_to_revno(self, revision): + "Map a native ID to a revno" + item = self.by_native_d.get(revision) + return item and item.revno + def by_revno(self, revno): + "Map a revno to a revision item." + try: + return self.by_revno_d[revno] + except KeyError: + if revno == 0: + # This case comes up if we try to select the tip + # revision of a history without revisions. + croak("{0} has no revisions".format(self.name)) + else: + croak("{0} has no revno {1}".format(self.name, revno)) + def revno_to_native(self, revno): + "Map a revno to a native ID" + return self.by_revno(revno).native + def set_annotations(self): + "Write auxilary symbols as JSON." + backend.write_description(json.dumps(self.annotations), self) + +help_topics = { + "topics": """ +The following help topics are available: + +intro -- Basic concepts: commits, tags, branches. The form of commands. +revisions -- How to specify ranges of commits to operate on. +commands -- a summary of the commands. +commit -- the commit command: how to commit changes to a file. +amend -- the amend command: editing stored change comments. +checkout -- the checkout command: retrieving historical versions of files. +cat -- the cat command: dumping revisions to standard output. +status -- the status command: more details and unusual status codes. +log -- the log command: dump commit log information to standard output. +list -- the list command: dump commit summaries to standard output. +diff -- the diff command: dump revision differences to standard output. +fast-export -- the fast-export command: export history to other systems. +fast-import -- the fast-import command: import history from other systems. +ignores -- .srcignore files and their uses. + +The 'help', 'rename', 'ls', 'move', 'copy', 'visualize', and 'version' +commands are completely described in the command summary. +""", + "intro": """ +SRC Introduction + + SRC (or src) is designed for version control on single-file + projects. + + A SRC history is a sequence of commits numbered in strict time order + starting from 1. Each holds a modification to the file, a comment, + and the date-time of the commit. + + The sequence also has a branch structure. By default there is + just one branch named 'trunk'. You can start a new named branch + at any commit. Branches can later be renamed or deleted. Because + of branching, parent and child commits do not necessarily have + consecutive numbers. + + Commits will always be be added to the tip of the current branch. + You can change the current branch by either checking out a revision + that is on that branch, or using a 'src branch' command to + explicitly change the current branch. + + You can assign tags (names) to point to commits. They too can be + renamed later or deleted. + + The general form of a SRC command is + + src verb [switches] [revision-spec] [files...] + + That is, a command verb is followed by optional switches, which are + (sometimes) optionally followed by a range of commits to operate + on, which is optionally followed by a list of files to operate on. + Usually you will specify either a revision range or multiple files, + but not both. + + The token '--' tells the command-line interpreter that subcommands, + switches, and revision-specs are done - everything after it is a + filename, even if it looks like a subcommand or revision number. + + If you do not specify any files, SRC will operate sequentially on + each individual file in the current directory with a history. + + A good help topics to read after this one would be 'revisions'. +""", + "revisions": """ +SRC Revisions + + A 'revision' is a 1-origin integer, or a tag name designating an + integer revision, or a branch name designating the tip revision of + its branch, or '@' meaning the currently fetched revision. Revision + numbers always increase in commit-date order. + + A revision range is a single revision, or a pair of revisions 'M-N' + (all revisions numerically from M to N) or 'M..N' (all revisions + that are branch ancestors of N and branch successors of M). If N is + less than M, the range is generated as if N >= M then reversed. + + If SRC complains that your revision spec looks like a nonexistent + filename, you can prefix it with '@' (this is always allowed). + + Some commands (help, commit, status, delete/rename commands for tags + and branches, ls, move, copy, fast-import, release, version) don't + take a revision spec at all and will abort if you give one. + + Some commands (amend, checkout, cat, tag and branch creation) + optionally take a singleton revision spec. + + Some commands (log, list, diff, fast-export) accept a range or a + singleton. + + Unless otherwise noted under individual commands, the default + revision is the tip revision on the current branch. + + A good topic to read next would be 'commands'. +""", + "commands": """ +SRC Commands Summary + +src help [command] + Displays help for commands. + +src commit [-|-m 'string'|-f 'file'|-e] ['file'...] + Enters a commit for specified files, separately to each one. + A history is created for the file if it does not already exist. + With '-', take comment text from stdin; with '-m' use the following + string as the comment; with '-f' take from a file. With '-e', edit + even after '-', '-f' or '-m'. 'ci' is a synonym for 'commit'. + +src amend [-|-m 'string'|-f 'file'|-e] ['revision'] ['file'...] + Amends the stored comment for a specified revision, defaulting to + the latest revision on the current branch. Flags are as for commit. + +src checkout ['revision'] ['file'...] + Refresh the working copies of the file(s) from their history files. + 'co' is a synonym for 'checkout'. + +src cat ['revision'] ['file'...] + Send the specified revisions of the files to standard output. + +src status [-a] ['file'...] + '=' = unmodified, 'M' = modified, '!' = missing, + '?' = not tracked, 'I' = ignored, 'A' = added, + 'L' = locked (recover with 'src checkout'). + Find more details under 'help status'. 'st' is a synonym for + 'status'. + +src tag [list|-l|create|-c|delete|del|-d] ['name'] ['revision'] ['file'...] + List tags, create tags, or delete tags. Create/delete takes a + singleton revision, defaulting to the current branch tip. List + defaults to all revisions. + +src branch [list|-l|create|-c|delete|del|-d] ['name'] ['file'...] + List, create, or delete branches. When listing, the active branch + is first in the list. The default branch is 'trunk'. Create/delete + takes a singleton revision, defaulting to the current branch tip. + List defaults to all revisions, including 0 (the trunk root phantom + revision). + +src rename ['tag'|'branch'] ['oldname'] ['newname'] ['file'...] + Rename a tag or branch. Refuses to step on an existing symbol or + rename a nonexistent one. 'rn' is a synonym for 'rename'. + +src list [(-<n>|-l <n>)] [-f 'fmt'] ['revision-range'] ['file'...] + Sends summary information about the specified commits to standard + output. The summary line tagged with '*' is the state that the file + would return to on checkout without a revision-spec. See 'help + list' for information about custom formats. Use '-<n>' or '-l <n>', + where <n> is a number, to limit the listing length. Default range is + the current branch, reversed. + +src log [-v] [(-<n>|-l <n>)] [(-p|-u|-c) [-b|-w]] ['revision-range'] ['file'...] + Sends log information about the specified commits to standard + output. Use '-<n>' or '-l <n>', where <n> is a number, to limit the + listing length. Default range is the current branch, reversed. + Use '--patch', '-p' or '-u' to also send a unified format diff + listing to standard output for each revision against its immediate + ancestor revision; '-c' emits a context diff instead. When generating + a diff, '-b' ignores changes in the amount of whitespace, and '-w' + ignores all whitespace. Histories imported via 'fast-import' (when + not using its '-p' option) have RFC-822-style headers inserted into + the log comment to preserve metadata not otherwise representable in + SRC, such as distinct author and committer identifications and + dates. These headers are normally suppressed by 'log', however, + '-v' shows a summarized view of important headers; '-v -v' shows + all headers as-is. + +src diff [(-u|-c) [-b|-w]] ['revision-range'] ['file'...] + Sends a diff listing to standard output. With no revision spec, + diffs the working copy against the last version checked in. With + one revno, diffs the working copy against that stored revision; with + a range, diff between the beginning and end of the range. 'di' is a + synonym for 'diff'. + +src ls + List all registered files. + +src visualize + Emit a DOT visualization of repository structures. To use this, + install the graphviz package and pipe the output to something like + 'dot -Tpng | display -'. 'vis' is a synonym for 'visualize'. + +src move 'old' 'new' + Rename a workfile and its history. Refuses to step on existing + workfiles or histories. 'mv' is a synonym for 'move'. + +src copy 'old' 'new' + Copy a workfile and its history. Refuses to step on existing files + or histories. 'cp' is a synonym for 'copy'. + +src fast-export ['revision-range'] ['file'...] + Export one or more projects to standard output as a Git fast-import + stream. For a history originally imported from elsewhere, author + and committer identification is gleaned from the RFC-822-style + headers inserted into the commit comment by 'fast-import' (if its + '-p' option was not used). Otherwise, this information is copied + from your Git configuration. The default range is all commits. + +src fast-import [-p] ['file'...] + Parse a git-fast-import stream from standard input. The + modifications for each individual file become separate SRC + histories. Mark, committer and author data, and mark cross- + references to parent commits, are preserved in RFC-822-style headers + on log comments unless the '-p' (plain) option is given, in which + case this metadata is discarded. Give arguments to restrict the + files imported. + +src release ['file'...] + Release locks on files. This is never necessary in a normal + workflow, which will be repeated edit-commit cycles, but it may be + handy if you have to interoperate with other tools that expect RCS + masters to be in their normal (unlocked/unwritable) state. + +src version + Report the versions of SRC, the underlying Python, and the back end. + +The omission of 'src remove' is a deliberate speed bump. +""", + "status": """ +src status [-a] ['file'...] + + The status command shows you the version-control status of files. + It is designed to be useful for both humans and software front ends + such as Emacs VC mode. + + The status codes, in roughly most common to rarest, are: + + = - Unmodified. File is the same as the latest stored revision. + M - Modified. File has been changed since the latest stored revision. + ? - Not tracked. SRC does not keep a history for this file. + I - ignored. This file matches the patterns in '.srcignore'. + ! - Missing. There is a history for this file but the workfile is missing. + A - The file has been registered into SRC but has no commits. + L - The file is locked/writable. + + Modification status is by content (using a SHA1 hash) rather than + the filesystem's last-modified date. Thus, if you make changes to a + work file in your editor, then undo them, the file's status returns + to '='. + + You can usually recover a file from 'A', 'L', and '!' status with + 'src checkout'. 'A' and 'L' statuses should only occur if you have + used RCS directly on a file, or if you have called 'src commit' with + the deliberately undocumented '-a' option meant for Emacs VC's use. + + If you give 'src status' no filename arguments, it surveys all files + in the current directory but untracked and ignored files are not + listed. If you give it filename arguments, status is listed for all + of them. + + The '-a' option forces status listing of all files. This differs + from 'src status *' because the latter will not see dotfiles and + thus not list the status of them. +""", + "commit":""" +src commit [-|-m 'string'|-f 'file'| -e] ['file'...] + + The commit command is how you add revisions to your file history. + It always adds the contents of the workfile as a revision to the tip + of the current branch. + + You also use commit on files that have not been registered to start + an SRC history for them. + + When you commit, you must specify a change comment to go with the + revision. There are several ways to do this. + + The '-m' option to the command takes the following string argument + as the comment. The '-' option takes the comment text from standard + input. The '-f' option takes the comment from a named file. + + If you use none of these, or if you use one of them and the '-e' + option, SRC will start an editor in which you can compose the + comment. Text specified via '-m', '-f', or '-' becomes the initial + contents of the comment. + + SRC respects the EDITOR variable and calls it on a temporary file to + create your comment. Th file will have a footer including its name + and revision which will be discarded when you finish editing. + + If you leave the comment empty (except for the generated footer) + or consisting only of whitespace, the commit will be aborted. The + commit will also be aborted if your editor returns a failure status. + + If you commit to multiple files at once, separate changes will be + registered for each one, and you may get a separate edit session for + each (if you have not set the comment text with options, or have + forced editing with '-e'). This is a major difference from other + VCSes, which are usually designed to create changesets common to + multiple files. + + 'ci' is a synonym for 'commit'. +""", + "amend" : """ +src amend [-|-m 'string'|-f 'file'| -e] ['revision'] ['file'...] + + Use this command to amend (modify) the change comment in a saved + revision. The commit date is not changed. + + Takes a singleton revision number, tag, or branch, defaulting to the + latest revision on the current branch. + + The edit flags and EDITOR variable are interpreted are as for + commit. The only difference is that existing change comment is + appended to any text you specify with switches as the initial + comment passed to your editor. + + 'am' is a synonym for 'amend' +""", + "checkout":""" +src checkout ['revision'] ['file'...] + + Refresh the working copies of the file(s) from their history files. + + Takes a single revision number, tag, or branch name. The default if + you give none is the tip revision of the current branch. + + This command is how you discard the contents of a modified workfile. + + You can also use it to revert the workfile to match a previous + stored revision. Doing do may, as a side effect, change your + current branch. + + 'co' is a synonym for 'checkout'. +""", + "cat" : """ +src cat ['revision'] ['file'...] + + Send the specified revision of each file to standard output. This + is not normally very useful with more than one file argument, but + SRC does not prevent that. + + Takes a single revision number, tag, or branch name. The default if + you give none is the tip revision of the current branch. + + This command is mainly intended for use in scripts. +""", + "tag" : """ +src tag [list|-l|create|-c|delete|del|-d] ['name'] ['revision'] ['file'...] + + List tags (with '-l'), create tags (with '-c'), or delete tags (with + '-d'). + + Takes at most a singleton revision; the default is the current + branch tip. + + Tag creation and deletion require a following name argument. Tag + creation will not step on an existing tag name, and a nonexistent + branch cannot be deleted. +""", + "branch" : """ +src branch [list|-l|create|-c|delete|del|-d] ['name'] ['file'...] + + List branches (with '-l'), create branches (with '-c'), or delete + branches (with '-d'). + + In the list produced by '-', the active branch is first in the list. + + Branch creation and deletion require a following name argument. + Branch creation will not step on an existing branch name, and a + nonexistent branch cannot be deleted. +""", + "log" : """ +src log [-v] [(-<n>|-l <n>)] [(-p|-u|-c) [-b|-w]] ['revision-range'] ['file'...] + + Sends log information about the specified commits of each file to + standard output. The log information includes the revision number, + the date, and the log comment. + + With no revision, dumps a log of the entire current branch. + + The '--patch', '-p' or '-u' option additionally sends a unified + format diff listing to standard output for each revision against its + immediate ancestor revision; '-c' emits a context diff instead. When + generating a diff, '-b' ignores changes in the amount of whitespace, + and '-w' ignores all whitespace. + + The '-<n>' or '-l <n>' option, where <n> is a number, can be used to + limit the listing length. + + Histories imported via 'fast-import' (when not using its '-p' option) + have RFC-822-style headers inserted into the log comment to preserve + metadata not otherwise representable in SRC, such as distinct author + and committer identifications and dates. These headers are normally + suppressed by 'log', however, '-v' shows a summarized view of + important headers; '-v -v' shows all headers as-is. +""", + "list" : """ +src list [(-<n>|-l <n>)] [-f 'fmt'] ['revision-range'] ['file'...] + + Sends summary information about the specified commits of each file + to standard output. The summary information includes the revision + number, the date, and the first line of the log comment. + + This command is provided assuming you will use the good practice of + beginning each commit with a self-contained summary line. + + With no revision, dumps a log of the entire current branch. + + The '-f' option allows you to set a custom format string. Available + substitutions are: + + {0} - the file name + {1} - the revision number + {2} - the mark '*' if this is the currently checked out revision, else '-'. + {3} - the date in RFC3339 format + {4} - the summary line + + The '-<n>' or '-l <n>' option, where <n> is a number, can be used to + limit the listing length. + + 'li' is a synonym for 'list'. +""", + "diff" : """ +src diff [(-u|-c) [-b|-w]] ['revision-range'] ['file'...] + + Sends a diff listing to standard output. + + With no revision spec, diffs the working copy against the last + version checked in. With one revno, diffs the working copy against + that stored revision; with a range, diff between the beginning and + end of the range. + + The actual difference generation is done with diff(1). The default + diff format is '-u' (unified), but if you specify a '-c' option + after the verb a context diff will be emitted. '-b' ignores changes + in the amount of whitespace, and '-w' ignores all whitespace. + + 'di' is a synonym for 'diff'. +""", + "fast-export" : """ +src fast-export ['revision-range'] ['file'...] + + Export one or more projects to standard output as a git fast-import + stream. This can be consumed by 'git fast-import' to create a Git + repository containing the project history. + + It is possible (though probably not very useful) to fast-export a + limited range of commits, producing an incremental dump. In this + case branch joins are done with the magic ^0 suffix. + + Fast-exporting multiple files produces a single stream with a joint + history. + + For a history originally imported from elsewhere, author and + committer identification is gleaned from the RFC-822-style headers + inserted into the commit comment by 'fast-import' (if its '-p' + option was not used). Otherwise, this information is copied from + your Git configuration. + + The default range is all commits. +""", + "fast-import" : """ +src fast-import [-p] ['file'...] + + Parse a git-fast-import stream from standard input. The + modifications for each individual file become separate SRC + histories. Give arguments to restrict the files imported. + + The import is actually done with the rcs-fast-import(1) tool, which + must be on your $PATH for this command to work. + + Some gitspace metadata cannot be represented in the SRC/RCS + model of version control. Mark, committer and author data, and + mark cross-references to parent commits. These are preserved in + RFC-822-style headers on log comments unless the '-p' (plain) option + is given, in which case this metadata is discarded. +""", + "ignores" : """ +Making SRC Ignore Certain Files + + You can have a file named '.srcignore' containing the names of files + that SRC should ignore, or more commonly patterns describing files + to ignore. + + When SRC is told to ignore a file, it won't show up in 'src status' + listings unless the '-a' (all) flag is used or you give it as an + explicit argument. It will also be ignored when commands that + expect a list of registered files see it (which could easily happen + when you use shell wildcards in SRC commands). + + Other version-control systems have these too. The classic example + of how to do this is using the pattern '*.o' to ignore C object + files. But if you need to do that, you should probably be using a + multi-file VCS with changesets, not this one. + + Patterns that might be useful with single-file projects include + '*~' to ignore editor backup files, or '*.html' if you're writing + documents that render to HTML but aren't sourced in it. + + The repo subdirectory - normally '.src' - is always ignored, but + '.srcignore' itself is not automatically ignored. + + SRC's pattern syntax is that of Unix glob(3), with initial '!' + treated as a negation operator. This is forward-compatible to Git's + ignore syntax. + + * matches any string of characters. + ? matches any single character. + [] brackets a character class; it matches any character in the + class. So, for example, [0123456789] would match any decimal + digit. + [!] brackets a negated character class; [!0123456789] would match + any character not a decimal digit. +""", + } + +def help_method(*args): + "Summarize src commands, or (with argument) show help for a single command." + if not args: + sys.stdout.write(help_topics['topics']) + for arg in args: + if arg in args: + if arg in help_topics: + sys.stdout.write(help_topics[arg]) + else: + croak("%s is not a known help topic.\n%s" % (arg, help_topics['topics'])) + +def parse_as_revspec(token): + "Does this look like something that should be parsed as a revision spec?" + if "/" in token: + return False + elif ".." in token: + return True + elif token.count("-") == 1 and '.' not in token: + return True + elif token.isdigit(): + return True + elif token.startswith("@"): # Escape clause for tags that look like files + return True + else: + return False + +ignorable = None + +def ignore(filename): + "Should the specified file be ignored?" + global ignorable + if ignorable is None: + ignorable = set([]) + ignorable = set() + if os.path.exists(".srcignore"): + with open(".srcignore", "rb") as fp: + for line in fp: + # Use polystr to ensure internal data is Unicode in Python 3 + line = polystr(line) + if line.startswith("#") or not line.strip(): + continue + elif line.startswith("!"): + ignorable -= set(glob.glob(line[1:].strip())) + else: + ignorable |= set(glob.glob(line.strip())) + return (filename == repodir) or (filename in ignorable) + +class CommandContext: + "Consume a revision specification or range from an argument list" + def __init__(self, cmd, args, + require_empty=False, + default_to=None, + parse_revspec=True): + if not os.path.exists(repodir): + croak("repository subdirectory %s does not exist" % repodir) + self.start = self.end = None + self.seq = None + self.branchwise = None + if type(args) == type(()): + args = list(args) + self.args = list(args) + self.default_to = default_to + self.lo = self.start + self.hi = self.end + revspec = None + if self.args: + if self.args[0] == "--": + self.args.pop(0) + elif parse_revspec and parse_as_revspec(args[0]): + revspec = self.args.pop(0) + if revspec.startswith("@"): + revspec = revspec[1:] + try: + if "-" in revspec or ".." in revspec: + self.branchwise = ".." in revspec + try: + (self.start, self.end) = revspec.split("-") + except ValueError: + try: + (self.start, self.end) = revspec.split("..") + except ValueError: + croak("internal error - argument parser is confused") + try: + self.start = int(self.start) + except ValueError: + pass + try: + self.end = int(self.end) + except ValueError: + pass + else: + try: + self.end = self.start = int(revspec) + except ValueError: + self.end = self.start = revspec + except ValueError: + croak("malformed revision spec: %s" % revspec) + if require_empty and not self.is_empty(): + croak("%s doesn't take a revision spec" % cmd) + if not self.args: + try: + masters = [fn for fn in os.listdir(repodir) if is_history(fn)] + masters.sort() + except OSError: + croak("repo directory %s does not exist" % repodir) + if masters: + self.args += [backend.workfile(master) for master in masters] + else: + croak("%s requires at least one file argument" % cmd) + def is_empty(self): + "Is the spec empty?" + return self.start is None + def is_singleton(self): + "Is the spec a singleton?" + return self.start is not None and self.start == self.end + def is_range(self): + "Is the spec a range?" + return self.start is not None and self.start != self.end + def select_all(self, metadata): + "Set the range to all revisions." + self.lo = 1 + self.hi = len(metadata) + self.seq = [metadata.by_revno(i) for i in range(self.lo, self.hi+1)] + def select_tip(self, metadata): + "Set the range to the tip revision." + self.lo = len(metadata) + self.hi = None + self.seq = [metadata.by_revno(self.lo)] + def __contains__(self, i): + "Does the spec contain the given revno?" + if self.seq is None: + croak("revision spec hasn't been resolved") + return i in self.seq + def resolve(self, metadata): + "Resolve a revision spec that may contain tags into revnos." + if debug >= DEBUG_SEQUENCE: + sys.stderr.write("Entering resolve with start=%s, end=%s\n" % (self.start, self.end)) + if self.is_empty(): + if self.default_to == "branch": + self.seq = metadata.current_branch(backwards=False) + elif self.default_to == "branch_reversed": + self.seq = metadata.current_branch(backwards=True) + else: + self.seq = [] + return self.seq + def subresolve(token): + part = token + if type(part) == type(0): + return part + if token == '': # User specified @ + current = metadata.current() + if current is None: + croak("in {0}, no current revision".format(metadata.name)) + return current.revno + if part not in metadata.symbols: + croak("in {0}, can't resolve symbol {1}".format(metadata.name,token)) + else: + part = metadata.symbols[part] + if backend.isbranch(part): + part = backend.branch_to_tip(part, metadata) + return metadata.native_to_revno(part) + self.lo = subresolve(self.start) + self.hi = subresolve(self.end) + mreversed = (self.lo > self.hi) + if mreversed: + swapme = self.hi + self.hi = self.lo + self.lo = swapme + if self.hi > len(metadata): + croak("{0} has no {1} revision".format(metadata.name, self.hi)) + if not self.branchwise: + self.seq = [metadata.by_revno(i) for i in range(self.lo, self.hi+1)] + else: + self.seq = [] + e = metadata.by_revno(self.hi) + while True: + self.seq.append(e) + if e.revno == self.lo: + break + if e.parent is None: + croak("%s is not an ancestor of %s" % (self.lo, self.hi)) + else: + e = e.parent + if debug >= DEBUG_SEQUENCE: + sys.stderr.write("selection: %s, branchwise is %s\n" % ([x.revno for x in self.seq], "on" if self.branchwise else "off")) + for item in metadata.revlist: + sys.stdout.write("%s\t%s\t%s\n" % (item.revno, item.date, item.native)) + # Because in the branchwise case the sequence is generated in reverse + if self.branchwise: + self.seq.reverse() + # Range might have been reversed + if mreversed: + self.seq.reverse() + return self.seq + +class CommentContext: + COMMENT_CUTLINE = """\ +............................................................................. +""" + COMMENT_EXPLANATION = """\ +The cut line and things below it will not become part of the comment text. +""" + def __init__(self, legend, args): + "Attempt to collect a comment from command line args." + self.leader = "" + self.comment = None + self.force_edit = False + self.parse_revspec = True + if args: + if args[0] == '--': + self.parse_revspec = False + args.pop(0) + elif args[0] == "-": + self.leader = sys.stdin.read() + args.pop(0) + elif args[0] == "-e": + self.force_edit = True + elif args[0] == "-m": + args.pop(0) + try: + self.leader = args[0] + args.pop(0) + except IndexError: + croak("%s -m requires a following string" % legend) + elif args[0].startswith("-m"): + self.leader = args[2:] + args.pop(0) + elif args[0] == "-f": + args.pop(0) + try: + # Read filesystem data as binary for robustness, but + # decode to Unicode for internal use + with open(args[0], "rb") as fp: + self.leader = polystr(fp.read()) + args.pop(0) + except IndexError: + croak("%s -f requires a following filename argument" % legend) + except OSError: + croak("couldn't open %s." % args[1]) + elif args[0].startswith("-"): + croak("unexpected %s option" % args[0]) + def edit(self, content="", trailer="", diff=None): + "Interactively edit a comment if required, then prepare for handoff." + if self.leader and not self.force_edit: + self.comment = self.leader + else: + orig_content = content + if self.leader: + content = self.leader + content + if trailer or diff: + content += "\n" + CommentContext.COMMENT_CUTLINE + CommentContext.COMMENT_EXPLANATION + trailer + editor = os.getenv("EDITOR") or "emacsclient" + try: + # Use polybytes to ensure that binary data is written + # on Python 3 + with tempfile.NamedTemporaryFile(prefix = "src", suffix = "tmp", delete = False) as fp: + commentfile = fp.name + fp.write(polybytes(content)) + if diff: + fp.write(b"\nChanges to be committed:\n") + for s in diff: + fp.write(polybytes(s)) + fp.write(b"\n") + do_or_die(editor + " " + commentfile, mute=False) + # Use polystr to ensure that incoming data is decoded + # to Unicode on Python 3 + with open(commentfile, "rb") as fp: + self.comment = polystr(fp.read()) + os.unlink(commentfile) + except IOError: + croak("edit aborted.") + where = self.comment.find(CommentContext.COMMENT_CUTLINE) + if where != -1: + self.comment = self.comment[:where] + self.comment = self.comment.strip() + if self.comment == orig_content.strip() or not self.comment: + return False + # Can be removed if we ever parse RCS/SCCS files directly + for badnews in backend.delimiters: + if badnews in self.comment: + croak("malformed comment") + if not self.comment.endswith("\n"): + self.comment += "\n" + return True + def content(self): + "Return the edited comment." + return self.comment + +def external_diff(file0, s0, file1, s1, unified=True, ignore_ws=None): + "Compute diff using external program." + def writetmp(s): + with tempfile.NamedTemporaryFile(prefix = "src", suffix = "tmp", delete = False) as fp: + fp.write(polybytes(s)) + return fp.name + if unified: + opts, tok0, tok1 = '-u', '-', '+' + else: + opts, tok0, tok1 = '-c', '*', '-' + if ignore_ws: + opts += ' ' + ignore_ws + tmp0 = writetmp(s0) + tmp1 = writetmp(s1) + with popen_or_die('diff %s "%s" "%s" || :' % (opts, tmp0, tmp1)) as fp: + diff = polystr(fp.read()) + os.unlink(tmp0) + os.unlink(tmp1) + diff = re.sub(r"(?m)^([%s]{3} ).+$" % tok0, r"\1" + file0, diff, 1) + diff = re.sub(r"(?m)^([%s]{3} ).+$" % tok1, r"\1" + file1, diff, 1) + return diff.split('\n') + +def compute_diff(metadata, lo, hi, differ, ignore_ws=None): + """Compute diff between two revs of a file. + If 'lo' is None, then this is a "creation event" in which 'hi' + materializes fully formed from nothing. + If 'hi' is None, then diff 'lo' against the working file. + File must already have been "lifted".""" + name = metadata.name + if lo is None: + file0 = '/dev/null' + old_content = '' + else: + file0 = name + ' (r' + str(lo) + ')' + with backend.cat(name, metadata.revno_to_native(lo)) as fp: + old_content = fp.read() # this data will be binary + if hi is None: + file1 = name + ' (workfile)' + with open(name, "rb") as fp: + new_content = fp.read() + else: + file1 = name + ' (r' + str(hi) + ')' + with backend.cat(name, metadata.revno_to_native(hi)) as fp: + new_content = fp.read() # this data will be binary + # Don't list identical files (comparison uses binary data + # for maximum accuracy). + if old_content == new_content: + return () + # All operations here will need Unicode in Python 3 + if ignore_ws: + return external_diff(file0, old_content, file1, new_content, + differ == difflib.unified_diff, ignore_ws) + lines0 = polystr(old_content).split('\n') + lines1 = polystr(new_content).split('\n') + return differ(lines0, lines1, + fromfile=file0, + tofile=file1, + lineterm="") + +def colorize_unified(s): + for x in colorize_unified.colors: + if s.startswith(x[0]): + return x[1] + s + RESET + return s + +colorize_unified.colors = (('+++ ', BOLD), ('--- ', BOLD), ('@@ ', CCYAN), + ('+', CGREEN), ('-', CRED)) + +def print_diff(metadata, lo, hi, differ, ignore_ws=None): + "Dump diff between revisions to standard output." + if differ == difflib.unified_diff: + colorizer = colorize_unified + else: + colorizer = lambda x: x + with backend.lifter(metadata.name): + for line in compute_diff(metadata, lo, hi, differ, ignore_ws): + sys.stdout.write(colorizer(line) + "\n") + +def commit_method(*args): + "Commit changes to files." + if not os.path.exists(repodir): + try: + os.mkdir(repodir) + except OSError: + croak(" %s creation failed, check directory permissions." % repodir) + args = list(args) + register_only = False + parse_revspec = True + differ = difflib.unified_diff + while args: + if args[0] == '--': + args.pop(0) + parse_revspec = False + break + elif args[0] == '-a': + # The Emacs VC-mode support for SRC was written early, + # before 1.0, when I hadn't quite figured out what the + # most efficient method for file reigstration would be. + # VC-mode wants to have a registration as well as a + # checkin method. This is no longer needed for normal SRC + # operation, but it's better to leave this in place than + # risk causing hassles for people running old Enacs versions. + register_only = True + parse_revspec = False + args.pop(0) + else: + break + if not register_only: + comment = CommentContext("commit", args) + ctx = CommandContext("commit", args, + require_empty=True, + parse_revspec=parse_revspec and comment.parse_revspec) + for arg in ctx.args: + if not os.path.exists(arg): + croak("I see no '%s' here." % arg) + if os.path.isdir(arg): + croak("cannot commit directory '%s'" % arg) + for arg in ctx.args: + if not registered(arg): + trailer = "Committing initial revision of {0}.\n".format(arg) + revcount = 0 + metadata = None + diff = compute_diff(type('', (), {'name':arg}), None, None, differ) + elif register_only: + croak("attempt to re-add a registered file failed") + else: + metadata = History(arg) + ctx.resolve(metadata) + if metadata and ctx.is_empty(): + ctx.select_tip(metadata) + revcount = len(metadata) + trailer = "Committing {0} revision {1}.\n".format(arg, revcount+1) + with backend.lifter(arg): + diff = compute_diff(metadata, ctx.lo, None, differ) + if not register_only and not diff: + announce("in %s, no changes to commit" % arg) + continue + if not register_only and not comment.edit('', trailer, diff): + announce("in %s, commit cancelled" % arg) + else: + if not registered(arg): + backend.register(arg) + if not register_only: + with backend.lifter(arg): + # If the user changed the executable bit while + # modifying the workfile, propagate this change to + # the master. Without this hack, the sequence (1) + # Commit workfile (2) Make workfile executable, (3) + # checkin workfile fails to work as expected because + # the VCS doesn't propagate the changed executable + # bit to the master, leading to misbehavior on the + # next checkout. + master = os.path.basename(backend.history(arg)) + if os.path.exists(master): + oldmastermode = newmastermode = os.stat(master).st_mode + userworkmode = os.stat(arg).st_mode + for bitmask in (stat.S_IXUSR, stat.S_IXGRP, stat.S_IXOTH): + if bitmask & userworkmode: + newmastermode |= bitmask + else: + newmastermode &=~ bitmask + if newmastermode != oldmastermode: + os.chmod(master, newmastermode) + backend.checkin(arg, comment.content()) + if metadata is None: + metadata = History(arg) + modified(arg, metadata) + if not quiet and len(args) > 1: + announce("%s -> %d" % (arg, revcount)) + +def add_method(*args): + "For Emacs VC compatibility." + commit_method("-a", *args) + +def amend_method(*args): + "Amend comments in stored revisions." + if not os.path.exists(repodir): + croak("repository subdirectory %s does not exist" % repodir) + args = list(args) + differ = difflib.unified_diff + comment = CommentContext("amend", args) + ctx = CommandContext("amend", args, parse_revspec=comment.parse_revspec) + if ctx.is_range(): + croak("amend cannot take a range") + for arg in ctx.args: + if not os.path.exists(arg): + croak("I see no '%s' here." % arg) + elif not registered(arg): + croak("%s is not registered." % arg) + for arg in ctx.args: + metadata = History(arg) + ctx.resolve(metadata) + if ctx.is_empty(): + ctx.lo = metadata.tip().revno + trailer ="Amending {0} revision {1}.\n".format(arg, ctx.lo) + item = metadata.by_revno(ctx.lo) + parent = item.parent.revno if item.parent else None + with backend.lifter(arg): + diff = compute_diff(metadata, parent, ctx.lo, differ) + if not comment.edit(metadata.by_revno(ctx.lo).log, trailer, diff): + announce("in %s, amend cancelled" % arg) + else: + with backend.lifter(arg): + backend.amend(arg, + metadata.revno_to_native(ctx.lo), + comment.content()) + if not quiet and len(args) > 1: + announce("%s : %d" % (arg, ctx.lo)) + +def list_method(*args): + "Generate a summary listing of commits, one line per commit." + args = list(args) + custom = None + limit = None + parse_revspec = True + while args and args[0].startswith("-"): + if args[0] == '--': + parse_revspec = False + args.pop(0) + break + elif args[0] == "-f": + args.pop(0) + try: + custom = args[0] + args.pop(0) + except IndexError: + croak("list -f requires a following string") + elif args[0].startswith("-f"): + custom = args[0][2:] + args.pop(0) + elif args[0] == "-l": + args.pop(0) + try: + limit = args[0] + args.pop(0) + limit = int(limit) + except IndexError: + croak("list -l requires a following integer") + except ValueError: + croak("%s is not an integer" % limit) + elif args[0][1:].isdigit(): + limit = int(args.pop(0)[1:]) # it's all digits, so no ValueError + else: + croak("unexpected %s option" % args[0]) + ctx = CommandContext("list", args, + default_to="branch_reversed", + parse_revspec=parse_revspec) + for arg in ctx.args: + if ignore(arg) or os.path.isdir(arg) or not registered(arg): + continue + if custom is None: + sys.stdout.write("= %s %s\n" % (arg, ((WIDTH - len(arg) - 3) * "="))) + for item in ctx.resolve(History(arg)): + # Must allow enough room for revno and date + if item.selected(): + mark = "*" + else: + mark = "-" + summary = item.log.split('\n')[0] + if custom is None: + summary = summary[:WIDTH - 34] + sys.stdout.write("%-4d %s %s %s\n" \ + % (item.revno, mark, item.getdate('author'), summary)) + else: + sys.stdout.write(custom.format(arg, item.revno, mark, item.getdate('author'), summary)) + if limit is not None: + limit -= 1 + if limit <= 0: + break + +def log_method(*args): + "Report revision logs" + limit = None + args = list(args) + parse_revspec = True + differ = None + ignore_ws = None + verbose = 0 + while args and args[0].startswith("-"): + if args[0] == '--': + parse_revspec = False + args.pop(0) + break + elif args[0] == "-l": + args.pop(0) + try: + limit = args[0] + args.pop(0) + limit = int(limit) + except IndexError: + croak("list -l requires a following integer") + except ValueError: + croak("%s is not an integer" % limit) + elif args[0][1:].isdigit(): + limit = int(args.pop(0)[1:]) # it's all digits, so no ValueError + elif args[0] in ('--patch', '-p', '-u'): + differ = difflib.unified_diff + args.pop(0) + elif args[0] == '-c': + differ = difflib.context_diff + args.pop(0) + elif args[0] in ('-b', '-w'): + ignore_ws = args.pop(0) + elif args[0] == '-v': + verbose += 1 + args.pop(0) + else: + croak("unexpected %s option" % args[0]) + ctx = CommandContext("log", args, + default_to="branch_reversed", + parse_revspec=parse_revspec) + for arg in ctx.args: + if ignore(arg) or os.path.isdir(arg) or not registered(arg): + continue + sys.stdout.write("= %s %s\n" % (arg, ((WIDTH - len(arg) - 3) * "="))) + metadata = History(arg) + for item in ctx.resolve(metadata): + sys.stdout.write("%s%-4d%s | %s%s%s | %s%s%s\n" \ + % (BOLD + CCYAN, item.revno, RESET, + CYELLOW, item.getdate('author'), RESET, + BOLD + CGREEN, item.branch, RESET)) + if verbose and item.headers: + headers = item.headers + if verbose == 1: + headers = {} + for k in ('author', 'committer'): + if k in item.headers: + v = item.headers[k] + if k + "-date" in item.headers: + v += " " + item.headers[k + "-date"] + headers[k] = v + for k in sorted(headers.keys()): + sys.stdout.write("%s%s%s: %s\n" % + (BOLD, k.title(), RESET, headers[k])) + sys.stdout.write("\n") + sys.stdout.write("%s" % item.log) + if differ: + if item.parent: + parent = item.parent.revno + pdesc = 'r%d/%s' % (parent, arg) + else: + parent = None + pdesc = '/dev/null' + sys.stdout.write("\n%sdiff %s r%s/%s%s\n" % + (BOLD, pdesc, item.revno, arg, RESET)) + print_diff(metadata, parent, item.revno, differ, ignore_ws) + sys.stdout.write(("-" * WIDTH) + "\n") + if limit is not None: + limit -= 1 + if limit <= 0: + break + +def checkout_method(*args): + "Refresh the working copy from the history file." + ctx = CommandContext("checkout", args) + if ctx.is_range(): + croak("checkout needs an empty or singleton revision spec") + for arg in ctx.args: + metadata = History(arg) + ctx.resolve(metadata) + if ctx.is_empty(): + ctx.select_tip(metadata) + revision = "" + elif ctx.lo > len(metadata): + croak("%s has only %d revisions" % (arg, len(metadata))) + else: + revision = metadata.revno_to_native(ctx.lo) + with backend.lifter(arg): + backend.checkout(arg, revision) + if not quiet and len(args) > 1: + announce("%s <- %d" % (arg, ctx.lo)) + +def status_method(*args): + "Get status of some or all files." + try: + managed = [fn for fn in os.listdir(repodir) if is_history(fn)] + except OSError: + croak("repo directory %s does not exist" % repodir) + args = list(args) + allflag = False + if args: + if args[0] == '--': + args.pop(0) + elif args[0] == '-a': + allflag = True + args.pop(0) + if args: + candidates = args + else: + candidates = [f for f in os.listdir(".") if f != repodir] + pairs = [] + for fn in candidates: + if ignore(fn): + if allflag or fn in args: + pairs.append((fn, "I")) + continue + masterbase = os.path.basename(backend.history(fn)) + if masterbase not in managed: + if allflag or fn in args: + if not os.access(fn, os.R_OK): + croak("%s does not exist or is unreadable." % fn) + else: + pairs.append((fn, "?")) + elif not os.path.exists(fn): + pairs.append((fn, "!")) + elif modified(fn): + pairs.append((fn, "M")) + elif not os.access(fn, os.W_OK): + pairs.append((fn, "L")) + elif not backend.has_revisions(fn): + pairs.append((fn, "A")) + else: + pairs.append((fn, "=")) + if not args: + for m in managed: + if backend.workfile(m) not in candidates: + pairs.append((m, "!")) + pairs.sort() + for (fn, status) in pairs: + sys.stdout.write(status + "\t" + fn + "\n") + +def cat_method(*args): + "Dump revision content to standard output." + ctx = CommandContext("cat", args) + if ctx.is_range(): + croak("cat refuses to cough up a hairball") + elif ctx.args[0] == '--': + ctx.args.pop(0) + for arg in ctx.args: + metadata = History(arg) + ctx.resolve(metadata) + if ctx.is_empty(): + ctx.select_tip(metadata) + with backend.lifter(arg): + for item in ctx.seq: + with backend.cat(arg, item.native) as fp: + # Use polystr to ensure that sys.stdout gets Unicode + # in Python 3. + sys.stdout.write(polystr(fp.read())) + +def diff_method(*args): + "Dump diffs between revisions to standard output." + if type(args) == type(()): + args = list(args) + differ = difflib.unified_diff + ignore_ws = None + while args and args[0] != '--' and args[0].startswith("-"): + if args[0] == '-u': + differ = difflib.unified_diff + args.pop(0) + elif args[0] == '-c': + differ = difflib.context_diff + args.pop(0) + elif args[0] in ('-b', '-w'): + ignore_ws = args.pop(0) + else: + croak("unexpected %s option" % args[0]) + ctx = CommandContext("diff", args) + for arg in ctx.args: + metadata = History(arg) + ctx.resolve(metadata) + if ctx.is_empty(): + ctx.select_tip(metadata) + print_diff(metadata, ctx.lo, ctx.hi, differ, ignore_ws) + +def tag_helper(args, legend, validation_hook, delete_method, set_method): + "Dispatch to handlers for tag and branch manipulation." + if not os.path.exists(repodir): + croak("repository subdirectory %s does not exist" % repodir) + args = list(args) + if not args: + args = ["list"] + args + if args[0] == '--': + args.pop(0) + else: + if args[0] in ("-d", "del", "delete"): + args.pop(0) + if not args: + croak("%s deletion requires a name argument" % legend) + name = args.pop(0) + ctx = CommandContext(legend, args) + if not ctx.is_empty(): + croak("can't accept a revision-spec when deleting a %s." % legend) + for arg in ctx.args: + metadata = History(arg) + if name not in metadata.symbols: + croak("in %s, %s is not a symbol" % (arg, name)) + elif backend.isbranch(metadata.symbols[name]) != (legend == "branch"): + croak("in %s, %s is not a %s" % (arg, name, legend)) + else: + with backend.lifter(arg): + delete_method(name, metadata) + if not quiet and len(args) > 1: + announce("in %s, %s %s removed" % (arg, legend, name)) + return + if args[0] in ("-l", "list"): + args.pop(0) + ctx = CommandContext(legend + " listing", args, require_empty=True) + for arg in ctx.args: + metadata = History(arg) + ctx.resolve(metadata) + if ctx.is_empty(): + prepend = [0] + ctx.select_all(metadata) + else: + prepend = [] + revisions = prepend + [item.revno for item in ctx.seq] + sys.stdout.write("= %s %s\n" \ + % (arg, ((WIDTH - len(arg) - 5) * "="))) + keys = list(metadata.symbols.keys()) + if metadata.branch in keys: + keys.remove(metadata.branch) + keys.sort() + keys = [metadata.branch] + keys + for key in keys: + value = metadata.symbols[key] + if legend == "branch": + # Note! This code relies on + # backend.branch_to_parent() returning an empty + # string when called on a trunk revision. + displaystr = backend.branch_to_parent(value) + if not displaystr: + display = 0 + else: + display = metadata.native_to_revno(displaystr) + else: + display = metadata.native_to_revno(value) + if display in revisions and backend.isbranch(value) == (legend == "branch"): + sys.stdout.write("%4s\t%s\n" % (display,key)) + return + if args[0] in ("-c", "create"): + args.pop(0) + if not args: + croak("%s setting requires a name argument" % legend) + name = args.pop(0) + ctx = CommandContext(legend, args) + if ctx.is_range(): + croak("can't accept a range when setting a %s" % legend) + for arg in ctx.args: + metadata = History(arg) + revision = validation_hook(ctx, metadata, name) + with backend.lifter(arg): + set_method(name, revision, metadata) + if not quiet and len(args) > 1: + announce("in %s, %s %s = %s" % (arg, legend, name, ctx.start)) + return + else: + croak("%s requires a list, create, or delete modifier" % legend) + +def tag_method(*args): + "Inspect, create, and delete tags." + def tag_set_validate(ctx, metadata, name): + ctx.resolve(metadata) + if name in metadata.symbols: + croak("tag %s already set." % name) + if ctx.is_empty(): + ctx.select_tip(metadata) + return metadata.revno_to_native(ctx.lo) + tag_helper(args, "tag", + tag_set_validate, + backend.delete_tag, backend.set_tag) + +def branch_method(*args): + "Inspect, create, and delete branches." + def branch_set_validate(ctx, _metadata, _name): + if not ctx.is_empty(): + croak("cannot accept a revision after a branch name") + tag_helper(args, "branch", + branch_set_validate, + backend.delete_branch, backend.set_branch) + +def rename_method(*args): + "Rename a branch or tag." + args = list(args) + if not args or args[0] not in ("tag", "branch"): + croak("rename requires a following 'tag' or 'branch'") + legend = args.pop(0) + if not args: + croak("rename requires a source name argument") + name = args.pop(0) + if not args: + croak("rename requires a target name argument") + newname = args.pop(0) + ctx = CommandContext(legend + " renaming", args, require_empty=True) + for arg in ctx.args: + metadata = History(arg) + if name not in metadata.symbols: + croak("in %s, cannot rename nonexistent %s %s" % (arg, legend, name)) + if newname in metadata.symbols: + croak("in %s, cannot rename to existing %s %s" % (arg, legend, name)) + ctx = CommandContext(legend, args) + if not ctx.is_empty(): + croak("can't accept a revision-spec when renaming a %s." \ + % legend) + # In the case of a branch, we want to change only the + # tag reference. + with backend.lifter(arg): + backend.set_tag(newname, metadata.symbols[name], metadata) + backend.delete_tag(name, metadata) + if not quiet and len(args) > 1: + announce("in %s, %s -> %s" % (arg, name, newname)) + +def filecmd(legend, hook, args): + CommandContext(legend, args, require_empty=True) + if len(args) != 2: + croak("%s requires exactly two arguments" % legend) + (source, target) = args + if not os.path.exists(source): + croak("I see no '%s' here." % source) + elif os.sep in target and not os.path.exists(os.path.dirname(target)): + croak("directory '%s' does not exist." % target) + elif not registered(source): + croak("%s is not registered" % source) + elif registered(target): + croak("%s is registered, I won't step on it" % source) + elif os.path.exists(target): + croak("%s exists, please delete manually if you want it gone" % target) + else: + hook(source, target) + +def move_method(*args): + "Move a file and its history." + filecmd("move", backend.move, args) + +def copy_method(*args): + "Copy a file and its history." + filecmd("copy", backend.copy, args) + +def release_method(*args): + "Release locks." + ctx = CommandContext("release", args, require_empty=True) + for arg in ctx.args: + if not os.path.exists(arg): + croak("I see no '%s' here." % arg) + elif not registered(arg): + croak("%s is not registered, skipping" % arg) + else: + with backend.lifter(arg): + backend.release(arg) + +def ls_method(*args): + "List registered files." + if args: + croak("ls cannot accept arguments") + try: + masters = [fn for fn in os.listdir(repodir) if is_history(fn)] + except OSError: + croak("repo directory %s does not exist" % repodir) + masters.sort() + for master in masters: + sys.stdout.write(backend.workfile(master) + "\n") + +def visualize_method(*args): + "Generate (and possibly display) a DOT visualization of repo structure." + if not os.path.exists(repodir): + croak("repository subdirectory %s does not exist" % repodir) + args = list(args) + comment = CommentContext("visualize", args) + ctx = CommandContext("visualize", args, parse_revspec=comment.parse_revspec) + for arg in ctx.args: + #if not os.path.exists(arg): + # croak("I see no '%s' here." % arg) + if not registered(arg): + croak("%s is not registered." % arg) + for arg in ctx.args: + metadata = History(arg) + ctx.resolve(metadata) + if ctx.is_empty(): + ctx.select_all(metadata) + sys.stdout.write("digraph {\n") + if len(ctx.args) > 1: + pref = arg + ":" + else: + pref = "" + for item in ctx.seq: + if item.parent: + sys.stdout.write('\t%s%d -> %s%d;\n' \ + % (pref, item.parent.revno, pref, item.revno)) + summary = cgi.escape(item.log.split('\n')[0][:42]) + sys.stdout.write('\t%s%s [shape=box,width=5,label=<<table cellspacing="0" border="0" cellborder="0"><tr><td><font color="blue">%s%s</font></td><td>%s</td></tr></table>>];\n' \ + % (pref, item.revno, pref, item.revno, summary)) + if metadata.tip(item.native) == item: + sys.stdout.write('\t"%s%s" [shape=oval,width=2];\n' \ + % (pref, item.branch)) + sys.stdout.write('\t"%s%s" -> "%s%s" [style=dotted];\n' \ + % (pref, item.revno, pref, item.branch)) + keys = sorted(metadata.symbols.keys()) + for name in keys: + native = metadata.symbols[name] + branch_label = backend.isbranch(native) + if branch_label: + native = backend.branch_to_tip(native, metadata) + revno = metadata.native_to_revno(native) + if not branch_label: + sys.stdout.write('\t{rank=same; "%s%s"; "%s%s"}\n' \ + % (pref, name, pref, revno)) + sys.stdout.write('\t"%s%s" -> "%s%s" [style=dotted];\n' \ + % (pref, name, pref, revno)) + sys.stdout.write("}\n") + +def fast_export_method(*args): + "Dump revision content to standard output." + def attribute(item, who, fallback): + s = fallback + if item.headers and who in item.headers: + s = item.headers[who] + t, o = item.unixtime(who) + return "%s %s %d %s%02d%02d\n" % \ + ((who, s, t, "-" if o < 0 else "+") + divmod(abs(o), 3600)) + ctx = CommandContext("fast-export", args) + mark = 0 + if pseudotime: + username = "J. Random Hacker" + useremail = "jrh@nowhere.man" + else: + # Use polystr to ensure that data is decoded to Unicode on + # Python 3. + username = polystr(capture_or_die("git config --get user.name")).strip() + useremail = polystr(capture_or_die("git config --get user.email")).strip() + attribution = "%s <%s>" % (username, useremail) + for arg in ctx.args: + if not registered(arg): + croak("%s is not registered" % arg) + executable = os.stat(backend.history(arg)).st_mode & stat.S_IXUSR + if executable: + perms = "100755" + else: + perms = "100644" + metadata = History(arg) + ctx.resolve(metadata) + if ctx.is_empty(): + ctx.select_all(metadata) + with backend.lifter(arg): + markmap = {} + last_commit_mark = 0 + if len(ctx.args) == 1: + branch = "master" + else: + # FIXME: Make this canonical even with tags + branch = arg + "/master" + oldbranch = branch + for c in "~^\\*?": + branch = branch.replace(c, "") + if not branch: + croak("branch name %s is ill-formed" % oldbranch) + elif branch != oldbranch: + announce("branch name %s sanitized to %s" % (oldbranch, branch)) + for i in range(ctx.lo, ctx.hi+1): + item = metadata.by_revno(i) + with backend.cat(arg, item.native) as fp: + content = fp.read() # this data will be binary + size = len(content) # size will be # of bytes, as desired + mark += 1 + markmap[item.revno] = mark + sys.stdout.write("blob\nmark :%d\ndata %d\n" % (mark, size)) + # Use polystr to ensure sys.stdout gets Unicode in Python 3 + sys.stdout.write(polystr(content) + "\n") + if item.revno == ctx.lo: + if ctx.lo == 1: + sys.stdout.write("reset refs/heads/%s\n" % branch) + else: + sys.stdout.write("from refs/heads/%s^0\n" % branch) + sys.stdout.write("commit refs/heads/%s\n" % branch) + sys.stdout.write("mark :%d\n" % (mark + 1)) + sys.stdout.write(attribute(item, "author", attribution)) + sys.stdout.write(attribute(item, "committer", attribution)) + sys.stdout.write("data %s\n%s" % (len(item.log), item.log)) + if last_commit_mark: + sys.stdout.write("from :%d\n" % last_commit_mark) + if len(arg.split()) > 1: + arg = '"' + arg + '"' + sys.stdout.write("M %s :%d %s\n\n" % (perms, mark, arg)) + mark += 1 + last_commit_mark = mark + markmap[item.revno] = mark + sys.stdout.write("reset refs/heads/%s\nfrom :%d\n\n" % (branch, mark)) + for (key, val) in list(metadata.symbols.items()): + val = metadata.native_to_revno(val) + if val in ctx: + sys.stdout.write("reset refs/tags/%s\nfrom :%d\n\n" % (key, markmap[val])) + +def fast_import_method(*args): + "Accept a git fast-import stream on stdin, turn it into file histories." + if not isinstance(backend, RCS): + croak("fast-import is only supported with the RCS backend") + if os.path.exists("RCS"): + croak("refusing to unpack into existing RCS directory!") + # Force -l to fit SRC's lockless interface. + do_or_die(r"rcs-fast-import -l " +" ".join(args), missing="rcs-fast-import") + try: + os.makedirs(repodir) + except OSError: + pass + for fn in os.listdir("RCS"): + corresponding = os.path.join("RCS", os.path.basename(fn)) + fn = os.path.join(repodir, os.path.basename(fn)) + if os.path.exists(fn): + croak("%s exists, aborting leaving RCS in place!" % corresponding) + os.rename(corresponding, fn) + shutil.rmtree("RCS") + + +def version_method(*args): + "Report SRC's version" + sys.stdout.write("src: %s\n" % version) + (major, minor, micro, _releaselevel, _serial) = sys.version_info + sys.stdout.write("python: %s.%s.%s\n" % (major, minor, micro)) + sys.stdout.write("%s: %s\n" % (backend.__class__.__name__,backend.version())) + sys.stdout.write("platform: %s\n" % sys.platform) + +dispatch = { + "help": help_method, + "commit": commit_method, + "ci": commit_method, + "add": add_method, + "amend": amend_method, + "am": amend_method, + "list": list_method, + "li": list_method, + "log": log_method, + "checkout": checkout_method, + "co": checkout_method, + "status": status_method, + "st": status_method, + "cat": cat_method, + "diff": diff_method, + "di": diff_method, + "tag": tag_method, + "branch": branch_method, + "rn": rename_method, + "rename": rename_method, + "ls": ls_method, + "move": move_method, + "mv": move_method, + "copy": copy_method, + "cp": copy_method, + "visualize": visualize_method, + "vis": visualize_method, + "fast-export": fast_export_method, + "fast-import": fast_import_method, + "release": release_method, + "version": version_method, +} + +class RevisionMixin: + "Express common operations on RCS and SCCS revisions." + def splitrev(self, rev): + "RCS revision to numeric tuple." + return [int(d) for d in rev.split(".")] + def joinrev(self, rev): + "Numeric tuple to RCS revision." + return ".".join([str(d) for d in rev]) + def pred(self, rev): + "Our predecessor. Walks up parent branch." + n = self.splitrev(rev) + if n[-1] > 1: + n[-1] -= 1 + rev = self.joinrev(n) + else: + rev = self.joinrev(n[:-2]) + return rev + def succ(self, rev): + "Our successor." + if rev: + n = self.splitrev(rev) + n[-1] += 1 + return self.joinrev(n) + else: + return "1.1" + def isbranch(self, symbol): + "Is this a branch symbol?" + # RCS convention - this could work for SCCS too, if we simulated + # RCS symbols in some way. + return "0." in symbol + def branch_to_parent(self, revid): + "Go from a branch ID sticky tag to the revision it was based on." + # Must return an empty string for the fake root sticky tag. + return self.joinrev(self.splitrev(revid)[:-2]) + def branch_to_base(self, revid, metadata): + "Go from a branch ID sticky tag to the first revision of its branch." + rev = self.branch_to_parent(revid) + if rev: + rev += ".1.1" + else: + rev = "1.1" + return rev + def branch_to_tip(self, revid, metadata): + "Go from a branch ID sticky tag to the tip revision of its branch." + rev = self.branch_to_base(revid, metadata) + while True: + nxt = self.succ(rev) + if metadata.native_to_revno(nxt) is None: + return rev + else: + rev = nxt + croak("internal error: couldn't find branch tip of %s" % rev) + def branch_stickybase(self, name, revision, metadata): + "Compute a base and tip revision for a named branch." + if name in metadata.symbols: + # Must unstickify... + base = metadata.symbols[name].split(".") + base = base[:-2] + base[-1:] + base = ".".join(base) + return(None, base) + else: + def branchfrom(c, p): + "Is c a branch child (not direct descendant) of parent p?" + c = c.split(".") + p = p.split(".") + return len(c) == len(p) + 2 and c[len(p):] == p + baserev = metadata.current() + newsib = len([item for item in metadata.revlist \ + if branchfrom(item.native, baserev)]) + newsib += 1 + base = baserev + "." + str(newsib) + sticky = baserev + ".0." + str(newsib) + return (sticky, base) + def branch_initial(self, branchname, metadata): + "Return initial commit of a named branch (the child of the root)." + # What we need to do is find the branch tip, then walk back to + # just after the first point where it joins another branch, then + # do a delete to end of branch from there. + base = self.branch_to_tip(metadata.symbols[branchname], metadata) + while True: + if base is not None and metadata.by_native_d[base].branches: + break + else: + base = self.pred(base) + return self.succ(base) + +class RCS(RevisionMixin): + "Encapsulate RCS back end methods." + delimiters = ('----------------------------', + '=============================================================================') + class RCSLifter: + "Temporarily lift a master to the working directory." + def __init__(self, name): + self.name = name + self.where = os.getcwd() + self.deferred = [] + self.previous_handlers = {} + def defer_signal(self, sig_num, stack_frame): + self.deferred.append(sig_num) + def __enter__(self): + # Replace existing signal handlers with deferred handler + for sig_num in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM): + # signal.signal returns None when no handler has been + # set in Python, which is the same as the default + # handler (SIG_DFL) being set + self.previous_handlers[sig_num] = ( + signal.signal(sig_num, self.defer_signal) or signal.SIG_DFL) + if os.path.dirname(self.name): + os.chdir(os.path.dirname(self.name)) + do_or_die("mv '{0}'/'{1},v' .".format(repodir, self.name)) + def __exit__(self, extype, value, traceback_unused): + os.chdir(self.where) + if extype and debug > 0: + raise extype(value) + do_or_die("mv '{0},v' '{1}'".format(self.name, repodir)) + # Restore handlers + for sig_num, handler in self.previous_handlers.items(): + signal.signal(sig_num, handler) + # Send deferred signals + while self.deferred: + sig_num = self.deferred.pop(0) + os.kill(os.getpid(), sig_num) + return True + def lifter(self, name): + return RCS.RCSLifter(name) + def history(self, arg): + return os.path.join(os.path.dirname(arg), + repodir, + os.path.basename(arg) + ",v") + @staticmethod + def is_history(path): + return path.endswith(",v") + def has_revisions(self, arg): + "Does the master for this file have any revisions" + # The magic number 105 is the size of an empty RCS file (no + # metadata, no revisions) at 76 bytes, plus 29 bytes. We assume + # that this size has stayed constant or increased since ancient + # times. In fact the size of an RCS file with revisions goes up + # more - by the 78 bytes for the final, fixed line of the log + # display. This gives us plenty of slack to cope with minor + # format differences. + # + # The motivation here is to make "src status" faster by avoiding + # the need for an entire log parse when checking for "A" status. + return os.path.getsize(self.history(arg)) > 105 + def workfile(self, arg): + "Workfile corresponding to an RCS master" + return arg[:-2] + def register(self, arg): + "Register a file" + # Key choices here: -b suppresses all keyword expansion, -U sets + # non-strict locking (which makes branch appends less painful). + do_or_die("rcs -q -U -kb -i '{0}' </dev/null && mv '{0},v' '{1}'".format(arg, repodir)) + def checkin(self, arg, comment): + "Check in a commit, with comment." + comment = "'" + comment.replace("'", r"'\''") + "'" + # By unlocking the file before checkin we invoke the following + # property described on the rcs(1) manual page: "If rev is + # omitted and the caller has no lock, but owns the file and + # locking is not set to strict, then the revision is appended to + # the default branch (normally the trunk; see the -b option of + # rcs(1))." This is the behavior we want and why locking is set + # to non-strict. + do_or_die("rcs -q -U -u '{0},v' && ci -l -m{1} '{0}'".format(arg, comment)) + def checkout(self, arg, revision): + "Check out a revision. Leaves it writeable." + do_or_die("rm -f '{0}' && rcs -q -u '{0},v' && co -q -l{1} '{0}'".format(arg, revision)) + def amend(self, arg, rev, comment): + "Amend a commit comment." + # Relies on caller to escape comment string + comment = "'" + comment.replace("'", r"'\''") + "'" + do_or_die("rcs -m{0}:{1} '{2}'".format(rev, comment, arg)) + def cat(self, arg, revision): + "Ship the contents of a revision to stdout or a named file." + return popen_or_die("co -q -p -r{1} '{0}'".format(arg, revision), "r") + def delete_tag(self, tagname, metadata): + "Delete a specified tag." + do_or_die("rcs -n{0} '{1}'".format(tagname, metadata.name)) + def set_tag(self, tagname, revision, metadata): + "Set a specified tag." + do_or_die("rcs -n{0}:{1} '{2}'".format(tagname, revision, metadata.name)) + def delete_branch(self, branchname, metadata): + "Delete a specified branch." + # From rcs(1): -orange deletes ("outdates") the revisions given + # by range. A range consisting of a single revision number + # means that revision. A range consisting of a branch number + # means the latest revision on that branch. A range of the form + # rev1:rev2 means revisions rev1 to rev2 on the same branch, + # :rev means from the beginning of the branch containing rev + # up to and including rev, and rev: means from revision rev to + # the end of the branch containing rev. None of the outdated + # revisions can have branches or locks. + do_or_die("rcs -o{0}: '{1}'".format(self.branch_initial(branchname, metadata), metadata.name)) + self.delete_tag(branchname, metadata) + def set_branch(self, name, revision, metadata): + "Set the specified branch to be default, creating it if required." + (sticky, base) = self.branch_stickybase(name, revision, metadata) + if sticky: + do_or_die("rcs -n{0}:{1} '{2}'".format(name, sticky, metadata.name)) + do_or_die("rcs -b%s %s" % (base, metadata.name)) + def move(self, source, target): + "Move a file and its history." + do_or_die("mv '{0}' '{1}' && mv '{2}' '{3}'".format(source, target, + self.history(source), + self.history(target))) + def copy(self, source, target): + "Copy a file and its history." + do_or_die("cp '{0}' '{1}' && cp '{2}' '{3}'".format(source, target, + self.history(source), + self.history(target))) + def release(self, arg): + "Release locks." + do_or_die("rcs -q -u '{0},v'".format(arg)) + def write_description(self, text, metadata): + "Write description field. If text is empty, clear the field." + text = "'" + text.replace("'", r"'\''") + "'" + do_or_die("rcs -t-{1} '{0}'".format(metadata.name, text)) + def version(self): + rawversion = capture_or_die("rcs --version") + m = re.search(r"[0-9]+\.[0-9]+\.[0-9]+".encode('ascii'),rawversion) + return m and polystr(m.group(0)) + def parse(self, metadata): + "Get and parse the RCS log output for this file." + def sortkey(rev): + return rev.date + \ + '.'.join('%010d' % int(n) for n in rev.native.split('.')) + metadata.symbols["trunk"] = "0.1" + with popen_or_die("cd %s >/dev/null; rlog %s 2>/dev/null; cd ..>/dev/null" % (repodir, metadata.name)) as fp: + if debug >= DEBUG_PARSE: + sys.stderr.write("\t-> init\n") + state = "init" + for line in fp: + # All operations here will need Unicode in Python 3 + line = polystr(line) + if debug >= DEBUG_PARSE: + sys.stderr.write("in: %s\n" % repr(line)) + if state == "init": + if line.startswith("locks:"): + if debug >= DEBUG_PARSE: + sys.stderr.write("\t-> locks\n") + state = "locks" + elif line.startswith("symbolic names:"): + if debug >= DEBUG_PARSE: + sys.stderr.write("\t-> symbols\n") + state = "symbols" + elif line.startswith("branch:"): + branch = line.split(":")[1].strip() + # Undocumented fact about RCS: The branch "1" + # is the same as the blank branch. Significant + # because you can't reset to the blank branch + # using rcs -b, that resets to the dynamically + # highest branch. + if not branch or branch == "1": + metadata.branch = "trunk" + elif line.startswith("description:"): + state = "description" + elif state == "description": + if line.startswith("============================"): + break + elif line.startswith("----------------------------"): + if debug >= DEBUG_PARSE: + sys.stderr.write("\t-> logheader\n") + state = "logheader" + metadata.revlist.append(HistoryEntry(metadata)) + metadata.description = metadata.description[:-1] + else: + metadata.description += line + elif state == "locks": + if not line[0].isspace(): + if debug >= DEBUG_PARSE: + sys.stderr.write("\t-> init\n") + state = "init" + else: + fields = line.strip().split() + metadata.lockrevs.append(fields[1]) + elif state == "symbols": + if not line[0].isspace(): + if debug >= DEBUG_PARSE: + sys.stderr.write("\t-> init\n") + state = "init" + else: + fields = line.strip().split() + name = fields[0] + if name.endswith(":"): + name = name[:-1] + rev = fields[1] + metadata.symbols[name] = rev + elif state == "logheader": + if line.startswith("revision "): + fields = line.split() + metadata.revlist[-1].native = fields[1] + elif line.startswith("----------------------------"): + metadata.revlist.append(HistoryEntry(metadata)) + elif line.startswith("date: "): + fields = line.split() + date = fields[1] + " " + fields[2] + if date.endswith(";"): + date = date[:-1] + date = date.replace("/","-").replace(" ","T") + "Z" + metadata.revlist[-1].date = date + elif line.startswith("branches:"): + continue + elif line.startswith("======================="): + # This deals with RCS v5.7 issuing a log header + # divider just before the terminator, something + # v5.8 does not do. + if not metadata.revlist[-1].native: + metadata.revlist.pop() + elif not metadata.revlist[-1].log.endswith("\n"): + metadata.revlist[-1].log += "\n" + break + elif line.strip() == "*** empty log message ***": + continue + elif metadata.revlist: + metadata.revlist[-1].log += line + # Now that we have the symbol table... + if metadata.branch != "trunk": + for (k, v) in list(metadata.symbols.items()): + if v == metadata.branch: + metadata.branch = k + break + else: + croak("unrecognized branch ID '%s'" % branch) + metadata.revlist.sort(key=sortkey) + for (i, item) in enumerate(metadata.revlist): + if pseudotime: + # Artificial date one day after the epoch + # to avoid timezone issues. + item.date = rfc3339(86400 + i * 60) + item.revno = i + 1 + metadata.revlist.reverse() + if debug >= DEBUG_PARSE: + #sys.stdout.write("\t%d revisions\n" % len(metadata.revlist) + sys.stdout.write("\tlockrevs: %s\n" % metadata.lockrevs) + sys.stdout.write("\tsymbols: %s\n" % metadata.symbols) + metadata.build_indices() + +# SCCS branch numbering (not supported yet) works differently from RCS +# branch numbering. Node IDs have at most 4 parts in the form R.B.L.S +# (release, level, branch, sequence): +# +# 1.0 - initial trunk version +# 1.1 - next checkin on trunk +# 1.1.1.0 - First branch from trunk v1.1 +# 1.1.1.1 - Next checkin on that branch +# 1.1.2.0 - Second branch from v1.1 +# 1.2 - next checkin on trunk. +# +# The main difference is that you cannot branch from a branch, only from +# trunk. + +class SCCS(RevisionMixin): + "Encapsulate SCCS back end methods." + delimiters = ("-X-X-X-X-X--X-X-X-X-X--X-X-X-X-X-",) + class SCCSLifter: + "Temporarily lift a master to the working directory." + def __init__(self, name): + self.name = name + self.where = os.getcwd() + if not os.path.isdir("SCCS"): + croak("no SCCS directory.") + self.deferred = [] + self.previous_handlers = {} + def defer_signal(self, sig_num, stack_frame): + self.deferred.append(sig_num) + def __enter__(self): + # Replace existing signal handlers with deferred handler + for sig_num in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM): + # signal.signal returns None when no handler has been + # set in Python, which is the same as the default + # handler (SIG_DFL) being set + self.previous_handlers[sig_num] = ( + signal.signal(sig_num, self.defer_signal) or signal.SIG_DFL) + def __exit__(self, extype, value, traceback_unused): + # Restore handlers + for sig_num, handler in self.previous_handlers.items(): + signal.signal(sig_num, handler) + # Send deferred signals + while self.deferred: + sig_num = self.deferred.pop(0) + os.kill(os.getpid(), sig_num) + return True + def lifter(self, name): + return SCCS.SCCSLifter(name) + def __sccsfile(self, arg, pref): + return os.path.join(os.path.dirname(arg), + "SCCS", + pref + "." + os.path.basename(arg)) + def history(self, arg): + return self.__sccsfile(arg, "s") + @staticmethod + def is_history(path): + return path.startswith("s.") + def has_revisions(self, arg): + "Does the master for this file have any revisions" + # It's not possible to create an SCCS file without at least one + # revision. + return True + def workfile(self, arg): + "Workfile corresponding to an SCCS master" + return arg[2:] + def register(self, arg): + "Register a file" + if not os.path.isdir("SCCS"): + os.mkdir("SCCS") + def checkin(self, arg, comment): + "Check in a commit, with comment." + comment = "'" + comment.replace("'", r"'\''") + "'" + if os.path.exists(self.history(arg)): + cmd = "delta -s -y{1} {0}".format(arg, comment) + else: + # Yuck - 2>/dev/null is required to banish the message + # admin: warning: SCCS/XXXXX: No id keywords. + cmd = "admin -fb -i '{0}' -y{1} <'{0}' 2>/dev/null".format(arg, comment) + do_or_die("TZ=UTC sccs " + cmd) + do_or_die("rm -f {0} && sccs get -e -s '{0}' >/dev/null".format(arg)) + def checkout(self, arg, revision): + "Check out a revision. Leaves it writeable." + do_or_die("rm -f SCCS/p.{0}".format(arg)) + if revision: + do_or_die("rm -f '{0}' && sccs get -s -e -r{1} '{0}' >/dev/null".format(arg, revision)) + else: + do_or_die("rm -f '{0}' && sccs get -s -e '{0}' >/dev/null".format(arg)) + def amend(self, arg, rev, comment): + "Amend a commit comment." + comment = "'" + comment.replace("'", r"'\''") + "'" + do_or_die("sccs cdc -r{1} -y{2} '{0}'".format(arg, rev, comment)) + def cat(self, arg, revision): + "Ship the contents of a revision to stdout or a named file." + if revision: + return popen_or_die("sccs get -s -p -r{1} '{0}' 2>/dev/null".format(arg, revision), "r") + else: + return popen_or_die("sccs get -p {0}".format(arg), "r") + def delete_tag(self, tagname, metadata): + "Delete a specified tag." + croak("tags are not supported in the SCCS back end") + def set_tag(self, tagname, revision, metadata): + "Set a specified tag." + croak("tags are not supported in the SCCS back end") + def delete_branch(self, branchname, metadata): + "Delete a specified branch." + croak("branches are not supported in the SCCS back end") + def set_branch(self, name, revision, metadata): + "Set the specified branch to be default, creating it if required." + croak("branches are not supported in the SCCS back end") + def move(self, source, target): + "Move a file and its history." + do_or_die("mv '{0}' '{1}' && mv '{2}' '{3}'".format(source, target, + self.history(source), + self.history(target))) + def copy(self, source, target): + "Copy a file and its history." + do_or_die("cp '{0}' '{1}' && cp '{2}' '{3}'".format(source, target, + self.history(source), + self.history(target))) + def release(self, arg): + "Release locks." + do_or_die("cssc admin -dla '{0}'".format(arg)) + def write_description(self, text, metadata): + "Write description field. If text is empty, clear the field." + if text: + scribblefile = os.path.join(os.path.dirname(metadata.name),"SCCS",".scribble") + # Write to filesystem as binary ASCII data for robustness + with open(scribblefile, "wb") as wfp: + wfp.write(polybytes(text) + b"\n") + else: + scribblefile = '' + do_or_die("sccs admin -t{0} {1} 2>/dev/null".format(scribblefile, metadata.name)) + if text: + os.remove(scribblefile) + def version(self): + rawversion = capture_or_die("sccs --version 2>&1") + m = re.search(r"[0-9]+\.[0-9]+\.[0-9]+".encode('ascii'), rawversion) + return m and polystr(m.group(0)) + def parse(self, metadata): + "Get and parse the SCCS log output for this file." + metadata.symbols["trunk"] = "0.1" + with popen_or_die("sccs prs -e -d':FD::Dt:\n:C:{1}' {0} 2>/dev/null".format(metadata.name, SCCS.delimiters[0])) as fp: + if debug >= DEBUG_PARSE: + sys.stderr.write("\t-> init\n") + state = "init" + for line in fp: + # All operations here will need Unicode in Python 3 + line = polystr(line) + if debug >= DEBUG_PARSE: + sys.stderr.write("in %s: %s\n" % (state, repr(line))) + if state == 'init': + if line == 'none\n': + line = '' + metadata.description = line + state = 'header' + elif state == "header": + comment = "" + if line.startswith("D "): + try: + (_, rev, yymmdd, hhmmss) = line.split()[:4] + metadata.revlist.append(HistoryEntry(metadata)) + metadata.revlist[-1].native = rev + yymmdd = CENTURY + yymmdd.replace('/','-') + metadata.revlist[-1].date = yymmdd+"T"+hhmmss+"Z" + except ValueError: + croak("ill-formed delta line") + if debug >= DEBUG_PARSE: + sys.stderr.write("\t-> header\n") + state = "comment" + elif state == "comment": + if line == SCCS.delimiters[0] + "\n": + metadata.revlist[-1].log = comment.rstrip() + "\n" + if debug >= DEBUG_PARSE: + sys.stderr.write("\t-> init\n") + state = "header" + else: + comment += line + metadata.revlist.sort(key=lambda x: x.date) + for (i, item) in enumerate(metadata.revlist): + if pseudotime: + # Artificial date one day after the epoch + # to avoid timezone issues. + item.date = rfc3339(86400 + i * 60) + item.revno = i + 1 + metadata.revlist.reverse() + try: + with open(self.__sccsfile(metadata.name, "p"), "rb") as fp: + for line in fp: + metadata.lockrevs.append(polystr(line).split()[0]) + except IOError: + pass + metadata.build_indices() + +backends = (RCS, SCCS) + +if __name__ == "__main__": + try: + commandline = list(sys.argv[1:]) + explicit = False + repodir = ".src" + backend = RCS + + if not os.path.exists(".src"): + for vcs in backends: + if os.path.exists(vcs.__name__): + repodir = vcs.__name__ + backend = vcs + break + + while commandline and commandline[0].startswith("-"): + if commandline[0] == '-d': + debug += 1 + elif commandline[0] == '-q': + quiet = True + elif commandline[0] == '-T': + pseudotime = True + elif commandline[0] == '-S': + repodir = commandline[1] + explicit = True + commandline.pop(0) + else: + croak("unknown option %s before command verb" % commandline[0]) + commandline.pop(0) + + if not commandline: + help_method() + raise SystemExit(0) + + # User might want to force the back end + for vcs in backends: + if commandline[0] == vcs.__name__.lower(): + backend = vcs + commandline.pop(0) + break + + # Ugly constraint... + if backend.__name__ == 'SCCS': + repodir = "SCCS" + + backend = backend() + + if not commandline: + help_method() + raise SystemExit(0) + + if commandline[0] in dispatch: + dispatch[commandline[0]](*commandline[1:]) + else: + croak("no such command as '%s'. Try 'src help'" \ + % commandline[0]) + except KeyboardInterrupt: + pass + +# The following sets edit modes for GNU EMACS +# Local Variables: +# mode:python +# End: |