#!/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<flag>] 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<flag>] [-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 <any-p4-command> "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<backup-name>'.") # 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 <p4-move-old-depot-path>? ] 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 <client>", 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)
# | Change | User | Description | Committed | |
---|---|---|---|---|---|
#14 | 11411 | wmesard |
p5 revert's handling of abandoned files could be a little friendlier: 1. When reverting a non-existent file, don't print the inevitable "No such file or directory" error when we go and try to delete it. 2. Add a "v" option ("Delete abandoned files? (y/n/v) ") so the user can view which files are to be deleted before agreeing to it. |
||
#13 | 10925 | wmesard |
1. p5 share: strip "delete" lines from "p4 shelve" output. 2. Fix minor doc typo. |
||
#12 | 10776 | wmesard |
1. The "p5 revert" clean up of abandoned files is best-effort. It shouldn't abort if an unlink fails. 2. "p4 shelve" is too verbose for "p5 share" to just echo unfiltered. But throwing it out isn't okay either, since it may contain a useful error message. Compromise: strip out the lines that we're expecting, and don't care about. |
||
#11 | 10129 | wmesard |
"p5 revert". Like "p4 revert", but deletes abandoned files (files that were added, and then reverted). By default, prompts the user before doing this. With the "-f" flag, doesn't prompt; just does it. Motivation: If you leave abandoned files laying around, tools like Maven will happily keep compiling them...until they break months later causing the build to mysteriously fail. |
||
#10 | 9969 | wmesard |
Tame the on-by-default echoing of the p4 commands being executed behind the scenes. No one is impressed (except for me, and I can still say "p5 -v CMD..." to see what's going on). |
||
#9 | 9963 | wmesard |
The "file..." args to restore, bdiff and bprint should support Perforce filespec syntax ("//depot", "...", "*", "@", "#"). Note: share and backup already supported it, since they already used "p4 opened" and "p4 where" to do their thing. |
||
#8 | 9687 | wmesard |
New option to p5 backup: "-d description" passes a description for the backup rather than prompting the user (a la "p4 submit"). Note: this quietly obsoletes the old ill-considered "-d" flag (which overrode the diff format used in the patch file). |
||
#7 | 9657 | wmesard |
1. New commands "p5 bdiff" and "p5 bprint" for examining files in a previously saved backup. 2. On a syntax error, only print the relevant line of the Usage message instead of the whole thing. 3. Be less chatty about what commands we're executing to get the job done ("p4 client" and "p4 opened" in particular). Anyone who really cares can say "p5 -v COMMAND". |
||
#6 | 9610 | wmesard |
1. If "p5 share" sees files in the default changeset, it should print something that looks like a changeset description summary for those files. (It's too easy to have files in the default changeset unintentionally, so it makes sense to have p5 desc/p5 share scream about them.) 2. Add support for "-s" flag to "p5 share" and "p5 desc". (If the user says "p5 desc -s", they probably mean something like "p5 describe -s <all-changesets-including-default>". Thanks to (1), above, that now makes sense.) 3. Make "p5 resolve" honor P5_DIFF_FLAG. |
||
#5 | 9604 | wmesard |
1. Make "p5 backup" store the depot version number for each file. This necessitated a new backup format. So add file format versioning, and a converter to read old files. 2. Make "p5 backups -l" print that version number. 3. "p4 diff -du" doesn't print the version number. It does print it for the other diff formats (and even for "-du3"). Since "p5 share" uses "p4 diff", add the version number in if necessary. 4. General code clean up. Make pylint happy. (Well...less unhappy.) New "File" class replaces the old list-munging code in Workspace class. |
||
#4 | 9248 | wmesard |
If set in the environment, "p4 set P4USER" prints: "P4USER=useridhere" But if it comes from .p4config, it adds garbage: "P4USER=useridhere (config)" Neither "p4 -s" nor "p4 -G" seem to make it better. So we have to clean the string ourselves. |
||
#3 | 9227 | wmesard | Update usage message. | ||
#2 | 9146 | wmesard |
Alternative implementations of Matt's p5 enhancements from @9100 and @9103: 1. "-u me" should honor P4USER. 2. Add a "p5 shelved" command, analogous to "p5 pending". # review @matt_attaway |
||
#1 | 9083 | wmesard |
p5 is a drop-in replacement for p4. It provides some new commands, and enhances the behavior of some existing commands. |