From 4b8e71236723bd4b424fae333ef112f23a400cf1 Mon Sep 17 00:00:00 2001 From: "Edward Z. Yang" Date: Wed, 6 Dec 2023 11:22:38 +0800 Subject: [PATCH] Rewrite ghstack head/base commit construction algorithm Signed-off-by: Edward Z. Yang ghstack-source-id: b8adce7587d5c0fb55a08b33dbfda7a2cf67855b Pull Request resolved: https://github.com/ezyang/ghstack/pull/200 --- ghstack/cli.py | 24 + ghstack/diff.py | 11 + ghstack/git.py | 46 +- ghstack/shell.py | 5 +- ghstack/submit.py | 866 ++++++++++++++++++----------------- test_ghstack.py | 1102 ++++++++++++++++++++++++++------------------- 6 files changed, 1157 insertions(+), 897 deletions(-) diff --git a/ghstack/cli.py b/ghstack/cli.py index a3e5061..9d123e8 100644 --- a/ghstack/cli.py +++ b/ghstack/cli.py @@ -58,6 +58,7 @@ def cli_context( @click.option("--no-skip", is_flag=True, hidden=True) @click.option("--draft", is_flag=True, hidden=True) @click.option("--base", "-B", default=None, hidden=True) +@click.option("--stack/--no-stack", "-s/-S", is_flag=True, hidden=True) def main( ctx: click.Context, debug: bool, @@ -68,6 +69,7 @@ def main( no_skip: bool, draft: bool, base: Optional[str], + stack: bool, ) -> None: """ Submit stacks of diffs to Github @@ -84,6 +86,7 @@ def main( no_skip=no_skip, draft=draft, base=base, + stack=stack, ) @@ -207,6 +210,23 @@ def status(pull_request: str) -> None: help="Branch to base the stack off of; " "defaults to the default branch of a repository", ) +@click.option( + "--stack/--no-stack", + "-s/-S", + is_flag=True, + default=True, + help="Submit the entire of stack of commits reachable from HEAD, versus only single commits. " + "This affects the meaning of REVS. With --stack, we submit all commits that " + "are reachable from REVS, excluding commits already on the base branch. Revision ranges " + "supported by git rev-list are also supported. " + "With --no-stack, we support only non-range identifiers, and will submit each commit " + "listed in the command line.", +) +@click.argument( + "revs", + nargs=-1, + metavar="REVS", +) def submit( message: str, update_fields: bool, @@ -215,6 +235,8 @@ def submit( no_skip: bool, draft: bool, base: Optional[str], + revs: Tuple[str, ...], + stack: bool, ) -> None: """ Submit or update a PR stack @@ -233,6 +255,8 @@ def submit( github_url=config.github_url, remote_name=config.remote_name, base=base, + revs=revs, + stack=stack, ) diff --git a/ghstack/diff.py b/ghstack/diff.py index 4335666..16703e9 100644 --- a/ghstack/diff.py +++ b/ghstack/diff.py @@ -81,6 +81,13 @@ class Diff: # a valid identifier would be the tree hash of the commit (rather # than the commit hash itself); in Phabricator it could be the # version of the diff. + # + # It is OK for this source id to wobble even if the tree stays the + # same. This simply means we will think there are changes even + # if there aren't any, which should be safe (but just generate + # annoying updates). What we would like is for the id to quiesce: + # if you didn't rebase your hg rev, the source id is guaranteed to + # be the same. source_id: str # The contents of 'Pull Request resolved'. This is None for @@ -101,3 +108,7 @@ class Diff: # authorship information when constructing a rebased commit author_name: Optional[str] author_email: Optional[str] + + # If this isn't actually a diff; it's a boundary commit (not part + # of the stack) that we've parsed for administrative purposes + boundary: bool diff --git a/ghstack/git.py b/ghstack/git.py index f0f0bf4..ba1ebc4 100644 --- a/ghstack/git.py +++ b/ghstack/git.py @@ -8,7 +8,7 @@ import ghstack.shell from ghstack.types import GitCommitHash, GitTreeHash -RE_RAW_COMMIT_ID = re.compile(r"^(?P[a-f0-9]+)$", re.MULTILINE) +RE_RAW_COMMIT_ID = re.compile(r"^(?P-?)(?P[a-f0-9]+)$", re.MULTILINE) RE_RAW_AUTHOR = re.compile( r"^author (?P(?P[^<]+?) <(?P[^>]+)>)", re.MULTILINE ) @@ -45,6 +45,10 @@ def title(self) -> str: def commit_id(self) -> GitCommitHash: return GitCommitHash(self._search_group(RE_RAW_COMMIT_ID, "commit")) + @cached_property + def boundary(self) -> bool: + return self._search_group(RE_RAW_COMMIT_ID, "boundary") == "-" + @cached_property def parents(self) -> List[GitCommitHash]: return [ @@ -75,27 +79,21 @@ def split_header(s: str) -> List[CommitHeader]: return list(map(CommitHeader, s.split("\0")[:-1])) -def parse_header(s: str, github_url: str) -> List[ghstack.diff.Diff]: - def convert(h: CommitHeader) -> ghstack.diff.Diff: - parents = h.parents - if len(parents) != 1: - raise RuntimeError( - "The commit {} has {} parents, which makes my head explode. " - "`git rebase -i` your diffs into a stack, then try again.".format( - h.commit_id, len(parents) - ) - ) - return ghstack.diff.Diff( - title=h.title, - summary=h.commit_msg, - oid=h.commit_id, - source_id=h.tree, - pull_request_resolved=ghstack.diff.PullRequestResolved.search( - h.raw_header, github_url - ), - tree=h.tree, - author_name=h.author_name, - author_email=h.author_email, - ) +def convert_header(h: CommitHeader, github_url: str) -> ghstack.diff.Diff: + return ghstack.diff.Diff( + title=h.title, + summary=h.commit_msg, + oid=h.commit_id, + source_id=h.tree, + pull_request_resolved=ghstack.diff.PullRequestResolved.search( + h.raw_header, github_url + ), + tree=h.tree, + author_name=h.author_name, + author_email=h.author_email, + boundary=h.boundary, + ) + - return list(map(convert, split_header(s))) +def parse_header(s: str, github_url: str) -> List[ghstack.diff.Diff]: + return [convert_header(h, github_url) for h in split_header(s)] diff --git a/ghstack/shell.py b/ghstack/shell.py index 8dec431..682294d 100644 --- a/ghstack/shell.py +++ b/ghstack/shell.py @@ -91,7 +91,8 @@ def sh( input: Optional[str] = None, stdin: _HANDLE = None, stdout: _HANDLE = subprocess.PIPE, - exitcode: bool = False + exitcode: bool = False, + tick: bool = False ) -> _SHELL_RET: """ Run a command specified by args, and return string representing @@ -116,6 +117,8 @@ def sh( code 0. We never raise an exception when this is True. """ assert not (stdin and input) + if tick: + self.test_tick() if input: stdin = subprocess.PIPE if not self.quiet: diff --git a/ghstack/submit.py b/ghstack/submit.py index d56d8f3..2ca7444 100644 --- a/ghstack/submit.py +++ b/ghstack/submit.py @@ -4,7 +4,7 @@ import os import re from dataclasses import dataclass -from typing import List, NamedTuple, Optional, Sequence, Set, Tuple +from typing import Dict, List, NamedTuple, Optional, Sequence, Set, Tuple import ghstack import ghstack.git @@ -13,13 +13,7 @@ import ghstack.gpg_sign import ghstack.logs import ghstack.shell -from ghstack.types import ( - GhNumber, - GitCommitHash, - GitHubNumber, - GitHubRepositoryId, - GitTreeHash, -) +from ghstack.types import GhNumber, GitCommitHash, GitHubNumber, GitHubRepositoryId # Either "base", "head" or "orig"; which of the ghstack generated # branches this diff corresponds to @@ -32,15 +26,14 @@ [ ("title", str), ("number", GitHubNumber), + # The PR body to put on GitHub ("body", str), + # The commit message to put on the orig commit + ("commit_msg", str), ("username", str), ("ghnum", GhNumber), # What Git commit hash we should push to what branch - ("push_branches", Tuple[Tuple[GitCommitHash, BranchKind], ...]), - # What Git commit hash corresponds to head for this - # (previously, we got this out of push_branches, but this is not - # guaranteed to be set.) None if we didn't change it. - ("head_branch", Optional[GitCommitHash]), + ("push_branches", List[Tuple[GitCommitHash, BranchKind]]), # A human-readable string like 'Created' which describes what # happened to this pull request ("what", str), @@ -151,6 +144,8 @@ def main( github_url: str, remote_name: str, base: Optional[str] = None, + revs: Sequence[str] = (), + stack: bool = True, ) -> List[Optional[DiffMeta]]: if sh is None: @@ -191,33 +186,151 @@ def main( sh.git( "fetch", "--prune", remote_name, f"+refs/heads/*:refs/remotes/{remote_name}/*" ) - base = GitCommitHash( - sh.git("merge-base", f"{remote_name}/{default_branch}", "HEAD") - ) - # compute the stack of commits to process (reverse chronological order), - stack = ghstack.git.parse_header( - sh.git("rev-list", "--header", "^" + base, "HEAD"), - github_url, + # There are two distinct usage patterns: + # + # 1. You may want to submit only HEAD, but not everything below it, + # because you only did minor changes to the commits below and + # you want to let the CI finish without those changes. + # See https://github.com/ezyang/ghstack/issues/165 + # + # 2. I want to submit a prefix of the stack, because I'm still working + # on the top of the stack and don't want to spam people with + # useless changes. See https://github.com/ezyang/ghstack/issues/101 + # + # If we use standard git log/rev-list style parsing, you get (2) by + # default because a single commit implies a reachability constraint. + # Specifying (1) is a bit inconvenient; you have to say something + # like `ghstack submit HEAD~..`. In particular, both (1) and (2) would like + # the meaning of `ghstack submit HEAD` to do different things (1 wants a single + # commit, whereas 2 wants everything reachable from the commit.) + # + # To resolve the ambiguity, we introduce a new command line argument + # --no-stack (analogous to the --stack argument on jf) which disables + # "stacky" behavior. With --no-stack, we only submit HEAD by default + # and you can also specify a specific commit to submit if you like + # (if this commit is not reachable from HEAD, we will tell you how + # to checkout the updated commit.) If you specify multiple commits, + # we will process each of them in turn. Ranges are not supported; use + # git rev-list to preprocess them into single commits first (in principle + # we could support this, but it would require determining if a REV was + # a range versus a commit, as different handling would be necessary + # in each case.) + # + # Without --no-stack, we use standard git rev-list semantics. Some of the + # more advanced spellings can be counterintuitive, but `ghstack submit X` + # is equivalent to checking out X and then performing ghstack (and then + # restacking HEAD on top, if necessary), and you can say `X..Y` + # (exclusive-inclusive) to specify a specific range of commits (oddly, + # `X..` will do what you expect, but `..Y` will almost always be empty.) + # But I expect this to be fairly niche. + # + # In both cases, we support submitting multiple commits, because the set + # of commits you specify affects what rebasing we do, which is sometimes + # not conveniently done by calling ghstack multiple times. + + # Interestingly, the default is the same whether it is --stack or + # --no-stack + if not revs: + revs = ("HEAD",) + + # In jf, we determine whether or not we should consider a diff by checking + # if it is draft or not (only draft commits can be posted). Git doesn't + # have a directly analogous concept, so we need some other strategy. A + # simple approach is to inspect the base branch in the upstream + # repository, and exclude all commits which are reachable from it. + # We don't want to blast ALL remote branches into the list here though; + # it's possible the draft commits were pushed to the remote repo for + # unrelated reasons, and we don't want to treat them as non-draft if + # this happens! + + base_ref = f"{remote_name}/{default_branch}" + commits_to_submit_and_boundary = [] + if stack: + # Easy case, make rev-list do the hard work + commits_to_submit_and_boundary.extend( + ghstack.git.split_header( + sh.git( + "rev-list", + "--header", + "--topo-order", + "--boundary", + *revs, + f"^{base_ref}", + ), + ) + ) + else: + # Hard case, need to query rev-list repeatedly + for rev in revs: + # We still do rev-list as it gets us the parent commits + r = ghstack.git.split_header( + sh.git( + "rev-list", + "--header", + "--topo-order", + "--boundary", + f"{rev}~..{rev}", + f"^{base_ref}", + ), + ) + if not r: + raise RuntimeError( + f"{r} doesn't seem to be a commit that can be submitted!" + ) + # NB: There may be duplicate commits that are + # boundary/not-boundary, but once we generate commits_to_submit + # there should not be any dupes if rev was not duped + # TODO: check no dupe revs, though actually it's harmless + commits_to_submit_and_boundary.extend(r) + + commits_to_submit = [d for d in commits_to_submit_and_boundary if not d.boundary] + + # NB: A little bit of redundant parsing here, because we will re-parse + # commits that we had already parsed in commits_to_submit, and we will + # also parse prefix even if it's not being processed, but it's at most ~10 + # extra parses so whatever + commits_to_rebase = ghstack.git.split_header( + sh.git( + "rev-list", + "--header", + "--topo-order", + # Get all commits reachable from HEAD... + "HEAD", + # ...as well as all the commits we are going to submit... + *[c.commit_id for c in commits_to_submit], + # ...but we don't need any commits that aren't draft + f"^{base_ref}", + ) ) - # compute the base commit - base_obj = ghstack.git.split_header( - sh.git("rev-list", "--header", "^" + base + "^@", base) - )[0] + # NB: commits_to_rebase does not necessarily contain diffs_to_submit, as you + # can specify REVS that are not connected to HEAD. In principle, we + # could also rebase them, if we identified all local branches for which + # the REV was reachable from--this is left for future work. + # + # NB: commits_to_submit does not necessarily contain diffs_to_rebase. If + # you ask to submit only a prefix of your current stack, the suffix is + # not to be submitted, but it needs to be rebased (to, e.g., update the + # ghstack-source-id) - assert len(stack) > 0 + commit_count = len(commits_to_submit) - if len(stack) > 8 and not force: + if commit_count == 0: + raise RuntimeError( + "There appears to be no commits to process, based on the revs you passed me. " + f"I determined this by running `git rev-list {' '.join(revs)} ^{base_ref}`." + ) + elif commit_count > 8 and not force: raise RuntimeError( "Cowardly refusing to handle a stack with more than eight PRs. " "You are likely to get rate limited by GitHub if you try to create or " "manipulate this many PRs. You can bypass this throttle using --force" ) - run_pre_ghstack_hook(sh, base, stack[0].oid) - - ghstack.logs.record_status('{} "{}"'.format(stack[0].oid[:9], stack[0].title)) + # TODO: figure this out. BC when revs is standard; maybe multiple calls + # for non standard rev patterns + # run_pre_ghstack_hook(sh, base, top.oid) submitter = Submitter( github=github, @@ -226,9 +339,6 @@ def main( repo_owner=repo_owner_nonopt, repo_name=repo_name_nonopt, repo_id=repo_id, - base_commit=base, - base_tree=base_obj.tree, - stack_base=base, stack_header=stack_header, update_fields=update_fields, msg=msg, @@ -236,15 +346,28 @@ def main( force=force, no_skip=no_skip, draft=draft, - stack=list(reversed(stack)), github_url=github_url, remote_name=remote_name, + commit_index={d.commit_id: d for d in commits_to_submit_and_boundary}, ) - submitter.prepare_updates() - submitter.push_updates() + + diff_meta_index = submitter.prepare_submit(commits_to_submit) + # Also has side effect of updating branch updates + rebase_index = submitter.prepare_rebase(commits_to_rebase, diff_meta_index) + logging.debug("rebase_index = %s", rebase_index) + diffs_to_submit = [ + diff_meta_index[h.commit_id] + for h in commits_to_submit + if h.commit_id in diff_meta_index + ] + submitter.push_updates(diffs_to_submit) + if new_head := rebase_index.get(GitCommitHash(sh.git("rev-parse", "HEAD"))): + sh.git("reset", "--soft", new_head) + # TODO: print out commit hashes for things we rebased but not accessible + # from HEAD # NB: earliest first - return submitter.stack_meta + return list(reversed(diffs_to_submit)) def all_branches(username: str, ghnum: GhNumber) -> Tuple[str, str, str]: @@ -259,22 +382,12 @@ def push_spec(commit: GitCommitHash, branch: str) -> str: return "{}:refs/heads/{}".format(commit, branch) -class Submitter(object): +class Submitter: """ - A class responsible for managing all of the environment and mutable - state associated with submitting PRs to GitHub. - - Standard usage is:: - - submitter.prepare_updates() # populates stack_meta, creates new PRs - submitter.push_updates() - - This is split up in a weird way because some users of this class - need to interpose between the initial preparation of updates, - and when we actually push the updates. + A class responsible for managing the environment associated + with submitting PRs at GitHub. - NB: prepare_updates() will push updates to GitHub in order to get - GitHub PR numbers for other usage. + This used to contain mutable state but now it is immutable. """ # Endpoint to access GitHub @@ -295,40 +408,18 @@ class Submitter(object): # GraphQL ID of the repository repo_id: GitHubRepositoryId - # The base commit of the prev diff we submitted. This - # corresponds to the 'head' branch in GH. - # INVARIANT: This is REALLY a hash, and not some random ref! - base_commit: GitCommitHash - - # The base tree of the prev diff we submitted. - # INVARIANT: This is base_commit^{tree}! Cached here so we - # don't have to keep asking about it. - base_tree: GitTreeHash - - # The orig commit of the prev diff we submitted. This - # corresponds to the 'orig' branch in GH. - base_orig: GitCommitHash - - # The base commit of the entire stack. - stack_base: GitCommitHash - # Message describing the update to the stack that was done msg: Optional[str] - # Description of all the diffs we submitted; to be populated - # by Submitter. - stack_meta: List[Optional[DiffMeta]] - # List of input diffs which we ignored (i.e., treated as if they # did not exist on the stack at all), because they were associated # with a patch that contains no changes. GhNumber may be false # if the diff was never associated with a PR. + # TODO: mutable state, move out ignored_diffs: List[Tuple[ghstack.diff.Diff, Optional[GitHubNumber]]] - # List of diffs to process, in chronological order - stack: List[ghstack.diff.Diff] - # Set of seen ghnums + # TODO: mutable state, move out seen_ghnums: Set[GhNumber] # String used to describe the stack in question @@ -356,6 +447,10 @@ class Submitter(object): # Name of the upstream remote (normally origin) remote_name: str + # Index for original commit hashes to the commit header we parsed out for + # them + commit_index: Dict[GitCommitHash, ghstack.git.CommitHeader] + def __init__( self, github: ghstack.github.GitHubEndpoint, @@ -364,19 +459,16 @@ def __init__( repo_owner: str, repo_name: str, repo_id: GitHubRepositoryId, - base_commit: GitCommitHash, - base_tree: GitTreeHash, - stack_base: GitCommitHash, stack_header: str, update_fields: bool, msg: Optional[str], - stack: List[ghstack.diff.Diff], short: bool, force: bool, no_skip: bool, draft: bool, github_url: str, remote_name: str, + commit_index: Dict[GitCommitHash, ghstack.git.CommitHeader], ): self.github = github self.sh = sh @@ -384,16 +476,8 @@ def __init__( self.repo_owner = repo_owner self.repo_name = repo_name self.repo_id = repo_id - self.base_commit = base_commit - self.base_orig = base_commit - self.base_tree = base_tree - self.stack_base = stack_base self.update_fields = update_fields self.stack_header = stack_header.format(github_url=github_url) - self.stack_meta = [] - self.ignored_diffs = [] - self.stack = stack - self.seen_ghnums = set() self.msg = msg self.short = short self.force = force @@ -401,6 +485,11 @@ def __init__( self.draft = draft self.github_url = github_url self.remote_name = remote_name + self.commit_index = commit_index + + # TODO: mutable state to remove + self.ignored_diffs = [] + self.seen_ghnums = set() def _default_title_and_body( self, commit: ghstack.diff.Diff, old_pr_body: Optional[str] @@ -581,49 +670,6 @@ def elaborate_diff( pull_request_resolved=commit.pull_request_resolved, ) - def skip_commit(self, commit: DiffWithGitHubMetadata) -> None: - """ - Skip a diff, because we happen to know that there were no local - changes. We have to update the internal metadata of the Submitter, - so you're still obligated to call this even if you think there's - nothing to do. - """ - - ghnum = commit.ghnum - username = commit.username - - assert ghnum not in self.seen_ghnums - self.seen_ghnums.add(ghnum) - - self.stack_meta.append( - DiffMeta( - title=commit.title, - number=commit.number, - body=commit.body, - ghnum=ghnum, - username=username, - push_branches=(), - head_branch=None, - what="Skipped", - closed=commit.closed, - pr_url=commit.pull_request_resolved.url(self.github_url), - ) - ) - - self.base_commit = GitCommitHash( - self.sh.git( - "rev-parse", self.remote_name + "/" + branch_head(username, ghnum) - ) - ) - self.base_orig = GitCommitHash( - self.sh.git( - "rev-parse", self.remote_name + "/" + branch_orig(username, ghnum) - ) - ) - self.base_tree = GitTreeHash( - self.sh.git("rev-parse", self.base_orig + "^{tree}") - ) - def git_push(self, branches: Sequence[str], force: bool = False) -> None: assert branches, "empty branches would push master, probably bad!" try: @@ -645,11 +691,17 @@ def git_push(self, branches: Sequence[str], force: bool = False) -> None: raise self.github.push_hook(branches) - def process_new_commit(self, commit: ghstack.diff.Diff) -> None: + def process_new_commit( + self, base: ghstack.git.CommitHeader, commit: ghstack.diff.Diff + ) -> Optional[DiffMeta]: """ Process a diff that has never been pushed to GitHub before. """ + logging.debug( + "process_new_commit(base=%s, commit=%s)", base.commit_id, commit.oid + ) + if "[ghstack-poisoned]" in commit.summary: raise RuntimeError( """\ @@ -689,23 +741,35 @@ def process_new_commit(self, commit: ghstack.diff.Diff) -> None: tree = commit.tree # Actually, if there's no change in the tree, stop processing - if tree == self.base_tree: + if tree == base.tree: self.ignored_diffs.append((commit, None)) logging.warning( "Skipping {} {}, as the commit has no changes".format(commit.oid, title) ) - self.stack_meta.append(None) - return + return None assert ghnum not in self.seen_ghnums self.seen_ghnums.add(ghnum) + new_base = GitCommitHash( + self.sh.git( + "commit-tree", + *ghstack.gpg_sign.gpg_args_if_necessary(self.sh), + # No parents! We could hypothetically give this a parent + # back to the base branch, but it's not really necessary + base.tree, + input='Update base for {} on "{}"\n\n{}\n\n[ghstack-poisoned]'.format( + self.msg, commit.title, base.commit_msg + ), + ) + ) + new_pull = GitCommitHash( self.sh.git( "commit-tree", *ghstack.gpg_sign.gpg_args_if_necessary(self.sh), "-p", - self.base_commit, + new_base, tree, input=commit.summary + "\n\n[ghstack-poisoned]", ) @@ -714,7 +778,7 @@ def process_new_commit(self, commit: ghstack.diff.Diff) -> None: # Push the branches, so that we can create a PR for them new_branches = ( push_spec(new_pull, branch_head(self.username, ghnum)), - push_spec(self.base_commit, branch_base(self.username, ghnum)), + push_spec(new_base, branch_base(self.username, ghnum)), ) self.git_push(new_branches) @@ -753,48 +817,31 @@ def process_new_commit(self, commit: ghstack.diff.Diff) -> None: github_url=self.github_url, ) ) - env = {} - if commit.author_name is not None: - env["GIT_AUTHOR_NAME"] = commit.author_name - if commit.author_email is not None: - env["GIT_AUTHOR_EMAIL"] = commit.author_email - new_orig = GitCommitHash( - self.sh.git( - "commit-tree", - *ghstack.gpg_sign.gpg_args_if_necessary(self.sh), - "-p", - self.base_orig, - tree, - input=commit_msg, - env=env, - ) - ) - - self.stack_meta.append( - DiffMeta( - title=title, - number=number, - body=pr_body, - ghnum=ghnum, - username=self.username, - push_branches=((new_orig, "orig"),), - head_branch=new_pull, - what="Created", - closed=False, - pr_url=pull_request_resolved.url(self.github_url), - ) + return DiffMeta( + title=title, + number=number, + body=pr_body, + commit_msg=commit_msg, + ghnum=ghnum, + username=self.username, + push_branches=[], + what="Created", + closed=False, + pr_url=pull_request_resolved.url(self.github_url), ) - self.base_commit = new_pull - self.base_orig = new_orig - self.base_tree = tree - - def process_old_commit(self, elab_commit: DiffWithGitHubMetadata) -> None: + def process_old_commit( + self, base: ghstack.git.CommitHeader, elab_commit: DiffWithGitHubMetadata + ) -> Optional[DiffMeta]: """ Process a diff that has an existing upload to GitHub. """ + # Do not process closed commits + if elab_commit.closed: + return None + commit = elab_commit.diff username = elab_commit.username ghnum = elab_commit.ghnum @@ -854,279 +901,298 @@ def process_old_commit(self, elab_commit: DiffWithGitHubMetadata) -> None: "ghstack-source-id: {}\n".format(commit.source_id), summary ) + # I vacillated between whether or not we should use the PR + # body or the literal commit message here. Right now we use + # the PR body, because after initial commit the original + # commit message is not supposed to "matter" anymore. orig + # still uses the original commit message, however, because + # it's supposed to be the "original". + non_orig_commit_msg = strip_mentions(RE_STACK.sub("", elab_commit.body)) + # We've got an update to do! But what exactly should we # do? # - # Here are a number of situations which may have - # occurred. + # Here is the relevant state: + # - Local parent tree + # - Local commit tree + # - Remote base branch + # - Remote head branch # - # 1. None of the parent commits changed, and this is - # the first change we need to push an update to. + # Our job is to synchronize local with remote. Here are a few + # common situations: # - # 2. A parent commit changed, so we need to restack - # this commit too. (You can't easily tell distinguish - # between rebase versus rebase+amend) + # - Neither this commit nor any of the earlier commits were + # modified; everything is in sync. We want to do nothing in this + # case. # - # 3. The parent is now master (any prior parent - # commits were absorbed into master.) + # - User updated top commit on stack, but none of the earlier commits. + # Here, we expect local parent tree to match remote base tree (BA), but + # local commit tree to mismatch remote head branch (A). We will push + # a new commit to head (A2), no merge necessary. # - # 4. The parent is totally disconnected, the history - # is bogus but at least the merge-base on master - # is the same or later. (This can occur if you - # cherry-picked a commit out of an old stack and - # want to make it independent.) + # BA + # \ + # A - A2 # - # In cases 1-3, we can maintain a clean merge history - # if we do a little extra book-keeping, which is what - # we do now. + # - User updated an earlier commit in the stack (it doesn't matter + # if the top commit is logically modified or not: it always counts as + # having been modified to resolve the merge.) We don't expect + # local parent tree to match remote base tree, so we must push a + # new base commit (BA2), and a merge commit (A2) on it. # - # TODO: What we have here actually works pretty hard to - # maintain a consistent merge history between all PRs; - # so, e.g., you could merge with master and things - # wouldn't break. But we don't necessarily have to do - # this; all we need is the delta between base and head - # to make sense. The benefit to doing this is you could - # more easily update single revs only, without doing - # the rest of the stack. The downside is that you - # get less accurate merge structure for your changes - # (because each "diff" is completely disconnected.) + # BA - BA2 + # \ \ + # A - A2 # - - # First, check if the parent commit hasn't changed. - # We do this by checking if our base_commit is the same - # as the gh/ezyang/X/base commit. + # Notably, this must happen even if the local commit tree matches + # the remote head branch. A common situation this could occur is + # if we squash commits I and J into IJ (keeping J as the tree). + # Then for J we see: # - # In this case, we don't need to include the base as a - # parent at all; just construct our new diff as a plain, - # non-merge commit. - base_args: Tuple[str, ...] - orig_base_hash = self.sh.git( - "rev-parse", self.remote_name + "/" + branch_base(username, ghnum) - ) + # BJ - BJ2 + # \ \ + # J - BJ2 + # + # Where BJ contains I, but BJ2 does NOT contain I. The net result + # is the changes of I are included inside the BJ2 merge commit. + # + # Note that, counterintuitively, the base of a diff has no + # relationship to the head of an earlier diff on the stack. This + # makes it possible to selectively only update one diff in a stack + # without updating any others. This also makes our handling uniform + # even if you rebase a commit backwards: you just see that the base + # is updated to also remove changes. + + def resolve_remote(branch: str) -> Tuple[str, ghstack.diff.Diff, str]: + remote_ref = self.remote_name + "/" + branch + (remote_diff,) = ghstack.git.parse_header( + self.sh.git("rev-list", "--header", "-1", remote_ref), self.github_url + ) + remote_tree = remote_diff.tree - # I vacillated between whether or not we should use the PR - # body or the literal commit message here. Right now we use - # the PR body, because after initial commit the original - # commit message is not supposed to "matter" anymore. orig - # still uses the original commit message, however, because - # it's supposed to be the "original". - non_orig_commit_msg = strip_mentions(RE_STACK.sub("", elab_commit.body)) + return remote_ref, remote_diff, remote_tree - if orig_base_hash == self.base_commit: + # Edge case: check if the commit is empty; if so we don't + # submit a PR for it + if base.tree == elab_commit.diff.tree: + self.ignored_diffs.append((commit, number)) + logging.warning( + "Skipping PR #{} {}, as the commit now has no changes".format( + number, elab_commit.title + ) + ) + return None - new_base = self.base_commit - base_args = () + remote_base_ref, remote_base, remote_base_tree = resolve_remote( + branch_base(username, ghnum) + ) + remote_head_ref, remote_head, remote_head_tree = resolve_remote( + branch_head(username, ghnum) + ) - else: - # Second, check if our stack as a whole is still rooted on - # the old base. If not, we need to include the local stack - # base as a parent of the new commit base. - same_stack_base = self.sh.git( - "merge-base", - "--is-ancestor", - self.stack_base, - self.remote_name + "/" + branch_base(username, ghnum), - exitcode=True, - ) + push_branches = [] - # Make a fake commit that - # "resets" the tree back to something that makes - # sense and merge with that. This doesn't fix - # the fact that we still incorrectly report - # the old base as an ancestor of our commit, but - # it's better than nothing. + # Check if bases match + base_args: Tuple[str, ...] = () + if remote_base_tree != base.tree: + # Base is not the same, perform base update new_base = GitCommitHash( self.sh.git( "commit-tree", *ghstack.gpg_sign.gpg_args_if_necessary(self.sh), "-p", - self.remote_name + "/" + branch_base(username, ghnum), - *(() if same_stack_base else ("-p", self.stack_base)), - self.base_tree, + remote_base_ref, + base.tree, input='Update base for {} on "{}"\n\n{}\n\n[ghstack-poisoned]'.format( self.msg, elab_commit.title, non_orig_commit_msg ), ) ) - base_args = ("-p", new_base) + push_branches.append((new_base, "base")) - # Blast our current tree as the newest commit, merging - # against the previous pull entry, and the newest base. - - tree = commit.tree - - # Nothing to do, just ignore the diff - if tree == self.base_tree: - self.ignored_diffs.append((commit, number)) - logging.warning( - "Skipping PR #{} {}, as the commit now has no changes".format( - number, elab_commit.title + # Check if heads match, or base was updated + new_head: Optional[GitCommitHash] = None + if base_args or remote_head_tree != elab_commit.diff.tree: + new_head = GitCommitHash( + self.sh.git( + "commit-tree", + *ghstack.gpg_sign.gpg_args_if_necessary(self.sh), + "-p", + remote_head_ref, + *base_args, + elab_commit.diff.tree, + input='{} on "{}"\n\n{}\n\n[ghstack-poisoned]'.format( + self.msg, elab_commit.title, non_orig_commit_msg + ), ) ) - return - - new_pull = GitCommitHash( - self.sh.git( - "commit-tree", - *ghstack.gpg_sign.gpg_args_if_necessary(self.sh), - "-p", - self.remote_name + "/" + branch_head(username, ghnum), - *base_args, - tree, - input='{} on "{}"\n\n{}\n\n[ghstack-poisoned]'.format( - self.msg, elab_commit.title, non_orig_commit_msg - ), - ) - ) - - # Perform what is effectively an interactive rebase - # on the orig branch. - # - # Hypothetically, there could be a case when this isn't - # necessary, but it's INCREDIBLY unlikely (because we'd - # have to look EXACTLY like the original orig, and since - # we're in the branch that says "hey we changed - # something" that's probably not what happened. - - logging.info("Restacking commit on {}".format(self.base_orig)) - new_orig = GitCommitHash( - self.sh.git( - "commit-tree", - *ghstack.gpg_sign.gpg_args_if_necessary(self.sh), - "-p", - self.base_orig, - tree, - input=summary, - ) - ) - - push_branches = ( - (new_base, "base"), - (new_pull, "head"), - (new_orig, "orig"), - ) + push_branches.append((new_head, "head")) - if elab_commit.closed: + if not push_branches: + what = "Skipped" + elif elab_commit.closed: + # TODO: this does not seem synced with others what = "Skipped closed" else: what = "Updated" - self.stack_meta.append( - DiffMeta( - title=elab_commit.title, - number=number, - # NB: Ignore the commit message, and just reuse the old commit - # message. This is consistent with 'jf submit' default - # behavior. The idea is that people may have edited the - # PR description on GitHub and you don't want to clobber - # it. - body=elab_commit.body, - ghnum=ghnum, - username=username, - push_branches=push_branches, - head_branch=new_pull, - what=what, - closed=elab_commit.closed, - pr_url=elab_commit.pull_request_resolved.url(self.github_url), - ) + return DiffMeta( + title=elab_commit.title, + number=number, + # NB: Ignore the commit message, and just reuse the old commit + # message. This is consistent with 'jf submit' default + # behavior. The idea is that people may have edited the + # PR description on GitHub and you don't want to clobber + # it. + body=elab_commit.body, + commit_msg=summary, + ghnum=ghnum, + username=username, + push_branches=push_branches, + what=what, + closed=elab_commit.closed, + pr_url=elab_commit.pull_request_resolved.url(self.github_url), ) - self.base_commit = new_pull - self.base_orig = new_orig - self.base_tree = tree - - def _format_stack(self, index: int) -> str: - rows = [] - for i, s in reversed(list(enumerate(self.stack_meta))): - if s is None: - continue - if index == i: - rows.append(f"* __->__ #{s.number}") + def prepare_submit( + self, commits_to_submit: List[ghstack.git.CommitHeader] + ) -> Dict[GitCommitHash, DiffMeta]: + commit_index = self.commit_index + + # NB: this can be done in parallel, if you like. + # When sequentially, reversed means we update early commits first (this + # doesn't really matter, but it's more intuitive for users) + diff_meta_index: Dict[GitCommitHash, DiffMeta] = {} + for commit in reversed(commits_to_submit): + parents = commit.parents + if len(parents) != 1: + raise RuntimeError( + "The commit {} has {} parents, which makes my head explode. " + "`git rebase -i` your diffs into a stack, then try again.".format( + commit.commit_id, len(parents) + ) + ) + parent_commit = commit_index[parents[0]] + diff = ghstack.git.convert_header(commit, self.github_url) + if diff.pull_request_resolved is not None: + diff_meta = self.process_old_commit( + parent_commit, self.elaborate_diff(diff) + ) else: - rows.append(f"* #{s.number}") - return self.stack_header + ":\n" + "\n".join(rows) + "\n" + diff_meta = self.process_new_commit(parent_commit, diff) + if diff_meta is not None: + diff_meta_index[commit.commit_id] = diff_meta - def prepare_updates(self, *, is_ghexport: bool = False) -> None: - """ - Go through each commit in the stack and construct the commits - which we will push to GitHub shortly. If a commit does not have - an associated PR, push it immediately and create a PR so that we - ensure that every diff in stack_meta has an associated pull - request. - """ + return diff_meta_index - # start with the earliest commit - skip = True - for s in self.stack: - if s.pull_request_resolved is not None: - d = self.elaborate_diff(s, is_ghexport=is_ghexport) - # In principle, we can still skip commits if both their - # trees and orig commits match; and even if the messages - # don't match we only really need to push an updated - # orig commit. However, the code to make this happen is - # a bit bothersome, so I don't do it for now, to fix a - # very real bug. - if ( - skip - and d.remote_source_id == s.source_id - and not self.no_skip - and not self.update_fields - ): - self.skip_commit(d) + def prepare_rebase( + self, + commits_to_rebase: List[ghstack.git.CommitHeader], + diff_meta_index: Dict[GitCommitHash, DiffMeta], + ) -> Dict[GitCommitHash, GitCommitHash]: + # Now that we've prepared the head/base branches, now we prepare the orig + # by performing a rebase in reverse topological order. + # (Reverse here is important because we must have processed parents + # first.) + rebase_index: Dict[GitCommitHash, GitCommitHash] = {} + for commit in reversed(commits_to_rebase): + # Check if we actually need to rebase it, or can use it as is + assert len(commit.parents) == 1 + if commit.parents[0] in rebase_index or commit.commit_id in diff_meta_index: + # Yes, we need to rebase it + + if diff_meta := diff_meta_index.get(commit.commit_id): + # use the updated commit message, if it exists + commit_msg = diff_meta.commit_msg else: - skip = False - self.process_old_commit(d) - else: - skip = False - self.process_new_commit(s) + commit_msg = commit.commit_msg - def push_updates(self, *, import_help: bool = True) -> None: # noqa: C901 - """ - To be called after prepare_updates: actually push the updates to - GitHub, and also update other metadata in GitHub as necessary. - Also update the local Git checkout to point to the rewritten commit - stack. - """ + if rebase_id := rebase_index.get(commit.parents[0]): + # use the updated base, if it exists + base_commit_id = rebase_id + else: + base_commit_id = commit.parents[0] + + # Preserve authorship of original commit + # (TODO: for some reason, we didn't do this for old commits, + # maybe it doesn't matter) + env = {} + if commit.author_name is not None: + env["GIT_AUTHOR_NAME"] = commit.author_name + if commit.author_email is not None: + env["GIT_AUTHOR_EMAIL"] = commit.author_email + + new_orig = GitCommitHash( + self.sh.git( + "commit-tree", + *ghstack.gpg_sign.gpg_args_if_necessary(self.sh), + "-p", + base_commit_id, + commit.tree, + input=commit_msg, + env=env, + ) + ) + + if diff_meta is not None: + # Add the new_orig to push + # This may not exist. If so, that means this diff only exists + # to update HEAD. + diff_meta.push_branches.append((new_orig, "orig")) + + rebase_index[commit.commit_id] = new_orig - # fix the HEAD pointer - self.sh.git("reset", "--soft", self.base_orig) + return rebase_index + + # TODO: do the tree formatting minigame + # Main things: + # - need to express some tree structure + # - want "as complete" a tree as possible; this may involve + # poking around the xrefs to find out all the other PRs + # involved in the stack + def _format_stack(self, diffs_to_submit: List[DiffMeta], number: int) -> str: + rows = [] + # NB: top is top of stack, opposite of update order + for s in diffs_to_submit: + if s.number == number: + rows.append(f"* __->__ #{s.number}") + else: + rows.append(f"* #{s.number}") + return self.stack_header + ":\n" + "\n".join(rows) + "\n" + def push_updates( + self, diffs_to_submit: List[DiffMeta], *, import_help: bool = True + ) -> None: # update pull request information, update bases as necessary # preferably do this in one network call # push your commits (be sure to do this AFTER you update bases) base_push_branches: List[str] = [] push_branches: List[str] = [] force_push_branches: List[str] = [] - for i, s in enumerate(self.stack_meta): + + for s in reversed(diffs_to_submit): # NB: GraphQL API does not support modifying PRs - if s is None: - continue - if not s.closed: - logging.info( - "# Updating https://{github_url}/{owner}/{repo}/pull/{number}".format( - github_url=self.github_url, - owner=self.repo_owner, - repo=self.repo_name, - number=s.number, - ) - ) - self.github.patch( - "repos/{owner}/{repo}/pulls/{number}".format( - owner=self.repo_owner, repo=self.repo_name, number=s.number - ), - body=RE_STACK.sub(self._format_stack(i), s.body), - title=s.title, - ) - else: - logging.info( - "# Skipping closed https://{github_url}/{owner}/{repo}/pull/{number}".format( - github_url=self.github_url, - owner=self.repo_owner, - repo=self.repo_name, - number=s.number, - ) + assert not s.closed + logging.info( + "# Updating https://{github_url}/{owner}/{repo}/pull/{number}".format( + github_url=self.github_url, + owner=self.repo_owner, + repo=self.repo_name, + number=s.number, ) + ) + # TODO: don't update this if it doesn't need updating + self.github.patch( + "repos/{owner}/{repo}/pulls/{number}".format( + owner=self.repo_owner, repo=self.repo_name, number=s.number + ), + body=RE_STACK.sub( + self._format_stack(diffs_to_submit, s.number), + s.body, + ), + title=s.title, + ) # It is VERY important that we do base updates BEFORE real # head updates, otherwise GitHub will spuriously think that @@ -1162,30 +1228,20 @@ def format_url(s: DiffMeta) -> str: if self.short: # Guarantee that the FIRST PR URL is the top of the stack - print( - "\n".join( - format_url(s) for s in reversed(self.stack_meta) if s is not None - ) - ) + print("\n".join(format_url(s) for s in reversed(diffs_to_submit))) return print() print("# Summary of changes (ghstack {})".format(ghstack.__version__)) print() - if self.stack_meta: - for s in reversed(self.stack_meta): - if s is None: - continue + if diffs_to_submit: + for s in reversed(diffs_to_submit): url = format_url(s) print(" - {} {}".format(s.what, url)) print() if import_help: - top_of_stack = None - for x in self.stack_meta: - if x is not None: - top_of_stack = x - assert top_of_stack is not None + top_of_stack = diffs_to_submit[0] print("Meta employees can import your changes by running ") print("(on a Meta machine):") diff --git a/test_ghstack.py b/test_ghstack.py index 3f88576..2618160 100644 --- a/test_ghstack.py +++ b/test_ghstack.py @@ -10,7 +10,17 @@ import sys import tempfile import unittest -from typing import Any, Callable, Dict, Iterator, List, NewType, Optional, Tuple +from typing import ( + Any, + Callable, + Dict, + Iterator, + List, + NewType, + Optional, + Sequence, + Tuple, +) import expecttest @@ -129,7 +139,7 @@ def substituteRev(self, rev: str, substitute: str) -> None: h = GitCommitHash(self.sh.git("rev-parse", "--short", rev)) self.rev_map[SubstituteRev(substitute)] = h print("substituteRev: {} = {}".format(substitute, h)) - self.substituteExpected(h, substitute) + # self.substituteExpected(h, substitute) # NB: returns earliest first def gh( @@ -139,6 +149,8 @@ def gh( short: bool = False, no_skip: bool = False, base: Optional[str] = None, + revs: Sequence[str] = (), + stack: bool = True, ) -> List[Optional[ghstack.submit.DiffMeta]]: return ghstack.submit.main( msg=msg, @@ -154,6 +166,8 @@ def gh( github_url="github.com", remote_name="origin", base=base, + revs=revs, + stack=stack, ) def gh_land(self, pull_request: str) -> None: @@ -195,17 +209,27 @@ def dump_github(self) -> str: """ ) prs = [] - refs = "" for pr in r["data"]["repository"]["pullRequests"]["nodes"]: pr["body"] = indent(pr["body"].replace("\r", ""), " ") + # TODO: Use of git --graph here is a bit of a loaded + # footgun, because git doesn't really give any guarantees + # about what the graph should look like. So there isn't + # really any assurance that this will output the same thing + # on multiple test runs. We'll have to reimplement this + # ourselves to do it right. + # + # UPDATE: Another good reason to rewrite this is because git + # puts the first parent on the left, which leads to ugly + # graphs. Swapping the parents would give us nice pretty graphs. if not pr["closed"]: pr["commits"] = self.upstream_sh.git( "log", - "--reverse", - "--pretty=format:%h %s", - pr["baseRefName"] + ".." + pr["headRefName"], + "--graph", + "--oneline", + "--pretty=format:%h%d%n%w(0,3,3)%s", + pr["headRefName"], ) - pr["commits"] = indent(pr["commits"], " * ") + pr["commits"] = indent(strip_trailing_whitespace(pr["commits"]), " ") else: pr["commits"] = " (omitted)" pr["status"] = "[X]" if pr["closed"] else "[O]" @@ -213,21 +237,7 @@ def dump_github(self) -> str: "{status} #{number} {title} ({headRefName} -> {baseRefName})\n\n" "{body}\n\n{commits}\n\n".format(**pr) ) - # TODO: Use of git --graph here is a bit of a loaded - # footgun, because git doesn't really give any guarantees - # about what the graph should look like. So there isn't - # really any assurance that this will output the same thing - # on multiple test runs. We'll have to reimplement this - # ourselves to do it right. - refs = self.upstream_sh.git( - "log", "--graph", "--oneline", "--branches=gh/*/*/head", "--decorate" - ) - return ( - "".join(prs) - + "Repository state:\n\n" - + indent(strip_trailing_whitespace(refs), " ") - + "\n\n" - ) + return "".join(prs) # ------------------------------------------------------------------------- # @@ -304,8 +314,6 @@ def test_simple(self) -> None: self.sh.git("commit", "-m", "Commit 1\n\nThis is my first commit") self.sh.test_tick() self.gh("Initial 1") - self.substituteRev("HEAD", "rCOM1") - self.substituteRev("origin/gh/ezyang/1/head", "rMRG1") self.assertExpectedInline( self.dump_github(), """\ @@ -316,12 +324,10 @@ def test_simple(self) -> None: This is my first commit - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 7aa1826 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -335,8 +341,6 @@ def test_simple(self) -> None: self.sh.git("commit", "-m", "Commit 2\n\nThis is my second commit") self.sh.test_tick() self.gh("Initial 2") - self.substituteRev("HEAD", "rCOM2") - self.substituteRev("origin/gh/ezyang/2/head", "rMRG2") self.assertExpectedInline( self.dump_github(), """\ @@ -348,7 +352,10 @@ def test_simple(self) -> None: This is my first commit - * rMRG1 Commit 1 + * 7aa1826 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -358,13 +365,10 @@ def test_simple(self) -> None: This is my second commit - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * dbf2b1f (gh/ezyang/2/head) + | Commit 2 + * fe1c83e (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -401,12 +405,10 @@ def test_when_malform_gh_branch_exist(self) -> None: This is my first commit - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/non_int/head, gh/ezyang/malform, gh/ezyang/1/base) Initial commit + * 7aa1826 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -429,7 +431,10 @@ def test_when_malform_gh_branch_exist(self) -> None: This is my first commit - * rMRG1 Commit 1 + * 7aa1826 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -439,13 +444,10 @@ def test_when_malform_gh_branch_exist(self) -> None: This is my second commit - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/non_int/head, gh/ezyang/malform, gh/ezyang/1/base) Initial commit + * dbf2b1f (gh/ezyang/2/head) + | Commit 2 + * fe1c83e (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -478,12 +480,10 @@ def test_empty_commit(self) -> None: - * rMRG1 Commit 2 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 2 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 1e2c3cc (gh/ezyang/1/head) + | Commit 2 + * d9f4eaf (gh/ezyang/1/base) + Update base for Initial on "Commit 2" """, ) @@ -568,12 +568,10 @@ def test_commit_amended_to_empty(self) -> None: This is my first commit - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * c4d36ed (gh/ezyang/1/head) + | Commit 1 + * 618c84d (gh/ezyang/1/base) + Update base for Initial on "Commit 1" """, ) @@ -592,12 +590,10 @@ def test_commit_amended_to_empty(self) -> None: This is my first commit - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * c4d36ed (gh/ezyang/1/head) + | Commit 1 + * 618c84d (gh/ezyang/1/base) + Update base for Initial on "Commit 1" """, ) @@ -626,12 +622,10 @@ def test_amend(self) -> None: A commit with an A - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -654,14 +648,12 @@ def test_amend(self) -> None: A commit with an A - * rMRG1 Commit 1 - * rMRG2 Update A on "Commit 1" - -Repository state: - - * rMRG2 (gh/ezyang/1/head) Update A on "Commit 1" - * rMRG1 Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 4d61fd8 (gh/ezyang/1/head) + | Update A on "Commit 1" + * 14686d5 + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -690,12 +682,10 @@ def test_amend_message_only(self) -> None: A commit with an A - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -723,14 +713,10 @@ def test_amend_message_only(self) -> None: A commit with an A - * rMRG1 Commit 1 - * rMRG2 Update A on "Commit 1" - -Repository state: - - * rMRG2 (gh/ezyang/1/head) Update A on "Commit 1" - * rMRG1 Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -797,7 +783,10 @@ def test_multi(self) -> None: A commit with an A - * rMRG1 Commit 1 + * e86f502 (gh/ezyang/1/head) + | Commit 1 + * 4acf701 (gh/ezyang/1/base) + Update base for Initial 1 and 2 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -807,13 +796,10 @@ def test_multi(self) -> None: A commit with a B - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 3081651 (gh/ezyang/2/head) + | Commit 2 + * 2939873 (gh/ezyang/2/base) + Update base for Initial 1 and 2 on "Commit 2" """, ) @@ -852,7 +838,10 @@ def test_amend_top(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -862,13 +851,10 @@ def test_amend_top(self) -> None: A commit with a B - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * e1050e2 (gh/ezyang/2/head) + | Commit 2 + * e04cdc0 (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -892,7 +878,10 @@ def test_amend_top(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -902,15 +891,12 @@ def test_amend_top(self) -> None: A commit with a B - * rMRG2 Commit 2 - * rMRG2A Update A on "Commit 2" - -Repository state: - - * rMRG2A (gh/ezyang/2/head) Update A on "Commit 2" - * rMRG2 Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * af4d6a4 (gh/ezyang/2/head) + | Update A on "Commit 2" + * e1050e2 + | Commit 2 + * e04cdc0 (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -949,7 +935,10 @@ def test_amend_bottom(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -959,13 +948,10 @@ def test_amend_bottom(self) -> None: A commit with a B - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * e1050e2 (gh/ezyang/2/head) + | Commit 2 + * e04cdc0 (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -990,8 +976,12 @@ def test_amend_bottom(self) -> None: A commit with an A - * rMRG1 Commit 1 - * rMRG1A Update A on "Commit 1" + * 882dcd5 (gh/ezyang/1/head) + | Update A on "Commit 1" + * 14686d5 + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1001,15 +991,10 @@ def test_amend_bottom(self) -> None: A commit with a B - * rMRG2 Commit 2 - -Repository state: - - * rMRG1A (gh/ezyang/1/head) Update A on "Commit 1" - | * rMRG2 (gh/ezyang/2/head) Commit 2 - |/ - * rMRG1 (gh/ezyang/2/base) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * e1050e2 (gh/ezyang/2/head) + | Commit 2 + * e04cdc0 (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -1033,8 +1018,12 @@ def test_amend_bottom(self) -> None: A commit with an A - * rMRG1 Commit 1 - * rMRG1A Update A on "Commit 1" + * 882dcd5 (gh/ezyang/1/head) + | Update A on "Commit 1" + * 14686d5 + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1044,20 +1033,14 @@ def test_amend_bottom(self) -> None: A commit with a B - * rMRG2 Commit 2 - * rMRG2A Update B on "Commit 2" - -Repository state: - - * rMRG2A (gh/ezyang/2/head) Update B on "Commit 2" - |\\ - | * rINI2A (gh/ezyang/2/base) Update base for Update B on "Commit 2" - * | rMRG2 Commit 2 - |/ - | * rMRG1A (gh/ezyang/1/head) Update A on "Commit 1" - |/ - * rMRG1 Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 5174ea8 (gh/ezyang/2/head) + |\\ Update B on "Commit 2" + | * 0cbbe2b (gh/ezyang/2/base) + | | Update base for Update B on "Commit 2" + * | e1050e2 + |/ Commit 2 + * e04cdc0 + Update base for Initial 2 on "Commit 2" """, ) @@ -1096,7 +1079,10 @@ def test_amend_all(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1106,13 +1092,10 @@ def test_amend_all(self) -> None: A commit with a B - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * e1050e2 (gh/ezyang/2/head) + | Commit 2 + * e04cdc0 (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -1146,8 +1129,12 @@ def test_amend_all(self) -> None: A commit with an A - * rMRG1 Commit 1 - * rMRG1A Update A on "Commit 1" + * a90cfbb (gh/ezyang/1/head) + | Update A on "Commit 1" + * 14686d5 + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1157,21 +1144,14 @@ def test_amend_all(self) -> None: A commit with a B - * rMRG2 Commit 2 - * rMRG2A Update A on "Commit 2" - -Repository state: - - * rMRG1A (gh/ezyang/1/head) Update A on "Commit 1" - | * rMRG2A (gh/ezyang/2/head) Update A on "Commit 2" - | |\\ - | | * rINI2A (gh/ezyang/2/base) Update base for Update A on "Commit 2" - | |/ - |/| - | * rMRG2 Commit 2 - |/ - * rMRG1 Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * f848251 (gh/ezyang/2/head) + |\\ Update A on "Commit 2" + | * c5e35e9 (gh/ezyang/2/base) + | | Update base for Update A on "Commit 2" + * | e1050e2 + |/ Commit 2 + * e04cdc0 + Update base for Initial 2 on "Commit 2" """, ) @@ -1213,7 +1193,10 @@ def test_rebase(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1223,13 +1206,10 @@ def test_rebase(self) -> None: A commit with a B - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * e1050e2 (gh/ezyang/2/head) + | Commit 2 + * e04cdc0 (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -1268,8 +1248,14 @@ def test_rebase(self) -> None: A commit with an A - * rMRG1 Commit 1 - * rMRG1A Rebase on "Commit 1" + * 827d27a (gh/ezyang/1/head) + |\\ Rebase on "Commit 1" + | * 23955ae (gh/ezyang/1/base) + | | Update base for Rebase on "Commit 1" + * | 14686d5 + |/ Commit 1 + * c6ab36c + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1279,29 +1265,14 @@ def test_rebase(self) -> None: A commit with a B - * rMRG2 Commit 2 - * rMRG2A Rebase on "Commit 2" - -Repository state: - - * rMRG1A (gh/ezyang/1/head) Rebase on "Commit 1" - |\\ - | * rINI1A (gh/ezyang/1/base) Update base for Rebase on "Commit 1" - | |\\ - | | | * rMRG2A (gh/ezyang/2/head) Rebase on "Commit 2" - | | | |\\ - | | | | * rINI2A (gh/ezyang/2/base) Update base for Rebase on "Commit 2" - | |_|_|/| - |/| | |/ - | | |/| - | | * | rINI2 (HEAD -> master) Master commit 1 - | |/ / - | | * rMRG2 Commit 2 - | |/ - |/| - * | rMRG1 Commit 1 - |/ - * rINI0 Initial commit + * 4170976 (gh/ezyang/2/head) + |\\ Rebase on "Commit 2" + | * 02a704f (gh/ezyang/2/base) + | | Update base for Rebase on "Commit 2" + * | e1050e2 + |/ Commit 2 + * e04cdc0 + Update base for Initial 2 on "Commit 2" """, ) @@ -1343,7 +1314,10 @@ def test_cherry_pick(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1353,13 +1327,10 @@ def test_cherry_pick(self) -> None: A commit with a B - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * e1050e2 (gh/ezyang/2/head) + | Commit 2 + * e04cdc0 (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -1394,7 +1365,10 @@ def test_cherry_pick(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1403,21 +1377,14 @@ def test_cherry_pick(self) -> None: A commit with a B - * rMRG2 Commit 2 - * rMRG2A Cherry pick on "Commit 2" - -Repository state: - - * rMRG2A (gh/ezyang/2/head) Cherry pick on "Commit 2" - |\\ - | * rINI2A (gh/ezyang/2/base) Update base for Cherry pick on "Commit 2" - | |\\ - | | * rINI2 (HEAD -> master) Master commit 1 - * | | rMRG2 Commit 2 - |/ / - * / rMRG1 (gh/ezyang/1/head) Commit 1 - |/ - * rINI0 (gh/ezyang/1/base) Initial commit + * 7f9f66e (gh/ezyang/2/head) + |\\ Cherry pick on "Commit 2" + | * e1f1793 (gh/ezyang/2/base) + | | Update base for Cherry pick on "Commit 2" + * | e1050e2 + |/ Commit 2 + * e04cdc0 + Update base for Initial 2 on "Commit 2" """, ) @@ -1449,7 +1416,10 @@ def test_reorder(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 172327a (gh/ezyang/1/head) + | Commit 1 + * 9f734b6 (gh/ezyang/1/base) + Update base for Initial on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1459,13 +1429,10 @@ def test_reorder(self) -> None: A commit with an B - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 01780cd (gh/ezyang/2/head) + | Commit 2 + * fd93c85 (gh/ezyang/2/base) + Update base for Initial on "Commit 2" """, ) @@ -1494,8 +1461,14 @@ def test_reorder(self) -> None: A commit with an A - * rMRG1 Commit 1 - * rMRG1A Reorder on "Commit 1" + * fc55fb8 (gh/ezyang/1/head) + |\\ Reorder on "Commit 1" + | * 3fc1547 (gh/ezyang/1/base) + | | Update base for Reorder on "Commit 1" + * | 172327a + |/ Commit 1 + * 9f734b6 + Update base for Initial on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1505,25 +1478,14 @@ def test_reorder(self) -> None: A commit with an B - * rMRG2 Commit 2 - * rMRG2A Reorder on "Commit 2" - -Repository state: - - * rMRG1A (gh/ezyang/1/head) Reorder on "Commit 1" - |\\ - | * rINI1A (gh/ezyang/1/base) Update base for Reorder on "Commit 1" - | | * rMRG2A (gh/ezyang/2/head) Reorder on "Commit 2" - | | |\\ - | | | * rINI2A (gh/ezyang/2/base) Update base for Reorder on "Commit 2" - | |_|/ - |/| | - | | * rMRG2 Commit 2 - | |/ - |/| - * | rMRG1 Commit 1 - |/ - * rINI0 (HEAD -> master) Initial commit + * 13de781 (gh/ezyang/2/head) + |\\ Reorder on "Commit 2" + | * 17e125b (gh/ezyang/2/base) + | | Update base for Reorder on "Commit 2" + * | 01780cd + |/ Commit 2 + * fd93c85 + Update base for Initial on "Commit 2" """, ) @@ -1553,12 +1515,10 @@ def test_no_clobber(self) -> None: Original message - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1585,12 +1545,10 @@ def test_no_clobber(self) -> None: Directly updated message body - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1615,14 +1573,12 @@ def test_no_clobber(self) -> None: Directly updated message body - * rMRG1 Commit 1 - * rMRG2 Update 1 on "Directly updated title" - -Repository state: - - * rMRG2 (gh/ezyang/1/head) Update 1 on "Directly updated title" - * rMRG1 Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * a1f0d88 (gh/ezyang/1/head) + | Update 1 on "Directly updated title" + * babdde9 + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1653,12 +1609,10 @@ def test_no_clobber_carriage_returns(self) -> None: Original message - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1698,7 +1652,10 @@ def test_no_clobber_carriage_returns(self) -> None: Directly updated message body - * rMRG1 Commit 1 + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -1708,13 +1665,10 @@ def test_no_clobber_carriage_returns(self) -> None: - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 974a2d5 (gh/ezyang/2/head) + | Commit 2 + * 4ac20dd (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -1763,12 +1717,10 @@ def test_update_fields(self) -> None: Original message - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1788,12 +1740,10 @@ def test_update_fields(self) -> None: Directly updated message body - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1813,14 +1763,10 @@ def test_update_fields(self) -> None: Original message - * rMRG1 Commit 1 - * 49615a9 Update 1 on "Commit 1" - -Repository state: - - * 49615a9 (gh/ezyang/1/head) Update 1 on "Commit 1" - * rMRG1 Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1850,12 +1796,10 @@ def test_update_fields_preserves_commit_message(self) -> None: Original message - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1880,14 +1824,10 @@ def test_update_fields_preserves_commit_message(self) -> None: Original message - * rMRG1 Commit 1 - * 93de014 Update 1 on "Amended" - -Repository state: - - * 93de014 (gh/ezyang/1/head) Update 1 on "Amended" - * rMRG1 Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1920,12 +1860,10 @@ def test_update_fields_preserve_differential_revision(self) -> None: Original message - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1952,12 +1890,10 @@ def test_update_fields_preserve_differential_revision(self) -> None: Differential Revision: [D14778507](https://our.internmc.facebook.com/intern/diff/D14778507) - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -1978,14 +1914,10 @@ def test_update_fields_preserve_differential_revision(self) -> None: Differential Revision: [D14778507](https://our.internmc.facebook.com/intern/diff/D14778507) - * rMRG1 Commit 1 - * 0800457 Update 1 on "Commit 1" - -Repository state: - - * 0800457 (gh/ezyang/1/head) Update 1 on "Commit 1" - * rMRG1 Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * babdde9 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -2027,7 +1959,10 @@ def test_remove_bottom_commit(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -2037,13 +1972,10 @@ def test_remove_bottom_commit(self) -> None: A commit with a B - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rMRG1 (gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * e1050e2 (gh/ezyang/2/head) + | Commit 2 + * e04cdc0 (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -2073,7 +2005,10 @@ def test_remove_bottom_commit(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 14686d5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -2082,18 +2017,14 @@ def test_remove_bottom_commit(self) -> None: A commit with a B - * rMRG2 Commit 2 - * rMRG2A Cherry pick on "Commit 2" - -Repository state: - - * rMRG2A (gh/ezyang/2/head) Cherry pick on "Commit 2" - |\\ - | * rINI2A (gh/ezyang/2/base) Update base for Cherry pick on "Commit 2" - * | rMRG2 Commit 2 - |/ - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * d185442 (gh/ezyang/2/head) + |\\ Cherry pick on "Commit 2" + | * a3c6b3c (gh/ezyang/2/base) + | | Update base for Cherry pick on "Commit 2" + * | e1050e2 + |/ Commit 2 + * e04cdc0 + Update base for Initial 2 on "Commit 2" """, ) @@ -2126,8 +2057,8 @@ def test_land_ff(self) -> None: self.assertExpectedInline( self.upstream_sh.git("log", "--oneline", "master"), """\ -rCOM1 Commit 1 -rINI0 Initial commit""", +c7c1805 Commit 1 +dc8bfe4 Initial commit""", ) # ------------------------------------------------------------------------- # @@ -2154,9 +2085,9 @@ def test_land_ff_stack(self) -> None: self.assertExpectedInline( self.upstream_sh.git("log", "--oneline", "master"), """\ -rCOM2 Commit 2 -rCOM1 Commit 1 -rINI0 Initial commit""", +3600902 Commit 2 +a32aa2b Commit 1 +dc8bfe4 Initial commit""", ) # ------------------------------------------------------------------------- # @@ -2185,9 +2116,9 @@ def test_land_ff_stack_two_phase(self) -> None: self.assertExpectedInline( self.upstream_sh.git("log", "--oneline", "master"), """\ -rCOM2 Commit 2 -rCOM1 Commit 1 -rINI0 Initial commit""", +3600902 Commit 2 +a32aa2b Commit 1 +dc8bfe4 Initial commit""", ) # ------------------------------------------------------------------------- # @@ -2221,10 +2152,10 @@ def test_land_non_ff_stack_two_phase(self) -> None: self.assertExpectedInline( self.upstream_sh.git("log", "--oneline", "master"), """\ -rCOM2 Commit 2 -rCOM1 Commit 1 -rCOM3 Commit 3 -rINI0 Initial commit""", +16c0410 Commit 2 +cf230e2 Commit 1 +367e236 Commit 3 +dc8bfe4 Initial commit""", ) # ------------------------------------------------------------------------- # @@ -2285,9 +2216,9 @@ def test_land_non_ff(self) -> None: self.assertExpectedInline( self.upstream_sh.git("log", "--oneline", "master"), """\ -rUP2 Commit 1 -rUP1 Upstream commit -rINI0 Initial commit""", +31fb74c Commit 1 +b29d563 Upstream commit +dc8bfe4 Initial commit""", ) # ------------------------------------------------------------------------- # @@ -2323,7 +2254,10 @@ def test_unlink(self) -> None: A commit with an A - * rMRG1 Commit 1 + * a73ebff (gh/ezyang/1/head) + | Commit 1 + * 5a30b61 (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 1 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -2333,7 +2267,10 @@ def test_unlink(self) -> None: A commit with an B - * rMRG2 Commit 1 + * 65b4539 (gh/ezyang/2/head) + | Commit 1 + * 3a3ce62 (gh/ezyang/2/base) + Update base for Initial 1 on "Commit 1" [O] #502 Commit 1 (gh/ezyang/3/head -> gh/ezyang/3/base) @@ -2343,7 +2280,10 @@ def test_unlink(self) -> None: A commit with an A - * rMRG1 Commit 1 + * 7752bcb (gh/ezyang/3/head) + | Commit 1 + * a062e4f (gh/ezyang/3/base) + Update base for Initial 2 on "Commit 1" [O] #503 Commit 1 (gh/ezyang/4/head -> gh/ezyang/4/base) @@ -2353,13 +2293,10 @@ def test_unlink(self) -> None: A commit with an B - * rMRG2 Commit 1 - -Repository state: - - * rMRG2 (gh/ezyang/4/head, gh/ezyang/2/head) Commit 1 - * rMRG1 (gh/ezyang/4/base, gh/ezyang/3/head, gh/ezyang/2/base, gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/3/base, gh/ezyang/1/base) Initial commit + * 74a9d8a (gh/ezyang/4/head) + | Commit 1 + * cca68c8 (gh/ezyang/4/base) + Update base for Initial 2 on "Commit 1" """, ) @@ -2396,12 +2333,10 @@ def test_default_branch_change(self) -> None: This is my first commit - * rMRG1 Commit 1 - -Repository state: - - * rMRG1 (gh/ezyang/1/head) Commit 1 - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * d176ee5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" """, ) @@ -2412,14 +2347,13 @@ def test_default_branch_change(self) -> None: self.assertExpectedInline( self.upstream_sh.git("log", "--oneline", "master"), - """\ -rINI0 Initial commit""", + """dc8bfe4 Initial commit""", ) self.assertExpectedInline( self.upstream_sh.git("log", "--oneline", "main"), """\ -rUP1 Commit 1 -rINI0 Initial commit""", +c7c1805 Commit 1 +dc8bfe4 Initial commit""", ) # make another commit @@ -2448,7 +2382,10 @@ def test_default_branch_change(self) -> None: This is my first commit - * rMRG1 Commit 1 + * d176ee5 (gh/ezyang/1/head) + | Commit 1 + * c6ab36c (gh/ezyang/1/base) + Update base for Initial 1 on "Commit 1" [O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) @@ -2457,15 +2394,10 @@ def test_default_branch_change(self) -> None: This is my second commit - * rMRG2 Commit 2 - -Repository state: - - * rMRG2 (gh/ezyang/2/head) Commit 2 - * rUP1 (main, gh/ezyang/2/base, gh/ezyang/1/orig) Commit 1 - | * rMRG1 (gh/ezyang/1/head) Commit 1 - |/ - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 7a61932 (gh/ezyang/2/head) + | Commit 2 + * 530b586 (gh/ezyang/2/base) + Update base for Initial 2 on "Commit 2" """, ) @@ -2478,15 +2410,15 @@ def test_default_branch_change(self) -> None: self.assertExpectedInline( self.upstream_sh.git("log", "--oneline", "master"), """\ -rUP3 Commit 2 -rUP2 Commit 1 -rINI0 Initial commit""", +9523c9d Commit 2 +75108b9 Commit 1 +dc8bfe4 Initial commit""", ) self.assertExpectedInline( self.upstream_sh.git("log", "--oneline", "main"), """\ -rUP1 Commit 1 -rINI0 Initial commit""", +c7c1805 Commit 1 +dc8bfe4 Initial commit""", ) # ------------------------------------------------------------------------- # @@ -2553,22 +2485,14 @@ def test_update_after_land(self) -> None: This is my second commit - * rSELF2A Commit 2 - * rSELF2B Run 3 on "Commit 2" - -Repository state: - - * rSELF2B (gh/ezyang/2/head) Run 3 on "Commit 2" - |\\ - | * rBASE2B (gh/ezyang/2/base) Update base for Run 3 on "Commit 2" - | |\\ - | | * rSELF1 (HEAD -> master) Commit 1 - | | * rUP1 Upstream commit - * | | rSELF2A Commit 2 - |/ / - * / rSELF1 Commit 1 - |/ - * rINI0 Initial commit + * ba56806 (gh/ezyang/2/head) + |\\ Run 3 on "Commit 2" + | * 931fc17 (gh/ezyang/2/base) + | | Update base for Run 3 on "Commit 2" + * | e30072e + |/ Commit 2 + * fb303b0 + Update base for Initial 1 on "Commit 2" """, ) @@ -2702,13 +2626,10 @@ def test_land_and_invalid_resubmit(self) -> None: This is my first commit - * rHEAD Commit 1 - -Repository state: - - * rHEAD (gh/ezyang/1/head) Commit 1 - * rBASE (HEAD -> master, gh/ezyang/1/base) Commit 1 - * rINI0 Initial commit + * 8295531 (gh/ezyang/1/head) + | Commit 1 + * 3278eec (gh/ezyang/1/base) + Update base for New PR on "Commit 1" """, ) @@ -2758,13 +2679,10 @@ def test_non_standard_base(self) -> None: - * rHEAD PR on release - -Repository state: - - * rHEAD (gh/ezyang/1/head) PR on release - * rBASE (release, gh/ezyang/1/base) Release commit - * rINI0 Initial commit + * 06f4a75 (gh/ezyang/1/head) + | PR on release + * dd738db (gh/ezyang/1/base) + Update base for Initial 1 on "PR on release" """, ) @@ -2796,12 +2714,10 @@ def test_bullet_divider(self) -> None: * It starts with a fabulous * Bullet list - * rHEAD This is my commit - -Repository state: - - * rHEAD (gh/ezyang/1/head) This is my commit - * rINI0 (HEAD -> master, gh/ezyang/1/base) Initial commit + * 2b4bd9d (gh/ezyang/1/head) + | This is my commit + * f145152 (gh/ezyang/1/base) + Update base for Initial on "This is my commit" """, ) @@ -2820,6 +2736,258 @@ def test_fail_same_source_id(self) -> None: RuntimeError, "occurs twice", lambda: self.gh("Should fail") ) + def make_commit(self, name: str) -> None: + self.writeFileAndAdd(f"{name}.txt", "A") + self.sh.git("commit", "-m", f"Commit {name}") + self.sh.test_tick() + + def amend_commit(self, name: str) -> None: + self.writeFileAndAdd(f"{name}.txt", "A") + self.sh.git("commit", "--amend", "--no-edit", tick=True) + + def test_submit_prefix_only_no_stack(self) -> None: + self.make_commit("A") + self.make_commit("B") + self.gh("Initial") + B = self.sh.git("rev-parse", "HEAD") + + self.sh.git("checkout", "HEAD~") + self.amend_commit("A2") + self.sh.git("cherry-pick", B, tick=True) + self.gh("Update base only", revs=["HEAD~"], stack=False) + + self.assertExpectedInline( + self.dump_github(), + """\ +[O] #500 Commit 1 (gh/ezyang/1/head -> gh/ezyang/1/base) + + Stack: + * __->__ #500 + + + + * eb2eae4 (gh/ezyang/1/head) + | Update base only on "Commit 1" + * 9820f4b + | Commit 1 + * 9f734b6 (gh/ezyang/1/base) + Update base for Initial on "Commit 1" + +[O] #501 Commit 2 (gh/ezyang/2/head -> gh/ezyang/2/base) + + Stack: + * __->__ #501 + * #500 + + + + * b7e67b6 (gh/ezyang/2/head) + | Commit 2 + * ae5961f (gh/ezyang/2/base) + Update base for Initial on "Commit 2" + +""", + ) + + def test_submit_suffix_only_no_stack(self) -> None: + self.make_commit("A") + self.make_commit("B") + self.gh("Initial") + B = self.sh.git("rev-parse", "HEAD") + + self.sh.git("checkout", "HEAD~") + self.amend_commit("A2") + self.sh.git("cherry-pick", B, tick=True) + self.gh("Update head only", revs=["HEAD"], stack=False) + + self.assertExpectedInline( + self.dump_github(), + """\ +[O] #500 Commit A (gh/ezyang/1/head -> gh/ezyang/1/base) + + Stack: + * #501 + * __->__ #500 + + + + * cb3c5eb (gh/ezyang/1/head) + | Commit A + * 9e2ff2f (gh/ezyang/1/base) + Update base for Initial on "Commit A" + +[O] #501 Commit B (gh/ezyang/2/head -> gh/ezyang/2/base) + + Stack: + * __->__ #501 + + + + * b3d3bb5 (gh/ezyang/2/head) + |\\ Update head only on "Commit B" + | * 3d127c6 (gh/ezyang/2/base) + | | Update base for Update head only on "Commit B" + * | 3cf9ec1 + |/ Commit B + * 97d9a92 + Update base for Initial on "Commit B" + +""", + ) + + def test_submit_prefix_only_stack(self) -> None: + self.make_commit("A") + self.make_commit("B") + self.make_commit("C") + self.gh("Initial") + B = self.sh.git("rev-parse", "HEAD~") + C = self.sh.git("rev-parse", "HEAD") + + self.sh.git("checkout", "HEAD~~") + self.amend_commit("A2") + self.sh.git("cherry-pick", B, tick=True) + self.sh.git("cherry-pick", C, tick=True) + self.gh("Don't update C", revs=["HEAD~"], stack=True) + + self.assertExpectedInline( + self.dump_github(), + """\ +[O] #500 Commit A (gh/ezyang/1/head -> gh/ezyang/1/base) + + Stack: + * #501 + * __->__ #500 + + + + * c70b354 (gh/ezyang/1/head) + | Don't update C on "Commit A" + * 290340a + | Commit A + * 1fa4e09 (gh/ezyang/1/base) + Update base for Initial on "Commit A" + +[O] #501 Commit B (gh/ezyang/2/head -> gh/ezyang/2/base) + + Stack: + * __->__ #501 + * #500 + + + + * ebfcd77 (gh/ezyang/2/head) + |\\ Don't update C on "Commit B" + | * 78a7d98 (gh/ezyang/2/base) + | | Update base for Don't update C on "Commit B" + * | ff5373b + |/ Commit B + * 31b98af + Update base for Initial on "Commit B" + +[O] #502 Commit C (gh/ezyang/3/head -> gh/ezyang/3/base) + + Stack: + * __->__ #502 + * #501 + * #500 + + + + * 3a7e22b (gh/ezyang/3/head) + | Commit C + * 4f0f679 (gh/ezyang/3/base) + Update base for Initial on "Commit C" + +""", + ) + + def test_submit_range_only_stack(self) -> None: + self.make_commit("A") + self.make_commit("B") + self.make_commit("C") + self.make_commit("D") + self.gh("Initial") + B = self.sh.git("rev-parse", "HEAD~~") + C = self.sh.git("rev-parse", "HEAD~") + D = self.sh.git("rev-parse", "HEAD") + + self.sh.git("checkout", "HEAD~~~") + self.amend_commit("A2") + self.sh.git("cherry-pick", B, tick=True) + self.sh.git("cherry-pick", C, tick=True) + self.sh.git("cherry-pick", D, tick=True) + self.gh("Update B and C only", revs=["HEAD~~~..HEAD~"], stack=True) + + self.assertExpectedInline( + self.dump_github(), + """\ +[O] #500 Commit A (gh/ezyang/1/head -> gh/ezyang/1/base) + + Stack: + * #503 + * #502 + * #501 + * __->__ #500 + + + + * af9017a (gh/ezyang/1/head) + | Commit A + * 65b6341 (gh/ezyang/1/base) + Update base for Initial on "Commit A" + +[O] #501 Commit B (gh/ezyang/2/head -> gh/ezyang/2/base) + + Stack: + * #502 + * __->__ #501 + + + + * d18dfb9 (gh/ezyang/2/head) + |\\ Update B and C only on "Commit B" + | * d876758 (gh/ezyang/2/base) + | | Update base for Update B and C only on "Commit B" + * | aaa7211 + |/ Commit B + * 48e3720 + Update base for Initial on "Commit B" + +[O] #502 Commit C (gh/ezyang/3/head -> gh/ezyang/3/base) + + Stack: + * __->__ #502 + * #501 + + + + * d78bd70 (gh/ezyang/3/head) + |\\ Update B and C only on "Commit C" + | * cf940ad (gh/ezyang/3/base) + | | Update base for Update B and C only on "Commit C" + * | f38afc9 + |/ Commit C + * b937b45 + Update base for Initial on "Commit C" + +[O] #503 Commit D (gh/ezyang/4/head -> gh/ezyang/4/base) + + Stack: + * __->__ #503 + * #502 + * #501 + * #500 + + + + * d3f5f5e (gh/ezyang/4/head) + | Commit D + * 3f41f25 (gh/ezyang/4/base) + Update base for Initial on "Commit D" + +""", + ) + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG, format="%(message)s")