#!/usr/bin/env python # Copyright 2014 Wayne Mesard # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import sys, os, subprocess, errno import getopt, re, pickle import fnmatch, datetime def Usage(err=""): if len(err): err = err + "\n" sys.exit(err+ """Usage: p5 backup [-ef][-b backup-name][-c changeset][-C client][-d][file...] p5 restore [-b backup-name] [-B src-client] [-c changeset] [-C client] [-n] [-p | file...] p5 backups [-b backup-glob | -m max] [-B src-client] [-l] p5 share [-o] [-c changeset] [-d] [-s] [-t] [file...] p5 desc == p5 describe or p5 share as appropriate p5 pending == p5 changes -u me -sp p5 shelved == p5 changes -u me -ssh p5 "changeset" can be a number or "(p|su|sh)[:USERID][:NTH]" See the help page for details: http://bit.ly/1hBRvXO """) # TODO: "p5 bdiff" to diff against a backup, and "p5 bprint" to print files. # TODO: Naming (UCC vs lower_case) is horrifically non-standard and should be cleaned up. # TODO: add an option to specify the p5 backup message on the command line. # Too bad that "p5 submit" uses -d for that. That causes confusion with -d for diff. ## Implementation notes: ## - Functions that implement the user interface are in CamelCase. ## Functions that implement the actual p5 commands take two arguments: ## a Workspace and a Runner. ## - Helper functions are in lower_case. ## - The Workspace class holds information about the current workspace ## (clientname, root directory, opened files). Basically, it exists to remember whether ## or not we've already run "p4 client" and "p4 opened", and to hold the results from those. def Fatal(err): sys.exit("p5 fatal error: " + err) def do_share(w, r, dash_d, dash_s, dash_t, output_fd): w.describe(output_fd) if dash_s: # If the user said "p5 desc -s" return p4_diff = ["diff"] native_diff = ["diff"] do_preamble = False if dash_d: p4_diff.append("-d" + dash_d) # Now map the p4 diff -d flag to the native diff equivalent m = re.match("[cu]([0-9]*)$", dash_d) if m: if (m.group(1)): native_flags = ["-"+dash_d[0], "-C"+dash_d[1:]] else: native_flags = ["-"+dash_d[0]] else: native_flags = [] native_diff = native_diff + native_flags else: # The default diff output doesn't have a filename header, so fake one up do_preamble = True if dash_t: p4_diff.append("-t") for f in w.adds(): filename = f.local_path() if dash_t or f.is_text(): if do_preamble: output_fd.write("==== /dev/null - "+filename+" ====\n") output_fd.flush() r.run(native_diff+["/dev/null", filename], verbose=False, output_fd=output_fd) else: output_fd.write("==== Skipping file: "+filename+" (" +f.file_type()+ ") ====\n") output_fd.flush() for f in (w.edits()+w.moves()): if dash_t or f.is_text(): out = r.p4_run(p4_diff+[f.local_path()], verbose=False) # -du format doesn't contain the version number. So add it. out = out.replace(f.depot_path()+"\t", f.depot_path(ver=True)+"\t") # p4 diff replaces our lovingly constructed rel-path with an abs-path. # So we have to chop it off again. out = out.replace(" "+w.root()+os.sep, " ", 1) output_fd.write(out) else: output_fd.write("==== Skipping file: "+f.local_path()+ " (" +f.file_type()+ ") ====\n") output_fd.flush() def Desc(w, r): # Drop the "-c" to get into a canonical form old_argv = r.argv[:] r.strip_dash_c() if not r.argv[-1].isdigit(): # No changeset specified. Use the local workspace # Put the args back how we found them for Share to reprocess r.argv = old_argv Share(w, r, True) return changeset = r.argv[-1] # Figure out if the changeset is local, shelved or submitted change_info = r.p4_run(["change", "-o", "-O", changeset]) status = re.search("^Status:\\s*(.*)$", change_info, re.MULTILINE).group(1) if status == "submitted": # "-O" will translate the changelist number if it was renamed at submit-time, # so pick up the [possibly] new one changeset = re.search("^Change:\\s*(.*)$", change_info, re.MULTILINE).group(1) r.argv[0] = "describe" r.argv[-1] = "-c"+changeset Describe(w, r) else: # status == "pending" w.init_client_info(dash_C=None, no_client_okay=True) if w.client() == re.search("^Client:\\s*(.*)$", change_info, re.MULTILINE).group(1): # Local pending changeset r.argv[-1] = "-c"+changeset Share(w, r, True) else: # Non-local pending changeset. Assume shelved and hope for the best. r.argv.insert(1, "-S") Describe(w, r) def Share(w, r, always_dash_o=False): try: opts, args = getopt.getopt(r.argv[1:], "c:d:ost") except getopt.GetoptError, err: Usage(str(err)) changeset = None dash_o = always_dash_o dash_d = None dash_s = False dash_t = False default_diff = os.getenv("P5_DIFF_FLAG") if default_diff: dash_d = default_diff[2:] for o, a in opts: if o == "-c": changeset = a elif o == "-d": dash_d = a elif o == "-o": dash_o = True elif o == "-s": dash_s = True elif o == "-t": dash_t = True else: # NOTREACHED assert False, "unhandled option:"+o # Usually None, but the user may only want to diff a subset of the open files. files = args w.init_client_info(dash_C=None) w.init_file_list(changeset, files) # N.B. chdir() has to happen *after* init_file_list() so that "files" is # processed with the correct relpath. os.chdir(w.root()) if dash_o: output_fd = sys.stdout else: # 1. shelve the changeset(s). for change in w.changesets(): r.p4_run(["shelve", "-c", change, "-r"]) # 2. Create a single file containing the diffs. if len(w.changesets()) == 1: # If there's one and only one changeset, use that in the filename fn_root = w.changesets()[0] else: fn_root = "p5" fn_format = (os.getenv("P5_SHARE_FILE") or os.getenv("P5_BUDDY_FILE") or os.path.join(os.getenv("HOME"), "%s.share")) if "%s" in fn_format: try: fname = fn_format % fn_root except TypeError, e: Fatal("$P5_SHARE_FILE may contain one and only one '%s': "+str(e)) else: fname = fn_format try: output_fd = open(fname, "w") except IOError, e: Fatal(str(e)) do_share(w, r, dash_d, dash_s, dash_t, output_fd=output_fd) if not dash_o: sys.stderr.write("[ Writing "+fname+" ]\n") def Describe(w, r): """Like 'p4 describe' but: (1) tolerate '-c' (2) honor P5_DIFF_FLAG (3) 'p4 print' new files""" tweaked = r.strip_dash_c() r.argv[0] = "describe" # in case we got here from Desc() Differ(w, r, verbose=tweaked) # 'p4 describe' only showed changed files, not new files. Let's fix that now. if filter(lambda a: a == "-s", r.argv[1:]): # But if the user said "-s", then we're not showing file contents. return # Now iterate over the changesets and "p4 print" any added files. csindex = -1 while re.match("^[0-9]+$", r.argv[csindex]): changeset = r.argv[csindex] csindex = csindex - 1 if filter(lambda a: a == "-S", r.argv[1:]): # User specified a shelved changeset filerev = "@="+changeset else: # Submitted changeset filerev = "@"+changeset+","+changeset all_files = r.p4_run(["files", "//depot/..."+filerev], verbose=False, return_error=True)[0] new_files = re.findall("^([^ ].*) - add change [0-9]* \((.*)\)", all_files, re.MULTILINE) for f in new_files: fname = f[0] ftype = f[1] if re.match(".*text$", ftype): m = re.match("(.*)#none$", fname) if m: # User requested a shelved changeset, and that's actually what p4 showed us. # So that's what we'll print. fname = m.group(1) + filerev sys.stdout.write("\n==== " +fname+ " (" +ftype+ ") ====\n") sys.stdout.flush() out = r.p4_run(["print", "-q", fname], verbose=False) for line in out.split("\n"): print "+", line else: sys.stdout.write("\n==== Skipping new file: " +fname+" (" +ftype+ ") ====\n") def Backup(w, r): try: opts, args = getopt.getopt(r.argv[1:], "B:b:C:c:d:ef") except getopt.GetoptError, err: Usage(str(err)) saveset = changeset = None dash_C = None dash_e = False prompt = True # the default diff format is too lame to be used by default. For one thing, it won't # play well with path ("p5 restore -p") dash_d = "u" for o, a in opts: if o == "-b": saveset = a elif o == "-C" or o == "-B": # For symmetry with "p5 backups" and "p5 restore", -B is an undocumented synonym for -C dash_C = a elif o == "-c": changeset = a elif o == "-d": dash_d = a elif o == "-e": dash_e = True elif o == "-f": prompt = False else: # NOTREACHED assert False, "unhandled option:"+o if prompt and not dash_e: # We do this early so the user can hit Control-C before we've actually done anything. print("Enter a description for this backup ('.' or EOF when done):") comment = [] while True: line = sys.stdin.readline() if len(line)==0 or line=='.\n': break comment.append(line) else: comment = None w.init_client_info(dash_C) # Usually None, but the user may only want to backup a subset of the open files. files = args client_backup_dir = w.get_backup_dir() if saveset == None: # The user didn't specify a saveset, get the next numeric value # (or the current one if we're just editing) if dash_e: if len(args) == 1: # Looks like the user forgot "-b". Do the right thing: saveset = args[0] args = [] else: saveset = 'latest' else: try: os.makedirs(client_backup_dir) except OSError, err: if err.errno != errno.EEXIST: Fatal(str(err)) id_fn = os.path.join(client_backup_dir, ".id") try: fd = open(id_fn, "r+") saveset = fd.read() fd.seek(0, 0) except IOError, err: if err.errno != errno.ENOENT: Fatal(str(err)) fd = open(id_fn, "w") saveset = "000" fd.write("%03d" % (int(saveset)+1)) fd.close() saveset = saveset.strip() # just in case saveset_dir = w.get_saveset_dir(saveset, not_found_okay=(not dash_e)) if dash_e: if args: Fatal("File arguments not allowed with 'p5 backup -e'") # If the user said "-e", we're not actually creating a backup, we're just # editing the comment.txt of an existing backup. editor = os.getenv("P4EDITOR") or os.getenv("EDITOR") or "vi" editor = editor.split(" ") editor.append(os.path.join(saveset_dir, "comment.txt")) r.run(editor, do_exec=True) ## ## Do the backup! ## w.init_file_list(changeset, files) os.chdir(w.root()) try: os.makedirs(saveset_dir) except OSError, err: if err.errno != errno.EEXIST: Fatal(str(err)) try: if sys.platform != "win32": os.unlink(os.path.join(client_backup_dir, 'latest')) except OSError: pass try: if sys.platform != "win32": os.symlink(saveset, os.path.join(client_backup_dir, 'latest')) except OSError, e: print "Warning: Couldn't create 'latest' link:", e, ":", e.filename if comment: f = open(os.path.join(saveset_dir, "comment.txt"), 'w') f.writelines(comment) f.close() f = open(os.path.join(saveset_dir, "files.p5"), 'w') pickle.dump(w.file_list_archive(), f) f.close() files = map(lambda f: r.cygpath(f.local_path()), w.adds()+w.edits()+w.moves()) r.run(["tar", "--create","--files-from","-","--file", r.cygpath(os.path.join(saveset_dir, "p5.tar"))], input='\n'.join(files)) do_share(w, r, dash_d, False, False, open(os.path.join(saveset_dir, "p5.patch"), 'w')) sys.stderr.write("Created backup at " +saveset_dir+ "\n") def Restore(dst_w, r): try: opts, args = getopt.getopt(r.argv[1:], "B:b:C:c:np") except getopt.GetoptError, err: Usage(str(err)) src_client = dst_client = None src_saveset = 'latest' dst_changeset = 'default' dry_run = None dash_p = False for o, a in opts: if o == "-B": src_client = a elif o == "-b": src_saveset = a elif o == "-C": dst_client = a elif o == "-c": dst_changeset = a elif o == "-n": dry_run = "-n" elif o == "-p": dash_p = True else: # NOTREACHED assert False, "unhandled option:"+o if args and dash_p: Usage("'-p' cannot be used with a file list") dst_w.init_client_info(dst_client) if src_client == None: src_w = dst_w else: # restoring from a saveset created in another client. Create a second Workspace to # hold the info about it. We won't actually run any p4 commands on it, so the # Perforce client doesn't have to exist anymore. src_w = Workspace(r) src_w.init_client_info(src_client) # Usually None, but the user may only want to backup a subset of the open files. files = map(lambda f: relpath(f, dst_w.root()), args) src_saveset_dir = src_w.get_saveset_dir(src_saveset, not_found_okay=False) ## ## Do the restore! ## os.chdir(dst_w.root()) try: f = open(os.path.join(src_saveset_dir, "files.p5"), 'r') except IOError, err: Fatal(str(err)) src_w.restore_file_list(pickle.load(f), files) f.close() err = r.p4_run(["-x", "-", "revert", dry_run], input='\n'.join(src_w.files()), return_error=True)[1] if err: # This error is beneign, so filter it out err = "\n".join(filter(lambda e: not re.search("file.s. not opened on this client", e), err.split("\n"))) if err: # There's still error text left, then we're dead Fatal(err) for f in src_w.adds(): out = r.p4_run(["add", dry_run, "-c", dst_changeset, "-t", f.file_type(), f.local_path()]) sys.stdout.write(out) for f in src_w.edits(): out = r.p4_run(["edit", dry_run, "-c", dst_changeset, "-t", f.file_type(), f.local_path()]) sys.stdout.write(out) for f in src_w.deletes(): out = r.p4_run(["delete", dry_run, "-c", dst_changeset, "-t", f.file_type(), f.local_path()]) sys.stdout.write(out) for f in src_w.moves(): out = r.p4_run(["edit", dry_run, "-c", dst_changeset, f.old_path()]) sys.stdout.write(out) out = r.p4_run(["move", "-c", dst_changeset, "-t", f.file_type(), f.old_path(), f.local_path()], dry_run=dry_run) sys.stdout.write(out) # Now that everything's in the correct state, restore the contents. if dash_p: # We exec this so that the user can answer any prompts r.run(["patch", "-p0", "-i", os.path.join(src_saveset_dir, "p5.patch")], dry_run=dry_run, do_exec=True) else: if files: # If the user specified a subset of the files, then build that list. # tar_targets = map(lambda f: f.local_path(), src_w.adds()+src_w.edits()) tar_targets = map(File.local_path, src_w.adds()+src_w.edits()) else: # Otherwise, just take everything in the tar file tar_targets = [] r.run(["tar", "xfm", r.cygpath(os.path.join(src_saveset_dir, "p5.tar"))] + tar_targets, dry_run=dry_run, do_exec=True) # We need os.path.relpath() but it was only added in Python 2.6. # Here it is (from ntpath.py): def relpath(path, start): """Return a relative version of a path""" if not path: raise ValueError("no path specified") start_list = os.path.abspath(start).split(os.sep) path_list = os.path.abspath(path).split(os.sep) if start_list[0].lower() != path_list[0].lower(): unc_path, rest = os.path.splitunc(path) unc_start, rest = os.path.splitunc(start) if bool(unc_path) ^ bool(unc_start): raise ValueError("Cannot mix UNC and non-UNC paths (%s and %s)" % (path, start)) else: raise ValueError("path is on drive %s, start on drive %s" % (path_list[0], start_list[0])) # Work out how much of the filepath is shared by start and path. i=0 for i in range(min(len(start_list), len(path_list))): if start_list[i].lower() != path_list[i].lower(): break else: i += 1 rel_list = [os.pardir] * (len(start_list)-i) + path_list[i:] if not rel_list: return None return os.path.join(*rel_list) def Backups(w, r): try: opts, args = getopt.getopt(r.argv[1:], "B:b:lm:") except getopt.GetoptError, err: Usage(str(err)) client = None pattern = None max_count = None list_files = False for o,a in opts: if o == "-B": client = a elif o == "-b": pattern = a elif o == "-l": list_files = True elif o == "-m": try: max_count = int(a) except ValueError: Usage("-m requires a numeric argument") else: # NOTREACHED assert False, "unhandled option:"+o if pattern and max_count: # Because we do the pattern match late, the count will be short. # (And it's not worth rewriting the way we do this just to make this work.) Usage("-b and -m are mutually exclusive") if args: Usage("Unexpected argument: "+ ' '.join(args)) w.init_client_info(client) client_backup_dir = w.get_backup_dir() try: backups = os.listdir(client_backup_dir) except OSError, e: Fatal(str(e)) backups.sort() if max_count: backups = backups[-(max_count+1):] latest_inode = -1 try: latest_inode = os.stat(os.path.join(client_backup_dir, 'latest', 'p5.tar')).st_ino except: pass for bu in backups: if bu in ['.id', 'latest']: continue if pattern and not fnmatch.fnmatch(bu, pattern): continue try: st = os.stat(os.path.join(client_backup_dir, bu, 'p5.tar')) except: continue if st.st_ino == latest_inode: is_latest = "[latest]" else: is_latest = "" print datetime.datetime.fromtimestamp(st.st_mtime).ctime(),":", bu, is_latest try: f = open(os.path.join(client_backup_dir, bu, 'comment.txt')) sys.stdout.write("\t") sys.stdout.write('\t'.join(f.readlines())) f.close() except: pass if list_files: print " Location: "+os.path.join(client_backup_dir, bu) f = open(os.path.join(client_backup_dir, bu, "files.p5"), 'r') w.restore_file_list(pickle.load(f), None) f.close() operations = [[" Adds:", map(lambda f:f.local_path(True), w.adds())], [" Edits:", map(lambda f:f.local_path(True), w.edits())], [" Deletes:", map(lambda f:f.local_path(True), w.deletes())], [" Moves:", map(lambda f:f.pretty_move(True), w.moves())]] for op in operations: if op[1]: print "\n\t".join([op[0]]+op[1]) def Submit(w, r): """Like 'p4 submit' but will automatically run 'p4 shelve -d' if necessary""" argc = len(r.argv) if not re.match("^(-c)?([0-9]*)$", r.argv[argc-1]): # The last command line arg is not a changeset number, so p4 may invoke an editor. # So we have to get out of the way. r.p4_exec(r.argv) # NOTREACHED (out,err) = r.p4_run(r.argv, return_error=True) # Check for old-style error message format m = re.match("Change has shelved files - use 'p4 shelve -d (.*)'", err) # Check for 2012 error message format if not m: m = re.match("Change ([0-9]*) has shelved files .* cannot submit", err) if m: # Run the recommended fixup command, then retry the 'p4 submit' r.p4_run(["shelve", "-d", "-c", m.group(1)]) r.p4_exec(r.argv) else: sys.stderr.write(err) sys.stderr.write(out) def Change(w, r): """Like 'p4 change', but tolerate '-c'""" tweaked = r.strip_dash_c() r.p4_exec(r.argv, verbose=tweaked) def Unshelve(w, r): """Like 'p4 unshelve', but with p5's enhanced changelist syntax on '-s'""" tweaked = fixup_dash_c(w, r, "-s") r.p4_exec(r.argv, verbose=tweaked) def Differ(w, r, verbose=False): """Like the 'p4' command, but get default value for -d from P5_DIFF_FLAG""" # This is an interface function for 'p5 diff|diff2|annotate'; and a helper function for 'p5 describe' flag = os.getenv("P5_DIFF_FLAG") if flag and not filter(lambda a: a[0:2] == "-d", r.argv[1:]): r.argv.insert(1, flag) r.p4_run(r.argv, verbose=verbose, output_fd=sys.stdout) def RestrictedChanges(w, r): """Shorthand for 'p4 changes -ume -s pending' or 'p4 changes -ume -s shelved'""" u_default=["-ume"] s_default=["-s"+r.argv[0]] for a in r.argv[1:]: if a[0:2] == '-u': u_default = [] elif a[0:2] == '-s': s_default = [] r.argv = ["changes"] + u_default + s_default + r.argv[1:] Changes(w, r, True) def current_user_id(r): # TODO: get_p4user() executes a command, so consider changing the callers of # current_user_id() to do so lazily (i.e., only when "-u me" is actually specified. return get_p4user(r) or os.getenv('USER') or os.getenv('USERNAME') or os.getenv('LOGNAME') def get_p4user(r): result = r.p4_run(["set", "P4USER"], verbose=False) if result: return result.split('=')[1].split(' ')[0].rstrip() else: return result def Changes(w, r, tweaked=False): """Like 'p4 changes' but allow some shortcuts on the command line""" tweaked = fixup_dash_C(w, r, True) or tweaked tweaked = fixup_dash_us(r) or tweaked r.p4_exec(r.argv, verbose=tweaked) def DashuCommand(w, r): r.p4_exec(r.argv, fixup_dash_u(r)) def Opened(w, r): tweaked=fixup_dash_C(w, r) tweaked=fixup_dash_u(r) or tweaked r.p4_exec(r.argv, tweaked) def get_local_changeset(w, r, status, nth): w.init_client_info(None) changeset = final_changeset(r.p4_run(["changes", "-s", status, "-m", nth, "-c", w.client()])) if changeset: return changeset else: Fatal("Couldn't find a "+status+" changeset in current client") def get_users_changeset(r, status, nth, user): changeset = final_changeset(r.p4_run(["changes", "-s", status, "-m", nth, "-u", user])) if changeset: return changeset else: Fatal("Couldn't find a "+status+" changeset for user "+user) def final_changeset(changes_output): m = re.search("Change ([0-9]*).*$", changes_output) if m: return m.group(1) else: return None def fixup_dash_C(w, r, lower_case_okay=False): if lower_case_okay: dash_C = "-c" opt_re = "-[cC]" else: dash_C = "-C" opt_re = "-C" for i in range(1, len(r.argv)): m = re.match("("+opt_re+")?here", r.argv[i]) if m and (m.group(1) or (i > 0 and re.match("^"+opt_re+"$", r.argv[i-1]))): if m.group(1): # -C was in this arg (so put it back when we're done) dash_C_prefix = dash_C else: # -C was in prev arg (so don't insert it below) dash_C_prefix = "" r.argv[i] = dash_C_prefix + w.client() return True return False def fixup_dash_c(w, r, dash_c="-c"): # Enhanced "-c" syntax. # "-c p" gets replaced with the "-c CHANGESET", where CHANGESET is the # most recent pending changeset in the current client. # * But wait, there's more. "-c sh" and "-c su" specify the most recent # shelved and submitted changesets, respectively. # * And an optional ":userid" says look up the changeset based on the "userid", # not the current workspace. And "me" means the current user, just like it # does in "p5 changes -u me". # * An optional ":nth" says to use the "nth" most recent changeset instead. # Examples: # p5 desc -cp # most recent pending changeset in the current workspace # p5 desc -csu:me # my most recent submitted changeset from any workspace # p5 desc -csu:2 # my penultimate submitted changeset from the current workspace # p5 desc -csu:sam:4 # Sam's 4th most recent changeset stat_map = {"p" : "pending", "sh" : "shelved", "su" : "submitted" } for i in range(1, len(r.argv)): # group(1): optional "-c" # group(2): "p" | "su" | "sh" # group(3): optional userid # group(4): optional nth m = re.match("("+dash_c+")?(p|su|sh)(:[a-z][a-z0-9]*)?(:[0-9]*)?$", r.argv[i]) if m and m.group(2) and (m.group(1) or (i > 0 and r.argv[i-1] == dash_c)): if m.group(1): # -c was in this arg (so put it back when we're done) dash_c_prefix = dash_c else: # -c was in prev arg (so don't insert it below) dash_c_prefix = "" status = stat_map[m.group(2)] userid = m.group(3) if userid: userid = m.group(3)[1:] if userid == "me": userid = current_user_id(r) if m.group(4): nth = m.group(4)[1:] else: nth = "1" if userid: # lookup changeset based on userid changeset = get_users_changeset(r, status, nth, userid) else: # lookup changeset based on current workspace changeset = get_local_changeset(w, r, status, nth) r.argv[i] = dash_c_prefix + changeset return True return False def fixup_dash_u(r): return fixup_by_map(r, { ("-u", "me") : ("-u",current_user_id(r)) }) def fixup_dash_us(r): return fixup_by_map(r, { ("-u", "me") : ("-u",current_user_id(r)), ("-s", "p") : ("-s","pending"), ("-s", "sh") : ("-s","shelved"), ("-s", "su") : ("-s","submitted"), }) def fixup_by_map(r, map2): tweaked = False map1 = dict() # Now build a second list with the keys and values as strings instead of tuples for (k,v) in map2.iteritems(): map1["".join(k)] = "".join(v) for i in range(1, len(r.argv)): try: subst = map1[r.argv[i]] if subst: r.argv[i] = subst tweaked = True except KeyError: try: subst = map2[(r.argv[i-1], r.argv[i])] if subst: r.argv[i-1:i+1] = subst tweaked = True except KeyError: pass return tweaked def Help(w, r): r.p4_run(r.argv, output_fd=sys.stdout, verbose=False) if (len(r.argv) == 1): # No help topic specified: print our top-level help as well. Usage("p5") def OtherP4Script(w, r): # "p5 foo args..." => "p4foo args..." others_string = os.getenv("P5_WRAPPED") if others_string: try: others = others_string.split(':') for pair in others: (command, script) = pair.split('=') if command == r.argv[0]: r.argv[0] = script if sys.platform == "win32": # Freaking Windows. "sh -c" seems to work. NFI why. # Even subprocess.call(shell=True) didn't do the trick r.argv = ["sh", "-c", " ".join(r.argv)] r.run(r.argv, verbose=True, do_exec=True) return True except (ValueError): Fatal("P5_WRAPPED must be a colon-separated list of 'command=executable' pairs") return False class File: UNKNOWN_VERSION = "0" UNKNOWN_CHANGESET = "default" def __init__(self, local_path, depot_path, operation, file_type, version, changeset): self.__local_path = local_path self.__depot_path = depot_path self.__operation = operation self.__file_type = file_type self.__version = version self.__changeset = changeset or File.UNKNOWN_CHANGESET # For p5move self.__old_path = None self.__old_version = None def set_move(self, old_path, old_version): self.__old_path = old_path self.__old_version = old_version def set_operation(self, operation): self.__operation = operation def local_path(self, vers=False): return maybe_add_version(self.__local_path, vers, self.__version) def old_path(self, vers=False): return maybe_add_version(self.__old_path, vers, self.__old_version) def depot_path(self, ver=False): return maybe_add_version(self.__depot_path, ver, self.__version) def operation(self): return self.__operation def file_type(self): return self.__file_type def is_text(self): return re.match(".*text$", self.file_type()) def version(self): return self.__version def changeset(self): return self.__changeset # Returns a string: old-rel-path -> new-rel-path def pretty_move(self, ver=False): return self.old_path(ver)+" -> "+self.local_path(ver) def maybe_add_version(path, do_it, version): # UNKNOWN_VERSION because the old backup format didn't record file versions if do_it and version != File.UNKNOWN_VERSION: return path + "#" + version else: return path class Workspace: def __init__(self, r): self.__dash_C = None # user-supplied self.__clientname = None # user-supplied or "p4 client -o" supplied self.__root = None self.__files = None self.r = r self.__no_client_okay = False self.__changesets = [] def get_backup_dir(self): backup_root = os.getenv("P5_BACKUP_DIR") or os.path.join(os.getenv("HOME"), "p5.backup") return os.path.join(backup_root, self.client()) def get_saveset_dir(self, saveset, not_found_okay): # Returns the path to an *existing* saveset. Used by "p5 restore" and "p5 backup -e" saveset_dir = os.path.join(self.get_backup_dir(), saveset) if not_found_okay or os.path.exists(saveset_dir): return saveset_dir # Get ready to print an error message. if self.__dash_C: backups_arg = ' -B '+self.__dash_C else: backups_arg = '' if not os.path.exists(os.path.dirname(saveset_dir)): Fatal("Source backup directory not found: " + os.path.dirname(saveset_dir)) # 'latest' feature is not supported on Windows, because Windows symbolic links are a joke. # But to avoid confusion, print a more helpful error message than 'latest' not found. if saveset == 'latest': Fatal("Couldn't determine which backup is the most recent.\n"+ "(Probably because the backup was probably created under Windows.)\n"+ "Use 'p5 backups"+backups_arg+ "' to see a list, then 'p5 restore"+backups_arg+" -b'.") # Directory not found. Before giving up, try padding it with 0's # (E.g., if the user said "-b 12", we'll try "-b 012".) if saveset.isdigit(): padded_saveset_dir = os.path.join(self.get_backup_dir(), ("%03d" % int(saveset))) if os.path.exists(padded_saveset_dir): # We padded with 0's and now found a directory that exists. Sweet! return padded_saveset_dir # Either we couldn't pad with 0's, or we did and the directory still doesn't exist. # Time to die. Fatal(saveset_dir + " not found.\nUse 'p5 backups"+backups_arg+"' to see a list.") def init_file_list(self, changeset, files): cmd = ["opened"] if self.client(): cmd = cmd + ["-C" , self.client()] if changeset: cmd = cmd + ["-c", changeset] cmd = cmd + files out = self.r.p4_run(cmd) # Each result from findall has: //depot-path operation default? changeset# file-type (ODPATH, OVERSION, OOP, ODEFAULT, OCS, OTYPE) = range(6) depot = re.findall("(.*)#([0-9]*) - ([a-z/]*)( default)? change ?([0-9]*) \(([+A-Za-z0-9]*)\)", out) # Construct a list of all the non-"default" changesets we saw for d in depot: if len(d[OCS]) > 0 and not d[OCS] in self.__changesets: self.__changesets.append(d[OCS]) # Just strip out any move/delete entries. We'll recover that info in the move/add entry below depot = filter(lambda o: o[OOP]!="move/delete", depot) raw_where = self.r.p4_run(["-x", "-", "where", "-T", "ignored"], verbose=False, input = '\n'.join(map(lambda t:t[ODPATH], depot))) abs_where = re.findall("^... path (.*)", raw_where, re.MULTILINE) rel_where = map(lambda abs: abs[len(self.root())+1:].rstrip(), abs_where) self.__files = map(lambda d, relpath: File(relpath, d[ODPATH], d[OOP], d[OTYPE], d[OVERSION], d[OCS]), depot, rel_where) # Finally, patch up any move/add entries adding the old relative path and version info # Note that it can't be the //depot path, because we may wind up restoring to a client that # doesn't have that path in its view (i.e., to another branch). for e in self.__files: if e.operation() == 'move/add': e.set_operation('p5move') out = self.r.p4_run(["fstat",e.depot_path()]) m = re.search("movedRev (.*)", out) old_version = m.group(1) m = re.search("movedFile (.*)", out) depot_move_from = m.group(1).rstrip() if depot_move_from == None: Fatal("Internal error: couldn't find move-from //depot path for "+e.depot_path()) raw_where = self.r.p4_run(["where","-T","ignored",depot_move_from]) m = re.search("^... path (.*)", raw_where, re.MULTILINE) if not m: Fatal("Internal error: couldn't find move-from local path for "+e.depot_path()) e.set_move(m.group(1)[len(self.root())+1:], old_version) @staticmethod def convert_version0_entry(o): # Each entry is: [ local-path //depot-path operation file-type ? ] f = File(local_path=o[0], depot_path=o[1], operation=o[2], file_type=o[3], version=File.UNKNOWN_VERSION, changeset=File.UNKNOWN_CHANGESET) if len(o) == 5: f.set_move(o[4], File.UNKNOWN_VERSION) elif len(o) != 4: Fatal("Corrupt backup file") return f def restore_file_list(self, blob, subset): version = blob[0] if type(version) == int and version == 1: self.__files = blob[1] elif type(version) == list: # I stupidly forgot to version the file format in the initial release. # So if the first element is a list, assume a version 0 file format, # and convert each entry. self.__files = map(Workspace.convert_version0_entry, blob) else: Fatal("unknown file format") if subset: # make sure there are no duplicates subset = set(subset) # If the user specified a file-list, then weed out all the entries # that don't exactly match either the //depot pathname or the # local filename relative to the root self.__files = filter(lambda f: (remove_if_found(f.local_path(), subset) or remove_if_found(f.depot_path(), subset)), self.__files) if subset: Fatal("Requested file(s) not found in backup:\n\t"+"\n\t".join(subset)) def file_list_archive(self): return (1, self.__files) def describe(self, output_fd): default_found = False for f in self.__files: if f.changeset() == File.UNKNOWN_CHANGESET: if not default_found: output_fd.write('Affected files in the DEFAULT changeset ...\n\n') default_found = True output_fd.write('... ' + f.depot_path(True) + ' ' + f.operation() + '\n') if default_found: output_fd.write('\n') output_fd.flush() if self.__changesets: self.r.p4_run(["describe","-s"]+self.__changesets, output_fd=output_fd) def adds(self): return filter(lambda t:t.operation()=='add', self.__files) def edits(self): return filter(lambda t:t.operation()=='edit', self.__files) def deletes(self): return filter(lambda t:t.operation()=='delete', self.__files) def moves(self): return filter(lambda t:t.operation()=='p5move', self.__files) def files(self): return map(lambda f: f.local_path(), self.__files) def changesets(self): return self.__changesets ## This MUST be called before calling get_backup_dir() or root() def init_client_info(self, dash_C, no_client_okay=False): # Just remember the value for now. We defer running p4 client until we're sure we need to. self.__no_client_okay = no_client_okay if dash_C: self.__dash_C = dash_C self.__clientname = dash_C def __run_p4_client(self): if self.__clientname == None: clientname = [] else: clientname = [self.__clientname] out = self.r.p4_run(["client", "-o"] + clientname) m = re.search("^Client:\\s*(.*)$", out, re.MULTILINE) if not m: if self.__no_client_okay: self.__clientname = -1 return Fatal("Couldn't find client name") self.__clientname = m.group(1).rstrip() m = re.search("^Root:\\s*(.*)$", out, re.MULTILINE) self.__root = m.group(1).rstrip() def client(self): if self.__clientname == None: self.__run_p4_client() if self.__clientname == -1: return None return self.__clientname def root(self): # If the user said "backup -C ", then we won't run 'p4 client' # unless and until we try to cd to the root dir. That's why we have to # check for an unset value here. if self.__root == None: self.__run_p4_client() return self.__root def remove_if_found(elt, some_set): if elt in some_set: some_set.remove(elt) return True return False class Runner: def __init__(self, argv): self.argv = argv # Pop off our universal flag (-v) self.__very_verbose = False self.__very_quiet = False if len(argv) > 0: if argv[0] == '-v': self.__very_verbose = True del(self.argv[0]) elif argv[0] == '-q': self.__very_quiet = True del(self.argv[0]) # Now pop off p4's universal flags self.__p4_cmd = ["p4"] while (len(argv)>0 and argv[0][0] == '-'): flag = argv.pop(0) if flag[0:2] in ["-G", "-s", "-V"]: # These'll mess us up if we use them on commands where we parse the output. # We could try to figure out when to use 'em and when not to. But for now # just forbid them. Fatal("unsupported option: "+flag) self.__p4_cmd.append(flag) if len(flag)==2 and len(argv)>0: # Pop the flag's arg as well. self.__p4_cmd.append(argv.pop(0)) if len(argv)==0: Usage() def strip_dash_c(self): for i in range(1, len(self.argv)): m = re.match("^(-c)?([0-9]+)$", self.argv[i]) if m and m.group(1): # -c456789 del self.argv[i] self.argv.append(m.group(2)) return True elif self.argv[i-1] == '-c': # -c 456789 cs = self.argv[i] del self.argv[i-1:i+1] self.argv.append(cs) return True return False def p4_exec(self, argv, verbose=True): self.run(self.__p4_cmd + argv, verbose=verbose, do_exec=True) # NOTREACHED def p4_run(self, argv, verbose=True, input=None, output_fd=None, dry_run=False, return_error=False, do_exec=False): return self.run(self.__p4_cmd + argv, verbose, input, output_fd, dry_run, return_error, do_exec) def run(self, argv, verbose=True, input=None, output_fd=None, dry_run=False, return_error=False, do_exec=False): if self.__very_verbose: verbose = True elif self.__very_quiet: verbose = False if verbose: if type(argv) == list: # Strip out any None's, as a convenience to callers (so, they can pass in # a user's command line flag (e.g., "dash_n" or "files") without having to # think about whether the user actually set it). argv = filter(lambda v: v!=None, argv) pretty = ' '.join(argv) else: # type==str pretty = argv argv = argv.split() if dry_run: sys.stderr.write("[ !Runnin '" +pretty+ "' ") elif do_exec: sys.stderr.write("[ Execing '" +pretty+ "' ]\n") else: sys.stderr.write("[ Running '" +pretty+ "' ") if output_fd: # If we're spewing to the terminal, then we should close that brace right away sys.stderr.write("]\n") if input: stdin=subprocess.PIPE else: stdin=None if output_fd: stdout = output_fd else: stdout = subprocess.PIPE if dry_run: out = err = "" elif do_exec: if sys.platform == "win32": # os.execv*() seems problematic on some Windows machines. subprocess.call() seems to work. sys.stderr.flush() subprocess.call(argv) sys.exit(0) else: try: os.execvp(argv[0], argv) except: Fatal("couldn't exec "+argv[0]) else: (out, err) = subprocess.Popen(argv, stdin=stdin, stdout=stdout, stderr=subprocess.PIPE).communicate(input) if verbose and not output_fd: sys.stderr.write("]\n") sys.stderr.flush() if return_error: # The caller will check for errors. return (out, err) if err: Fatal(err) return out def cygpath(self, filename): if sys.platform == "win32": return(self.run(["cygpath", filename]).rstrip()) else: return filename def main(): verbs = { "annotate": Differ, "backup" : Backup, "backups" : Backups, "buddy" : Share, # Old, deprecated name "branches": DashuCommand, "change" : Change, "changes" : Changes, "clients" : DashuCommand, "desc" : Desc, "describe": Describe, "diff" : Differ, "diff2" : Differ, "help" : Help, "labels" : DashuCommand, "opened" : Opened, "pending" : RestrictedChanges, "resolve" : Differ, "restore" : Restore, "shelved" : RestrictedChanges, "share" : Share, "submit" : Submit, "unshelve": Unshelve, } if sys.version_info < (2,5): Fatal("This script requires python 2.5 or greater") if os.environ.has_key('PWD'): # Incredibly, p4 prefers $PWD over the cwd on Windows. Unset so it won't be tempted. del os.environ["PWD"] runner = Runner(sys.argv[1:]) workspace = Workspace(runner) fixup_dash_c(workspace, runner) # Now do one of three things: # 1. Run the p5 command. # 2. See if there is home-grown script out there that we are supposed to invoke. # 3. Assume that it is a p4 command, and run that. if verbs.has_key(runner.argv[0]): verbs[runner.argv[0]](workspace, runner) elif not OtherP4Script(workspace, runner): runner.p4_exec(runner.argv, verbose=False) if __name__ == "__main__": try: sys.exit(main()) except (IOError, KeyboardInterrupt): # Suppress python stack trace when piped to head, or when user hits Control-c sys.exit(1)