"""Moves a changelist from a workspace into the main working copy, where it gets reviewed and committed. python Scripts/deliver.py "Glass: add DropdownRow" [--workspace PATH] [--dry-run] python Scripts/deliver.py "Glass: add DropdownRow" --landed [--workspace PATH] [--dry-run] The workspace stays the source of truth until the changelist is committed. Delivering again first reverts the previous delivery of that changelist in main, so main never holds a stale mixture of two deliveries. Each delivery is recorded beside the workspace's lock, as a digest of every file. Once the user has committed the changelist from main, --landed uses that record to prove the workspace holds nothing but what was delivered and that main shows it committed. Only then does it drop the workspace's copy of the changelist and update to the commit, which brings in any edits made in main before committing. It also takes the changelist's name off the committed files in main, which a commit that keeps changelists leaves them tagged with. A file moved with `svn move` is delivered as a move, so main keeps its history, when both the file and the one it moved from are in the changelist. Any other copy or move is refused. Landing copes with a workspace that was updated after the delivery. A copy that the update merged commits into counts as unedited when main holds the very same content and properties. A changelist whose files the update left with no change, as happens to one that was committed along with the changelist landed before it, only has to be found committed in main. Delivering never deletes a file from main that it didn't put there. If a delivery fails, the rollback leaves alone any file that was already in main when it began, and delivering a new file that main already has is refused up front. """ import argparse import collections import hashlib import json import os import re import shutil import subprocess import sys import tempfile from xml.etree import ElementTree import scratch IGNORED_WORKING_COPIES = ("bin", "junk") STATUS_HEADER = re.compile(r"^--- Changelist '(.*)':$") StatusEntry = collections.namedtuple("StatusEntry", ["flags", "path", "changelist"]) class DeliveryRefused(Exception): """Delivering would be unsafe or can't be done faithfully, so nothing was changed.""" def path_key(path): """Returns a path in the form used to compare paths, since Windows paths differ in case and separators.""" return os.path.normcase(os.path.normpath(path)) def svn_bytes(arguments, cwd=None): """Returns the stdout of an svn command as bytes, raising RuntimeError when it fails.""" completed = subprocess.run(["svn"] + arguments + ["--non-interactive"], cwd=cwd, capture_output=True) if completed.returncode != 0: raise RuntimeError("svn {} failed ({}):\n{}{}".format(" ".join(arguments[:2]), completed.returncode, completed.stdout.decode("utf-8", "replace"), completed.stderr.decode("utf-8", "replace"))) return completed.stdout def svn_output(arguments, cwd=None): """Returns the stdout of an svn command as text.""" return svn_bytes(arguments, cwd).decode("utf-8", "replace") def parse_status(output): """Returns a StatusEntry for each item line of `svn status`, with the changelist it was listed under.""" returnMe = [] changelist = None for line in output.splitlines(): header = STATUS_HEADER.match(line) if header: changelist = header.group(1) continue # A move or a conflict adds a "> ..." line under its item, which would otherwise read as an item with no flags. if len(line) > 8 and line[7] == " " and not line.lstrip().startswith("> "): returnMe.append(StatusEntry(line[:7], os.path.normpath(line[8:].strip()), changelist)) return returnMe def working_copy_status(working_copy): """Returns the status entries of one working copy, leaving its externals to be handled as their own.""" return parse_status(svn_output(["status", "--ignore-externals", working_copy])) def working_copy_revision(working_copy): """Returns the revision a working copy is at.""" match = re.search(r"^Revision: (\d+)\r?$", svn_output(["info", working_copy]), re.MULTILINE) return int(match.group(1)) def moved_files(working_copy): """Returns the (source, destination) of each file svn has recorded as moved in a working copy.""" returnMe = [] status = ElementTree.fromstring(svn_bytes(["status", "--xml", "--ignore-externals", working_copy])) for entry in status.iter("entry"): destination = entry.find("wc-status").get("moved-to") if destination: returnMe.append((os.path.normpath(entry.get("path")), os.path.normpath(destination))) return returnMe def unsupported_reason(entry, is_move_destination=False): """Returns why an entry can't be delivered faithfully, or None when it can. A move's destination carries copy history, which is delivered by moving the source in main. """ flags = entry.flags if flags[0] not in "ADM " or (flags[0] == " " and flags[1] != "M"): return "has the status '{}'".format(flags[0]) if flags[3] == "+" and not is_move_destination: return ("has copy or move history, which a patch can't carry. Add it as a new file instead, or for a move " "put the file it moved from in the changelist too") if flags[4] == "S": return "is switched" if flags[6] == "C": return "has a tree conflict" return None def is_under(path, directory): return path_key(path).startswith(path_key(directory) + os.sep) class WorkingCopyPlan(object): """What a changelist holds in one working copy of the workspace.""" def __init__(self, workspace_directory, main_directory, files, added_directories, revision, moves=()): self.workspace_directory = workspace_directory self.main_directory = main_directory self.files = files self.added_directories = added_directories self.revision = revision self.moves = list(moves) # Delivered files the workspace has no change to any more, which landing finds after an update merged a commit # over them, and the tags of the changelist they still carry. self.absorbed = [] self.stale_tags = [] def main_path(self, workspace_path): return os.path.join(self.main_directory, os.path.relpath(workspace_path, self.workspace_directory)) def landing_files(self): """Returns every file of the changelist that landing has to find committed in main.""" return self.files + self.absorbed def plan_working_copy(workspace_directory, main_directory, name): """Returns the plan for one working copy, or None when the changelist holds nothing there.""" entries = working_copy_status(workspace_directory) # A reverted file stays in its changelist and is listed with blank flags. files = [entry for entry in entries if entry.changelist == name and entry.flags.strip()] if not files: return None # Only a move with both ends in the changelist can be redone in main. One end alone is a copy or a plain delete. moves = [] if any(entry.flags[3] == "+" for entry in files): in_changelist = set(path_key(entry.path) for entry in files) moves = [(source, destination) for source, destination in moved_files(workspace_directory) if path_key(source) in in_changelist and path_key(destination) in in_changelist] destinations = set(path_key(destination) for source, destination in moves) for entry in files: reason = unsupported_reason(entry, is_move_destination=path_key(entry.path) in destinations) if reason: raise DeliveryRefused("{} {}.".format(entry.path, reason)) unassigned = [entry for entry in entries if entry.changelist is None] for entry in unassigned: if entry.flags[0] == "D" and any(is_under(file.path, entry.path) for file in files): raise DeliveryRefused("{} is a deleted directory. Deleting directories isn't supported.".format( entry.path)) added_directories = sorted((entry.path for entry in unassigned if entry.flags[0] == "A" and entry.flags[3] != "+" and any(is_under(file.path, entry.path) for file in files)), key=lambda path: len(path_key(path))) return WorkingCopyPlan(workspace_directory, main_directory, files, added_directories, working_copy_revision(workspace_directory), moves) def plan_delivery(workspace_path, main_root, name): """Returns a plan for every working copy of the workspace that holds the changelist.""" returnMe = [] for workspace_directory in scratch.working_copies(workspace_path): if os.path.basename(workspace_directory).lower() in IGNORED_WORKING_COPIES: continue plan = plan_working_copy(workspace_directory, os.path.normpath(os.path.join(main_root, os.path.relpath(workspace_directory, workspace_path))), name) if plan: returnMe.append(plan) if not returnMe: raise DeliveryRefused("The workspace {} has no changelist named '{}'.".format(workspace_path, name)) return returnMe def plan_landing(workspace_path, main_root, name, recorded): """Returns a plan for every working copy of the workspace that holds the changelist or part of what was delivered. An update of the workspace past a commit merges the commit over the workspace's copies. Where it was the very change a copy held, the copy is a plain file again and its changelist is only a tag. Such a file has nothing in the workspace to lose, so it counts as part of the changelist that landing needs only to find committed. """ directories = [directory for directory in scratch.working_copies(workspace_path) if os.path.basename(directory).lower() not in IGNORED_WORKING_COPIES] plans = {} statuses = {} for directory in directories: main_directory = os.path.normpath(os.path.join(main_root, os.path.relpath(directory, workspace_path))) plan = plan_working_copy(directory, main_directory, name) if plan: plans[path_key(directory)] = plan for item in recorded: file_path = os.path.normpath(os.path.join(workspace_path, *item["path"].split("/"))) holders = [directory for directory in directories if is_under(file_path, directory)] if not holders: continue # The innermost working copy owns the file, since the outer one leaves its externals to be handled as their own. directory = max(holders, key=lambda holder: len(path_key(holder))) key = path_key(directory) if key not in statuses: statuses[key] = dict((path_key(entry.path), entry) for entry in working_copy_status(directory)) existing = plans.get(key) if existing is not None and any(path_key(entry.path) == path_key(file_path) for entry in existing.files): continue listed = statuses[key].get(path_key(file_path)) if listed is not None and listed.flags.strip(): continue if existing is None: main_directory = os.path.normpath(os.path.join(main_root, os.path.relpath(directory, workspace_path))) existing = WorkingCopyPlan(directory, main_directory, [], [], working_copy_revision(directory)) plans[key] = existing existing.absorbed.append(StatusEntry(item["flags"].ljust(7), file_path, name)) if listed is not None and listed.changelist == name: existing.stale_tags.append(file_path) if not plans: # Nothing of it is left in the changelist, so every recorded file reports having left. raise DeliveryRefused("'{}' isn't ready to drop from the workspace:\n{}".format( name, "\n".join(changes_since_delivery([], workspace_path, recorded)))) return list(plans.values()) def find_previous_delivery(plan, name): """Returns the files, added directories and leftover tags an earlier delivery of this changelist left in main. A leftover tag is a file in the changelist with no change pending, which is what a commit that keeps changelists leaves behind. Refuses when someone else's work is in the way, or when a file the changelist adds is already there. """ entries = working_copy_status(plan.main_directory) targets = set(path_key(plan.main_path(entry.path)) for entry in plan.files) # A file with no change pending is only tagged, and delivering retags it without disturbing anyone. in_the_way = [entry for entry in entries if entry.changelist != name and entry.flags.strip() and path_key(entry.path) in targets] if in_the_way: raise DeliveryRefused("Main already has changes to files this changelist touches:\n{}".format( "\n".join("{} {} (changelist {})".format(entry.flags, entry.path, entry.changelist) for entry in in_the_way))) tagged = [entry for entry in entries if entry.changelist == name] files = [entry for entry in tagged if entry.flags.strip()] leftover_tags = [entry for entry in tagged if not entry.flags.strip()] delivered_additions = set(path_key(entry.path) for entry in files if entry.flags[0] == "A") already_there = [plan.main_path(entry.path) for entry in plan.files if entry.flags[0] == "A" and os.path.exists(plan.main_path(entry.path)) and path_key(plan.main_path(entry.path)) not in delivered_additions] if already_there: raise DeliveryRefused("These files this changelist adds are already in main:\n{}\nIf the changelist has been " "committed, run this again with --landed.".format("\n".join(already_there))) directories = [entry.path for entry in entries if entry.changelist is None and entry.flags[0] == "A" and os.path.isdir(entry.path) and any(is_under(file.path, entry.path) for file in files)] return files, directories, leftover_tags def discard_previous_delivery(files): """Reverts an earlier delivery so a new one starts from main's own base.""" for entry in files: svn_bytes(["changelist", "--remove", entry.path]) svn_bytes(["revert", entry.path]) if entry.flags[0] == "A" and os.path.exists(entry.path): os.remove(entry.path) def remove_empty_added_directories(directories): """Reverts and removes the given added directories that are empty, deepest first.""" for directory in sorted(directories, key=lambda path: -len(path_key(path))): if os.path.isdir(directory) and not os.listdir(directory): svn_bytes(["revert", "-R", directory]) os.rmdir(directory) def without_files(patch, workspace_directory, paths): """Returns a patch with the sections for the given files taken out.""" names = set(path_key(os.path.relpath(path, workspace_directory)) for path in paths) returnMe = [] skipping = False for line in patch.splitlines(keepends=True): if line.startswith(b"Index: "): skipping = path_key(line[len(b"Index: "):].strip().decode("utf-8", "replace")) in names if not skipping: returnMe.append(line) return b"".join(returnMe) def apply_working_copy(plan, name, scratch_directory, created): """Brings one working copy's part of the changelist into main, adding each directory it makes to `created`. `created` is filled as it goes so a failure partway still tells the caller what to undo. """ for directory in plan.added_directories: main_directory = plan.main_path(directory) if not os.path.isdir(main_directory): svn_bytes(["mkdir", main_directory]) created.append(main_directory) # The patch deletes a moved file's source and has nothing for an unedited destination. Moving in main keeps the # history, and the patch then only has to carry the destination's edits. for source, destination in plan.moves: svn_bytes(["move", plan.main_path(source), plan.main_path(destination)]) patch = svn_bytes(["diff", "--changelist", name, "."], cwd=plan.workspace_directory) patch = without_files(patch, plan.workspace_directory, [source for source, destination in plan.moves]) # Anchored to a line start, since a changed line of text may quote the warning. if re.search(rb"^Cannot display: file marked as a binary type", patch, re.MULTILINE): raise DeliveryRefused("The changelist has a binary file in {}. A patch can't carry binary files.".format( plan.workspace_directory)) if not patch.strip(): return patch_path = os.path.join(scratch_directory, os.path.basename(plan.workspace_directory) + ".patch") with open(patch_path, "wb") as patch_file: patch_file.write(patch) output = svn_output(["patch", patch_path, plan.main_directory]) problems = [line for line in output.splitlines() if line.startswith("C ") or "rejected hunk" in line or line.startswith("Skipped")] rejects = [entry.path + ".svnpatch.rej" for entry in plan.files if os.path.exists(plan.main_path(entry.path) + ".svnpatch.rej")] if problems or rejects: raise DeliveryRefused("The patch didn't apply cleanly to {}:\n{}".format(plan.main_directory, "\n".join(problems + rejects))) def assign_changelist(plan, name): """Puts the delivered files in the changelist, in chunks small enough for a Windows command line.""" paths = [plan.main_path(entry.path) for entry in plan.files] for start in range(0, len(paths), 40): svn_bytes(["changelist", name] + paths[start:start + 40]) def verify(plan, name): """Returns the problems found comparing main with the workspace, which exist only when both are at one revision.""" returnMe = [] status = dict((path_key(entry.path), entry) for entry in working_copy_status(plan.main_directory)) for entry in plan.files: main_path = plan.main_path(entry.path) delivered = status.get(path_key(main_path)) if delivered is None or delivered.changelist != name or delivered.flags[:2] != entry.flags[:2]: returnMe.append("{} should be '{}' in changelist '{}' but is {}".format( main_path, entry.flags[:2], name, "unchanged" if delivered is None else "'{}' in '{}'".format(delivered.flags[:2], delivered.changelist))) continue if entry.flags[0] == "D": if os.path.exists(main_path): returnMe.append("{} should be deleted".format(main_path)) else: with open(entry.path, "rb") as workspace_file, open(main_path, "rb") as main_file: if workspace_file.read() != main_file.read(): returnMe.append("{} differs from the workspace's copy".format(main_path)) if plan.moves: moved = set((path_key(source), path_key(destination)) for source, destination in moved_files(plan.main_directory)) for source, destination in plan.moves: if (path_key(plan.main_path(source)), path_key(plan.main_path(destination))) not in moved: returnMe.append("{} should be a move from {}".format(plan.main_path(destination), plan.main_path(source))) return returnMe def chunks(items, size=40): """Splits a list into pieces small enough for a Windows command line.""" for start in range(0, len(items), size): yield items[start:start + size] def clear_changelist_tags(paths): """Takes the given paths out of whatever changelist they are in, which changes no file.""" for chunk in chunks(paths): svn_bytes(["changelist", "--remove"] + chunk) def file_digest(path): """Returns the SHA-256 of a file's bytes.""" with open(path, "rb") as file: return hashlib.sha256(file.read()).hexdigest() def properties_digest(path): """Returns a digest of a versioned file's properties, so a property edit shows up the way a content edit does.""" return hashlib.sha256(svn_bytes(["proplist", "-v", path])).hexdigest() def last_changed_revisions(paths): """Returns the revision each versioned path was last changed in, keyed by path_key.""" returnMe = {} for chunk in chunks(paths): for entry in ElementTree.fromstring(svn_bytes(["info", "--xml"] + chunk)).iter("entry"): returnMe[path_key(entry.get("path"))] = int(entry.find("commit").get("revision")) return returnMe def read_records(record_path): """Returns what has been delivered from a workspace as {changelist: [file entry, ...]}, empty when nothing has.""" try: with open(record_path, "r", encoding="utf-8") as record_file: return json.load(record_file) except FileNotFoundError: return {} def write_records(record_path, records): """Saves the delivery records, deleting the file when none are left.""" if not records: try: os.remove(record_path) except FileNotFoundError: pass return os.makedirs(os.path.dirname(record_path), exist_ok=True) with open(record_path, "w", encoding="utf-8") as record_file: json.dump(records, record_file, indent=1) def record_delivery(plans, workspace_path, name, record_path): """Records what a delivery of the changelist put in main, replacing any earlier record of it. A file is kept as its digest and its properties' digest. A file that existed before the change also keeps the revision it was last changed in, which is how a later commit of it shows. """ files = [entry for plan in plans for entry in plan.files] base_revisions = last_changed_revisions([entry.path for entry in files if entry.flags[0] not in "AD"]) entries = [] for entry in files: deleted = entry.flags[0] == "D" entries.append({ "path": os.path.relpath(entry.path, workspace_path).replace(os.sep, "/"), "flags": entry.flags[:2], "digest": None if deleted else file_digest(entry.path), "properties_digest": None if deleted else properties_digest(entry.path), "base_revision": base_revisions.get(path_key(entry.path)), }) records = read_records(record_path) records[name] = entries write_records(record_path, records) def roll_back(plans, created_directories, existing_files): """Returns main to the state it had before this delivery touched it, as far as the delivery got. `existing_files` holds the path_key of each file that was already in main when the delivery began. The workspace calling a file added says nothing about main, where it may since have been committed, so only a file that wasn't there to begin with is removed. """ for plan in plans: for entry in plan.files: main_path = plan.main_path(entry.path) # A file the patch never reached is unmodified or unversioned, and svn may refuse to revert it. try: svn_bytes(["revert", main_path]) except RuntimeError: pass if entry.flags[0] == "A" and os.path.exists(main_path) and path_key(main_path) not in existing_files: os.remove(main_path) if os.path.exists(main_path + ".svnpatch.rej"): os.remove(main_path + ".svnpatch.rej") remove_empty_added_directories(created_directories) def deliver(workspace_path, main_root, name, dry_run=False, log=print, record_path=None): """Delivers the changelist, returning the (flags, path) of each file delivered. With a `record_path`, a delivery that succeeds is recorded there for landed() to use. """ workspace_path = os.path.normpath(workspace_path) main_root = os.path.normpath(main_root) plans = plan_delivery(workspace_path, main_root, name) previous_files = [] previous_directories = [] leftover_tags = [] for plan in plans: found_files, found_directories, found_tags = find_previous_delivery(plan, name) previous_files.extend(found_files) previous_directories.extend(found_directories) leftover_tags.extend(found_tags) files = [(entry.flags[:2], plan.main_path(entry.path)) for plan in plans for entry in plan.files] log("{} files in '{}'{}".format(len(files), name, ", replacing an earlier delivery" if previous_files else "")) if dry_run: for flags, path in files: log(" {} {}".format(flags, path)) return files discard_previous_delivery(previous_files) clear_changelist_tags([entry.path for entry in leftover_tags]) remove_empty_added_directories(previous_directories) existing = set(path_key(plan.main_path(entry.path)) for plan in plans for entry in plan.files if os.path.exists(plan.main_path(entry.path))) created = [] scratch_directory = tempfile.mkdtemp(prefix="deliver-") try: for plan in plans: apply_working_copy(plan, name, scratch_directory, created) assign_changelist(plan, name) for plan in plans: main_revision = working_copy_revision(plan.main_directory) if main_revision != plan.revision: log("Workspace {} is at r{} but main is at r{}. The patch merged over a different base, so review the " "diff.".format(plan.workspace_directory, plan.revision, main_revision)) continue problems = verify(plan, name) if problems: raise DeliveryRefused("Main doesn't match the workspace after delivery:\n" + "\n".join(problems)) # Inside the try so a delivery that can't be recorded is undone, not left for landed() to refuse. if record_path: record_delivery(plans, workspace_path, name, record_path) except (DeliveryRefused, RuntimeError): roll_back(plans, created, existing) raise finally: shutil.rmtree(scratch_directory, ignore_errors=True) return files def property_values(path): """Returns a versioned file's properties as text that doesn't depend on where the file is. Listing them names the path first, and the same file in two working copies has two paths. """ listing = svn_output(["proplist", "-v", path]).splitlines() return "\n".join(line for line in listing if not line.startswith("Properties on ")) def matches_main(plan, entry): """Returns whether main holds the very content and properties the workspace's copy of a file does. An update that merges commits over a delivered copy changes it from what was delivered. When main, which had the same commits, holds what the copy now holds, dropping the copy loses nothing. """ main_path = plan.main_path(entry.path) return (os.path.isfile(entry.path) and os.path.isfile(main_path) and file_digest(main_path) == file_digest(entry.path) and property_values(main_path) == property_values(entry.path)) def changes_since_delivery(plans, workspace_path, recorded): """Returns how the workspace's copy of the changelist differs from what was delivered.""" returnMe = [] recorded_by_path = dict((path_key(os.path.join(workspace_path, *item["path"].split("/"))), item) for item in recorded) current = set() for plan in plans: for entry in plan.absorbed: current.add(path_key(entry.path)) for entry in plan.files: key = path_key(entry.path) current.add(key) item = recorded_by_path.get(key) if item is None: returnMe.append("{} joined the changelist after it was delivered.".format(entry.path)) elif matches_main(plan, entry): continue elif entry.flags[:2] != item["flags"]: returnMe.append("{} was '{}' when delivered and is '{}' now.".format(entry.path, item["flags"], entry.flags[:2])) elif item["digest"] is not None and file_digest(entry.path) != item["digest"]: returnMe.append("{} was edited after it was delivered.".format(entry.path)) elif item["properties_digest"] is not None and properties_digest(entry.path) != item["properties_digest"]: returnMe.append("{} had its properties changed after it was delivered.".format(entry.path)) for key, item in recorded_by_path.items(): if key not in current: returnMe.append("{} was delivered and has left the changelist.".format( os.path.join(workspace_path, *item["path"].split("/")))) return returnMe def uncommitted_in_main(plans, workspace_path, recorded): """Returns the delivered files that main doesn't show as committed. A file counts as committed when main has no pending change to it and its history shows the change. A file that already existed was last changed in a later revision than the one it was delivered over. An added file is versioned, and a deleted one is gone. Someone else's commit to the same file, after the delivery was reverted in main, would pass for ours. """ returnMe = [] base_revisions = dict((path_key(os.path.join(workspace_path, *item["path"].split("/"))), item["base_revision"]) for item in recorded) for plan in plans: # A reverted file lingers in its changelist with blank flags. pending = dict((path_key(entry.path), entry) for entry in working_copy_status(plan.main_directory) if entry.flags.strip()) changed = [] for entry in plan.landing_files(): main_path = plan.main_path(entry.path) waiting = pending.get(path_key(main_path)) if entry.flags[0] == "D": if waiting is not None or os.path.exists(main_path): returnMe.append("{} is still in main, so its deletion hasn't been committed.".format(main_path)) elif waiting is not None: returnMe.append("{} is '{}' in main, so it hasn't been committed.".format(main_path, waiting.flags.strip())) elif not os.path.exists(main_path): returnMe.append("{} isn't in main.".format(main_path)) elif base_revisions.get(path_key(entry.path)) is not None: changed.append(entry) revisions = last_changed_revisions([plan.main_path(entry.path) for entry in changed]) for entry in changed: main_path = plan.main_path(entry.path) if revisions[path_key(main_path)] <= base_revisions[path_key(entry.path)]: returnMe.append("{} has no commit in main since it was delivered.".format(main_path)) return returnMe def discard_delivered_copy(plan): """Reverts the changelist's files in one working copy of the workspace, and the directories only they held.""" paths = [entry.path for entry in plan.files] for chunk in chunks(paths): svn_bytes(["changelist", "--remove"] + chunk) svn_bytes(["revert"] + chunk) # Reverting an added file leaves it on disk, and the update would find it in the way of the committed one. for entry in plan.files: if entry.flags[0] == "A" and os.path.exists(entry.path): os.remove(entry.path) remove_empty_added_directories(plan.added_directories) def clear_landed_tags(plan, name): """Takes the changelist off the files of one working copy that main shows committed. A commit that keeps changelists leaves them tagged. A file with a change still pending is left alone. """ targets = set(path_key(plan.main_path(entry.path)) for entry in plan.landing_files()) clear_changelist_tags([entry.path for entry in working_copy_status(plan.main_directory) if entry.changelist == name and not entry.flags.strip() and path_key(entry.path) in targets]) def landed(workspace_path, main_root, name, record_path, dry_run=False, log=print): """Brings the workspace level with a delivered changelist that has been committed from main. Refuses, changing nothing, unless the workspace still holds exactly what was delivered and main shows all of it committed. Returns the (flags, path) of each file in the changelist. """ workspace_path = os.path.normpath(workspace_path) main_root = os.path.normpath(main_root) recorded = read_records(record_path).get(name) if recorded is None: raise DeliveryRefused("No delivery of '{}' is recorded for {}. Without one there is no telling whether the " "workspace's copy is safe to drop.".format(name, workspace_path)) plans = plan_landing(workspace_path, main_root, name, recorded) problems = (changes_since_delivery(plans, workspace_path, recorded) + uncommitted_in_main(plans, workspace_path, recorded)) if problems: raise DeliveryRefused("'{}' isn't ready to drop from the workspace:\n{}".format(name, "\n".join(problems))) files = [(entry.flags[:2], entry.path) for plan in plans for entry in plan.landing_files()] log("{} files in '{}' are committed in main.".format(len(files), name)) if dry_run: log("Would revert the workspace's copies, update {} and clear the changelist from the committed files in " "main.".format(", ".join(plan.workspace_directory for plan in plans))) return files for plan in plans: discard_delivered_copy(plan) clear_changelist_tags(plan.stale_tags) # Forgotten before the update, since the delivered state is gone and a failed update is finished by hand. records = read_records(record_path) del records[name] write_records(record_path, records) for plan in plans: clear_landed_tags(plan, name) for plan in plans: output = svn_output(["update", plan.workspace_directory]) if "Summary of conflicts" in output: raise RuntimeError("Updating {} left conflicts:\n{}".format(plan.workspace_directory, output)) return files def workspace_for_current_directory(): """Returns the workspace the shell is in, or raises when it isn't in one.""" name = scratch.workspace_name_for_path(scratch.SCRATCH_ROOT, os.getcwd()) return os.path.join(scratch.SCRATCH_ROOT, name) def main(): parser = argparse.ArgumentParser(description="Moves a changelist from a workspace into the main working copy.") parser.add_argument("changelist") parser.add_argument("--workspace", help="The workspace to take it from. Defaults to the workspace the shell is in.") parser.add_argument("--landed", action="store_true", help="The changelist has been committed from main. Drops the workspace's copy and updates.") parser.add_argument("--dry-run", action="store_true", help="Reports what would happen and changes nothing.") arguments = parser.parse_args() try: workspace_path = arguments.workspace or workspace_for_current_directory() record_path = scratch.deliveries_path(scratch.SCRATCH_ROOT, scratch.workspace_name_for_path(scratch.SCRATCH_ROOT, workspace_path)) if arguments.landed: landed(workspace_path, scratch.MAIN_ROOT, arguments.changelist, record_path, dry_run=arguments.dry_run) return 0 files = deliver(workspace_path, scratch.MAIN_ROOT, arguments.changelist, dry_run=arguments.dry_run, record_path=record_path) except (DeliveryRefused, RuntimeError, ValueError) as error: print(error, file=sys.stderr) return 1 if not arguments.dry_run: print("Delivered {} files to {} as changelist '{}'.".format(len(files), scratch.MAIN_ROOT, arguments.changelist)) return 0 if __name__ == "__main__": sys.exit(main())