#!/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. # $Id: //guest/wmesard/p5/p5#14 $ import sys, os, subprocess, errno import getopt, re, pickle import fnmatch, datetime import tempfile, shutil def print_usage(command, err): usage = """ p5 backup [-ef] [-b backup-name] [-c changeset] [-C client] [-d description] [file...] p5 backups [-b backup-glob | -m max] [-B src-client] [-l] p5 bdiff [-b backup-name] [-B src-client] [-d] file... p5 bprint [-b backup-name] [-B src-client] [-q] [-r] file... p5 restore [-b backup-name] [-B src-client] [-c changeset] [-C client] [-n] [-p | file...] 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]" """.strip("\n") if len(err): err = "Error: " + err + "\n" if command: for line in usage.splitlines(): if "p5 "+command+" " in line: usage = line + "\n" break usage = "Usage:\n"+ usage + "\nSee the help page for details: http://bit.ly/1hBRvXO" sys.exit(err + usage) # TODO: Style-cleanup: Make this code more pythonic. ## 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: # The user said "p5 desc -s" return p4_diff = ["diff"] if dash_d: p4_diff.append("-d" + dash_d) if dash_t: p4_diff.append("-t") r.init_diff(dash_d) for f in w.adds(): filename = f.local_path() if dash_t or f.is_text(): r.run_diff("/dev/null", filename, 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()]) # -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): opts, args = getopt.getopt(r.argv[1:], "c:d:ost") 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). changesets = w.changesets() if changesets: for change in changesets: (out, err) = r.p4_run(["shelve", "-c", change, "-r"], return_error=True) if err: print out Fatal(err) # p4 shelve is too chatty. Remove the lines we don't care about out = re.sub("^Shelving files.*\n", "", out) out = re.sub("\n\n", "\n", out) out = re.sub("(move/add|move/delete|delete|edit|add) //.*\n", "", out) print out, # 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("Wrote "+fname+"\n") def Describe(w, r): """Like 'p4 describe' but: (1) tolerate '-c' (2) honor P5_DIFF_FLAG (3) 'p4 print' new files""" r.strip_dash_c() r.argv[0] = "describe" # in case we got here from Desc() Differ(w, r) # '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], 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]) for line in out.split("\n"): print "+", line else: sys.stdout.write("\n==== Skipping new file: " +fname+" (" +ftype+ ") ====\n") def Backup(w, r): opts, args = getopt.getopt(r.argv[1:], "B:b:C:c:d:ef") saveset = changeset = None dash_C = None dash_e = False prompt = True description = None 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": description = [ a, "\n"] prompt = False 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):") description = [] while True: line = sys.stdin.readline() if len(line)==0 or line=='.\n': break description.append(line) 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, dir_must_exist=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) w.init_file_list(changeset, files) files = map(lambda f: r.cygpath(f.local_path()), w.adds()+w.edits()+w.moves()) if not files: Fatal("File(s) not opened for editing on this client") ## ## Do the backup! ## 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 description: f = open(os.path.join(saveset_dir, "comment.txt"), 'w') f.writelines(description) f.close() f = open(os.path.join(saveset_dir, "files.p5"), 'w') pickle.dump(w.file_list_archive(), f) f.close() r.run(["tar", "--create","--files-from","-","--file", r.cygpath(os.path.join(saveset_dir, "p5.tar"))], input='\n'.join(files)) # For the p5.patch file, the user doesn't get to choose the backup format. # "u"niversal format is the most reliable for patch(1). do_share(w, r, "u", False, False, open(os.path.join(saveset_dir, "p5.patch"), 'w')) sys.stderr.write("Created backup at " +saveset_dir+ "\n") def Bdiff(w, r): do_backupOp(w, r, "diff") def Bprint(w, r): do_backupOp(w, r, "print") def do_backupOp(current_w, r, operation): # bprint and bdiff share so much, it's easier (albeit cheesier) # to implement them in a single function if operation == "print": flags = "B:b:qr" else: # operation == "diff" dash_q = True flags = "B:b:d:" opts, args = getopt.getopt(r.argv[1:], flags) saveset = 'latest' src_client = None dash_d = None dash_q = False dash_r = False for o, a in opts: if o == "-B": src_client = a elif o == "-b": saveset = a elif o == "-d": dash_d = a elif o == "-q": dash_q = True elif o == "-r": dash_r = True else: # NOTREACHED assert False, "unhandled option:"+o if not args: r.usage("File argument required") r.init_diff(dash_d) if dash_r: # treat the file args as literal paths relative to the backup workspace root files = args else: # The directories between cwd and the current root may have to be prepended to the file name. # So we need to figure out what the current root is. # Example: # client root: /work/a # cwd: /work/a/src/uts # cmd line: sun4v/cpu/generic.c # fetched from tar: src/uts/sun4v/cpu/generic.c current_w.init_client_info(None) (files, wildcards_present) = current_w.relpaths(args) if src_client == None: src_w = current_w else: # We're using 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) # It might've been cleaner to use the stdin and stdout options of GNU diff and tar # instead of untar'ing to disk. But this way is more portable. saveset_dir = src_w.load_saveset(saveset) tempd = tempfile.mkdtemp() os.chdir(tempd) success = 0 try: # We won't necessarily report an error if tar says "file not found". For # example, there may be multiple Makefiles in the workspace and only one # in the backup, but we want "p5 bdiff .../Makefile" to succeed. error_message = r.run(["tar", "xf", r.cygpath(os.path.join(saveset_dir, "p5.tar"))] + files, return_error=True)[1] last_exception = None for fname in files: if operation == "print": try: fd = open(fname) if not dash_q: sys.stdout.write("=== " + fname + " ===\n") sys.stdout.write(fd.read()) fd.close() success = success+1 except IOError, e: # May be benign. See note above. last_exception = e else: # operation == "diff" stderr = r.run_diff(fname, os.path.join(current_w.root(), fname), sys.stdout, return_error=True) if not stderr: success = success+1 finally: shutil.rmtree(tempd, ignore_errors=True) if success < len(args): # Fewer successes than args. We use that as our heuristic for failure. if error_message: Fatal(error_message) elif last_exception: Fatal(str(last_exception)) else: Fatal("internal error") def Restore(dst_w, r): opts, args = getopt.getopt(r.argv[1:], "B:b:C:c:np") 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: r.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, wildcards_present) = dst_w.relpaths(args) ## ## Do the restore! ## src_saveset_dir = src_w.load_saveset(src_saveset, files, not wildcards_present) os.chdir(dst_w.root()) if dry_run: r.set_verbose() 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): opts, args = getopt.getopt(r.argv[1:], "B:b:lm:") 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: r.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.) r.usage("-b and -m are mutually exclusive") if args: r.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: saveset_dir = w.load_saveset(bu) print " Location: " + saveset_dir 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'""" r.strip_dash_c() r.p4_exec(r.argv) def Unshelve(w, r): """Like 'p4 unshelve', but with p5's enhanced changelist syntax on '-s'""" fixup_dash_c(w, r, "-s") r.p4_exec(r.argv) def Revert(w, r): """Like 'p4 revert', but also deletes new files, instead of leaving them lying around""" opts, args = getopt.getopt(r.argv[1:], "ac:fnk") do_delete = True dash_f = False revert_cmd = ["revert"] for o, a in opts: if o in ("-a", "-n", "-k"): # Any of these flags means don't delete abandoned files do_delete = False if o == "-f": # Don't ask about deleting abandoned files. Just delete them! dash_f = True else: revert_cmd.append(o) if a: revert_cmd.append(a) revert_cmd = revert_cmd + args # First run the command... result = r.p4_run(revert_cmd) # ...then see if any formerly "added" files need to be cleaned up print result, add_files = re.findall("^([^ ].*)#none - was add, abandoned", result, re.MULTILINE) if add_files: where_output = r.p4_run(["-x", "-", "where", "-T", "ignored"], input='\n'.join(add_files)) abs_files = re.findall(r"^... path (.*)", where_output, re.MULTILINE) if do_delete and abs_files: if dash_f: response = 'y' else: response = None while response is not 'y': print "\nDelete abandoned files? (y/n/v) ", response = sys.stdin.readline() if not response: continue response = response[0] if response == 'n': return elif response == 'v': print for f in abs_files: print " ", f map(safe_unlink, abs_files) def safe_unlink(path): try: os.unlink(path) except OSError, err: # If reverting a file that was p4-added but not created, then just carry on error_string = str(err) if "No such file" not in error_string: # Print the error, but don't abort sys.stderr.write(error_string) def Differ(w, r): """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, 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) 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"]) if result: return result.split('=')[1].split(' ')[0].rstrip() else: return result def Changes(w, r): """Like 'p4 changes' but allow some shortcuts on the command line""" fixup_dash_C(w, r, True) fixup_dash_us(r) r.p4_exec(r.argv) def DashuCommand(w, r): fixup_dash_u(r) r.p4_exec(r.argv) def Opened(w, r): fixup_dash_C(w, r) fixup_dash_u(r) r.p4_exec(r.argv) 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): fixup_by_map(r, { ("-u", "me") : ("-u",current_user_id(r)) }) def fixup_dash_us(r): 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): 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 except KeyError: try: subst = map2[(r.argv[i-1], r.argv[i])] if subst: r.argv[i-1:i+1] = subst except KeyError: pass def Help(w, r): r.p4_run(r.argv, output_fd=sys.stdout) if (len(r.argv) == 1): # No help topic specified: print our top-level help as well. print_usage(None, "") def Usage(w, r): print_usage(None, "") 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, 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, dir_must_exist): saveset_dir = os.path.join(self.get_backup_dir(), saveset) if (not dir_must_exist) 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 load_saveset(self, saveset, files=None, files_must_exist=True): saveset_dir = self.get_saveset_dir(saveset, dir_must_exist=True) try: f = open(os.path.join(saveset_dir, "files.p5"), 'r') except IOError, err: Fatal(str(err)) self.restore_file_list(pickle.load(f), files, files_must_exist) f.close() return saveset_dir 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"], 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, rel_path: File(rel_path, 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, files_must_exist): 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 files_must_exist and 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 relpaths(self, paths): """Given a list of filespecs, returns the local filenames relative to the client root, and True if any of the filespecs contained a Perforce wildcard (in which case the number of files may not equal the number of filespecs), False otherwise (in which case there will be a one-to-one correspondence). """ if filter(lambda path: re.match(r".*//depot|.*\.\.\.|.*\*|.*#|.*@", path), paths): # At least one of the paths contained a Perforce filespec element. # So just do the whole thing the slow way files_output = self.r.p4_run(["files", "-e"] + paths) depot_specs = re.findall(r"^(.*)#", files_output, re.MULTILINE) where_output = self.r.p4_run(["-x", "-", "where", "-T", "ignored"], input='\n'.join(depot_specs)) client_files = re.findall(r"^... clientFile //"+self.client()+"/(.*)", where_output, re.MULTILINE) return (client_files, True) else: return (map(lambda path: relpath(path, self.root()), paths), False) 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.__verbose = False self.__diff = None if len(argv) > 0: if argv[0] == '-v': self.set_verbose() 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: print_usage(None, "") def set_verbose(self): self.__verbose = True def init_diff(self, dash_d): """This must be called before run_diff() can be used.""" diff_envar = os.getenv("P4_DIFF") if diff_envar: diff = diff_envar.split() else: diff = ["diff"] if len(diff) == 1: # $P4_DIFF didn't have any options. So look at: # 1. the options passed on the command line # 2. $P5_DIFF_FLAG if not dash_d: dash_d = os.getenv("P5_DIFF_FLAG") if dash_d: # For example: "-dc10" --> "c10" dash_d = dash_d[2:] if 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)): flags = ["-"+dash_d[0], "-C"+dash_d[1:]] else: flags = ["-"+dash_d[0]] else: flags = [] diff = diff + flags self.__diff = diff def usage(self, error): print_usage(self.argv[0], error) 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): self.run(self.__p4_cmd + argv, do_exec=True) # NOTREACHED def p4_run(self, argv, input=None, output_fd=None, dry_run=False, return_error=False, do_exec=False): return self.run(self.__p4_cmd + argv, input, output_fd, dry_run, return_error, do_exec) def run(self, argv, input=None, output_fd=None, dry_run=False, return_error=False, do_exec=False): if type(argv) == list: # Strip out any non-strings 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: (type(v)==str), argv) verbose = (dry_run or self.__verbose) if verbose: if type(argv) == list: 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 run_diff(self, file1, file2, output_fd=sys.stdout, return_error=False): result = self.run(self.__diff+[file1, file2], return_error=return_error) if return_error: (output, error) = result else: output = result error = None if output and len(self.__diff) == 1: # The default diff format doesn't have a filename header, so fake one up output_fd.write("==== "+file1+" - "+file2+" ====\n") output_fd.write(output) return error 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, "bdiff" : Bdiff, "bprint" : Bprint, "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, "revert" : Revert, "shelved" : RestrictedChanges, "share" : Share, "submit" : Submit, "unshelve": Unshelve, "usage" : Usage } 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. try: if verbs.has_key(runner.argv[0]): verbs[runner.argv[0]](workspace, runner) elif not OtherP4Script(workspace, runner): runner.p4_exec(runner.argv) except getopt.GetoptError, err: runner.usage(str(err)) if __name__ == "__main__": try: sys.exit(main()) except IOError, e: # Suppress python stack trace when piped to head or less or whatever if e.errno == errno.EPIPE: sys.exit(0) else: raise e except KeyboardInterrupt: # Suppress python stack trace when user hits Control-c sys.exit(0)