From 3408844e7a2a1f1ec9c03c9ca56e24117c6dd809 Mon Sep 17 00:00:00 2001 From: Steve Fink Date: Wed, 16 Apr 2025 05:00:22 +0000 Subject: [PATCH] Bug 1929372 - Implement JujutsuRepository in mozversioncontrol.repo.jj to allow using JJ directly (on top of a git repository, whether colocated or not). Warns unless $MOZ_AVOID_JJ_VCS for now, to avoid incurring a maintenance burden. r=ahal Differential Revision: https://phabricator.services.mozilla.com/D244807 --- .../mozversioncontrol/__init__.py | 1 + .../mozversioncontrol/factory.py | 52 ++- .../mozversioncontrol/repo/base.py | 6 +- .../mozversioncontrol/repo/jj.py | 346 ++++++++++++++++++ python/mozversioncontrol/test/conftest.py | 37 +- python/mozversioncontrol/test/test_branch.py | 26 +- python/mozversioncontrol/test/test_commit.py | 43 ++- .../test/test_context_manager.py | 16 +- .../test/test_get_branch_nodes.py | 8 + .../test/test_get_commit_patches.py | 10 + .../test/test_get_mozilla_remote_args.py | 3 +- .../test/test_get_upstream_remotes.py | 1 + .../test/test_push_to_try.py | 46 ++- .../mozversioncontrol/test/test_try_commit.py | 9 +- python/mozversioncontrol/test/test_update.py | 36 +- .../test/test_workdir_outgoing.py | 47 ++- .../test/test_working_directory.py | 4 + tools/tryselect/lando.py | 4 +- 18 files changed, 653 insertions(+), 42 deletions(-) create mode 100644 python/mozversioncontrol/mozversioncontrol/repo/jj.py diff --git a/python/mozversioncontrol/mozversioncontrol/__init__.py b/python/mozversioncontrol/mozversioncontrol/__init__.py index 30052970d771..8c8347714172 100644 --- a/python/mozversioncontrol/mozversioncontrol/__init__.py +++ b/python/mozversioncontrol/mozversioncontrol/__init__.py @@ -10,5 +10,6 @@ from mozversioncontrol.factory import ( # noqa ) from mozversioncontrol.repo.base import Repository # noqa from mozversioncontrol.repo.git import GitRepository # noqa +from mozversioncontrol.repo.jj import JujutsuRepository # noqa from mozversioncontrol.repo.mercurial import HgRepository # noqa from mozversioncontrol.repo.source import SrcRepository # noqa diff --git a/python/mozversioncontrol/mozversioncontrol/factory.py b/python/mozversioncontrol/mozversioncontrol/factory.py index c8a634309658..ba3f0acd1519 100644 --- a/python/mozversioncontrol/mozversioncontrol/factory.py +++ b/python/mozversioncontrol/mozversioncontrol/factory.py @@ -2,6 +2,9 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this, # file, You can obtain one at http://mozilla.org/MPL/2.0/. +import os +import subprocess +import sys from pathlib import Path from typing import ( Optional, @@ -15,12 +18,13 @@ from mozversioncontrol.errors import ( MissingVCSTool, ) from mozversioncontrol.repo.git import GitRepository +from mozversioncontrol.repo.jj import JujutsuRepository from mozversioncontrol.repo.mercurial import HgRepository from mozversioncontrol.repo.source import SrcRepository def get_repository_object( - path: Optional[Union[str, Path]], hg="hg", git="git", src="src" + path: Optional[Union[str, Path]], hg="hg", git="git", jj="jj", src="src" ): """Get a repository object for the repository at `path`. If `path` is not a known VCS repository, raise an exception. @@ -31,12 +35,42 @@ def get_repository_object( path = Path(path).resolve() if (path / ".hg").is_dir(): return HgRepository(path, hg=hg) - elif (path / ".git").exists(): + if (path / ".jj").is_dir(): + avoid = os.getenv("MOZ_AVOID_JJ_VCS") + if avoid not in (None, "0", ""): + use_jj = False + else: + try: + subprocess.call(["jj", "--version"], stdout=subprocess.DEVNULL) + use_jj = True + except OSError: + use_jj = False + print(".jj/ directory exists but jj binary not usable", file=sys.stderr) + + if use_jj and avoid not in ("0", ""): + # Warn (once) if MOZ_AVOID_JJ_VCS is unset. If it is set to 0, then use + # jj without warning. If it is set to anything else, do not use jj (so + # eg fall back to git if .git exists.) + if not hasattr(get_repository_object, "_warned"): + get_repository_object._warned = True + print( + """\ +Using JujutsuRepository because a .jj/ directory was detected! + +Warning: jj support is currently experimental, and may be disabled by setting the +environment variable MOZ_AVOID_JJ_VCS=1. (This warning may be suppressed by +setting MOZ_AVOID_JJ_VCS=0.)""", + file=sys.stderr, + ) + + if use_jj: + return JujutsuRepository(path, jj=jj, git=git) + + if (path / ".git").exists(): return GitRepository(path, git=git) - elif (path / "config" / "milestone.txt").exists(): + if (path / "config" / "milestone.txt").exists(): return SrcRepository(path, src=src) - else: - raise InvalidRepoPath(f"Unknown VCS, or not a source checkout: {path}") + raise InvalidRepoPath(f"Unknown VCS, or not a source checkout: {path}") def get_repository_from_build_config(config): @@ -58,6 +92,10 @@ def get_repository_from_build_config(config): if flavor == "hg": return HgRepository(Path(config.topsrcdir), hg=config.substs["HG"]) + elif flavor == "jj": + return JujutsuRepository( + Path(config.topsrcdir), jj=config.substs["JJ"], git=config.substs["GIT"] + ) elif flavor == "git": return GitRepository(Path(config.topsrcdir), git=config.substs["GIT"]) elif flavor == "src": @@ -89,4 +127,6 @@ def get_repository_from_env(): except InvalidRepoPath: continue - raise MissingVCSInfo(f"Could not find Mercurial or Git checkout for {Path.cwd()}") + raise MissingVCSInfo( + f"Could not find Mercurial / Git / JJ checkout for {Path.cwd()}" + ) diff --git a/python/mozversioncontrol/mozversioncontrol/repo/base.py b/python/mozversioncontrol/mozversioncontrol/repo/base.py index c78e34faeac3..4fc27ed5b466 100644 --- a/python/mozversioncontrol/mozversioncontrol/repo/base.py +++ b/python/mozversioncontrol/mozversioncontrol/repo/base.py @@ -59,6 +59,10 @@ class Repository: def _run(self, *args, encoding="utf-8", **runargs): return_codes = runargs.get("return_codes", []) + env = self._env + if "env" in runargs: + env = env.copy() + env.update(runargs["env"]) cmd = (str(self._tool),) + args # Check if we have a tool, either hg or git. If this is a @@ -72,7 +76,7 @@ class Repository: return subprocess.check_output( cmd, cwd=self.path, - env=self._env, + env=env, encoding=encoding, ) except subprocess.CalledProcessError as e: diff --git a/python/mozversioncontrol/mozversioncontrol/repo/jj.py b/python/mozversioncontrol/mozversioncontrol/repo/jj.py new file mode 100644 index 000000000000..40c10867ad7d --- /dev/null +++ b/python/mozversioncontrol/mozversioncontrol/repo/jj.py @@ -0,0 +1,346 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this, +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import string +import subprocess +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Union + +from mozpack.files import FileListFinder + +from mozversioncontrol.errors import ( + CannotDeleteFromRootOfRepositoryException, + MissingVCSExtension, + MissingVCSInfo, +) +from mozversioncontrol.repo.base import Repository +from mozversioncontrol.repo.git import GitRepository + + +class JujutsuRepository(Repository): + """An implementation of `Repository` for JJ repositories using the git backend.""" + + def __init__(self, path: Path, jj="jj", git="git"): + super(JujutsuRepository, self).__init__(path, tool=jj) + self._git = GitRepository(path, git=git) + + # Find git root. Newer jj has `jj git root`, but this should support + # older versions for now. + out = self._run("root") + if not out: + raise MissingVCSInfo("cannot find jj workspace root") + + try: + jj_ws_root = Path(out.rstrip()) + jj_repo = jj_ws_root / ".jj" / "repo" + if not jj_repo.is_dir(): + jj_repo = Path(jj_repo.read_text()) + except Exception: + raise MissingVCSInfo("cannot find jj repo") + + try: + git_target = jj_repo / "store" / "git_target" + git_dir = git_target.parent / Path(git_target.read_text()) + except Exception: + raise MissingVCSInfo("cannot find git dir") + + if not git_dir.is_dir(): + raise MissingVCSInfo("cannot find git dir") + + self._git._env["GIT_DIR"] = str(git_dir.resolve()) + + def resolve_to_change(self, revset: str) -> Optional[str]: + change_id = self._run( + "log", "--no-graph", "-n1", "-r", revset, "-T", "change_id.short()" + ).rstrip() + return change_id if change_id != "" else None + + @property + def name(self): + return "jj" + + @property + def head_ref(self): + # This is not really a defined concept in jj. Map it to @, or rather the + # persistent change id for the current @. Warning: this cannot be passed + # directly to a git command, it must be converted to a commit id first + # (eg via convert_change_to_commit). This isn't done here because + # callers should be aware when they're dropping down to git semantics. + return self.resolve_to_change("@") + + @property + def base_ref(self): + ref = self.resolve_to_change("latest(roots(::@ & mutable())-)") + return ref if ref else self.head_ref + + def convert_change_to_commit(self, change_id): + commit = self._run( + "log", "--no-graph", "-r", f"latest({change_id})", "-T", "commit_id" + ).rstrip() + return commit + + def base_ref_as_hg(self): + base_ref = self.convert_change_to_commit(self.base_ref) + try: + return self._git._run("cinnabar", "git2hg", base_ref).strip() + except subprocess.CalledProcessError: + return + + @property + def branch(self): + # jj does not have an "active branch" concept. Invent something similar, + # the latest bookmark in the descendants of @. + bookmark = self._run( + "log", "--no-graph", "-r", "latest(::@ & bookmarks())", "-T", "bookmarks" + ) + return bookmark or None + + @property + def has_git_cinnabar(self): + return self._git.has_git_cinnabar + + def get_commit_time(self): + return int( + self._run( + "log", "-n1", "--no-graph", "-T", 'committer.timestamp().format("%s")' + ).strip() + ) + + def sparse_checkout_present(self): + return self._run("sparse", "list").rstrip() != "." + + def get_user_email(self): + email = self._run("config", "get", "user.email", return_codes=[0, 1]) + if not email: + return None + return email.strip() + + def get_changed_files(self, diff_filter="ADM", mode="(ignored)", rev="@"): + assert all(f.lower() in self._valid_diff_filter for f in diff_filter) + + out = self._run( + "log", + "-r", + rev, + "--no-graph", + "-T", + 'diff.files().map(|f| surround("", "\n", separate("\t", f.status(), f.source().path(), f.target().path()))).join("")', + ) + changed = [] + for line in out.splitlines(): + op, source, target = line.split("\t") + if op == "modified": + if "M" in diff_filter: + changed.append(source) + elif op == "added": + if "A" in diff_filter: + changed.append(source) + elif op == "removed": + if "D" in diff_filter: + changed.append(source) + elif op == "copied": + if "A" in diff_filter: + changed.append(target) + elif op == "renamed": + if "A" in diff_filter: + changed.append(target) + if "D" in diff_filter: + changed.append(source) + else: + raise Exception(f"unexpected jj file status '{op}'") + + return changed + + def get_outgoing_files(self, diff_filter="ADM", upstream=None): + assert all(f.lower() in self._valid_diff_filter for f in diff_filter) + + if upstream is None: + upstream = self.base_ref + + lines = self._run( + "diff", + "--from", + upstream, + "--to", + "@", + "--summary", + ).splitlines() + + outgoing = [] + for line in lines: + op, file = line.split(" ", 1) + if op.upper() in diff_filter: + outgoing.append(file) + return outgoing + + def add_remove_files(self, *paths: Union[str, Path]): + if not paths: + return + + paths = [str(path) for path in paths] + + self._run("file", "track", *paths) + + def forget_add_remove_files(self, *paths: Union[str, Path]): + if not paths: + return + + paths = [str(path) for path in paths] + + self._run("file", "untrack", *paths) + + def get_tracked_files_finder(self, path=None): + return FileListFinder(self._run("file", "list").splitlines()) + + def get_ignored_files_finder(self): + raise Exception("unimplemented") + + def working_directory_clean(self, untracked=False, ignored=False): + # Working directory is in the top commit. + return True + + def update(self, ref): + self._run("new", ref) + + def edit(self, ref): + self._run("edit", ref) + + def clean_directory(self, path: Union[str, Path]): + if Path(self.path).samefile(path): + raise CannotDeleteFromRootOfRepositoryException() + + self._run("restore", "-r", "@-", str(path)) + + def commit(self, message, author=None, date=None, paths=None): + run_kwargs = {} + cmd = ["commit", "-m", message] + if author: + cmd += ["--author", author] + if date: + dt = datetime.strptime(date, "%Y-%m-%d %H:%M:%S %z") + run_kwargs["env"] = {"JJ_TIMESTAMP": dt.isoformat()} + if paths: + cmd.extend(paths) + self._run(*cmd, **run_kwargs) + + def push_to_try( + self, + message: str, + changed_files: Dict[str, str] = {}, + allow_log_capture: bool = False, + ): + if not self.has_git_cinnabar: + raise MissingVCSExtension("cinnabar") + + with self.try_commit(message, changed_files) as head: + self._run("git", "remote", "remove", "mach_tryserver", return_codes=[0, 1]) + # `jj git remote add` would barf on the cinnabar syntax here. + self._git._run( + "remote", "add", "mach_tryserver", "hg::ssh://hg.mozilla.org/try" + ) + self._run("git", "import") + cmd = ( + str(self._tool), + "git", + "push", + "--remote", + "mach_tryserver", + "--change", + head, + "--allow-new", + "--allow-empty-description", + ) + if allow_log_capture: + self._push_to_try_with_log_capture( + cmd, + { + "stdout": subprocess.PIPE, + "stderr": subprocess.STDOUT, + "cwd": self.path, + "universal_newlines": True, + "bufsize": 1, + }, + ) + else: + subprocess.check_call(cmd, cwd=self.path) + self._run("git", "remote", "remove", "mach_tryserver", return_codes=[0, 1]) + + def set_config(self, name, value): + self._run("config", name, value) + + def get_branch_nodes(self, head: Optional[str] = "@") -> List[str]: + """Return a list of commit SHAs for nodes on the current branch, in order that they should be applied.""" + # Note: lando gets grumpy if you try to push empty commits. + return list( + reversed( + self._run( + "log", + "--no-graph", + "-r", + f"(::{head} & mutable()) ~ empty()", + "-T", + 'commit_id ++ "\n"', + ).splitlines() + ) + ) + + def looks_like_change_id(self, id): + return len(id) > 0 and all(letter >= "k" and letter <= "z" for letter in id) + + def looks_like_commit_id(self, id): + return len(id) > 0 and all(letter in string.hexdigits for letter in id) + + def get_commit_patches(self, nodes: List[str]) -> List[bytes]: + """Return the contents of the patch `node` in the git standard format.""" + # Warning: tests, at least, may call this with change ids rather than + # commit ids. + nodes = [ + id if self.looks_like_commit_id(id) else self.convert_change_to_commit(id) + for id in nodes + ] + return [ + self._git._run( + "format-patch", node, "-1", "--always", "--stdout", encoding=None + ) + for node in nodes + ] + + @contextmanager + def try_commit( + self, commit_message: str, changed_files: Optional[Dict[str, str]] = None + ): + """Create a temporary try commit as a context manager. + + Create a new commit using `commit_message` as the commit message. The commit + may be empty, for example when only including try syntax. + + `changed_files` may contain a dict of file paths and their contents, + see `stage_changes`. + """ + opid = self._run( + "operation", "log", "-n1", "--no-graph", "-T", "id.short(16)" + ).rstrip() + try: + self._run("new", "-m", commit_message, "latest((@ | @-) ~ empty())") + for path, content in (changed_files or {}).items(): + p = self.path / Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + yield self.resolve_to_change("@") + finally: + self._run("operation", "restore", opid) + + def get_last_modified_time_for_file(self, path: Path) -> datetime: + """Return last modified in VCS time for the specified file.""" + date = self._run( + "log", + "--no-graph", + "-n1", + "-T", + "committer.timestamp()", + str(path), + ).rstrip() + return datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f %z") diff --git a/python/mozversioncontrol/test/conftest.py b/python/mozversioncontrol/test/conftest.py index 78e5ad7ca809..799299d58ca0 100644 --- a/python/mozversioncontrol/test/conftest.py +++ b/python/mozversioncontrol/test/conftest.py @@ -6,9 +6,13 @@ import os import shutil import subprocess from pathlib import Path +from typing import List import pytest +# Execute the first element in each list of steps within a `repo` directory, +# then copy the whole directory to a `remoterepo`, and finally execute the +# second element on just `repo`. SETUP = { "hg": [ """ @@ -40,11 +44,32 @@ SETUP = { git branch -u upstream/master """, ], + "jj": [ + """ + echo "foo" > foo + echo "bar" > bar + git init + git config user.name "Testing McTesterson" + git config user.email "" + git add * + git commit -am "Initial commit" + """, + """ + # Pass in user name/email via env vars because the initial commit + # will use them before we have a chance to configure them. + JJ_USER="Testing McTesterson" JJ_EMAIL="test@example.org" jj git init --colocate + jj config set --repo user.name "Testing McTesterson" + jj config set --repo user.email "test@example.org" + jj git remote add upstream ../remoterepo + jj git fetch --remote upstream + jj bookmark track master@upstream + """, + ], } class RepoTestFixture: - def __init__(self, repo_dir: Path, vcs: str, steps: [str]): + def __init__(self, repo_dir: Path, vcs: str, steps: List[str]): self.dir = repo_dir self.vcs = vcs @@ -61,8 +86,16 @@ def shell(cmd, working_dir): subprocess.check_call(step, shell=True, cwd=working_dir) -@pytest.fixture(params=["git", "hg"]) +@pytest.fixture(params=["git", "hg", "jj"]) def repo(tmpdir, request): + if request.param == "jj": + if os.getenv("MOZ_AVOID_JJ_VCS") not in (None, "0", ""): + pytest.skip("jj support disabled") + try: + subprocess.call(["jj", "--version"], stdout=subprocess.DEVNULL) + except OSError: + pytest.skip("jj unavailable") + tmpdir = Path(tmpdir) vcs = request.param steps = SETUP[vcs] diff --git a/python/mozversioncontrol/test/test_branch.py b/python/mozversioncontrol/test/test_branch.py index 428d9f9cec00..ca62727ba9ce 100644 --- a/python/mozversioncontrol/test/test_branch.py +++ b/python/mozversioncontrol/test/test_branch.py @@ -25,12 +25,22 @@ STEPS = { git commit -a -m "second commit" """, ], + "jj": [ + """ + jj bookmark set test + """, + """ + jj new -m "xyzzy" zzzzzzzz + jj new -m "second commit" test + echo "bar" > foo + """, + ], } def test_branch(repo): vcs = get_repository_object(repo.dir) - if vcs.name == "git": + if vcs.name in ("git", "jj"): assert vcs.branch == "master" else: assert vcs.branch is None @@ -42,11 +52,23 @@ def test_branch(repo): assert vcs.branch == "test" vcs.update(vcs.head_ref) - assert vcs.branch is None + if repo.vcs == "jj": + # jj "branches" do not auto-advance (in our JujutsuRepository + # implementation, anyway), so this is the rev marked as the root of the + # test branch. + assert vcs.branch == "test" + else: + assert vcs.branch is None vcs.update("test") assert vcs.branch == "test" + # for jj only, check that a topological branch with no bookmarks is not + # considered a "branch": + if repo.vcs == "jj": + vcs.update("description('xyzzy')") + assert vcs.branch is None + if __name__ == "__main__": mozunit.main() diff --git a/python/mozversioncontrol/test/test_commit.py b/python/mozversioncontrol/test/test_commit.py index a4cbfeb0ec9e..7e76fc59c17e 100644 --- a/python/mozversioncontrol/test/test_commit.py +++ b/python/mozversioncontrol/test/test_commit.py @@ -11,12 +11,25 @@ from mozversioncontrol import get_repository_object STEPS = { "hg": [ + "", """ echo "bar" >> bar echo "baz" > foo """, ], "git": [ + "", + """ + echo "bar" >> bar + echo "baz" > foo + """, + ], + "jj": [ + """ + jj describe -m 'Ignore file for testing' + echo foo > .gitignore + jj new + """, """ echo "bar" >> bar echo "baz" > foo @@ -29,9 +42,14 @@ def test_commit(repo): vcs = get_repository_object(repo.dir) assert vcs.working_directory_clean() + # Setup step for jj to allow untracked changes. + repo.execute_next_step() + # Modify both foo and bar repo.execute_next_step() - assert not vcs.working_directory_clean() + if repo.vcs != "jj": + # jj never has a dirty working directory. + assert not vcs.working_directory_clean() date_string = "2017-07-14 02:40:00 +0000" @@ -48,13 +66,18 @@ def test_commit(repo): assert original_date == date_from_vcs - # We only committed bar, so foo is still keeping the working dir dirty - assert not vcs.working_directory_clean() + # We only committed bar, so foo is still keeping the working dir dirty. jj + # always treats the working directory as clean, because the top commit holds + # any changes in it. + if repo.vcs == "jj": + assert vcs.working_directory_clean() + else: + assert not vcs.working_directory_clean() if repo.vcs == "git": log_cmd = ["log", "-1", "--format=%an,%ae,%aD,%B"] patch_cmd = ["log", "-1", "-p"] - else: + elif repo.vcs == "hg": log_cmd = [ "log", "-l", @@ -63,8 +86,18 @@ def test_commit(repo): "{person(author)},{email(author)},{date|rfc822date},{desc}", ] patch_cmd = ["log", "-l", "1", "-p"] + elif repo.vcs == "jj": + log_cmd = [ + "log", + "-n1", + "--no-graph", + "-r@-", + "-T", + 'separate(",", author.name(), author.email(), commit_timestamp(self).format("%a, %d %b %Y %H:%M:%S %z"), description)', + ] + patch_cmd = ["show", "@-"] - # Verify commit metadata (we rstrip to normalize trivial git/hg differences) + # Verify commit metadata (we rstrip to normalize trivial differences) log = vcs._run(*log_cmd).rstrip() assert log == ( "Testing McTesterson,test@example.org,Fri, 14 " diff --git a/python/mozversioncontrol/test/test_context_manager.py b/python/mozversioncontrol/test/test_context_manager.py index 3186a144d924..b34bd497e6e1 100644 --- a/python/mozversioncontrol/test/test_context_manager.py +++ b/python/mozversioncontrol/test/test_context_manager.py @@ -8,19 +8,25 @@ from mozversioncontrol import get_repository_object def test_context_manager(repo): - is_git = repo.vcs == "git" - cmd = ["show", "--no-patch"] if is_git else ["tip"] + cmd = { + "git": ["show", "--no-patch"], + "hg": ["tip"], + "jj": ["show", "@-"], + }[repo.vcs] vcs = get_repository_object(repo.dir) output_subprocess = vcs._run(*cmd) - assert is_git or vcs._client.server is None + if repo.vcs == "hg": + assert vcs._client.server is None assert "Initial commit" in output_subprocess with vcs: - assert is_git or vcs._client.server is not None + if repo.vcs == "hg": + assert vcs._client.server is not None output_client = vcs._run(*cmd) - assert is_git or vcs._client.server is None + if repo.vcs == "hg": + assert vcs._client.server is None assert output_subprocess == output_client diff --git a/python/mozversioncontrol/test/test_get_branch_nodes.py b/python/mozversioncontrol/test/test_get_branch_nodes.py index 898f1912c50c..710e3e60c206 100644 --- a/python/mozversioncontrol/test/test_get_branch_nodes.py +++ b/python/mozversioncontrol/test/test_get_branch_nodes.py @@ -26,6 +26,14 @@ STEPS = { git commit -m "commit 2" """ ], + "jj": [ + """ + jj new -m "commit 1" + echo bar >> bar + jj commit -m "commit 2" + echo baz > baz + """ + ], } diff --git a/python/mozversioncontrol/test/test_get_commit_patches.py b/python/mozversioncontrol/test/test_get_commit_patches.py index de7b934835a8..cde16886bfb0 100644 --- a/python/mozversioncontrol/test/test_get_commit_patches.py +++ b/python/mozversioncontrol/test/test_get_commit_patches.py @@ -30,6 +30,16 @@ STEPS = { git commit -m "SECOND PATCH" """, ], + "jj": [ + """ + jj new -m "FIRST PATCH" + echo bar >> bar + """, + """ + jj new -m "SECOND PATCH" + printf "baz\\r\\nqux" > baz + """, + ], } diff --git a/python/mozversioncontrol/test/test_get_mozilla_remote_args.py b/python/mozversioncontrol/test/test_get_mozilla_remote_args.py index 0d6872b64259..5b71283d2e46 100644 --- a/python/mozversioncontrol/test/test_get_mozilla_remote_args.py +++ b/python/mozversioncontrol/test/test_get_mozilla_remote_args.py @@ -16,10 +16,11 @@ STEPS = { git remote add try hg::https://hg.mozilla.org/try """, ], + "jj": [], } -def test_get_upstream_remotes(repo): +def test_get_mozilla_remote_args(repo): # Test is only relevant for Git. if not repo.vcs == "git": return diff --git a/python/mozversioncontrol/test/test_get_upstream_remotes.py b/python/mozversioncontrol/test/test_get_upstream_remotes.py index d1a4727ec306..d215e6483d07 100644 --- a/python/mozversioncontrol/test/test_get_upstream_remotes.py +++ b/python/mozversioncontrol/test/test_get_upstream_remotes.py @@ -16,6 +16,7 @@ STEPS = { git remote add try hg::https://hg.mozilla.org/try """, ], + "jj": [], } diff --git a/python/mozversioncontrol/test/test_push_to_try.py b/python/mozversioncontrol/test/test_push_to_try.py index 6eac9da35865..95070a1754a6 100644 --- a/python/mozversioncontrol/test/test_push_to_try.py +++ b/python/mozversioncontrol/test/test_push_to_try.py @@ -77,7 +77,7 @@ def test_push_to_try(repo, monkeypatch): (str(tool), "revert", "-a"), ] expected_inputs = [] - else: + elif repo.vcs == "git": expected = [ (str(tool), "cinnabar", "--version"), (str(tool), "rev-parse", "HEAD"), @@ -133,6 +133,46 @@ def test_push_to_try(repo, monkeypatch): """ ), ] + else: + assert repo.vcs == "jj" + expected = [ + (str(vcs._git._tool), "cinnabar", "--version"), + (str(tool), "operation", "log", "-n1", "--no-graph", "-T", "id.short(16)"), + (str(tool), "new", "-m", "commit message", "latest((@ | @-) ~ empty())"), + ( + str(tool), + "log", + "--no-graph", + "-n1", + "-r", + "@", + "-T", + "change_id.short()", + ), + (str(tool), "git", "remote", "remove", "mach_tryserver"), + ( + str(vcs._git._tool), + "remote", + "add", + "mach_tryserver", + "hg::ssh://hg.mozilla.org/try", + ), + (str(tool), "git", "import"), + ( + str(tool), + "git", + "push", + "--remote", + "mach_tryserver", + "--change", + None, + "--allow-new", + "--allow-empty-description", + ), + (str(tool), "operation", "restore", ""), + (str(tool), "git", "remote", "remove", "mach_tryserver"), + ] + expected_inputs = [] for i, value in enumerate(captured_commands): assert value == expected[i] @@ -146,7 +186,7 @@ def test_push_to_try(repo, monkeypatch): def test_push_to_try_missing_extensions(repo, monkeypatch): - if repo.vcs != "git": + if repo.vcs not in ("git", "jj"): return vcs = get_repository_object(repo.dir) @@ -160,6 +200,8 @@ def test_push_to_try_missing_extensions(repo, monkeypatch): return orig(*args, **kwargs) monkeypatch.setattr(vcs, "_run", cinnabar_raises) + if hasattr(vcs, "_git"): + monkeypatch.setattr(vcs._git, "_run", cinnabar_raises) with pytest.raises(MissingVCSExtension): vcs.push_to_try("commit message") diff --git a/python/mozversioncontrol/test/test_try_commit.py b/python/mozversioncontrol/test/test_try_commit.py index 1ac57b3319e7..3170aa8c7dac 100644 --- a/python/mozversioncontrol/test/test_try_commit.py +++ b/python/mozversioncontrol/test/test_try_commit.py @@ -6,17 +6,20 @@ import mozunit import pytest from mozversioncontrol import get_repository_object +from mozversioncontrol.errors import MissingVCSExtension -@pytest.mark.xfail(reason="Requires the Mercurial evolve extension.", strict=False) def test_try_commit(repo): commit_message = "try commit message" vcs = get_repository_object(repo.dir) initial_head_ref = vcs.head_ref # Create a non-empty commit. - with vcs.try_commit(commit_message, {"try_task_config.json": "{}"}) as head: - assert vcs.get_changed_files(rev=head) == ["try_task_config.json"] + try: + with vcs.try_commit(commit_message, {"try_task_config.json": "{}"}) as head: + assert vcs.get_changed_files(rev=head) == ["try_task_config.json"] + except MissingVCSExtension: + pytest.xfail("Requires the Mercurial evolve extension.") assert ( vcs.head_ref == initial_head_ref diff --git a/python/mozversioncontrol/test/test_update.py b/python/mozversioncontrol/test/test_update.py index 91c7469ee530..53f7fc7f0a65 100644 --- a/python/mozversioncontrol/test/test_update.py +++ b/python/mozversioncontrol/test/test_update.py @@ -31,6 +31,16 @@ STEPS = { echo "foobar" > foo """, ], + "jj": [ + """ + echo "bar" >> bar + echo "baz" > foo + jj commit -m "second commit" + """, + """ + echo "foobar" > foo + """, + ], } @@ -38,26 +48,36 @@ def test_update(repo): vcs = get_repository_object(repo.dir) rev0 = vcs.head_ref + # Create a commit with modified `foo` and `bar`. repo.execute_next_step() rev1 = vcs.head_ref assert rev0 != rev1 if repo.vcs == "hg": vcs.update(".~1") - else: + elif repo.vcs == "git": vcs.update("HEAD~1") + elif repo.vcs == "jj": + vcs.edit("@-") assert vcs.head_ref == rev0 - vcs.update(rev1) - assert vcs.head_ref == rev1 - - # Update should fail with dirty working directory. - repo.execute_next_step() - with pytest.raises(CalledProcessError): + if repo.vcs != "jj": + vcs.update(rev1) + else: vcs.update(rev0) - + rev1 = vcs.head_ref assert vcs.head_ref == rev1 + # Modify `foo` and update. Should fail with dirty working directory. + repo.execute_next_step() + if repo.vcs != "jj": + with pytest.raises(CalledProcessError): + vcs.update(rev0) + assert vcs.head_ref == rev1 + else: + # jj doesn't have a "dirty working directory". + pass + if __name__ == "__main__": mozunit.main() diff --git a/python/mozversioncontrol/test/test_workdir_outgoing.py b/python/mozversioncontrol/test/test_workdir_outgoing.py index 7bf2e6ec5766..ce28cd8de721 100644 --- a/python/mozversioncontrol/test/test_workdir_outgoing.py +++ b/python/mozversioncontrol/test/test_workdir_outgoing.py @@ -47,6 +47,23 @@ STEPS = { git commit -m "Modify baz; add baby" """, ], + "jj": [ + """ + echo "bar" >> bar + echo "baz" > baz + rm foo + """, + """ + jj commit -m "Remove foo; modify bar; add baz" + """, + """ + echo ooka >> baz + echo newborn > baby + """, + """ + jj describe -m "Modify baz; add baby" + """, + ], } @@ -58,7 +75,11 @@ def test_workdir_outgoing(repo): vcs = get_repository_object(repo.dir) assert vcs.path == str(repo.dir) - remote_path = "../remoterepo" if repo.vcs == "hg" else "upstream/master" + remote_path = { + "hg": "../remoterepo", + "git": "upstream/master", + "jj": "master@upstream", + }[repo.vcs] # Mutate files. repo.execute_next_step() @@ -68,11 +89,14 @@ def test_workdir_outgoing(repo): assert_files(vcs.get_changed_files("D", "all"), ["foo"]) if repo.vcs == "git": assert_files(vcs.get_changed_files("AM", mode="staged"), ["baz"]) - elif repo.vcs == "hg": - # Mercurial does not use a staging area (and ignores the mode parameter.) + else: + # Mercurial and jj do not use a staging area (and ignore the mode + # parameter.) assert_files(vcs.get_changed_files("AM", "unstaged"), ["bar", "baz"]) - assert_files(vcs.get_outgoing_files("AMD"), []) - assert_files(vcs.get_outgoing_files("AMD", remote_path), []) + if repo.vcs != "jj": + # Everything is already committed in jj, and therefore outgoing. + assert_files(vcs.get_outgoing_files("AMD"), []) + assert_files(vcs.get_outgoing_files("AMD", remote_path), []) # Create a commit. repo.execute_next_step() @@ -97,11 +121,22 @@ def test_workdir_outgoing(repo): if repo.vcs == "git": assert_files(vcs.get_changed_files("AM", rev="HEAD~1"), ["bar", "baz"]) assert_files(vcs.get_changed_files("AM", rev="HEAD"), ["baby", "baz"]) - else: + elif repo.vcs == "hg": assert_files(vcs.get_changed_files("AM", rev=".^"), ["bar", "baz"]) assert_files(vcs.get_changed_files("AM", rev="."), ["baby", "baz"]) assert_files(vcs.get_changed_files("AM", rev=".^::"), ["bar", "baz", "baby"]) assert_files(vcs.get_changed_files("AM", rev="modifies(baz)"), ["baz", "baby"]) + elif repo.vcs == "jj": + assert_files(vcs.get_changed_files("AM", rev="@-"), ["bar", "baz"]) + assert_files(vcs.get_changed_files("AM", rev="@"), ["baby", "baz"]) + assert_files(vcs.get_changed_files("AM", rev="@-::"), ["bar", "baz", "baby"]) + # Currently no direct equivalent of `modifies(baz)`. `files(baz)` will + # also select changes that added or deleted baz, and the diff_filter + # will applied be too late. + assert_files( + vcs.get_changed_files("AMD", rev="files(baz)"), + ["foo", "baz", "baby", "bar"], + ) if __name__ == "__main__": diff --git a/python/mozversioncontrol/test/test_working_directory.py b/python/mozversioncontrol/test/test_working_directory.py index 00094a0cc452..6314fc71736b 100644 --- a/python/mozversioncontrol/test/test_working_directory.py +++ b/python/mozversioncontrol/test/test_working_directory.py @@ -27,10 +27,14 @@ STEPS = { git commit -am "Remove foo; modify bar; touch baz (but don't add it)" """, ], + "jj": [], } def test_working_directory_clean_untracked_files(repo): + if repo.vcs == "jj": + return + vcs = get_repository_object(repo.dir) assert vcs.working_directory_clean() diff --git a/tools/tryselect/lando.py b/tools/tryselect/lando.py index 0ead68c7f9cd..a3fae1d4132c 100644 --- a/tools/tryselect/lando.py +++ b/tools/tryselect/lando.py @@ -33,6 +33,7 @@ from mozbuild.base import MozbuildObject from mozversioncontrol import ( GitRepository, HgRepository, + JujutsuRepository, ) TOKEN_FILE = ( @@ -40,7 +41,7 @@ TOKEN_FILE = ( ) # The supported variants of `Repository` for this workflow. -SupportedVcsRepository = Union[GitRepository, HgRepository] +SupportedVcsRepository = Union[GitRepository, HgRepository, JujutsuRepository] here = os.path.abspath(os.path.dirname(__file__)) build = MozbuildObject.from_environment(cwd=here) @@ -401,6 +402,7 @@ def push_to_lando_try( PATCH_FORMAT_STRING_MAPPING = { GitRepository: "git-format-patch", HgRepository: "hgexport", + JujutsuRepository: "git-format-patch", } patch_format = PATCH_FORMAT_STRING_MAPPING.get(type(vcs)) if not patch_format: