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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,11 +35,41 @@ def get_repository_object(
|
||||
path = Path(path).resolve()
|
||||
if (path / ".hg").is_dir():
|
||||
return HgRepository(path, hg=hg)
|
||||
elif (path / ".git").exists():
|
||||
return GitRepository(path, git=git)
|
||||
elif (path / "config" / "milestone.txt").exists():
|
||||
return SrcRepository(path, src=src)
|
||||
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)
|
||||
if (path / "config" / "milestone.txt").exists():
|
||||
return SrcRepository(path, src=src)
|
||||
raise InvalidRepoPath(f"Unknown VCS, or not a source checkout: {path}")
|
||||
|
||||
|
||||
@@ -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()}"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
346
python/mozversioncontrol/mozversioncontrol/repo/jj.py
Normal file
346
python/mozversioncontrol/mozversioncontrol/repo/jj.py
Normal file
@@ -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")
|
||||
@@ -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 "<test@example.org>"
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
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()
|
||||
|
||||
@@ -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,8 +42,13 @@ 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()
|
||||
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
|
||||
# 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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
""",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ STEPS = {
|
||||
git remote add try hg::https://hg.mozilla.org/try
|
||||
""",
|
||||
],
|
||||
"jj": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
@@ -31,6 +31,16 @@ STEPS = {
|
||||
echo "foobar" > foo
|
||||
""",
|
||||
],
|
||||
"jj": [
|
||||
"""
|
||||
echo "bar" >> bar
|
||||
echo "baz" > foo
|
||||
jj commit -m "second commit"
|
||||
""",
|
||||
"""
|
||||
echo "foobar" > foo
|
||||
""",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -38,25 +48,35 @@ 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
|
||||
|
||||
if repo.vcs != "jj":
|
||||
vcs.update(rev1)
|
||||
else:
|
||||
vcs.update(rev0)
|
||||
rev1 = vcs.head_ref
|
||||
assert vcs.head_ref == rev1
|
||||
|
||||
# Update should fail with dirty working directory.
|
||||
# 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__":
|
||||
|
||||
@@ -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,9 +89,12 @@ 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"])
|
||||
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), [])
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user