Merge "Use java.util.concurrent.locks.Lock for custom H2 locking"
diff --git a/.agents/rules/upload_rationale.md b/.agents/rules/upload_rationale.md
index f7a13a4..ebd3605 100644
--- a/.agents/rules/upload_rationale.md
+++ b/.agents/rules/upload_rationale.md
@@ -6,13 +6,6 @@
## Gerrit Rationale Upload Policy
Whenever you create a new Gerrit change or upload a patchset (via `git push`,
-`hg upload`, `g4 upload`, or any other VCS command), you **MUST** immediately
-upload AI rationales for that patchset.
-
-Follow this two-step workflow:
-
-1. **Push Code**: Execute your VCS push/upload command so the new patchset is
- created on Gerrit.
-2. **Upload Rationale**: Read the `gerrit` skill (specifically the
- `upload-rationale` section) and follow its instructions to construct and
- upload the complete set of rationales for the patchset.
+`hg upload`, `g4 upload`, or any other VCS command), check the `gerrit` skill
+(specifically the `upload-rationale` section) to determine whether an AI
+rationale should be uploaded, and follow its instructions.
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg=
index 23457d5..c9237ac 100755
--- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg=
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg=
@@ -1,7 +1,7 @@
# @generated
# Input hashes for repository rule npm_translate_lock(name = "plugins_npm", pnpm_lock = "@@//plugins:pnpm-lock.yaml").
# This file should be checked into version control along with the pnpm-lock.yaml file.
-plugins/package.json=1633560207
-plugins/pnpm-lock.yaml=-77521523
+plugins/package.json=-603960899
+plugins/pnpm-lock.yaml=-1898375944
plugins/pnpm-workspace.yaml=-408937369
-plugins/yarn.lock=-2081175573
+plugins/yarn.lock=-1311799368
diff --git a/.bazelignore b/.bazelignore
index 13bcfb8..aca15c1 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,4 +1,5 @@
eclipse-out
+modules/gitiles
modules/jgit
node_modules
polygerrit-ui/node_modules
diff --git a/.gitmodules b/.gitmodules
index 2170834..40d1489 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -61,3 +61,7 @@
url = ../plugins/webhooks
branch = .
+[submodule "modules/gitiles"]
+ path = modules/gitiles
+ url = ../gitiles
+ branch = .
diff --git a/.zuul.yaml b/.zuul.yaml
index 2979c1f..b6f2c1b5 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -7,6 +7,7 @@
This adds required projects needed for all Gerrit-related builds
(i.e., builds of Gerrit itself or plugins) on this branch.
required-projects:
+ - gitiles
- java-prettify
- jgit
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index e65a367..ed9fcd8 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -265,6 +265,16 @@
`^refs/heads/[a-z]{1,8}` matches all lower case branch names
between 1 and 8 characters long. Within a regular expression `.`
is a wildcard matching any character, but may be escaped as `\.`.
+Because access section names are stored as git config subsection
+names, a literal backslash in the subsection header must itself be
+escaped as `\\`. This means the regex `\.` must be written as `\\.`
+in `project.config`:
++
+----
+[access "^refs/heads/.*foo\\.bar"]
+ read = group Developers
+----
++
The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
is used for evaluation of regular expression access control
rules. See the library documentation for details on this
@@ -455,6 +465,15 @@
upon which we build the code review intercept before submitting a commit to
the branch it's uploaded to.
+For review pushes, `refs/for/<branch-name>` is shorthand for the destination
+branch `refs/for/refs/heads/<branch-name>`. For example, pushing to
+`refs/for/master` uploads a change for review to `refs/heads/master`.
+
+Access-control section names are evaluated as written and are not normalized
+using this shorthand. To grant, deny, or block review uploads to `master`,
+configure the permission on `refs/for/refs/heads/master`; to configure all
+normal branches, use `refs/for/refs/heads/*`.
+
Further documentation on how to push can be found on the
link:user-upload.html#push_create[Upload changes] page.
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 36c9077..9b3712d 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2973,6 +2973,22 @@
primaryWeblinkName = gitiles
----
+[[gerrit.submitCommitUrl]]gerrit.submitCommitUrl::
++
+URL used to link commit hashes in change messages generated when a change is
+submitted or cherry-picked. The URL must use the `http` or `https` scheme.
+The optional `${commit}` placeholder is replaced with the commit hash. If the
+placeholder is omitted, the commit hash is appended as a path segment.
++
+By default unset. In this case Gerrit derives the URL from the revision's
+configured code browser weblinks when possible.
++
+Example:
+----
+[gerrit]
+ submitCommitUrl = https://chromiumdash.appspot.com/commit/${commit}
+----
+
[[gerrit.reportBugUrl]]gerrit.reportBugUrl::
+
URL to direct users to when they need to report a bug.
@@ -3675,8 +3691,22 @@
+
Number of worker threads dedicated to accepting new incoming TCP
connections and allocating them connection-specific resources.
+<<httpd.selectorThreads,Selector threads>> handle I/O on accepted connections
+separately.
+
-By default, 2, which should be suitable for most high-traffic sites.
+By default, 0, which causes selector threads to also accept new incoming
+connections. Sites that need dedicated acceptor threads can increase this
+value.
+
+[[httpd.selectorThreads]]httpd.selectorThreads::
++
+Number of NIO selector threads used to dispatch I/O events for existing
+connections. Each selector thread runs a `java.nio.channels.Selector` loop and
+is shared across all open connections.
++
+By default, 2, which should be suitable for most high-traffic sites. When
+<<httpd.acceptorThreads,`httpd.acceptorThreads`>> is 0, selector threads also
+accept new incoming connections.
[[httpd.minThreads]]httpd.minThreads::
+
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 81f9d9f..a5fe0d0 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -649,19 +649,21 @@
[[receive.rejectImplicitMerges]]receive.rejectImplicitMerges::
+
-Controls whether a check for implicit merges will be performed when changes are
-pushed for review. An implicit merge is a case where merging an open change
-would implicitly merge another branch into the target branch. Typically, this
-happens when a change is done on master and, by mistake, pushed to a stable branch
-for review. When submitting such change, master would be implicitly merged into
-stable without anyone noticing that. When this option is set to 'true' Gerrit
-will reject the push if an implicit merge is detected.
+Controls whether a check for implicit merges will be performed when
+changes are pushed for review or submitted. An implicit merge is a case
+where merging an open change would implicitly merge another branch into
+the target branch. Typically, this happens when a change is done on
+master and, by mistake, pushed to a stable branch for review. When
+submitting such change, master would be implicitly merged into stable
+without anyone noticing that. When this option is set to 'true' Gerrit
+will reject the push or submit if an implicit merge is detected.
+
This check is only done for non-merge commits, merge commits are not subject of
the implicit merge check.
+
Default is `INHERIT`, which means that this property is inherited from
-the parent project.
+the parent project. If no project overrides it, the effective default is
+`true`.
[[receive.createNewChangeForAllNotInTarget]]receive.createNewChangeForAllNotInTarget::
+
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
index ffce6c2..c41b709 100644
--- a/Documentation/dev-core-plugins.txt
+++ b/Documentation/dev-core-plugins.txt
@@ -142,8 +142,8 @@
link:access-control.html#category_submit[Submit] or
link:access-control.html#category_review_labels[Code-Review+2]
permissions for non-Gerrit maintainers.
-** Create a component for the plugin in
- link:https://bugs.chromium.org/p/gerrit/adminComponents[Monorail] and assign
+** Have a Googler create a component for the plugin in the
+ link:https://issues.gerritcodereview.com[issue tracker] and assign
all issues that already exist for the plugin to this component.
** Add the plugin as
link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/.gitmodules[Git
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 52fa75a..ab52c66 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -204,24 +204,77 @@
available.
[[report-security-issue]]
-=== How to report a security vulnerability?
+=== How to report a security vulnerability
To report a security vulnerability file a
link:https://issues.gerritcodereview.com/issues/new?component=1371046[
-security issue,role=external,window=_blank] in the Gerrit issue tracker. Issues
+security issue,role=external,window=_blank] in the Gerrit issue tracker, if you
+have permissions to see the component, or send an e-mail to the
+link:#gerrit-maintainers[Gerrit Maintainers]. Issues
in the `Gerrit Code Review > Security` component are restricted to Gerrit
maintainers and a few long-term contributors. The reporter becomes a
collaborator on the issue and hence can see it as well. Security issues are
-triaged by the link:#steering-committee[Engineering Steering Committee].
+triaged by the link:#gerrit-maintainers[Gerrit Maintainers].
If an existing issue is found to be a security vulnerability it should be moved
to `Gerrit Code Review > Security` component (component ID: 1371046).
-In case of doubt, or if an issue cannot wait until the next ESC meeting,
-contact the link:#steering-committee[Engineering Steering Committee] directly
-by sending them an mailto:gerritcodereview-esc@googlegroups.com[email].
+If needed, one of the Gerrit Maintainers will contact the reporter for additional details.
-If needed, the ESC will contact the reporter for additional details.
+[[requirements]]
+=== Requirements for a security issue
+
+* Confidentiality
++
+Keep all vulnerability details strictly confidential between the reporting team and the
+mailto:gerritcodereview-maintainers@googlegroups.com[Gerrit Maintainers].
+Do not enter any related data into external tools, public issue trackers, or public AI analyzers.
+
+* Vulnerability Details
+
+. Expected Behavior
++
+What is the expected behavior? If applicable, please provide links to the corresponding
+Gerrit documentation.
+
+. Observed Behavior
++
+What actually happens? (Note: Pure code analysis without execution evidence is not accepted).
+
+. Reproduction & Environment
++
+** Affected Versions: List all impacted, supported versions and specify whether the issue affects
+ the master branch.
+** Configuration Triggers: Does this require specific configuration settings, or does this occur
+ on a default Gerrit installation?
+
+. Steps to Reproduce
++
+Provide an end-to-end walkthrough covering all impacted endpoints: SSH, REST-API, Git protocol,
+etc. (Note: Pure code analysis without execution steps is not accepted).
+
+. Security Impact
++
+** Data Exposure & Access: Does this leak sensitive information or bypass access protections?
+** Data Integrity: Can an attacker compromise, modify, or destroy private data?
+** System Availability: Can an attacker compromise the overall availability of the system?
+
+. ️Validation & Mitigation
++
+** Admin Validation: How can a Gerrit admin verify if their installation is vulnerable? (e.g., a
+ script, curl command, or log check)
+** Proposed Mitigation: Are there immediate steps admins can take to block the exploit without
+ upgrading? (e.g., reverse proxy rules, disabling features).
+
+* Acceptance Criteria
++
+. The issue has been reported respecting the confidentiality required.
+
+. The report is complete in all details.
+
+[NOTE]
+Incomplete reports may receive low-priority evaluation on a best-effort basis, but reports violating
+confidentiality rules will be rejected immediately.
[[embargo]]
=== The Embargo
@@ -248,26 +301,26 @@
[[handle-security-issue]]
=== Handling of the Security Vulnerability
-. Engineering Steering Committee evaluates the security vulnerability:
+. Gerrit maintainers evaluate the security vulnerability:
+
-The ESC discusses the security vulnerability and which actions should be taken
+The Gerrit maintainers discusses the security vulnerability and which actions should be taken
to address it. One person, usually one of the Gerrit maintainers, should be
appointed to drive and coordinate the investigation and the fix of the security
vulnerability. This coordinator doesn't need to do all the work alone, but is
responsible that the security vulnerability is getting fixed in a timely
manner.
+
-If the security vulnerability affects multiple or older releases the ESC should
+If the security vulnerability affects multiple or older releases the Gerrit maintainers should
decide which of the releases should be fixed. For critical security issue we
also consider fixing old releases that are otherwise not receiving any
bug-fixes anymore.
+
-It's also possible that the ESC decides that an issue is not a security issue
+It's also possible that the Gerrit maintainers decides that an issue is not a security issue
and the embargo is lifted immediately.
. Filing a CVE
+
-For every security issue a CVE that describes the issue and lists the affected
+For the most relevant security issues, a CVE that describes the issue and lists the affected
releases should be filed. Filing a CVE can be done by any maintainer that works
for an organization that can request CVE numbers (e.g. Googlers). The CVE
number must be included in the release notes. The CVE itself is only made
@@ -341,9 +394,9 @@
. Follow-Up
+
-The ESC should discuss if there are any learnings from the security
+The Gerrit maintainers should discuss if there are any learnings from the security
vulnerability and define action items to follow up in the
-link:https://bugs.chromium.org/p/gerrit[issue tracker,role=external,window=_blank].
+link:https://issues.gerritcodereview.com[issue tracker,role=external,window=_blank].
[[core-plugins]]
== Core Plugins
@@ -385,7 +438,7 @@
== Escalation channel to Google
If anything urgent is blocking that requires the attention of a Googler you may
-escalate this by writing an email to Chris Poucet: poucet@google.com
+escalate this by writing an email to Hari Jeyamani: hariprak@google.com.
[[deprecating-features]]
== Deprecating features
diff --git a/Documentation/intro-gerrit-walkthrough-github.txt b/Documentation/intro-gerrit-walkthrough-github.txt
index 173f709..8701c3c 100644
--- a/Documentation/intro-gerrit-walkthrough-github.txt
+++ b/Documentation/intro-gerrit-walkthrough-github.txt
@@ -6,9 +6,9 @@
====
This document aims to provide a concise description of the core principles of
code review in Gerrit for people that were previously using Pull Requests on
-Github or similar concepts. Nothing in this document is meant to state that
+GitHub or similar concepts. Nothing in this document is meant to state that
one or the other might be better, but only aims to help new users understand
-Gerrit more readily. We use Github as the point of comparison since it seems
+Gerrit more readily. We use GitHub as the point of comparison since it seems
to be the most popular service.
====
@@ -123,7 +123,7 @@
Next, you would go and visit your Gerrit change in the Web UI to get your change
ready for review (choose reviewers, cc people, check for failing CI builds or
-tests, etc.), very similar to what you do on Github. Reviewers will be notified
+tests, etc.), very similar to what you do on GitHub. Reviewers will be notified
via email once you add them. By default, anyone can add reviewers to a Gerrit
change. In GitHub, this ability is reserved for certain users, so you may have
relied on others adding reviewers for you before. This can be the case in a
@@ -139,7 +139,7 @@
The dashboard is the central overview of changes going on within a Gerrit
instance. By default, the dashboard shows changes that you are involved in, in
any way. You can also see all changes on a Gerrit server by using the top menu
-(“Changes” -> “Open”). This view is more similar to what you see on Github, when
+(“Changes” -> “Open”). This view is more similar to what you see on GitHub, when
you navigate to the Pull Requests tab of the project/repository you are working
on. Note, however, that a single Gerrit instance can host multiple projects
(also referred to as repositories; a list can be found, for example, https://gerrit-review.googlesource.com/admin/repos[here,role=external,window=_blank]). Your
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index fa3855d..cf4efb2 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -206,7 +206,7 @@
To push a commit for review it must be pushed to
link:access-control.html#refs_for[refs/for/<branch-name>]. This means
the link:access-control.html#category_push_review[Push] access right
-must be assigned on `refs/for/<branch-name>`.
+must be assigned on `refs/for/refs/heads/<branch-name>`.
To allow direct pushes and bypass code review, the
link:access-control.html#category_push_direct[Push] access right is
diff --git a/Documentation/release_war_jars.txt b/Documentation/release_war_jars.txt
index 099aae1..a53613d 100644
--- a/Documentation/release_war_jars.txt
+++ b/Documentation/release_war_jars.txt
@@ -14,7 +14,6 @@
bcpkix-jdk18on
bcprov-jdk18on
bcutil-jdk18on
-blame-cache
caffeine
caffeine-guava
commons-codec
@@ -63,6 +62,7 @@
jgit
jsoup
jsr305
+libcache
lucene-analysis-common
lucene-backward-codecs
lucene-core
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 637318c..fc5db7c5 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -563,7 +563,7 @@
.Response
----
- HTTP/1.1 200 OK
+ HTTP/1.1 201 Created
Content-Disposition: attachment
Content-Type: application/json; charset=UTF-8
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index fd58ed4..0e3f0c8 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -4697,7 +4697,8 @@
signed push validation is required on the project.
|`reject_implicit_merges`|optional|
link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
-implicit merges should be rejected on changes pushed to the project.
+implicit merges should be rejected on changes pushed to or submitted in
+the project.
|`private_by_default` ||
link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
all new changes are set as private by default.
@@ -4830,8 +4831,8 @@
This property is deprecated and will be removed in
a future release.
|`reject_implicit_merges` |optional|
-Whether a check for implicit merges will be performed when changes
-are pushed for review. +
+Whether a check for implicit merges will be performed when changes are
+pushed for review or submitted. +
Can be `TRUE`, `FALSE` or `INHERIT`. +
If not set, this setting is not updated.
|`max_object_size_limit` |optional|
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index fadbad9..6270db0 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -205,7 +205,7 @@
True if the number of reviewers satisfies the given relation
for the given number of reviewers.
+
-For example, reviewers:>2 will be true for any change which has at least
+For example, reviewercount:>2 will be true for any change which has at least
3 reviewers.
+
Valid relations are >=, >, \<=, <, or no relation, which will match if the
@@ -413,6 +413,19 @@
* `-path:^path/.*` - changes that do not modify files from `path/`.
+[[onlypaths]]
+onlypaths:'PATH_LIST'::
++
+Matches changes touching the exact set of files in 'PATH_LIST' (comma-separated
+list). By default exact path matching is used, but regular expressions can be
+enabled by starting with `^`. For example, to match all XML files use
+`file:"^.*\.xml$"`.
+The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns. In that case, all files changed by
+the change have to be matched by the provided regex for it to be returned as a
+result. For regex values, only a single item can be provided, since regex can be
+used to reflect multiple different paths.
+
[[file]]
file:'NAME', f:'NAME'::
+
@@ -425,6 +438,18 @@
Regular expression matching can be enabled by starting the string
with `^`. In this mode `file:` is an alias of `path:` (see above).
+[[filecount]]
+filecount:'RELATION''COUNT'::
++
+True if the number of files touched by the latest patchset of a
+change satisfies the given relation for the given number of files.
++
+For example, files:>2 will be true for any change which touches at least
+3 files.
++
+Valid relations are >=, >, \<=, <, or no relation, which will match if the
+number of files is exactly equal.
+
[[extension]]
extension:'EXT', ext:'EXT'::
+
diff --git a/MODULE.bazel b/MODULE.bazel
index a8e239c..ea2eb42 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -27,7 +27,7 @@
)
# Libraries / toolchains.
-bazel_dep(name = "protobuf", version = "35.1")
+bazel_dep(name = "protobuf", version = "36.1.bcr.1")
# In-tree modules.
bazel_dep(name = "jgit")
@@ -37,13 +37,6 @@
)
# Toolchain setup.
-bazel_dep(name = "rbe_autoconfig")
-git_override(
- module_name = "rbe_autoconfig",
- commit = "eb944ce4fc29a1608eae9adf0b0e0df2f9e05e33",
- remote = "https://github.com/davido/rbe_autoconfig.git",
-)
-
register_toolchains("//tools:all")
# Plugin packaging support: bazlets pin and generated Gerrit API version repo.
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index dfd2e59..ed5d99a 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -65,6 +65,7 @@
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.41.0/MODULE.bazel": "6e0f87fafed801273c371d41e22a15a6f8abf83fdd7f87d5e44ad317b94433d0",
"https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080",
+ "https://bcr.bazel.build/modules/bazel_features/1.43.0/MODULE.bazel": "defa2226f06ba20550d6548c3a2ea2a7929634437a52973869c20c225450eb91",
"https://bcr.bazel.build/modules/bazel_features/1.51.0/MODULE.bazel": "8e1310d09db6ee2e4f19f9994e360aca941ddc32083edc8d9bdc19c83c94cee4",
"https://bcr.bazel.build/modules/bazel_features/1.51.0/source.json": "d5af1f1748d2b4ace5f04087030c0a1cfd93f5be6bdf0b5c1beb24ed0fd24955",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
@@ -85,6 +86,7 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
+ "https://bcr.bazel.build/modules/bazel_skylib/1.8.0/MODULE.bazel": "2fb3fb53675f6adfc1ca5bfbd5cfb655ae350fba4706d924a8ec7e3ba945671c",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
@@ -162,8 +164,8 @@
"https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d",
"https://bcr.bazel.build/modules/protobuf/33.1/MODULE.bazel": "982c8a0cceab4d790076f72b7677faf836b0dfadc2b66a34aab7232116c4ae39",
"https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42",
- "https://bcr.bazel.build/modules/protobuf/35.1/MODULE.bazel": "9f25044d646c9c1b1e03b25aa3818bf5250078eabadbd213ea940262dba99471",
- "https://bcr.bazel.build/modules/protobuf/35.1/source.json": "5e256f85483431bd96fc9a6ae468e420326673e9d9f250d313725875597bf1ba",
+ "https://bcr.bazel.build/modules/protobuf/36.1.bcr.1/MODULE.bazel": "4cc1928927b3460ec07e35623ae3d52c8d77ebed515681e0400d9b14b221a586",
+ "https://bcr.bazel.build/modules/protobuf/36.1.bcr.1/source.json": "566e7f47e4efaa1b6b5197d0cda7a228fe3ee6324d29463b0a43fc6f56ead03d",
"https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e",
"https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34",
"https://bcr.bazel.build/modules/pybind11_bazel/3.0.0/MODULE.bazel": "a2bfa6020ed603a00d944161c63173c7f109774e99bee0c2cd8dbf24159f8134",
@@ -199,7 +201,8 @@
"https://bcr.bazel.build/modules/rules_cc/0.2.15/MODULE.bazel": "6a0a4a75a57aa6dc888300d848053a58c6b12a29f89d4304e1c41448514ec6e8",
"https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4",
"https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84",
- "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07",
+ "https://bcr.bazel.build/modules/rules_cc/0.2.18/MODULE.bazel": "4460ec36adc8f722a6a2a4ac9374cb91f2acebadaa93fc37966129afb3dece87",
+ "https://bcr.bazel.build/modules/rules_cc/0.2.18/source.json": "abad668ff2fd63ada1ac49bf386d37e27048b89a3465a6fd968bb832b00a09d3",
"https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc",
"https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642",
"https://bcr.bazel.build/modules/rules_cc/0.2.9/MODULE.bazel": "34263f1dca62ea664265438cef714d7db124c03e1ed55ebb4f1dc860164308d1",
@@ -352,7 +355,7 @@
"@@protobuf+//python/dist:system_python.bzl%system_python_extension": {
"general": {
"bzlTransitiveDigest": "qh0n9IrXU/xS94wxKQrG1J63zrLkA1Wy2Y3BQxptPcI=",
- "usagesDigest": "gM2v8KEcm9rpUrlYSngytVomDKFCsh+Qb9pL3rcZurY=",
+ "usagesDigest": "5nmtRivsScwzftla7fWqD7lfrvKOqRvRmabq+cY3KLU=",
"recordedInputs": [],
"generatedRepoSpecs": {
"system_python": {
@@ -451,8 +454,8 @@
},
"@@rules_rust+//crate_universe:extension.bzl%crate": {
"general": {
- "bzlTransitiveDigest": "K+bEGGE6qig9d3u/VfssYviFnqPTY/UqNwih5xbf3iI=",
- "usagesDigest": "3M03XhIi/1oRPtWHXCs5R9YQ+nQb9Yb2k0xdzczdmnk=",
+ "bzlTransitiveDigest": "U90M45KtFnjTe6xNBAsNewMYPMQwkk4TgsTSlRLuLEc=",
+ "usagesDigest": "iRH26LGhchbWPEi1YexMZWF2hNpipOdcshkFuFTVThY=",
"recordedInputs": [
"ENV:CARGO_BAZEL_DEBUG \\0",
"ENV:CARGO_BAZEL_GENERATOR_SHA256 \\0",
@@ -481,23 +484,23 @@
"repoRuleId": "@@rules_rust+//crate_universe:extensions.bzl%_generate_repo",
"attributes": {
"contents": {
- "BUILD.bazel": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\npackage(default_visibility = [\"//visibility:public\"])\n\nexports_files(\n [\n \"cargo-bazel.json\",\n \"crates.bzl\",\n \"defs.bzl\",\n ] + glob(\n allow_empty = True,\n include = [\"*.bazel\"],\n ),\n)\n\nfilegroup(\n name = \"srcs\",\n srcs = glob(\n allow_empty = True,\n include = [\n \"*.bazel\",\n \"*.bzl\",\n ],\n ),\n)\n\n# Workspace Member Dependencies\nalias(\n name = \"googletest-0.14.3\",\n actual = \"@crates__googletest-0.14.3//:googletest\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"googletest\",\n actual = \"@crates__googletest-0.14.3//:googletest\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"linkme-0.3.37\",\n actual = \"@crates__linkme-0.3.37//:linkme\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"linkme\",\n actual = \"@crates__linkme-0.3.37//:linkme\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"paste-1.0.15\",\n actual = \"@crates__paste-1.0.15//:paste\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"paste\",\n actual = \"@crates__paste-1.0.15//:paste\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"quote-1.0.47\",\n actual = \"@crates__quote-1.0.47//:quote\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"quote\",\n actual = \"@crates__quote-1.0.47//:quote\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"syn-3.0.3\",\n actual = \"@crates__syn-3.0.3//:syn\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"syn\",\n actual = \"@crates__syn-3.0.3//:syn\",\n tags = [\"manual\"],\n)\n",
+ "BUILD.bazel": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\npackage(default_visibility = [\"//visibility:public\"])\n\nexports_files(\n [\n \"cargo-bazel.json\",\n \"crates.bzl\",\n \"defs.bzl\",\n ] + glob(\n allow_empty = True,\n include = [\"*.bazel\"],\n ),\n)\n\nfilegroup(\n name = \"srcs\",\n srcs = glob(\n allow_empty = True,\n include = [\n \"*.bazel\",\n \"*.bzl\",\n ],\n ),\n)\n\n# Workspace Member Dependencies\nalias(\n name = \"googletest-0.14.3\",\n actual = \"@crates__googletest-0.14.3//:googletest\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"googletest\",\n actual = \"@crates__googletest-0.14.3//:googletest\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"linkme-0.3.37\",\n actual = \"@crates__linkme-0.3.37//:linkme\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"linkme\",\n actual = \"@crates__linkme-0.3.37//:linkme\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"paste-1.0.15\",\n actual = \"@crates__paste-1.0.15//:paste\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"paste\",\n actual = \"@crates__paste-1.0.15//:paste\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"quote-1.0.47\",\n actual = \"@crates__quote-1.0.47//:quote\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"quote\",\n actual = \"@crates__quote-1.0.47//:quote\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"syn-2.0.119\",\n actual = \"@crates__syn-2.0.119//:syn\",\n tags = [\"manual\"],\n)\n\nalias(\n name = \"syn\",\n actual = \"@crates__syn-2.0.119//:syn\",\n tags = [\"manual\"],\n)\n",
"alias_rules.bzl": "\"\"\"Alias that transitions its target to `compilation_mode=opt`. Use `transition_alias=\"opt\"` to enable.\"\"\"\n\nload(\"@rules_cc//cc:defs.bzl\", \"CcInfo\")\nload(\"@rules_rust//rust:rust_common.bzl\", \"COMMON_PROVIDERS\")\n\ndef _transition_alias_impl(ctx):\n # `ctx.attr.actual` is a list of 1 item due to the transition\n providers = [ctx.attr.actual[0][provider] for provider in COMMON_PROVIDERS]\n if CcInfo in ctx.attr.actual[0]:\n providers.append(ctx.attr.actual[0][CcInfo])\n return providers\n\ndef _change_compilation_mode(compilation_mode):\n def _change_compilation_mode_impl(_settings, _attr):\n return {\n \"//command_line_option:compilation_mode\": compilation_mode,\n }\n\n return transition(\n implementation = _change_compilation_mode_impl,\n inputs = [],\n outputs = [\n \"//command_line_option:compilation_mode\",\n ],\n )\n\ndef _transition_alias_rule(compilation_mode):\n return rule(\n implementation = _transition_alias_impl,\n provides = COMMON_PROVIDERS,\n attrs = {\n \"actual\": attr.label(\n mandatory = True,\n doc = \"`rust_library()` target to transition to `compilation_mode=opt`.\",\n providers = COMMON_PROVIDERS,\n cfg = _change_compilation_mode(compilation_mode),\n ),\n \"_allowlist_function_transition\": attr.label(\n default = \"@bazel_tools//tools/allowlists/function_transition_allowlist\",\n ),\n },\n doc = \"Transitions a Rust library crate to the `compilation_mode=opt`.\",\n )\n\ntransition_alias_dbg = _transition_alias_rule(\"dbg\")\ntransition_alias_fastbuild = _transition_alias_rule(\"fastbuild\")\ntransition_alias_opt = _transition_alias_rule(\"opt\")\n",
- "defs.bzl": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\"\"\"\n# `crates_repository` API\n\n- [aliases](#aliases)\n- [crate_deps](#crate_deps)\n- [all_crate_deps](#all_crate_deps)\n- [crate_repositories](#crate_repositories)\n\n\"\"\"\n\nload(\"@bazel_tools//tools/build_defs/repo:git.bzl\", \"new_git_repository\")\nload(\"@bazel_tools//tools/build_defs/repo:http.bzl\", \"http_archive\")\nload(\"@bazel_tools//tools/build_defs/repo:utils.bzl\", \"maybe\")\nload(\"@bazel_skylib//lib:selects.bzl\", \"selects\")\nload(\"@rules_rust//crate_universe/private:local_crate_mirror.bzl\", \"local_crate_mirror\")\n\n###############################################################################\n# MACROS API\n###############################################################################\n\n# An identifier that represent common dependencies (unconditional).\n_COMMON_CONDITION = \"\"\n\ndef _flatten_dependency_maps(all_dependency_maps):\n \"\"\"Flatten a list of dependency maps into one dictionary.\n\n Dependency maps have the following structure:\n\n ```python\n DEPENDENCIES_MAP = {\n # The first key in the map is a Bazel package\n # name of the workspace this file is defined in.\n \"workspace_member_package\": {\n\n # Not all dependencies are supported for all platforms.\n # the condition key is the condition required to be true\n # on the host platform.\n \"condition\": {\n\n # An alias to a crate target. # The label of the crate target the\n # Aliases are only crate names. # package name refers to.\n \"package_name\": \"@full//:label\",\n }\n }\n }\n ```\n\n Args:\n all_dependency_maps (list): A list of dicts as described above\n\n Returns:\n dict: A dictionary as described above\n \"\"\"\n dependencies = {}\n\n for workspace_deps_map in all_dependency_maps:\n for pkg_name, conditional_deps_map in workspace_deps_map.items():\n if pkg_name not in dependencies:\n non_frozen_map = dict()\n for key, values in conditional_deps_map.items():\n non_frozen_map.update({key: dict(values.items())})\n dependencies.setdefault(pkg_name, non_frozen_map)\n continue\n\n for condition, deps_map in conditional_deps_map.items():\n # If the condition has not been recorded, do so and continue\n if condition not in dependencies[pkg_name]:\n dependencies[pkg_name].setdefault(condition, dict(deps_map.items()))\n continue\n\n # Alert on any miss-matched dependencies\n inconsistent_entries = []\n for crate_name, crate_label in deps_map.items():\n existing = dependencies[pkg_name][condition].get(crate_name)\n if existing and existing != crate_label:\n inconsistent_entries.append((crate_name, existing, crate_label))\n dependencies[pkg_name][condition].update({crate_name: crate_label})\n\n return dependencies\n\ndef crate_deps(deps, package_name = None):\n \"\"\"Finds the fully qualified label of the requested crates for the package where this macro is called.\n\n Args:\n deps (list): The desired list of crate targets.\n package_name (str, optional): The package name of the set of dependencies to look up.\n Defaults to `native.package_name()`.\n\n Returns:\n list: A list of labels to generated rust targets (str)\n \"\"\"\n\n if not deps:\n return []\n\n if package_name == None:\n package_name = native.package_name()\n\n # Join both sets of dependencies\n dependencies = _flatten_dependency_maps([\n _NORMAL_DEPENDENCIES,\n _NORMAL_DEV_DEPENDENCIES,\n _PROC_MACRO_DEPENDENCIES,\n _PROC_MACRO_DEV_DEPENDENCIES,\n _BUILD_DEPENDENCIES,\n _BUILD_PROC_MACRO_DEPENDENCIES,\n ]).pop(package_name, {})\n\n # Combine all conditional packages so we can easily index over a flat list\n # TODO: Perhaps this should actually return select statements and maintain\n # the conditionals of the dependencies\n flat_deps = {}\n for deps_set in dependencies.values():\n for crate_name, crate_label in deps_set.items():\n flat_deps.update({crate_name: crate_label})\n\n missing_crates = []\n crate_targets = []\n for crate_target in deps:\n if crate_target not in flat_deps:\n missing_crates.append(crate_target)\n else:\n crate_targets.append(flat_deps[crate_target])\n\n if missing_crates:\n fail(\"Could not find crates `{}` among dependencies of `{}`. Available dependencies were `{}`\".format(\n missing_crates,\n package_name,\n dependencies,\n ))\n\n return crate_targets\n\ndef all_crate_deps(\n normal = False, \n normal_dev = False, \n proc_macro = False, \n proc_macro_dev = False,\n build = False,\n build_proc_macro = False,\n package_name = None):\n \"\"\"Finds the fully qualified label of all requested direct crate dependencies \\\n for the package where this macro is called.\n\n If no parameters are set, all normal dependencies are returned. Setting any one flag will\n otherwise impact the contents of the returned list.\n\n Args:\n normal (bool, optional): If True, normal dependencies are included in the\n output list.\n normal_dev (bool, optional): If True, normal dev dependencies will be\n included in the output list.\n proc_macro (bool, optional): If True, proc_macro dependencies are included\n in the output list.\n proc_macro_dev (bool, optional): If True, dev proc_macro dependencies are\n included in the output list.\n build (bool, optional): If True, build dependencies are included\n in the output list.\n build_proc_macro (bool, optional): If True, build proc_macro dependencies are\n included in the output list.\n package_name (str, optional): The package name of the set of dependencies to look up.\n Defaults to `native.package_name()` when unset.\n\n Returns:\n list: A list of labels to generated rust targets (str)\n \"\"\"\n\n if package_name == None:\n package_name = native.package_name()\n\n # Determine the relevant maps to use\n all_dependency_maps = []\n if normal:\n all_dependency_maps.append(_NORMAL_DEPENDENCIES)\n if normal_dev:\n all_dependency_maps.append(_NORMAL_DEV_DEPENDENCIES)\n if proc_macro:\n all_dependency_maps.append(_PROC_MACRO_DEPENDENCIES)\n if proc_macro_dev:\n all_dependency_maps.append(_PROC_MACRO_DEV_DEPENDENCIES)\n if build:\n all_dependency_maps.append(_BUILD_DEPENDENCIES)\n if build_proc_macro:\n all_dependency_maps.append(_BUILD_PROC_MACRO_DEPENDENCIES)\n\n # Default to always using normal dependencies\n if not all_dependency_maps:\n all_dependency_maps.append(_NORMAL_DEPENDENCIES)\n\n dependencies = _flatten_dependency_maps(all_dependency_maps).pop(package_name, None)\n\n if not dependencies:\n if dependencies == None:\n fail(\"Tried to get all_crate_deps for package \" + package_name + \" but that package had no Cargo.toml file\")\n else:\n return []\n\n crate_deps = list(dependencies.pop(_COMMON_CONDITION, {}).values())\n for condition, deps in dependencies.items():\n crate_deps += selects.with_or({\n tuple(_CONDITIONS[condition]): deps.values(),\n \"//conditions:default\": [],\n })\n\n return crate_deps\n\ndef aliases(\n normal = False,\n normal_dev = False,\n proc_macro = False,\n proc_macro_dev = False,\n build = False,\n build_proc_macro = False,\n package_name = None):\n \"\"\"Produces a map of Crate alias names to their original label\n\n If no dependency kinds are specified, `normal` and `proc_macro` are used by default.\n Setting any one flag will otherwise determine the contents of the returned dict.\n\n Args:\n normal (bool, optional): If True, normal dependencies are included in the\n output list.\n normal_dev (bool, optional): If True, normal dev dependencies will be\n included in the output list..\n proc_macro (bool, optional): If True, proc_macro dependencies are included\n in the output list.\n proc_macro_dev (bool, optional): If True, dev proc_macro dependencies are\n included in the output list.\n build (bool, optional): If True, build dependencies are included\n in the output list.\n build_proc_macro (bool, optional): If True, build proc_macro dependencies are\n included in the output list.\n package_name (str, optional): The package name of the set of dependencies to look up.\n Defaults to `native.package_name()` when unset.\n\n Returns:\n dict: The aliases of all associated packages\n \"\"\"\n if package_name == None:\n package_name = native.package_name()\n\n # Determine the relevant maps to use\n all_aliases_maps = []\n if normal:\n all_aliases_maps.append(_NORMAL_ALIASES)\n if normal_dev:\n all_aliases_maps.append(_NORMAL_DEV_ALIASES)\n if proc_macro:\n all_aliases_maps.append(_PROC_MACRO_ALIASES)\n if proc_macro_dev:\n all_aliases_maps.append(_PROC_MACRO_DEV_ALIASES)\n if build:\n all_aliases_maps.append(_BUILD_ALIASES)\n if build_proc_macro:\n all_aliases_maps.append(_BUILD_PROC_MACRO_ALIASES)\n\n # Default to always using normal aliases\n if not all_aliases_maps:\n all_aliases_maps.append(_NORMAL_ALIASES)\n all_aliases_maps.append(_PROC_MACRO_ALIASES)\n\n aliases = _flatten_dependency_maps(all_aliases_maps).pop(package_name, None)\n\n if not aliases:\n return dict()\n\n common_items = aliases.pop(_COMMON_CONDITION, {}).items()\n\n # If there are only common items in the dictionary, immediately return them\n if not len(aliases.keys()) == 1:\n return dict(common_items)\n\n # Build a single select statement where each conditional has accounted for the\n # common set of aliases.\n crate_aliases = {\"//conditions:default\": dict(common_items)}\n for condition, deps in aliases.items():\n condition_triples = _CONDITIONS[condition]\n for triple in condition_triples:\n if triple in crate_aliases:\n crate_aliases[triple].update(deps)\n else:\n crate_aliases.update({triple: dict(deps.items() + common_items)})\n\n return select(crate_aliases)\n\n###############################################################################\n# WORKSPACE MEMBER DEPS AND ALIASES\n###############################################################################\n\n_NORMAL_DEPENDENCIES = {\n \"\": {\n _COMMON_CONDITION: {\n \"googletest\": Label(\"@crates//:googletest-0.14.3\"),\n \"linkme\": Label(\"@crates//:linkme-0.3.37\"),\n \"quote\": Label(\"@crates//:quote-1.0.47\"),\n \"syn\": Label(\"@crates//:syn-3.0.3\"),\n },\n },\n}\n\n\n_NORMAL_ALIASES = {\n \"\": {\n _COMMON_CONDITION: {\n },\n },\n}\n\n\n_NORMAL_DEV_DEPENDENCIES = {\n \"\": {\n },\n}\n\n\n_NORMAL_DEV_ALIASES = {\n \"\": {\n },\n}\n\n\n_PROC_MACRO_DEPENDENCIES = {\n \"\": {\n _COMMON_CONDITION: {\n \"paste\": Label(\"@crates//:paste-1.0.15\"),\n },\n },\n}\n\n\n_PROC_MACRO_ALIASES = {\n \"\": {\n },\n}\n\n\n_PROC_MACRO_DEV_DEPENDENCIES = {\n \"\": {\n },\n}\n\n\n_PROC_MACRO_DEV_ALIASES = {\n \"\": {\n },\n}\n\n\n_BUILD_DEPENDENCIES = {\n \"\": {\n },\n}\n\n\n_BUILD_ALIASES = {\n \"\": {\n },\n}\n\n\n_BUILD_PROC_MACRO_DEPENDENCIES = {\n \"\": {\n },\n}\n\n\n_BUILD_PROC_MACRO_ALIASES = {\n \"\": {\n },\n}\n\n\n_CONDITIONS = {\n \"aarch64-apple-darwin\": [\"@rules_rust//rust/platform:aarch64-apple-darwin\"],\n \"aarch64-unknown-linux-gnu\": [\"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\"],\n \"wasm32-unknown-unknown\": [\"@rules_rust//rust/platform:wasm32-unknown-unknown\"],\n \"wasm32-wasip1\": [\"@rules_rust//rust/platform:wasm32-wasip1\"],\n \"x86_64-pc-windows-msvc\": [\"@rules_rust//rust/platform:x86_64-pc-windows-msvc\"],\n \"x86_64-unknown-linux-gnu\": [\"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\"],\n \"x86_64-unknown-nixos-gnu\": [\"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\"],\n}\n\n###############################################################################\n\ndef crate_repositories():\n \"\"\"A macro for defining repositories for all generated crates.\n\n Returns:\n A list of repos visible to the module through the module extension.\n \"\"\"\n maybe(\n http_archive,\n name = \"crates__aho-corasick-1.1.4\",\n sha256 = \"ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/aho-corasick/1.1.4/download\"],\n strip_prefix = \"aho-corasick-1.1.4\",\n build_file = Label(\"@crates//crates:BUILD.aho-corasick-1.1.4.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__autocfg-1.5.1\",\n sha256 = \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/autocfg/1.5.1/download\"],\n strip_prefix = \"autocfg-1.5.1\",\n build_file = Label(\"@crates//crates:BUILD.autocfg-1.5.1.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__googletest-0.14.3\",\n sha256 = \"f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/googletest/0.14.3/download\"],\n strip_prefix = \"googletest-0.14.3\",\n build_file = Label(\"@crates//crates:BUILD.googletest-0.14.3.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__googletest_macro-0.14.3\",\n sha256 = \"2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/googletest_macro/0.14.3/download\"],\n strip_prefix = \"googletest_macro-0.14.3\",\n build_file = Label(\"@crates//crates:BUILD.googletest_macro-0.14.3.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__linkme-0.3.37\",\n sha256 = \"3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/linkme/0.3.37/download\"],\n strip_prefix = \"linkme-0.3.37\",\n build_file = Label(\"@crates//crates:BUILD.linkme-0.3.37.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__linkme-impl-0.3.37\",\n sha256 = \"77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/linkme-impl/0.3.37/download\"],\n strip_prefix = \"linkme-impl-0.3.37\",\n build_file = Label(\"@crates//crates:BUILD.linkme-impl-0.3.37.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__memchr-2.8.3\",\n sha256 = \"cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/memchr/2.8.3/download\"],\n strip_prefix = \"memchr-2.8.3\",\n build_file = Label(\"@crates//crates:BUILD.memchr-2.8.3.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__num-traits-0.2.19\",\n sha256 = \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/num-traits/0.2.19/download\"],\n strip_prefix = \"num-traits-0.2.19\",\n build_file = Label(\"@crates//crates:BUILD.num-traits-0.2.19.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__paste-1.0.15\",\n sha256 = \"57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/paste/1.0.15/download\"],\n strip_prefix = \"paste-1.0.15\",\n build_file = Label(\"@crates//crates:BUILD.paste-1.0.15.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__proc-macro2-1.0.107\",\n sha256 = \"985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/proc-macro2/1.0.107/download\"],\n strip_prefix = \"proc-macro2-1.0.107\",\n build_file = Label(\"@crates//crates:BUILD.proc-macro2-1.0.107.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__quote-1.0.47\",\n sha256 = \"1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/quote/1.0.47/download\"],\n strip_prefix = \"quote-1.0.47\",\n build_file = Label(\"@crates//crates:BUILD.quote-1.0.47.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__regex-1.13.1\",\n sha256 = \"f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/regex/1.13.1/download\"],\n strip_prefix = \"regex-1.13.1\",\n build_file = Label(\"@crates//crates:BUILD.regex-1.13.1.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__regex-automata-0.4.16\",\n sha256 = \"8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/regex-automata/0.4.16/download\"],\n strip_prefix = \"regex-automata-0.4.16\",\n build_file = Label(\"@crates//crates:BUILD.regex-automata-0.4.16.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__regex-syntax-0.8.11\",\n sha256 = \"d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/regex-syntax/0.8.11/download\"],\n strip_prefix = \"regex-syntax-0.8.11\",\n build_file = Label(\"@crates//crates:BUILD.regex-syntax-0.8.11.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__rustversion-1.0.23\",\n sha256 = \"cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/rustversion/1.0.23/download\"],\n strip_prefix = \"rustversion-1.0.23\",\n build_file = Label(\"@crates//crates:BUILD.rustversion-1.0.23.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__syn-2.0.119\",\n sha256 = \"872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/syn/2.0.119/download\"],\n strip_prefix = \"syn-2.0.119\",\n build_file = Label(\"@crates//crates:BUILD.syn-2.0.119.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__syn-3.0.3\",\n sha256 = \"53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/syn/3.0.3/download\"],\n strip_prefix = \"syn-3.0.3\",\n build_file = Label(\"@crates//crates:BUILD.syn-3.0.3.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__unicode-ident-1.0.24\",\n sha256 = \"e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/unicode-ident/1.0.24/download\"],\n strip_prefix = \"unicode-ident-1.0.24\",\n build_file = Label(\"@crates//crates:BUILD.unicode-ident-1.0.24.bazel\"),\n )\n\n return [\n struct(repo=\"crates__googletest-0.14.3\", is_dev_dep = False),\n struct(repo=\"crates__linkme-0.3.37\", is_dev_dep = False),\n struct(repo=\"crates__paste-1.0.15\", is_dev_dep = False),\n struct(repo=\"crates__quote-1.0.47\", is_dev_dep = False),\n struct(repo=\"crates__syn-3.0.3\", is_dev_dep = False),\n ]\n"
+ "defs.bzl": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\"\"\"\n# `crates_repository` API\n\n- [aliases](#aliases)\n- [crate_deps](#crate_deps)\n- [all_crate_deps](#all_crate_deps)\n- [crate_repositories](#crate_repositories)\n\n\"\"\"\n\nload(\"@bazel_tools//tools/build_defs/repo:git.bzl\", \"new_git_repository\")\nload(\"@bazel_tools//tools/build_defs/repo:http.bzl\", \"http_archive\")\nload(\"@bazel_tools//tools/build_defs/repo:utils.bzl\", \"maybe\")\nload(\"@bazel_skylib//lib:selects.bzl\", \"selects\")\nload(\"@rules_rust//crate_universe/private:local_crate_mirror.bzl\", \"local_crate_mirror\")\n\n###############################################################################\n# MACROS API\n###############################################################################\n\n# An identifier that represent common dependencies (unconditional).\n_COMMON_CONDITION = \"\"\n\ndef _flatten_dependency_maps(all_dependency_maps):\n \"\"\"Flatten a list of dependency maps into one dictionary.\n\n Dependency maps have the following structure:\n\n ```python\n DEPENDENCIES_MAP = {\n # The first key in the map is a Bazel package\n # name of the workspace this file is defined in.\n \"workspace_member_package\": {\n\n # Not all dependencies are supported for all platforms.\n # the condition key is the condition required to be true\n # on the host platform.\n \"condition\": {\n\n # An alias to a crate target. # The label of the crate target the\n # Aliases are only crate names. # package name refers to.\n \"package_name\": \"@full//:label\",\n }\n }\n }\n ```\n\n Args:\n all_dependency_maps (list): A list of dicts as described above\n\n Returns:\n dict: A dictionary as described above\n \"\"\"\n dependencies = {}\n\n for workspace_deps_map in all_dependency_maps:\n for pkg_name, conditional_deps_map in workspace_deps_map.items():\n if pkg_name not in dependencies:\n non_frozen_map = dict()\n for key, values in conditional_deps_map.items():\n non_frozen_map.update({key: dict(values.items())})\n dependencies.setdefault(pkg_name, non_frozen_map)\n continue\n\n for condition, deps_map in conditional_deps_map.items():\n # If the condition has not been recorded, do so and continue\n if condition not in dependencies[pkg_name]:\n dependencies[pkg_name].setdefault(condition, dict(deps_map.items()))\n continue\n\n # Alert on any miss-matched dependencies\n inconsistent_entries = []\n for crate_name, crate_label in deps_map.items():\n existing = dependencies[pkg_name][condition].get(crate_name)\n if existing and existing != crate_label:\n inconsistent_entries.append((crate_name, existing, crate_label))\n dependencies[pkg_name][condition].update({crate_name: crate_label})\n\n return dependencies\n\ndef crate_deps(deps, package_name = None):\n \"\"\"Finds the fully qualified label of the requested crates for the package where this macro is called.\n\n Args:\n deps (list): The desired list of crate targets.\n package_name (str, optional): The package name of the set of dependencies to look up.\n Defaults to `native.package_name()`.\n\n Returns:\n list: A list of labels to generated rust targets (str)\n \"\"\"\n\n if not deps:\n return []\n\n if package_name == None:\n package_name = native.package_name()\n\n # Join both sets of dependencies\n dependencies = _flatten_dependency_maps([\n _NORMAL_DEPENDENCIES,\n _NORMAL_DEV_DEPENDENCIES,\n _PROC_MACRO_DEPENDENCIES,\n _PROC_MACRO_DEV_DEPENDENCIES,\n _BUILD_DEPENDENCIES,\n _BUILD_PROC_MACRO_DEPENDENCIES,\n ]).pop(package_name, {})\n\n # Combine all conditional packages so we can easily index over a flat list\n # TODO: Perhaps this should actually return select statements and maintain\n # the conditionals of the dependencies\n flat_deps = {}\n for deps_set in dependencies.values():\n for crate_name, crate_label in deps_set.items():\n flat_deps.update({crate_name: crate_label})\n\n missing_crates = []\n crate_targets = []\n for crate_target in deps:\n if crate_target not in flat_deps:\n missing_crates.append(crate_target)\n else:\n crate_targets.append(flat_deps[crate_target])\n\n if missing_crates:\n fail(\"Could not find crates `{}` among dependencies of `{}`. Available dependencies were `{}`\".format(\n missing_crates,\n package_name,\n dependencies,\n ))\n\n return crate_targets\n\ndef all_crate_deps(\n normal = False, \n normal_dev = False, \n proc_macro = False, \n proc_macro_dev = False,\n build = False,\n build_proc_macro = False,\n package_name = None):\n \"\"\"Finds the fully qualified label of all requested direct crate dependencies \\\n for the package where this macro is called.\n\n If no parameters are set, all normal dependencies are returned. Setting any one flag will\n otherwise impact the contents of the returned list.\n\n Args:\n normal (bool, optional): If True, normal dependencies are included in the\n output list.\n normal_dev (bool, optional): If True, normal dev dependencies will be\n included in the output list.\n proc_macro (bool, optional): If True, proc_macro dependencies are included\n in the output list.\n proc_macro_dev (bool, optional): If True, dev proc_macro dependencies are\n included in the output list.\n build (bool, optional): If True, build dependencies are included\n in the output list.\n build_proc_macro (bool, optional): If True, build proc_macro dependencies are\n included in the output list.\n package_name (str, optional): The package name of the set of dependencies to look up.\n Defaults to `native.package_name()` when unset.\n\n Returns:\n list: A list of labels to generated rust targets (str)\n \"\"\"\n\n if package_name == None:\n package_name = native.package_name()\n\n # Determine the relevant maps to use\n all_dependency_maps = []\n if normal:\n all_dependency_maps.append(_NORMAL_DEPENDENCIES)\n if normal_dev:\n all_dependency_maps.append(_NORMAL_DEV_DEPENDENCIES)\n if proc_macro:\n all_dependency_maps.append(_PROC_MACRO_DEPENDENCIES)\n if proc_macro_dev:\n all_dependency_maps.append(_PROC_MACRO_DEV_DEPENDENCIES)\n if build:\n all_dependency_maps.append(_BUILD_DEPENDENCIES)\n if build_proc_macro:\n all_dependency_maps.append(_BUILD_PROC_MACRO_DEPENDENCIES)\n\n # Default to always using normal dependencies\n if not all_dependency_maps:\n all_dependency_maps.append(_NORMAL_DEPENDENCIES)\n\n dependencies = _flatten_dependency_maps(all_dependency_maps).pop(package_name, None)\n\n if not dependencies:\n if dependencies == None:\n fail(\"Tried to get all_crate_deps for package \" + package_name + \" but that package had no Cargo.toml file\")\n else:\n return []\n\n crate_deps = list(dependencies.pop(_COMMON_CONDITION, {}).values())\n for condition, deps in dependencies.items():\n crate_deps += selects.with_or({\n tuple(_CONDITIONS[condition]): deps.values(),\n \"//conditions:default\": [],\n })\n\n return crate_deps\n\ndef aliases(\n normal = False,\n normal_dev = False,\n proc_macro = False,\n proc_macro_dev = False,\n build = False,\n build_proc_macro = False,\n package_name = None):\n \"\"\"Produces a map of Crate alias names to their original label\n\n If no dependency kinds are specified, `normal` and `proc_macro` are used by default.\n Setting any one flag will otherwise determine the contents of the returned dict.\n\n Args:\n normal (bool, optional): If True, normal dependencies are included in the\n output list.\n normal_dev (bool, optional): If True, normal dev dependencies will be\n included in the output list..\n proc_macro (bool, optional): If True, proc_macro dependencies are included\n in the output list.\n proc_macro_dev (bool, optional): If True, dev proc_macro dependencies are\n included in the output list.\n build (bool, optional): If True, build dependencies are included\n in the output list.\n build_proc_macro (bool, optional): If True, build proc_macro dependencies are\n included in the output list.\n package_name (str, optional): The package name of the set of dependencies to look up.\n Defaults to `native.package_name()` when unset.\n\n Returns:\n dict: The aliases of all associated packages\n \"\"\"\n if package_name == None:\n package_name = native.package_name()\n\n # Determine the relevant maps to use\n all_aliases_maps = []\n if normal:\n all_aliases_maps.append(_NORMAL_ALIASES)\n if normal_dev:\n all_aliases_maps.append(_NORMAL_DEV_ALIASES)\n if proc_macro:\n all_aliases_maps.append(_PROC_MACRO_ALIASES)\n if proc_macro_dev:\n all_aliases_maps.append(_PROC_MACRO_DEV_ALIASES)\n if build:\n all_aliases_maps.append(_BUILD_ALIASES)\n if build_proc_macro:\n all_aliases_maps.append(_BUILD_PROC_MACRO_ALIASES)\n\n # Default to always using normal aliases\n if not all_aliases_maps:\n all_aliases_maps.append(_NORMAL_ALIASES)\n all_aliases_maps.append(_PROC_MACRO_ALIASES)\n\n aliases = _flatten_dependency_maps(all_aliases_maps).pop(package_name, None)\n\n if not aliases:\n return dict()\n\n common_items = aliases.pop(_COMMON_CONDITION, {}).items()\n\n # If there are only common items in the dictionary, immediately return them\n if not len(aliases.keys()) == 1:\n return dict(common_items)\n\n # Build a single select statement where each conditional has accounted for the\n # common set of aliases.\n crate_aliases = {\"//conditions:default\": dict(common_items)}\n for condition, deps in aliases.items():\n condition_triples = _CONDITIONS[condition]\n for triple in condition_triples:\n if triple in crate_aliases:\n crate_aliases[triple].update(deps)\n else:\n crate_aliases.update({triple: dict(deps.items() + common_items)})\n\n return select(crate_aliases)\n\n###############################################################################\n# WORKSPACE MEMBER DEPS AND ALIASES\n###############################################################################\n\n_NORMAL_DEPENDENCIES = {\n \"\": {\n _COMMON_CONDITION: {\n \"googletest\": Label(\"@crates//:googletest-0.14.3\"),\n \"linkme\": Label(\"@crates//:linkme-0.3.37\"),\n \"quote\": Label(\"@crates//:quote-1.0.47\"),\n \"syn\": Label(\"@crates//:syn-2.0.119\"),\n },\n },\n}\n\n\n_NORMAL_ALIASES = {\n \"\": {\n _COMMON_CONDITION: {\n },\n },\n}\n\n\n_NORMAL_DEV_DEPENDENCIES = {\n \"\": {\n },\n}\n\n\n_NORMAL_DEV_ALIASES = {\n \"\": {\n },\n}\n\n\n_PROC_MACRO_DEPENDENCIES = {\n \"\": {\n _COMMON_CONDITION: {\n \"paste\": Label(\"@crates//:paste-1.0.15\"),\n },\n },\n}\n\n\n_PROC_MACRO_ALIASES = {\n \"\": {\n },\n}\n\n\n_PROC_MACRO_DEV_DEPENDENCIES = {\n \"\": {\n },\n}\n\n\n_PROC_MACRO_DEV_ALIASES = {\n \"\": {\n },\n}\n\n\n_BUILD_DEPENDENCIES = {\n \"\": {\n },\n}\n\n\n_BUILD_ALIASES = {\n \"\": {\n },\n}\n\n\n_BUILD_PROC_MACRO_DEPENDENCIES = {\n \"\": {\n },\n}\n\n\n_BUILD_PROC_MACRO_ALIASES = {\n \"\": {\n },\n}\n\n\n_CONDITIONS = {\n \"aarch64-apple-darwin\": [\"@rules_rust//rust/platform:aarch64-apple-darwin\"],\n \"aarch64-unknown-linux-gnu\": [\"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\"],\n \"wasm32-unknown-unknown\": [\"@rules_rust//rust/platform:wasm32-unknown-unknown\"],\n \"wasm32-wasip1\": [\"@rules_rust//rust/platform:wasm32-wasip1\"],\n \"x86_64-pc-windows-msvc\": [\"@rules_rust//rust/platform:x86_64-pc-windows-msvc\"],\n \"x86_64-unknown-linux-gnu\": [\"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\"],\n \"x86_64-unknown-nixos-gnu\": [\"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\"],\n}\n\n###############################################################################\n\ndef crate_repositories():\n \"\"\"A macro for defining repositories for all generated crates.\n\n Returns:\n A list of repos visible to the module through the module extension.\n \"\"\"\n maybe(\n http_archive,\n name = \"crates__aho-corasick-1.1.5\",\n sha256 = \"c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/aho-corasick/1.1.5/download\"],\n strip_prefix = \"aho-corasick-1.1.5\",\n build_file = Label(\"@crates//crates:BUILD.aho-corasick-1.1.5.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__autocfg-1.5.1\",\n sha256 = \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/autocfg/1.5.1/download\"],\n strip_prefix = \"autocfg-1.5.1\",\n build_file = Label(\"@crates//crates:BUILD.autocfg-1.5.1.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__googletest-0.14.3\",\n sha256 = \"f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/googletest/0.14.3/download\"],\n strip_prefix = \"googletest-0.14.3\",\n build_file = Label(\"@crates//crates:BUILD.googletest-0.14.3.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__googletest_macro-0.14.3\",\n sha256 = \"2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/googletest_macro/0.14.3/download\"],\n strip_prefix = \"googletest_macro-0.14.3\",\n build_file = Label(\"@crates//crates:BUILD.googletest_macro-0.14.3.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__linkme-0.3.37\",\n sha256 = \"3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/linkme/0.3.37/download\"],\n strip_prefix = \"linkme-0.3.37\",\n build_file = Label(\"@crates//crates:BUILD.linkme-0.3.37.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__linkme-impl-0.3.37\",\n sha256 = \"77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/linkme-impl/0.3.37/download\"],\n strip_prefix = \"linkme-impl-0.3.37\",\n build_file = Label(\"@crates//crates:BUILD.linkme-impl-0.3.37.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__memchr-2.8.3\",\n sha256 = \"cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/memchr/2.8.3/download\"],\n strip_prefix = \"memchr-2.8.3\",\n build_file = Label(\"@crates//crates:BUILD.memchr-2.8.3.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__num-traits-0.2.19\",\n sha256 = \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/num-traits/0.2.19/download\"],\n strip_prefix = \"num-traits-0.2.19\",\n build_file = Label(\"@crates//crates:BUILD.num-traits-0.2.19.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__paste-1.0.15\",\n sha256 = \"57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/paste/1.0.15/download\"],\n strip_prefix = \"paste-1.0.15\",\n build_file = Label(\"@crates//crates:BUILD.paste-1.0.15.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__proc-macro2-1.0.107\",\n sha256 = \"985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/proc-macro2/1.0.107/download\"],\n strip_prefix = \"proc-macro2-1.0.107\",\n build_file = Label(\"@crates//crates:BUILD.proc-macro2-1.0.107.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__quote-1.0.47\",\n sha256 = \"1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/quote/1.0.47/download\"],\n strip_prefix = \"quote-1.0.47\",\n build_file = Label(\"@crates//crates:BUILD.quote-1.0.47.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__regex-1.13.1\",\n sha256 = \"f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/regex/1.13.1/download\"],\n strip_prefix = \"regex-1.13.1\",\n build_file = Label(\"@crates//crates:BUILD.regex-1.13.1.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__regex-automata-0.4.18\",\n sha256 = \"ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/regex-automata/0.4.18/download\"],\n strip_prefix = \"regex-automata-0.4.18\",\n build_file = Label(\"@crates//crates:BUILD.regex-automata-0.4.18.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__regex-syntax-0.8.11\",\n sha256 = \"d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/regex-syntax/0.8.11/download\"],\n strip_prefix = \"regex-syntax-0.8.11\",\n build_file = Label(\"@crates//crates:BUILD.regex-syntax-0.8.11.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__rustversion-1.0.23\",\n sha256 = \"cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/rustversion/1.0.23/download\"],\n strip_prefix = \"rustversion-1.0.23\",\n build_file = Label(\"@crates//crates:BUILD.rustversion-1.0.23.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__syn-2.0.119\",\n sha256 = \"872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/syn/2.0.119/download\"],\n strip_prefix = \"syn-2.0.119\",\n build_file = Label(\"@crates//crates:BUILD.syn-2.0.119.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__syn-3.0.5\",\n sha256 = \"12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/syn/3.0.5/download\"],\n strip_prefix = \"syn-3.0.5\",\n build_file = Label(\"@crates//crates:BUILD.syn-3.0.5.bazel\"),\n )\n\n maybe(\n http_archive,\n name = \"crates__unicode-ident-1.0.24\",\n sha256 = \"e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75\",\n type = \"tar.gz\",\n urls = [\"https://static.crates.io/crates/unicode-ident/1.0.24/download\"],\n strip_prefix = \"unicode-ident-1.0.24\",\n build_file = Label(\"@crates//crates:BUILD.unicode-ident-1.0.24.bazel\"),\n )\n\n return [\n struct(repo=\"crates__googletest-0.14.3\", is_dev_dep = False),\n struct(repo=\"crates__linkme-0.3.37\", is_dev_dep = False),\n struct(repo=\"crates__paste-1.0.15\", is_dev_dep = False),\n struct(repo=\"crates__quote-1.0.47\", is_dev_dep = False),\n struct(repo=\"crates__syn-2.0.119\", is_dev_dep = False),\n ]\n"
}
}
},
- "crates__aho-corasick-1.1.4": {
+ "crates__aho-corasick-1.1.5": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"remote_patch_strip": 1,
- "sha256": "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301",
+ "sha256": "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba",
"type": "tar.gz",
"urls": [
- "https://static.crates.io/crates/aho-corasick/1.1.4/download"
+ "https://static.crates.io/crates/aho-corasick/1.1.5/download"
],
- "strip_prefix": "aho-corasick-1.1.4",
- "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"aho_corasick\",\n deps = [\n \"@crates__memchr-2.8.3//:memchr\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"perf-literal\",\n \"std\",\n ],\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=aho-corasick\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"1.1.4\",\n)\n"
+ "strip_prefix": "aho-corasick-1.1.5",
+ "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"aho_corasick\",\n deps = [\n \"@crates__memchr-2.8.3//:memchr\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"perf-literal\",\n \"std\",\n ],\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=aho-corasick\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"1.1.5\",\n)\n"
}
},
"crates__autocfg-1.5.1": {
@@ -562,7 +565,7 @@
"https://static.crates.io/crates/linkme-impl/0.3.37/download"
],
"strip_prefix": "linkme-impl-0.3.37",
- "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\n \"@rules_rust//cargo:defs.bzl\",\n \"cargo_build_script\",\n \"cargo_toml_env_vars\",\n)\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_proc_macro\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_proc_macro(\n name = \"linkme_impl\",\n deps = [\n \"@crates__linkme-impl-0.3.37//:build_script_build\",\n \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n \"@crates__quote-1.0.47//:quote\",\n \"@crates__syn-3.0.3//:syn\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=linkme-impl\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"0.3.37\",\n)\n\ncargo_build_script(\n name = \"_bs\",\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \"**/*.rs\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_name = \"build_script_build\",\n crate_root = \"build.rs\",\n data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n edition = \"2021\",\n pkg_name = \"linkme-impl\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=linkme-impl\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n version = \"0.3.37\",\n visibility = [\"//visibility:private\"],\n)\n\nalias(\n name = \"build_script_build\",\n actual = \":_bs\",\n tags = [\"manual\"],\n)\n"
+ "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\n \"@rules_rust//cargo:defs.bzl\",\n \"cargo_build_script\",\n \"cargo_toml_env_vars\",\n)\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_proc_macro\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_proc_macro(\n name = \"linkme_impl\",\n deps = [\n \"@crates__linkme-impl-0.3.37//:build_script_build\",\n \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n \"@crates__quote-1.0.47//:quote\",\n \"@crates__syn-3.0.5//:syn\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=linkme-impl\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"0.3.37\",\n)\n\ncargo_build_script(\n name = \"_bs\",\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \"**/*.rs\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_name = \"build_script_build\",\n crate_root = \"build.rs\",\n data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n edition = \"2021\",\n pkg_name = \"linkme-impl\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=linkme-impl\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n version = \"0.3.37\",\n visibility = [\"//visibility:private\"],\n)\n\nalias(\n name = \"build_script_build\",\n actual = \":_bs\",\n tags = [\"manual\"],\n)\n"
}
},
"crates__memchr-2.8.3": {
@@ -640,20 +643,20 @@
"https://static.crates.io/crates/regex/1.13.1/download"
],
"strip_prefix": "regex-1.13.1",
- "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"regex\",\n deps = [\n \"@crates__aho-corasick-1.1.4//:aho_corasick\",\n \"@crates__memchr-2.8.3//:memchr\",\n \"@crates__regex-automata-0.4.16//:regex_automata\",\n \"@crates__regex-syntax-0.8.11//:regex_syntax\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"default\",\n \"perf\",\n \"perf-backtrack\",\n \"perf-cache\",\n \"perf-dfa\",\n \"perf-inline\",\n \"perf-literal\",\n \"perf-onepass\",\n \"std\",\n \"unicode\",\n \"unicode-age\",\n \"unicode-bool\",\n \"unicode-case\",\n \"unicode-gencat\",\n \"unicode-perl\",\n \"unicode-script\",\n \"unicode-segment\",\n ],\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=regex\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"1.13.1\",\n)\n"
+ "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"regex\",\n deps = [\n \"@crates__aho-corasick-1.1.5//:aho_corasick\",\n \"@crates__memchr-2.8.3//:memchr\",\n \"@crates__regex-automata-0.4.18//:regex_automata\",\n \"@crates__regex-syntax-0.8.11//:regex_syntax\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"default\",\n \"perf\",\n \"perf-backtrack\",\n \"perf-cache\",\n \"perf-dfa\",\n \"perf-inline\",\n \"perf-literal\",\n \"perf-onepass\",\n \"std\",\n \"unicode\",\n \"unicode-age\",\n \"unicode-bool\",\n \"unicode-case\",\n \"unicode-gencat\",\n \"unicode-perl\",\n \"unicode-script\",\n \"unicode-segment\",\n ],\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=regex\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"1.13.1\",\n)\n"
}
},
- "crates__regex-automata-0.4.16": {
+ "crates__regex-automata-0.4.18": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"remote_patch_strip": 1,
- "sha256": "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad",
+ "sha256": "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2",
"type": "tar.gz",
"urls": [
- "https://static.crates.io/crates/regex-automata/0.4.16/download"
+ "https://static.crates.io/crates/regex-automata/0.4.18/download"
],
- "strip_prefix": "regex-automata-0.4.16",
- "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"regex_automata\",\n deps = [\n \"@crates__aho-corasick-1.1.4//:aho_corasick\",\n \"@crates__memchr-2.8.3//:memchr\",\n \"@crates__regex-syntax-0.8.11//:regex_syntax\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"alloc\",\n \"dfa-onepass\",\n \"hybrid\",\n \"meta\",\n \"nfa-backtrack\",\n \"nfa-pikevm\",\n \"nfa-thompson\",\n \"perf-inline\",\n \"perf-literal\",\n \"perf-literal-multisubstring\",\n \"perf-literal-substring\",\n \"std\",\n \"syntax\",\n \"unicode\",\n \"unicode-age\",\n \"unicode-bool\",\n \"unicode-case\",\n \"unicode-gencat\",\n \"unicode-perl\",\n \"unicode-script\",\n \"unicode-segment\",\n \"unicode-word-boundary\",\n ],\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=regex-automata\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"0.4.16\",\n)\n"
+ "strip_prefix": "regex-automata-0.4.18",
+ "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"regex_automata\",\n deps = [\n \"@crates__aho-corasick-1.1.5//:aho_corasick\",\n \"@crates__memchr-2.8.3//:memchr\",\n \"@crates__regex-syntax-0.8.11//:regex_syntax\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"alloc\",\n \"dfa-onepass\",\n \"hybrid\",\n \"meta\",\n \"nfa-backtrack\",\n \"nfa-pikevm\",\n \"nfa-thompson\",\n \"perf-inline\",\n \"perf-literal\",\n \"perf-literal-multisubstring\",\n \"perf-literal-substring\",\n \"std\",\n \"syntax\",\n \"unicode\",\n \"unicode-age\",\n \"unicode-bool\",\n \"unicode-case\",\n \"unicode-gencat\",\n \"unicode-perl\",\n \"unicode-script\",\n \"unicode-segment\",\n \"unicode-word-boundary\",\n ],\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=regex-automata\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"0.4.18\",\n)\n"
}
},
"crates__regex-syntax-0.8.11": {
@@ -692,20 +695,20 @@
"https://static.crates.io/crates/syn/2.0.119/download"
],
"strip_prefix": "syn-2.0.119",
- "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"syn\",\n deps = [\n \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n \"@crates__quote-1.0.47//:quote\",\n \"@crates__unicode-ident-1.0.24//:unicode_ident\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"clone-impls\",\n \"default\",\n \"derive\",\n \"extra-traits\",\n \"full\",\n \"parsing\",\n \"printing\",\n \"proc-macro\",\n ],\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=syn\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"2.0.119\",\n)\n"
+ "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"syn\",\n deps = [\n \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n \"@crates__quote-1.0.47//:quote\",\n \"@crates__unicode-ident-1.0.24//:unicode_ident\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"clone-impls\",\n \"default\",\n \"derive\",\n \"parsing\",\n \"printing\",\n \"proc-macro\",\n ] + select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [\n \"extra-traits\", # aarch64-apple-darwin\n \"full\", # aarch64-apple-darwin\n ],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [\n \"extra-traits\", # aarch64-unknown-linux-gnu\n \"full\", # aarch64-unknown-linux-gnu\n ],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [\n \"extra-traits\", # x86_64-pc-windows-msvc\n \"full\", # x86_64-pc-windows-msvc\n ],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [\n \"extra-traits\", # x86_64-unknown-linux-gnu\n \"full\", # x86_64-unknown-linux-gnu\n ],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [\n \"extra-traits\", # x86_64-unknown-nixos-gnu\n \"full\", # x86_64-unknown-nixos-gnu\n ],\n \"//conditions:default\": [],\n }),\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=syn\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"2.0.119\",\n)\n"
}
},
- "crates__syn-3.0.3": {
+ "crates__syn-3.0.5": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"remote_patch_strip": 1,
- "sha256": "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3",
+ "sha256": "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9",
"type": "tar.gz",
"urls": [
- "https://static.crates.io/crates/syn/3.0.3/download"
+ "https://static.crates.io/crates/syn/3.0.5/download"
],
- "strip_prefix": "syn-3.0.3",
- "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"syn\",\n deps = [\n \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n \"@crates__quote-1.0.47//:quote\",\n \"@crates__unicode-ident-1.0.24//:unicode_ident\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"clone-impls\",\n \"default\",\n \"derive\",\n \"parsing\",\n \"printing\",\n \"proc-macro\",\n ],\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=syn\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"3.0.3\",\n)\n"
+ "strip_prefix": "syn-3.0.5",
+ "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n# bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n name = \"cargo_toml_env_vars\",\n src = \"Cargo.toml\",\n)\n\nrust_library(\n name = \"syn\",\n deps = [\n \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n \"@crates__quote-1.0.47//:quote\",\n \"@crates__unicode-ident-1.0.24//:unicode_ident\",\n ],\n compile_data = glob(\n allow_empty = True,\n include = [\"**\"],\n exclude = [\n \"**/* *\",\n \".tmp_git_root/**/*\",\n \"BUILD\",\n \"BUILD.bazel\",\n \"WORKSPACE\",\n \"WORKSPACE.bazel\",\n ],\n ),\n crate_features = [\n \"clone-impls\",\n \"default\",\n \"derive\",\n \"parsing\",\n \"printing\",\n \"proc-macro\",\n ],\n crate_root = \"src/lib.rs\",\n edition = \"2021\",\n rustc_env_files = [\n \":cargo_toml_env_vars\",\n ],\n rustc_flags = [\n \"--cap-lints=allow\",\n ],\n srcs = glob(\n allow_empty = True,\n include = [\"**/*.rs\"],\n ),\n tags = [\n \"cargo-bazel\",\n \"crate-name=syn\",\n \"manual\",\n \"noclippy\",\n \"norustfmt\",\n ],\n target_compatible_with = select({\n \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n \"//conditions:default\": [\"@platforms//:incompatible\"],\n }),\n version = \"3.0.5\",\n)\n"
}
},
"crates__unicode-ident-1.0.24": {
@@ -726,7 +729,7 @@
},
"@@rules_rust+//crate_universe/private:internal_extensions.bzl%cu_nr": {
"general": {
- "bzlTransitiveDigest": "Mh1KhjCK1wknt8FkwvmldZKr3vKn/LcenTGUTVneC2U=",
+ "bzlTransitiveDigest": "hDN4kJRBylKi4ybZCxjOpeQvi+fhjfVKeXXOEj2zvpI=",
"usagesDigest": "tG3p3Nb5XxC7vWY/bcKdb//g0HoAxpxxH3F5/jBVlk4=",
"recordedInputs": [
"REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals",
diff --git a/README.md b/README.md
index c8f0b70..d772238 100644
--- a/README.md
+++ b/README.md
@@ -24,23 +24,35 @@
## Source
Our canonical Git repository is located on [googlesource.com](https://gerrit.googlesource.com/gerrit).
-There is a mirror of the repository on [Github](https://github.com/GerritCodeReview/gerrit).
+There is a mirror of the repository on [GitHub](https://github.com/GerritCodeReview/gerrit).
## Reporting bugs
-Please report bugs on the [issue tracker](https://bugs.chromium.org/p/gerrit/issues/list).
+Please report bugs on the
+[issue tracker](https://issues.gerritcodereview.com/issues?q=status:open%20componentid:1370072).
+
+Due to spam abuse, membership in the
+[repo-discuss](http://groups.google.com/group/repo-discuss) Google Group is
+required in order to create issues. See the
+[announcement](https://www.gerritcodereview.com/2025-06-05-community-managers-minutes.html#reducing-spam-on-both-issue-tracker-and-gerritgooglesource)
+for more info.
## Contribute
Gerrit is the work of hundreds of contributors. We appreciate your help!
-Please read the [contribution guidelines](https://gerrit.googlesource.com/gerrit/+/master/SUBMITTING_PATCHES).
+Please read the [contribution guidelines](SUBMITTING_PATCHES).
-Note that we do not accept Pull Requests via the Github mirror.
+Due to spam abuse, membership in the
+[repo-discuss](http://groups.google.com/group/repo-discuss) Google Group is
+required in order to create Gerrit changes on the
+[gerrit-review](https://gerrit-review.googlesource.com) Gerrit server.
+
+Note that we do not accept Pull Requests via the GitHub mirror.
## Getting in contact
-The Developer Mailing list is [repo-discuss on Google Groups](https://groups.google.com/forum/#!forum/repo-discuss).
+Find a full list of contact options on the [website](https://www.gerritcodereview.com/contact.html).
## License
diff --git a/SUBMITTING_PATCHES b/SUBMITTING_PATCHES
index 8a5b785..6e22345 100644
--- a/SUBMITTING_PATCHES
+++ b/SUBMITTING_PATCHES
@@ -8,6 +8,8 @@
git push https://gerrit.googlesource.com/gerrit HEAD:refs/for/master
+See https://gerrit-review.googlesource.com/Documentation/dev-contributing.html
+for full details.
Long Version:
@@ -62,6 +64,11 @@
(3) Sending your patches.
+Due to spam abuse, membership in the http://groups.google.com/group/repo-discuss
+Google Group is required in order to create Gerrit changes on the
+https://gerrit-review.googlesource.com Gerrit server. Ensure you've done that
+before attempting git push.
+
Do not email your patches to anyone.
Instead, login to the Gerrit Code Review tool at:
diff --git a/contrib/bash_completion b/contrib/bash_completion
index 19060a5c..a1cfd91 100644
--- a/contrib/bash_completion
+++ b/contrib/bash_completion
@@ -65,7 +65,7 @@
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
- opts="check restart run start status stop supervise threads"
+ opts="check histogram restart run start status stop supervise threads"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
}
diff --git a/external_deps.lock.json b/external_deps.lock.json
index e52734f..f05cc3a 100644
--- a/external_deps.lock.json
+++ b/external_deps.lock.json
@@ -23,8 +23,6 @@
"com.google.flogger:flogger-log4j-backend": 1730247028,
"com.google.flogger:flogger-system-backend": 1522076251,
"com.google.flogger:google-extensions": -355130314,
- "com.google.gitiles:blame-cache": -1424275928,
- "com.google.gitiles:gitiles-servlet": 1993647825,
"com.google.guava:failureaccess": -2032498474,
"com.google.guava:guava": -1756621521,
"com.google.guava:guava-testlib": -203887467,
@@ -33,7 +31,7 @@
"com.google.inject:guice": 2106361664,
"com.google.j2objc:j2objc-annotations": -727464895,
"com.google.jimfs:jimfs": -1004381565,
- "com.google.protobuf:protobuf-java": -559925028,
+ "com.google.protobuf:protobuf-java": 1938090909,
"com.google.template:soy": -843524660,
"com.google.truth.extensions:truth-java8-extension": -129319374,
"com.google.truth.extensions:truth-liteproto-extension": 1463279446,
@@ -126,8 +124,8 @@
"javax.servlet:javax.servlet-api": 669233360,
"junit:junit": -744267592,
"log4j:log4j": 182326902,
- "net.bytebuddy:byte-buddy": -1875367778,
- "net.bytebuddy:byte-buddy-agent": 1759328918,
+ "net.bytebuddy:byte-buddy": 612145055,
+ "net.bytebuddy:byte-buddy-agent": -48125545,
"net.java.dev.jna:jna": -867910362,
"net.java.dev.jna:jna-platform": -561312286,
"net.minidev:json-smart": -1043043954,
@@ -137,7 +135,7 @@
"org.antlr:antlr-runtime": -2145792567,
"org.antlr:stringtemplate": -752719922,
"org.apache.commons:commons-compress": -1289113474,
- "org.apache.commons:commons-lang3": 109544183,
+ "org.apache.commons:commons-lang3": 1729335886,
"org.apache.commons:commons-math3": -1738699872,
"org.apache.commons:commons-text": -1886494041,
"org.apache.httpcomponents:fluent-hc": 58615850,
@@ -161,26 +159,27 @@
"org.apache.sshd:sshd-sftp": 292810504,
"org.asciidoctor:asciidoctorj": -457860213,
"org.assertj:assertj-core": -1145412507,
- "org.bouncycastle:bcpg-jdk18on": -1572213535,
- "org.bouncycastle:bcpkix-jdk18on": 146639060,
- "org.bouncycastle:bcprov-jdk18on": -1405390253,
- "org.bouncycastle:bcutil-jdk18on": -469511060,
+ "org.bouncycastle:bcpg-jdk18on": 915299298,
+ "org.bouncycastle:bcpkix-jdk18on": -1660815403,
+ "org.bouncycastle:bcprov-jdk18on": -1559819624,
+ "org.bouncycastle:bcutil-jdk18on": 2018001773,
"org.commonmark:commonmark": 1129543740,
"org.commonmark:commonmark-ext-autolink": -1853742120,
"org.commonmark:commonmark-ext-gfm-strikethrough": 350394231,
"org.commonmark:commonmark-ext-gfm-tables": 1881582931,
- "org.eclipse.jetty.ee11:jetty-ee11-servlet": -562048764,
- "org.eclipse.jetty.ee8:jetty-ee8-nested": 860016002,
- "org.eclipse.jetty.ee8:jetty-ee8-security": -175183477,
- "org.eclipse.jetty.ee8:jetty-ee8-servlet": 1148815122,
- "org.eclipse.jetty:jetty-http": -2043364460,
- "org.eclipse.jetty:jetty-io": -1852333390,
- "org.eclipse.jetty:jetty-jmx": -26410899,
- "org.eclipse.jetty:jetty-security": 668339532,
- "org.eclipse.jetty:jetty-server": -1937626673,
- "org.eclipse.jetty:jetty-session": 1109126766,
- "org.eclipse.jetty:jetty-util": 2042889998,
- "org.eclipse.jetty:jetty-util-ajax": 1865427299,
+ "org.commonmark:commonmark-ext-yaml-front-matter": -1519651186,
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet": 1925464069,
+ "org.eclipse.jetty.ee8:jetty-ee8-nested": -947438461,
+ "org.eclipse.jetty.ee8:jetty-ee8-security": -1982637940,
+ "org.eclipse.jetty.ee8:jetty-ee8-servlet": -658639341,
+ "org.eclipse.jetty:jetty-http": 444148373,
+ "org.eclipse.jetty:jetty-io": 635179443,
+ "org.eclipse.jetty:jetty-jmx": -1833865362,
+ "org.eclipse.jetty:jetty-security": -1139114931,
+ "org.eclipse.jetty:jetty-server": 549886160,
+ "org.eclipse.jetty:jetty-session": -698327697,
+ "org.eclipse.jetty:jetty-util": 235435535,
+ "org.eclipse.jetty:jetty-util-ajax": 57972836,
"org.hamcrest:hamcrest": 1547523135,
"org.jruby:jruby-complete": -2103568068,
"org.json:json": -811907600,
@@ -239,7 +238,7 @@
"com.google.code.findbugs:jsr305:jar:sources": -640520676,
"com.google.code.gson:gson": -2014404431,
"com.google.code.gson:gson:jar:sources": 935710753,
- "com.google.common.html.types:types": -1433655673,
+ "com.google.common.html.types:types": -1909511587,
"com.google.common.html.types:types:jar:sources": -1323749402,
"com.google.errorprone:error_prone_annotations": 804114225,
"com.google.errorprone:error_prone_annotations:jar:sources": -2115535816,
@@ -251,10 +250,6 @@
"com.google.flogger:flogger:jar:sources": -1464119363,
"com.google.flogger:google-extensions": 1202868209,
"com.google.flogger:google-extensions:jar:sources": -1667103726,
- "com.google.gitiles:blame-cache": 2017928431,
- "com.google.gitiles:blame-cache:jar:sources": 467275313,
- "com.google.gitiles:gitiles-servlet": 1918113734,
- "com.google.gitiles:gitiles-servlet:jar:sources": -158011882,
"com.google.guava:failureaccess": 1715931538,
"com.google.guava:failureaccess:jar:sources": 1303858893,
"com.google.guava:guava": 555169272,
@@ -274,15 +269,15 @@
"com.google.jimfs:jimfs:jar:sources": -555304721,
"com.google.jsinterop:jsinterop-annotations": 1636460091,
"com.google.jsinterop:jsinterop-annotations:jar:sources": 694679492,
- "com.google.protobuf:protobuf-java": 911577887,
- "com.google.protobuf:protobuf-java:jar:sources": 773608461,
- "com.google.template:soy": -1107126680,
+ "com.google.protobuf:protobuf-java": 369447893,
+ "com.google.protobuf:protobuf-java:jar:sources": 1753068951,
+ "com.google.template:soy": 268221187,
"com.google.template:soy:jar:sources": 895044971,
"com.google.truth.extensions:truth-java8-extension": 766384514,
"com.google.truth.extensions:truth-java8-extension:jar:sources": 1257445795,
"com.google.truth.extensions:truth-liteproto-extension": -1774954418,
"com.google.truth.extensions:truth-liteproto-extension:jar:sources": -754757455,
- "com.google.truth.extensions:truth-proto-extension": 1860309564,
+ "com.google.truth.extensions:truth-proto-extension": 1498106386,
"com.google.truth.extensions:truth-proto-extension:jar:sources": -171856482,
"com.google.truth:truth": 2133252626,
"com.google.truth:truth:jar:sources": 494258718,
@@ -350,10 +345,10 @@
"javax.servlet:javax.servlet-api:jar:sources": -2015355058,
"junit:junit": -1256429642,
"junit:junit:jar:sources": 940567721,
- "net.bytebuddy:byte-buddy": -2144538556,
- "net.bytebuddy:byte-buddy-agent": -1307562154,
- "net.bytebuddy:byte-buddy-agent:jar:sources": 2038081020,
- "net.bytebuddy:byte-buddy:jar:sources": 1602176187,
+ "net.bytebuddy:byte-buddy": 570606503,
+ "net.bytebuddy:byte-buddy-agent": -647040865,
+ "net.bytebuddy:byte-buddy-agent:jar:sources": 1035878534,
+ "net.bytebuddy:byte-buddy:jar:sources": -750344117,
"net.java.dev.jna:jna": 1622514527,
"net.java.dev.jna:jna-platform": 1756885266,
"net.java.dev.jna:jna-platform:jar:sources": -507721531,
@@ -372,13 +367,13 @@
"org.antlr:antlr:jar:sources": -1910959184,
"org.antlr:stringtemplate": -1632674608,
"org.antlr:stringtemplate:jar:sources": -562160043,
- "org.apache.commons:commons-compress": 2043911487,
+ "org.apache.commons:commons-compress": -832484004,
"org.apache.commons:commons-compress:jar:sources": -1888643111,
- "org.apache.commons:commons-lang3": -850748327,
- "org.apache.commons:commons-lang3:jar:sources": 1059297009,
+ "org.apache.commons:commons-lang3": 1841935436,
+ "org.apache.commons:commons-lang3:jar:sources": -982252396,
"org.apache.commons:commons-math3": -1383243934,
"org.apache.commons:commons-math3:jar:sources": -2132756896,
- "org.apache.commons:commons-text": -549439287,
+ "org.apache.commons:commons-text": 869132518,
"org.apache.commons:commons-text:jar:sources": 400790036,
"org.apache.httpcomponents:fluent-hc": -1791063366,
"org.apache.httpcomponents:fluent-hc:jar:sources": -1265691559,
@@ -410,16 +405,16 @@
"org.apache.sshd:sshd-sftp:jar:sources": -1268961386,
"org.asciidoctor:asciidoctorj": 1685789893,
"org.asciidoctor:asciidoctorj:jar:sources": 2091708864,
- "org.assertj:assertj-core": -59722598,
+ "org.assertj:assertj-core": 1185991420,
"org.assertj:assertj-core:jar:sources": -697161745,
- "org.bouncycastle:bcpg-jdk18on": -1258294405,
- "org.bouncycastle:bcpg-jdk18on:jar:sources": -1733408473,
- "org.bouncycastle:bcpkix-jdk18on": -294727450,
- "org.bouncycastle:bcpkix-jdk18on:jar:sources": 1412420619,
- "org.bouncycastle:bcprov-jdk18on": 1743462207,
- "org.bouncycastle:bcprov-jdk18on:jar:sources": 1198104999,
- "org.bouncycastle:bcutil-jdk18on": 968717615,
- "org.bouncycastle:bcutil-jdk18on:jar:sources": -1508579276,
+ "org.bouncycastle:bcpg-jdk18on": 1314692470,
+ "org.bouncycastle:bcpg-jdk18on:jar:sources": -1476258101,
+ "org.bouncycastle:bcpkix-jdk18on": -1562281305,
+ "org.bouncycastle:bcpkix-jdk18on:jar:sources": 1945286569,
+ "org.bouncycastle:bcprov-jdk18on": -390275162,
+ "org.bouncycastle:bcprov-jdk18on:jar:sources": -2118706434,
+ "org.bouncycastle:bcutil-jdk18on": -1194874921,
+ "org.bouncycastle:bcutil-jdk18on:jar:sources": -1005159791,
"org.checkerframework:checker-compat-qual": -1467964223,
"org.checkerframework:checker-compat-qual:jar:sources": 187825033,
"org.checkerframework:checker-qual": -739034920,
@@ -431,33 +426,35 @@
"org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources": 992870423,
"org.commonmark:commonmark-ext-gfm-tables": -1205584749,
"org.commonmark:commonmark-ext-gfm-tables:jar:sources": 1341057091,
+ "org.commonmark:commonmark-ext-yaml-front-matter": 1918089254,
+ "org.commonmark:commonmark-ext-yaml-front-matter:jar:sources": -902462472,
"org.commonmark:commonmark:jar:sources": -1511261547,
- "org.eclipse.jetty.ee11:jetty-ee11-servlet": -2443254,
- "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources": -1546113901,
- "org.eclipse.jetty.ee8:jetty-ee8-nested": -1915352225,
- "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources": 673934800,
- "org.eclipse.jetty.ee8:jetty-ee8-security": -1918552702,
- "org.eclipse.jetty.ee8:jetty-ee8-security:jar:sources": 114520187,
- "org.eclipse.jetty.ee8:jetty-ee8-servlet": 57123647,
- "org.eclipse.jetty.ee8:jetty-ee8-servlet:jar:sources": 500172952,
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet": -820847559,
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources": -873060978,
+ "org.eclipse.jetty.ee8:jetty-ee8-nested": -159062536,
+ "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources": 466458242,
+ "org.eclipse.jetty.ee8:jetty-ee8-security": -198386270,
+ "org.eclipse.jetty.ee8:jetty-ee8-security:jar:sources": 494500175,
+ "org.eclipse.jetty.ee8:jetty-ee8-servlet": 130236687,
+ "org.eclipse.jetty.ee8:jetty-ee8-servlet:jar:sources": -1891333550,
"org.eclipse.jetty.toolchain:jetty-servlet-api": 1364182673,
"org.eclipse.jetty.toolchain:jetty-servlet-api:jar:sources": 736604807,
- "org.eclipse.jetty:jetty-http": -2005285297,
- "org.eclipse.jetty:jetty-http:jar:sources": 1510111824,
- "org.eclipse.jetty:jetty-io": 1963881320,
- "org.eclipse.jetty:jetty-io:jar:sources": -219635344,
- "org.eclipse.jetty:jetty-jmx": 974624101,
- "org.eclipse.jetty:jetty-jmx:jar:sources": -733987429,
- "org.eclipse.jetty:jetty-security": -628512589,
- "org.eclipse.jetty:jetty-security:jar:sources": 730204298,
- "org.eclipse.jetty:jetty-server": -1113673878,
- "org.eclipse.jetty:jetty-server:jar:sources": -1325429404,
- "org.eclipse.jetty:jetty-session": -240475595,
- "org.eclipse.jetty:jetty-session:jar:sources": -407525695,
- "org.eclipse.jetty:jetty-util": 1729944958,
- "org.eclipse.jetty:jetty-util-ajax": 1312600683,
- "org.eclipse.jetty:jetty-util-ajax:jar:sources": -2027361402,
- "org.eclipse.jetty:jetty-util:jar:sources": -1809018264,
+ "org.eclipse.jetty:jetty-http": -328633070,
+ "org.eclipse.jetty:jetty-http:jar:sources": 1289113758,
+ "org.eclipse.jetty:jetty-io": -1748418908,
+ "org.eclipse.jetty:jetty-io:jar:sources": -444551156,
+ "org.eclipse.jetty:jetty-jmx": 856481565,
+ "org.eclipse.jetty:jetty-jmx:jar:sources": -1523078582,
+ "org.eclipse.jetty:jetty-security": -1524832457,
+ "org.eclipse.jetty:jetty-security:jar:sources": -272643394,
+ "org.eclipse.jetty:jetty-server": 92951848,
+ "org.eclipse.jetty:jetty-server:jar:sources": -1602164191,
+ "org.eclipse.jetty:jetty-session": -1408099417,
+ "org.eclipse.jetty:jetty-session:jar:sources": -1187228748,
+ "org.eclipse.jetty:jetty-util": -1243632909,
+ "org.eclipse.jetty:jetty-util-ajax": -818511585,
+ "org.eclipse.jetty:jetty-util-ajax:jar:sources": -884639814,
+ "org.eclipse.jetty:jetty-util:jar:sources": 1776811920,
"org.hamcrest:hamcrest": 1282317766,
"org.hamcrest:hamcrest-core": 649657847,
"org.hamcrest:hamcrest-core:jar:sources": -1646511374,
@@ -468,7 +465,7 @@
"org.jsoup:jsoup:jar:sources": -2058254438,
"org.jspecify:jspecify": 117231129,
"org.jspecify:jspecify:jar:sources": -2134060298,
- "org.mockito:mockito-core": 493796464,
+ "org.mockito:mockito-core": -1701291388,
"org.mockito:mockito-core:jar:sources": 1900207417,
"org.nibor.autolink:autolink": -443901116,
"org.nibor.autolink:autolink:jar:sources": -1863403724,
@@ -656,20 +653,6 @@
},
"version": "0.8"
},
- "com.google.gitiles:blame-cache": {
- "shasums": {
- "jar": "41dc6fe7d9967d3726cca4ff5e92247cd8c8da5ddc61b730077b8778c4829fcf",
- "sources": "9e4d550f35331762434ec1bbd448d335971f00c8ee58c91500bc8fecad156a46"
- },
- "version": "1.6.0"
- },
- "com.google.gitiles:gitiles-servlet": {
- "shasums": {
- "jar": "08562ea7d57d881042e0598722e011e1c40bb69317065f2f5a67277f9c946be3",
- "sources": "1a6556cde47342a29b93c0ebca969c728ec60b97cbee40bdf4a795ef8a0eb2b2"
- },
- "version": "1.6.0"
- },
"com.google.guava:failureaccess": {
"shasums": {
"jar": "cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb",
@@ -741,10 +724,10 @@
},
"com.google.protobuf:protobuf-java": {
"shasums": {
- "jar": "a4345ba2aa009912ff6f90467fea2d104605256b72c50840d75f13256638a472",
- "sources": "83b680c10572c930de7631cd3376ead12fc11387d3515d8cfb15bd5a381dea1a"
+ "jar": "26434a93561a1a44bf7157b2630fa73a5b4d8df20e94df167bc03fcd14abd7e4",
+ "sources": "4107ed07313f8e6c0dc34c39690c41bb929470437a71509bd112a2e843b7f3f0"
},
- "version": "4.35.1"
+ "version": "4.36.1"
},
"com.google.template:soy": {
"shasums": {
@@ -1012,17 +995,17 @@
},
"net.bytebuddy:byte-buddy": {
"shasums": {
- "jar": "e32f454c2c1f4aca982f9ec764ed892d9a6eee7e8a77f435cbdd180f6ffdb821",
- "sources": "7f38a50fa7406b61d867a2c81a89610bb816de2fa28696b4a54fcaa8d52530a1"
+ "jar": "2ed11da684a8f5b088e0222baa87461cd757e433a8dc4a03671457229d91d5fa",
+ "sources": "b1d35137942a8bff4199b8a2baa00ed3872923bc6878a6ff2731f9324fe1a2fc"
},
- "version": "1.18.11"
+ "version": "1.18.12"
},
"net.bytebuddy:byte-buddy-agent": {
"shasums": {
- "jar": "780b3601c34150ab9fb822673c472f7229c1041e8e2a12facf6a5de9ba7a2d64",
- "sources": "30bbf5860aa58e4740b352d2c4c15fe2428a2019e81dccd25c8e3937a0a2a0a3"
+ "jar": "287703bea3473edf89e1b373a097e49bad04ed48b85796228f68b1474dc93cf8",
+ "sources": "d4a1b7ab677479288a48889da66ee6e5a76b331ed3fbfb832568b7fc01c6c094"
},
- "version": "1.18.11"
+ "version": "1.18.12"
},
"net.java.dev.jna:jna": {
"shasums": {
@@ -1096,10 +1079,10 @@
},
"org.apache.commons:commons-lang3": {
"shasums": {
- "jar": "4eeeae8d20c078abb64b015ec158add383ac581571cddc45c68f0c9ae0230720",
- "sources": "b15732a13e40df7f07c30f2cb8572874798e8dde581f1398943d2ad3765bafaa"
+ "jar": "69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4",
+ "sources": "eec245e820ec2800a1780cf756aefb427c1c6170e06902e67ac15b6910ce6335"
},
- "version": "3.18.0"
+ "version": "3.20.0"
},
"org.apache.commons:commons-math3": {
"shasums": {
@@ -1229,31 +1212,31 @@
},
"org.bouncycastle:bcpg-jdk18on": {
"shasums": {
- "jar": "c0e6303a0d7589040f400950ecee87a14b81312e84ed15e5390ebb0c4566ddab",
- "sources": "a8baa033c57614d36c3d2339a8c8e5902a8a2ed8cb7387cdb2b919e5a4b15f30"
+ "jar": "39426367dc247dfaae9b1253457a39298b6b9a6c28a39b908bca829e315323dd",
+ "sources": "605b09bd826d7e4e3e0cb4faf2e9b53a6bcb452be963c91499b79fd8497685be"
},
- "version": "1.84"
+ "version": "1.85"
},
"org.bouncycastle:bcpkix-jdk18on": {
"shasums": {
- "jar": "c87f16ed9e5ec61bc94151e9f3646ac44e50cd448121ce84367fa4b7ec7ec1bb",
- "sources": "fe00c12243c28ead30ad6c7742be40ff005ab29f493c350b83b637fe4a9b5597"
+ "jar": "c9f82b2d4e99c4bbdfccf684e52cc06ea06a0b567bfd0d08f9c5a3f417055996",
+ "sources": "e5331f467331aba29bda6ddfb0df0da6d568928e29c2b0f20ea2fe123d802d20"
},
- "version": "1.84"
+ "version": "1.85"
},
"org.bouncycastle:bcprov-jdk18on": {
"shasums": {
- "jar": "64d6c5a6121fcd927152dd182cbed39afe0fda641a970d9bcc0c9cb1858b2731",
- "sources": "e5f04550f7740e588edcbd1654c59277cd7ee8725d8b674e44f7f8f4b9c5674a"
+ "jar": "986b0fb92ec10e0c66b43e036ce0077e6150cfaecd1db9fb92b56672e157afe5",
+ "sources": "b37ac84b1d5435ab7b8d166c16ab9f75e09f68f8ec50479bae433939b241b03f"
},
- "version": "1.84"
+ "version": "1.85.2"
},
"org.bouncycastle:bcutil-jdk18on": {
"shasums": {
- "jar": "b374e16963421fb9cfb01cc20d7ad8fd2f8b8188e3eef0ec0a8965e245f7619a",
- "sources": "192b719273dc33e8fd6edc3b30b126760b6740cf2e1ac3cc7cf845c7ffec9f2b"
+ "jar": "590f55ed5d68529239898a4a5c4f730b6e37f45d1cfa3fbe51f8485abe32c42d",
+ "sources": "b470a692878f92abf00b9c3af9147a45453250a80930df8f23f1d092c55e2d5e"
},
- "version": "1.84"
+ "version": "1.85"
},
"org.checkerframework:checker-compat-qual": {
"shasums": {
@@ -1297,33 +1280,40 @@
},
"version": "0.24.0"
},
+ "org.commonmark:commonmark-ext-yaml-front-matter": {
+ "shasums": {
+ "jar": "a845baba681ccbf385695fbaa6d58eb40d5ecc68f3edb968c42f074f630e8fec",
+ "sources": "5cee64663842f1e7128af9c3e62ef183ea2cf205436d27a08b7cb5be0b20edb6"
+ },
+ "version": "0.24.0"
+ },
"org.eclipse.jetty.ee11:jetty-ee11-servlet": {
"shasums": {
- "jar": "f88e19a29f4e46322df60fe43d5ac4d852edefad923e8c38ef17ff14dc522e84",
- "sources": "345f50f1a2937968934f354134cf4dd7e2b94bbfec36a177bb015ba675bd345c"
+ "jar": "af76ee845345f184d96d8f6c926e64f8bda4cf858013598d392ad604d37e6bac",
+ "sources": "0daaa96e80d48f516c580b7f18bcdf2ebdea75654a7fddb1a1a933af36ae2d16"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty.ee8:jetty-ee8-nested": {
"shasums": {
- "jar": "b6b4db33b8894688a08f23aba9eee1bc951c66be12a43095f2811121a54018d8",
- "sources": "2ba3403fb5daf6eaa10e4c3e59fa859e35e4451593d0afd681c2d57b8f6056e9"
+ "jar": "1c44681c868acc128c046e83a7e649197a6aa1d9a78f2d7588a2202d8bc94302",
+ "sources": "c04b2473642ff95ccd12a743112a115c76ce93ba94b4d58e70624c1b094ad6ec"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty.ee8:jetty-ee8-security": {
"shasums": {
- "jar": "9f165768c7f7b7a02c7631b38d898f7c16a729b78307c5c3d5940f5de7ee955d",
- "sources": "7269d79523195225aab585f0bd799914b4d6a9d4746fd71758bb509e6c9b356e"
+ "jar": "2d2cc4f51a89cd43d2d5cab7603d6ceff3d78947e5cb2f62fb2d7eac5a1d39e9",
+ "sources": "a8b42ba8dfb60ed6805b7c4ef51631726a593b617cf4472724c00b16fa003c49"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty.ee8:jetty-ee8-servlet": {
"shasums": {
- "jar": "82bd9eed66b147f243231a164c1157d3dd72cf2d05ae9029e8b12aff9df25fec",
- "sources": "520e666d8dc4646ccd4b274946278322a393742f54f29d9911be8f46d087c54e"
+ "jar": "feeba82222e0dc9c517124254ecd79d1244cfb13ced1ed8aca59e0656e331e4e",
+ "sources": "3eba19d3ffe606959bd948600bb9e0cc107008b35d632747dd88906de2c4a15b"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty.toolchain:jetty-servlet-api": {
"shasums": {
@@ -1334,59 +1324,59 @@
},
"org.eclipse.jetty:jetty-http": {
"shasums": {
- "jar": "0e204f4c71c5d7e138949d50bb7fb15766840ecea6ca24513fb92ee34b363f55",
- "sources": "1410d5686da1b63ad5d290cdd09b27bd4dfb1a563619a12ef02f63ad8c174eea"
+ "jar": "5476e16ef7e28883dba3669ee06491021e4a9aeba9a76f901a3f5c2bb5252a3e",
+ "sources": "ed89dee39bef7a469218ea81ad69675e247ee35debcbb8d5e685cbf127301585"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty:jetty-io": {
"shasums": {
- "jar": "0534454eb445263a8577200f7bd009153d36f3a81ef1543e79ab551846ee3b94",
- "sources": "d7298bbee4ebde8b35a6566212c9b04c9b19ac9dff7b84eb9a9109ddde805fe9"
+ "jar": "a809a6a534adccfaa08bca94aa54328e3ac858973ccddbda770f7e9e0f5099fc",
+ "sources": "db0aa06342140ef5f91e1923120376a1abc8ef04911a4350da105f90428f8695"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty:jetty-jmx": {
"shasums": {
- "jar": "1207fefdedd9f690d3ac37f1408af6e41f4d9d60a00491e71b3b7137ef95da6f",
- "sources": "29621d7f6da9909f9214434fef0644c1b5ff20c7d34bc4084daf3de6f1700e86"
+ "jar": "21c9e70b26ec32c3f4c06af36538c17f86d835561de41575741058ded8c73a43",
+ "sources": "439f0045abe56c4a6b4e09e3120d0c0077c253b7f6c0fa5dc6bafb3003ed8d1c"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty:jetty-security": {
"shasums": {
- "jar": "6d21a7cbfd1915735cab6638011998f881161fdeff94936ee8f71e37d3528e79",
- "sources": "bd00bca2c71ba594de92e10215c705493cbb0fc79149069c6db643911715c9b9"
+ "jar": "fcc0206c1ac66b632ef29a59ccf76c3f3737a62ef54694b1d111eeb2e6efe804",
+ "sources": "e6b477ec23e940f00fc592a68f64118418b039c9f3e0a0f7d6409a4a56c98777"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty:jetty-server": {
"shasums": {
- "jar": "772f84baf043bbc34edd99de728f3c0b2de642c400dde0fefa763e40de2ea583",
- "sources": "433739222a745d1a4e14c71b78601065caac517bf307b44f5f85b1cd83da48ae"
+ "jar": "9ba04bb8d011444a10873a51b1cb8297c04f70db55c8a8d9efcc3a9b97896787",
+ "sources": "4c9f9b9162336d26315bd37d8dfd241c6dbfce2af9676255f11f6036dfd57b56"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty:jetty-session": {
"shasums": {
- "jar": "d00db0879d2dde6bc8742c3050e995314c5d423e20ca63601cf91add2547a381",
- "sources": "b86814b8efcff1b2a79bc3ebdd9e02851d131692f2b0e7ab7b9735bbfa86d5bf"
+ "jar": "73aea4cdd91f2027da1a09cd5953b661625017971d53d8e1ff342f8547752b06",
+ "sources": "12b6ae5b07e9614c81e0cfbb464475ab1e8a638bb35a4c770e5358e60fc64267"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty:jetty-util": {
"shasums": {
- "jar": "8a8d7e063cf312b044927f817b09ad7a4517162d747f7ff891a414e8ef476c26",
- "sources": "a1cf0445b69a53200633bc8b264cfd88a2927ce4c041048649666ad7cddaaacf"
+ "jar": "f6fbfa61cfcca032ff82ad8ff3b66a14374047f15486521dbc89a6c0df19ccf2",
+ "sources": "04b565fe8b23d70735515d135932bd3c6b283ff29e5944a9165df3043eeba99d"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.eclipse.jetty:jetty-util-ajax": {
"shasums": {
- "jar": "2fc1756acdde0bdbc44c693f2c9f5801d7843ccf823b25bd65e6fc52ed77d4f5",
- "sources": "501f94d01324e7455f365e471acf3c5a3ad6c7c11ceeacf9514f3bc60cdc59e7"
+ "jar": "ca4d49183b8f92af96c04dab7a40e70cc2ebac554e6de010e1b9a983ae342cb2",
+ "sources": "41bcaeb259a63a898885dd05bfa6b8231051079e38140bbb4f18181a9037b319"
},
- "version": "12.1.11"
+ "version": "12.1.12"
},
"org.hamcrest:hamcrest": {
"shasums": {
@@ -1809,6 +1799,9 @@
"org.commonmark:commonmark-ext-gfm-tables": [
"org.commonmark:commonmark"
],
+ "org.commonmark:commonmark-ext-yaml-front-matter": [
+ "org.commonmark:commonmark"
+ ],
"org.eclipse.jetty.ee11:jetty-ee11-servlet": [
"jakarta.servlet:jakarta.servlet-api",
"org.eclipse.jetty:jetty-security",
@@ -2060,15 +2053,6 @@
"com.google.flogger:google-extensions": [
"com.google.common.flogger"
],
- "com.google.gitiles:blame-cache": [
- "com.google.gitiles.blame.cache"
- ],
- "com.google.gitiles:gitiles-servlet": [
- "com.google.gitiles",
- "com.google.gitiles.blame",
- "com.google.gitiles.doc",
- "com.google.gitiles.doc.html"
- ],
"com.google.guava:failureaccess": [
"com.google.common.util.concurrent.internal"
],
@@ -3375,19 +3359,20 @@
"org.bouncycastle.openpgp.api.jcajce",
"org.bouncycastle.openpgp.api.util",
"org.bouncycastle.openpgp.bc",
- "org.bouncycastle.openpgp.examples",
"org.bouncycastle.openpgp.jcajce",
"org.bouncycastle.openpgp.operator",
"org.bouncycastle.openpgp.operator.bc",
"org.bouncycastle.openpgp.operator.jcajce"
],
"org.bouncycastle:bcpkix-jdk18on": [
+ "org.bouncycastle.cades",
"org.bouncycastle.cert",
"org.bouncycastle.cert.bc",
"org.bouncycastle.cert.cmp",
"org.bouncycastle.cert.crmf",
"org.bouncycastle.cert.crmf.bc",
"org.bouncycastle.cert.crmf.jcajce",
+ "org.bouncycastle.cert.ct",
"org.bouncycastle.cert.dane",
"org.bouncycastle.cert.dane.fetcher",
"org.bouncycastle.cert.jcajce",
@@ -3395,6 +3380,9 @@
"org.bouncycastle.cert.ocsp.jcajce",
"org.bouncycastle.cert.path",
"org.bouncycastle.cert.path.validations",
+ "org.bouncycastle.cert.plants",
+ "org.bouncycastle.cert.plants.bc",
+ "org.bouncycastle.cert.plants.jcajce",
"org.bouncycastle.cert.selector",
"org.bouncycastle.cert.selector.jcajce",
"org.bouncycastle.cmc",
@@ -3426,6 +3414,7 @@
"org.bouncycastle.pkcs",
"org.bouncycastle.pkcs.bc",
"org.bouncycastle.pkcs.jcajce",
+ "org.bouncycastle.pkcs.util",
"org.bouncycastle.pkix",
"org.bouncycastle.pkix.jcajce",
"org.bouncycastle.pkix.util",
@@ -3442,9 +3431,11 @@
"org.bouncycastle.asn1.bc",
"org.bouncycastle.asn1.cryptopro",
"org.bouncycastle.asn1.gm",
+ "org.bouncycastle.asn1.iana",
"org.bouncycastle.asn1.nist",
"org.bouncycastle.asn1.ocsp",
"org.bouncycastle.asn1.pkcs",
+ "org.bouncycastle.asn1.plants",
"org.bouncycastle.asn1.sec",
"org.bouncycastle.asn1.teletrust",
"org.bouncycastle.asn1.ua",
@@ -3460,14 +3451,15 @@
"org.bouncycastle.crypto.agreement.ecjpake",
"org.bouncycastle.crypto.agreement.jpake",
"org.bouncycastle.crypto.agreement.kdf",
+ "org.bouncycastle.crypto.agreement.owl",
"org.bouncycastle.crypto.agreement.srp",
+ "org.bouncycastle.crypto.bls",
"org.bouncycastle.crypto.commitments",
"org.bouncycastle.crypto.constraints",
"org.bouncycastle.crypto.digests",
"org.bouncycastle.crypto.ec",
"org.bouncycastle.crypto.encodings",
"org.bouncycastle.crypto.engines",
- "org.bouncycastle.crypto.examples",
"org.bouncycastle.crypto.fpe",
"org.bouncycastle.crypto.generators",
"org.bouncycastle.crypto.hash2curve",
@@ -3476,6 +3468,8 @@
"org.bouncycastle.crypto.hpke",
"org.bouncycastle.crypto.io",
"org.bouncycastle.crypto.kems",
+ "org.bouncycastle.crypto.kems.cmce",
+ "org.bouncycastle.crypto.kems.frodo",
"org.bouncycastle.crypto.kems.mlkem",
"org.bouncycastle.crypto.macs",
"org.bouncycastle.crypto.modes",
@@ -3494,14 +3488,12 @@
"org.bouncycastle.crypto.util",
"org.bouncycastle.i18n",
"org.bouncycastle.i18n.filter",
- "org.bouncycastle.iana",
"org.bouncycastle.internal.asn1.bsi",
"org.bouncycastle.internal.asn1.cms",
"org.bouncycastle.internal.asn1.cryptlib",
"org.bouncycastle.internal.asn1.eac",
"org.bouncycastle.internal.asn1.edec",
"org.bouncycastle.internal.asn1.gnu",
- "org.bouncycastle.internal.asn1.iana",
"org.bouncycastle.internal.asn1.isara",
"org.bouncycastle.internal.asn1.isismtt",
"org.bouncycastle.internal.asn1.iso",
@@ -3516,6 +3508,8 @@
"org.bouncycastle.jcajce.interfaces",
"org.bouncycastle.jcajce.io",
"org.bouncycastle.jcajce.provider.asymmetric",
+ "org.bouncycastle.jcajce.provider.asymmetric.cmce",
+ "org.bouncycastle.jcajce.provider.asymmetric.compositekem",
"org.bouncycastle.jcajce.provider.asymmetric.compositesignatures",
"org.bouncycastle.jcajce.provider.asymmetric.dh",
"org.bouncycastle.jcajce.provider.asymmetric.dsa",
@@ -3525,6 +3519,7 @@
"org.bouncycastle.jcajce.provider.asymmetric.ecgost12",
"org.bouncycastle.jcajce.provider.asymmetric.edec",
"org.bouncycastle.jcajce.provider.asymmetric.elgamal",
+ "org.bouncycastle.jcajce.provider.asymmetric.frodokem",
"org.bouncycastle.jcajce.provider.asymmetric.gost",
"org.bouncycastle.jcajce.provider.asymmetric.ies",
"org.bouncycastle.jcajce.provider.asymmetric.mldsa",
@@ -3570,46 +3565,64 @@
"org.bouncycastle.math.raw",
"org.bouncycastle.pqc.asn1",
"org.bouncycastle.pqc.crypto",
+ "org.bouncycastle.pqc.crypto.aimer",
"org.bouncycastle.pqc.crypto.cmce",
"org.bouncycastle.pqc.crypto.crystals.dilithium",
+ "org.bouncycastle.pqc.crypto.faest",
"org.bouncycastle.pqc.crypto.falcon",
"org.bouncycastle.pqc.crypto.frodo",
+ "org.bouncycastle.pqc.crypto.haetae",
+ "org.bouncycastle.pqc.crypto.hawk",
"org.bouncycastle.pqc.crypto.hqc",
"org.bouncycastle.pqc.crypto.lms",
"org.bouncycastle.pqc.crypto.mayo",
"org.bouncycastle.pqc.crypto.mldsa",
"org.bouncycastle.pqc.crypto.mlkem",
+ "org.bouncycastle.pqc.crypto.mqom",
"org.bouncycastle.pqc.crypto.newhope",
"org.bouncycastle.pqc.crypto.ntru",
"org.bouncycastle.pqc.crypto.ntruplus",
"org.bouncycastle.pqc.crypto.ntruprime",
+ "org.bouncycastle.pqc.crypto.qruov",
"org.bouncycastle.pqc.crypto.saber",
+ "org.bouncycastle.pqc.crypto.sdith",
"org.bouncycastle.pqc.crypto.slhdsa",
"org.bouncycastle.pqc.crypto.snova",
"org.bouncycastle.pqc.crypto.sphincs",
+ "org.bouncycastle.pqc.crypto.sqisign",
+ "org.bouncycastle.pqc.crypto.uov",
"org.bouncycastle.pqc.crypto.util",
"org.bouncycastle.pqc.crypto.xmss",
"org.bouncycastle.pqc.crypto.xwing",
"org.bouncycastle.pqc.jcajce.interfaces",
"org.bouncycastle.pqc.jcajce.provider",
+ "org.bouncycastle.pqc.jcajce.provider.aimer",
"org.bouncycastle.pqc.jcajce.provider.bike",
"org.bouncycastle.pqc.jcajce.provider.cmce",
"org.bouncycastle.pqc.jcajce.provider.dilithium",
+ "org.bouncycastle.pqc.jcajce.provider.faest",
"org.bouncycastle.pqc.jcajce.provider.falcon",
"org.bouncycastle.pqc.jcajce.provider.frodo",
+ "org.bouncycastle.pqc.jcajce.provider.haetae",
+ "org.bouncycastle.pqc.jcajce.provider.hawk",
"org.bouncycastle.pqc.jcajce.provider.hqc",
"org.bouncycastle.pqc.jcajce.provider.kyber",
"org.bouncycastle.pqc.jcajce.provider.lms",
"org.bouncycastle.pqc.jcajce.provider.mayo",
+ "org.bouncycastle.pqc.jcajce.provider.mqom",
"org.bouncycastle.pqc.jcajce.provider.newhope",
"org.bouncycastle.pqc.jcajce.provider.ntru",
"org.bouncycastle.pqc.jcajce.provider.ntruplus",
"org.bouncycastle.pqc.jcajce.provider.ntruprime",
"org.bouncycastle.pqc.jcajce.provider.picnic",
+ "org.bouncycastle.pqc.jcajce.provider.qruov",
"org.bouncycastle.pqc.jcajce.provider.saber",
+ "org.bouncycastle.pqc.jcajce.provider.sdith",
"org.bouncycastle.pqc.jcajce.provider.snova",
"org.bouncycastle.pqc.jcajce.provider.sphincs",
"org.bouncycastle.pqc.jcajce.provider.sphincsplus",
+ "org.bouncycastle.pqc.jcajce.provider.sqisign",
+ "org.bouncycastle.pqc.jcajce.provider.uov",
"org.bouncycastle.pqc.jcajce.provider.util",
"org.bouncycastle.pqc.jcajce.provider.xmss",
"org.bouncycastle.pqc.jcajce.spec",
@@ -3643,7 +3656,6 @@
"org.bouncycastle.asn1.ess",
"org.bouncycastle.asn1.est",
"org.bouncycastle.asn1.gnu",
- "org.bouncycastle.asn1.iana",
"org.bouncycastle.asn1.icao",
"org.bouncycastle.asn1.isara",
"org.bouncycastle.asn1.isismtt",
@@ -3742,6 +3754,10 @@
"org.commonmark.ext.gfm.tables",
"org.commonmark.ext.gfm.tables.internal"
],
+ "org.commonmark:commonmark-ext-yaml-front-matter": [
+ "org.commonmark.ext.front.matter",
+ "org.commonmark.ext.front.matter.internal"
+ ],
"org.eclipse.jetty.ee11:jetty-ee11-servlet": [
"org.eclipse.jetty.ee11.servlet",
"org.eclipse.jetty.ee11.servlet.internal",
@@ -4334,10 +4350,6 @@
"com.google.flogger:flogger:jar:sources",
"com.google.flogger:google-extensions",
"com.google.flogger:google-extensions:jar:sources",
- "com.google.gitiles:blame-cache",
- "com.google.gitiles:blame-cache:jar:sources",
- "com.google.gitiles:gitiles-servlet",
- "com.google.gitiles:gitiles-servlet:jar:sources",
"com.google.guava:failureaccess",
"com.google.guava:failureaccess:jar:sources",
"com.google.guava:guava",
@@ -4514,6 +4526,8 @@
"org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources",
"org.commonmark:commonmark-ext-gfm-tables",
"org.commonmark:commonmark-ext-gfm-tables:jar:sources",
+ "org.commonmark:commonmark-ext-yaml-front-matter",
+ "org.commonmark:commonmark-ext-yaml-front-matter:jar:sources",
"org.commonmark:commonmark:jar:sources",
"org.eclipse.jetty.ee11:jetty-ee11-servlet",
"org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources",
@@ -4634,10 +4648,6 @@
"com.google.flogger:flogger:jar:sources",
"com.google.flogger:google-extensions",
"com.google.flogger:google-extensions:jar:sources",
- "com.google.gitiles:blame-cache",
- "com.google.gitiles:blame-cache:jar:sources",
- "com.google.gitiles:gitiles-servlet",
- "com.google.gitiles:gitiles-servlet:jar:sources",
"com.google.guava:failureaccess",
"com.google.guava:failureaccess:jar:sources",
"com.google.guava:guava",
@@ -4814,6 +4824,8 @@
"org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources",
"org.commonmark:commonmark-ext-gfm-tables",
"org.commonmark:commonmark-ext-gfm-tables:jar:sources",
+ "org.commonmark:commonmark-ext-yaml-front-matter",
+ "org.commonmark:commonmark-ext-yaml-front-matter:jar:sources",
"org.commonmark:commonmark:jar:sources",
"org.eclipse.jetty.ee11:jetty-ee11-servlet",
"org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources",
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 2a4e5d9..2041509 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -455,19 +455,6 @@
// SystemReader must be overridden before creating any repos, since they read the user/system
// configs at initialization time, and are then stored in the RepositoryCache forever.
- if (enableExperimentsRejectImplicitMergesOnMerge()) {
- // When changes are merged/submitted - reject the operation if there is an implicit merge (
- // even if rejectImplicitMerges is disabled in the project config).
- baseConfig.setStringList(
- "experiments",
- null,
- "enabled",
- ImmutableList.of(
- "GerritBackendFeature__check_implicit_merges_on_merge",
- "GerritBackendFeature__reject_implicit_merges_on_merge",
- "GerritBackendFeature__always_reject_implicit_merges_on_merge"));
- }
-
server.initServer();
server.getTestInjector().injectMembers(this);
@@ -499,12 +486,6 @@
}
}
- protected boolean enableExperimentsRejectImplicitMergesOnMerge() {
- // By default any attempt to make an explicit merge is rejected. This allows to check
- // that existing workflows continue to work even if gerrit rejects implicit merges on merge.
- return true;
- }
-
protected void setUpDatabase() throws Exception {
admin = accountCreator.admin();
user = accountCreator.user1();
diff --git a/java/com/google/gerrit/acceptance/ssh/InterruptedCommand.java b/java/com/google/gerrit/acceptance/ssh/InterruptedCommand.java
new file mode 100644
index 0000000..bd27ee9
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/InterruptedCommand.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+
+/**
+ * Test command that reproduces the exception shape seen when an SSH client disconnects while the
+ * worker thread is blocked in a library call.
+ */
+@CommandMetaData(
+ name = "interrupted",
+ description = "Test command that wraps an interrupt in an unchecked exception",
+ runsAt = MASTER_OR_SLAVE)
+public class InterruptedCommand extends SshCommand {
+ /** Tripped once the command is running and ready to be interrupted. */
+ public static final CyclicBarrier syncPoint = new CyclicBarrier(2);
+
+ /** Counted down immediately before the wrapped exception is thrown. */
+ public static final CountDownLatch threwWrapped = new CountDownLatch(1);
+
+ @Override
+ protected void run() throws Exception {
+ syncPoint.await();
+ try {
+ Thread.sleep(Long.MAX_VALUE);
+ } catch (InterruptedException e) {
+ threwWrapped.countDown();
+ throw new RuntimeException("thread waiting for the response was interrupted", e);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
index f20851c..785fdf6 100644
--- a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
+++ b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
@@ -25,5 +25,6 @@
protected void configure() {
command("graceful").to(GracefulCommand.class);
command("non-graceful").to(NonGracefulCommand.class);
+ command("interrupted").to(InterruptedCommand.class);
}
}
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index fd682c1..37a7138 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -26,4 +26,5 @@
public String instanceId;
public String defaultBranch;
public Boolean projectStatePredicateEnabled;
+ public String submitCommitUrl;
}
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index 6de0712..b51ad90 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -38,14 +38,12 @@
this.start = start;
this.indexConfig = indexConfig;
- int c = Integer.MAX_VALUE;
Predicate<T> selectedSource = null;
int minCardinality = Integer.MAX_VALUE;
for (Predicate<T> p : getChildren()) {
if (p instanceof DataSource) {
DataSource<?> source = (DataSource<?>) p;
int cardinality = source.getCardinality();
- c = Math.min(c, source.getCardinality());
if (selectedSource == null
|| cardinality < minCardinality
@@ -60,7 +58,7 @@
throw new IllegalArgumentException("No DataSource Found");
}
this.filteredSource = toDataSource(selectedSource);
- this.cardinality = c;
+ this.cardinality = minCardinality;
}
@Override
@@ -75,11 +73,7 @@
@Override
public boolean match(T object) {
- if (super.isMatchable() && !super.match(object)) {
- return false;
- }
-
- return true;
+ return !super.isMatchable() || super.match(object);
}
protected List<T> transformBuffer(List<T> buffer) {
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
index 19251ca..3bde44d 100644
--- a/java/com/google/gerrit/index/query/PaginatingSource.java
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -62,7 +62,7 @@
int pageSizeMultiplier = opts.pageSizeMultiplier();
Object searchAfter = resultSet.searchAfter();
int nextStart = pageResultSize;
- while (pageResultSize == pageSize && r.size() <= limit) { // get 1 more than the limit
+ while (pageResultSize == pageSize && r.size() < limit) {
pageSize = getNextPageSize(pageSize, pageSizeMultiplier);
ResultSet<T> next =
indexConfig.paginationType().equals(PaginationType.SEARCH_AFTER)
@@ -74,7 +74,7 @@
r.add(data);
}
pageResultSize++;
- if (r.size() > limit) {
+ if (r.size() >= limit) {
break;
}
}
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 20f5bb3..02ac980 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -333,7 +333,8 @@
final int requestHeaderSize = cfg.getInt("httpd", "requestheadersize", 16386);
final URI[] listenUrls = listenURLs(cfg);
final boolean reuseAddress = cfg.getBoolean("httpd", "reuseaddress", true);
- final int acceptors = cfg.getInt("httpd", "acceptorThreads", 2);
+ final int acceptors = cfg.getInt("httpd", "acceptorThreads", 0);
+ final int selectors = cfg.getInt("httpd", "selectorThreads", 2);
final AuthType authType = cfg.getEnum("auth", null, "type", AuthType.OPENID);
reverseProxy = isReverseProxied(listenUrls);
@@ -376,7 +377,7 @@
if ("http".equals(u.getScheme())) {
defaultPort = 80;
- c = newServerConnector(server, acceptors, config);
+ c = newServerConnector(server, acceptors, selectors, config);
} else if ("https".equals(u.getScheme())) {
SslContextFactory.Server ssl = new SslContextFactory.Server();
@@ -409,15 +410,15 @@
null,
null,
null,
- 0,
acceptors,
+ selectors,
new SslConnectionFactory(ssl, "http/1.1"),
new HttpConnectionFactory(config));
} else if ("proxy-http".equals(u.getScheme())) {
defaultPort = 8080;
config.addCustomizer(FORWARDED_REQUEST_CUSTOMIZER);
- c = newServerConnector(server, acceptors, config);
+ c = newServerConnector(server, acceptors, selectors, config);
} else if ("proxy-https".equals(u.getScheme())) {
defaultPort = 8080;
@@ -443,7 +444,7 @@
return true;
}
});
- c = newServerConnector(server, acceptors, config);
+ c = newServerConnector(server, acceptors, selectors, config);
} else {
throw new IllegalArgumentException(
@@ -485,9 +486,9 @@
}
private static ServerConnector newServerConnector(
- Server server, int acceptors, HttpConfiguration config) {
+ Server server, int acceptors, int selectors, HttpConfiguration config) {
return new ServerConnector(
- server, null, null, null, 0, acceptors, new HttpConnectionFactory(config));
+ server, null, null, null, acceptors, selectors, new HttpConnectionFactory(config));
}
private HttpConfiguration defaultConfig(int requestHeaderSize) {
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index aed73de..82bb4f4 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.account;
+import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
@@ -139,6 +140,9 @@
@Override
public Optional<InternalGroup> get(AccountGroup.Id groupId) {
+ if (groupId == null) {
+ return Optional.empty();
+ }
try {
return byId.get(groupId);
} catch (ExecutionException e) {
@@ -176,12 +180,27 @@
@Override
public Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids) {
+ if (groupUuids == null || groupUuids.isEmpty()) {
+ return ImmutableMap.of();
+ }
+ if (groupUuids.size() == 1) {
+ AccountGroup.UUID singleUuid = Iterables.getOnlyElement(groupUuids);
+ return get(singleUuid)
+ .map(g -> (Map<AccountGroup.UUID, InternalGroup>) ImmutableMap.of(singleUuid, g))
+ .orElseGet(ImmutableMap::of);
+ }
try {
ImmutableSet<String> groupUuidsStringSet =
- groupUuids.stream().map(u -> u.get()).collect(toImmutableSet());
- return byUUID.getAll(groupUuidsStringSet).entrySet().stream()
- .filter(g -> g.getValue().isPresent())
- .collect(toImmutableMap(g -> AccountGroup.uuid(g.getKey()), g -> g.getValue().get()));
+ groupUuids.stream().map(AccountGroup.UUID::get).collect(toImmutableSet());
+ ImmutableMap<AccountGroup.UUID, InternalGroup> result =
+ byUUID.getAll(groupUuidsStringSet).entrySet().stream()
+ .filter(g -> g.getValue().isPresent())
+ .collect(toImmutableMap(g -> AccountGroup.uuid(g.getKey()), g -> g.getValue().get()));
+ for (InternalGroup group : result.values()) {
+ byId.asMap().putIfAbsent(group.getId(), Optional.of(group));
+ byName.asMap().putIfAbsent(group.getNameKey().get(), Optional.of(group));
+ }
+ return result;
} catch (ExecutionException e) {
logger.atWarning().withCause(e).log("Cannot look up groups %s by uuids", groupUuids);
return ImmutableMap.of();
@@ -231,7 +250,8 @@
public void evict(Collection<AccountGroup.UUID> groupUuids) {
if (groupUuids != null && !groupUuids.isEmpty()) {
logger.atFine().log("Evict groups %s by UUID", groupUuids);
- byUUID.invalidateAll(groupUuids);
+ byUUID.invalidateAll(
+ groupUuids.stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
}
}
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 004a14b..ee44ca6 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -21,10 +21,10 @@
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
@@ -227,9 +227,6 @@
static class ParentGroupsLoader
extends CacheLoader<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> {
- // Be conservative with batching: We don't want to exhaust the number of
- // results per page and maximum terms per query. Both are usually 1000+.
- private static final int MAX_BATCH_SIZE = 100;
private final RetryHelper retryHelper;
@Inject
@@ -250,16 +247,13 @@
public Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> loadAll(
Iterable<? extends AccountGroup.UUID> keys) {
int numKeys = Iterables.size(keys);
- Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> result =
- Maps.newHashMapWithExpectedSize(numKeys);
+ if (numKeys == 0) {
+ return ImmutableMap.of();
+ }
try (TraceTimer timer = TraceContext.newTimer("Loading " + numKeys + " parent groups")) {
- Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> bySubgroups =
- retryHelper
- .groupIndexQuery("loadParentGroups", q -> q.bySubgroups(ImmutableSet.copyOf(keys)))
- .call();
- Iterables.partition(keys, MAX_BATCH_SIZE)
- .forEach(keyPartition -> result.putAll(bySubgroups));
- return result;
+ return retryHelper
+ .groupIndexQuery("loadParentGroups", q -> q.bySubgroups(ImmutableSet.copyOf(keys)))
+ .call();
}
}
}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
index 2bd8d5f..45b5898 100644
--- a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -23,6 +23,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.common.hash.Hashing;
@@ -45,6 +46,7 @@
import com.google.inject.Module;
import com.google.inject.name.Named;
import java.io.IOException;
+import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -98,28 +100,31 @@
@Override
public ImmutableMap<CommentContextKey, CommentContext> getAll(
Iterable<CommentContextKey> inputKeys) {
- ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
-
// We do two transformations to the input keys: first we adjust the max context padding, and
// second we hash the file path. The transformed keys are used to request context from the
// cache. Keeping a map of the original inputKeys to the transformed keys
+ int estimatedSize = (inputKeys instanceof Collection) ? ((Collection<?>) inputKeys).size() : 16;
Map<CommentContextKey, CommentContextKey> inputKeysToCacheKeys =
- Streams.stream(inputKeys)
- .collect(
- Collectors.toMap(
- Function.identity(),
- k ->
- adjustMaxContextPadding(k).toBuilder()
- .path(Loader.hashPath(k.path()))
- .build()));
+ Maps.newHashMapWithExpectedSize(estimatedSize);
+ for (CommentContextKey k : inputKeys) {
+ inputKeysToCacheKeys.computeIfAbsent(
+ k,
+ key ->
+ adjustMaxContextPadding(key).toBuilder().path(Loader.hashPath(key.path())).build());
+ }
try {
ImmutableMap<CommentContextKey, CommentContext> allContext =
contextCache.getAll(inputKeysToCacheKeys.values());
- for (CommentContextKey inputKey : inputKeys) {
- CommentContextKey cacheKey = inputKeysToCacheKeys.get(inputKey);
- result.put(inputKey, allContext.get(cacheKey));
+ ImmutableMap.Builder<CommentContextKey, CommentContext> result =
+ ImmutableMap.builderWithExpectedSize(inputKeysToCacheKeys.size());
+ for (Map.Entry<CommentContextKey, CommentContextKey> entry :
+ inputKeysToCacheKeys.entrySet()) {
+ CommentContext ctx = allContext.get(entry.getValue());
+ if (ctx != null) {
+ result.put(entry.getKey(), ctx);
+ }
}
return result.build();
} catch (ExecutionException e) {
@@ -152,24 +157,23 @@
AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
allBuilder.setContentType(commentContext.contentType());
- commentContext
- .lines()
- .entrySet()
- .forEach(
- c ->
- allBuilder.addContext(
- CommentContextProto.newBuilder()
- .setLineNumber(c.getKey())
- .setContextLine(c.getValue())));
+ for (Map.Entry<Integer, String> c : commentContext.lines().entrySet()) {
+ allBuilder.addContext(
+ CommentContextProto.newBuilder()
+ .setLineNumber(c.getKey())
+ .setContextLine(c.getValue()));
+ }
return Protos.toByteArray(allBuilder.build());
}
@Override
public CommentContext deserialize(byte[] in) {
- ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
AllCommentContextProto proto = Protos.parseUnchecked(AllCommentContextProto.parser(), in);
- proto.getContextList().stream()
- .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
+ ImmutableMap.Builder<Integer, String> contextLinesMap =
+ ImmutableMap.builderWithExpectedSize(proto.getContextCount());
+ for (CommentContextProto c : proto.getContextList()) {
+ contextLinesMap.put(c.getLineNumber(), c.getContextLine());
+ }
return CommentContext.create(contextLinesMap.build(), proto.getContentType());
}
}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index c15e347..30c0ffe 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -26,37 +26,6 @@
/** Features, enabled by default in the current release. */
public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
- /**
- * If true, gerrit checks implicit merges on each merge operations.
- *
- * <p>If only this option is set (without {@link
- * #GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE}) - then the outcome of the check is
- * only logged and doesn't block merge operation. Any exceptions during the check are logged and
- * doesn't block merge operation.
- */
- public static String GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE =
- "GerritBackendFeature__check_implicit_merges_on_merge";
-
- /**
- * If true, gerrit rejects implicit merges on merge.
- *
- * <p>Should work together with {@link #GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE}.
- *
- * <p>If {@link #GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE} is set to true
- * then implicit merges are rejected even if rejectImplicitMerges in project config is set to
- * false.
- *
- * <p>If {@link #GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE} is set to false
- * then implicit merges are rejected only if rejectImplicitMerges in project config is set to
- * true.
- */
- public static String GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE =
- "GerritBackendFeature__reject_implicit_merges_on_merge";
-
- /** If true, gerrit ignores rejectImplicitMerges setting from the project config on merge. */
- public static String GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE =
- "GerritBackendFeature__always_reject_implicit_merges_on_merge";
-
/** Whether we allow fix suggestions in HumanComments. */
public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS =
"GerritBackendFeature__allow_fix_suggestions_in_comments";
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 2b3bdba..3db9a45 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -891,6 +891,11 @@
return executor.queueName;
}
+ private boolean isWaitingToStart() {
+ State state = runningState.get();
+ return state == State.READY || state == State.PARKED;
+ }
+
@Override
@CanIgnoreReturnValue
public boolean cancel(boolean mayInterruptIfRunning) {
@@ -906,6 +911,8 @@
if (runningState.compareAndSet(null, State.RUNNING)) {
isSetRunningDuringCancellation = true;
((CancelableRunnable) runnable).cancel();
+ } else if (isWaitingToStart()) {
+ ((CancelableRunnable) runnable).cancel();
} else if (runnable instanceof CanceledWhileRunning) {
((CanceledWhileRunning) runnable).setCanceledWhileRunning();
}
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index f7f1ce1..4f13619 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -258,6 +258,14 @@
return r;
}
+ public static final IndexedField<ChangeData, Integer> FILE_COUNT =
+ IndexedField.<ChangeData>integerBuilder("FileCount")
+ .stored()
+ .build(cd -> cd.currentFilePaths().size());
+
+ public static final IndexedField<ChangeData, Integer>.SearchSpec FILE_COUNT_SPEC =
+ FILE_COUNT.integerRange(ChangeQueryBuilder.FIELD_FILE_COUNT);
+
/** Hashtags tied to a change */
public static final IndexedField<ChangeData, Iterable<String>> HASHTAG_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("Hashtag")
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 843c5de..7eabcc5 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -20,6 +20,7 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Change.Status;
@@ -59,6 +60,8 @@
/** Rewriter that pushes boolean logic into the secondary index. */
@Singleton
public class ChangeIndexRewriter implements IndexRewriter<ChangeData> {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
/** Set of all open change statuses. */
public static final ImmutableSet<Change.Status> OPEN_STATUSES;
@@ -149,6 +152,10 @@
throws QueryParseException {
Predicate<ChangeData> s = rewriteImpl(in, opts);
if (!(s instanceof ChangeDataSource)) {
+ logger.atFine().log(
+ "Query rewrite did not produce a ChangeDataSource; falling back to full-status index"
+ + " scan (and(or(open,closed), ...)). Original query: %s, rewritten: %s",
+ in, s);
in = Predicate.and(Predicate.or(open(), closed()), in);
s = rewriteImpl(in, opts);
}
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 809dd0a..f08a7ae 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -283,6 +283,7 @@
.build();
/** Add met and unmet requirement tracking fields */
+ @Deprecated
static final Schema<ChangeData> V89 =
new Schema.Builder<ChangeData>()
.add(V88)
@@ -292,6 +293,14 @@
ChangeField.UNMET_REQUIREMENT_SPEC, ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC)
.build();
+ /** Add file count field */
+ static final Schema<ChangeData> V90 =
+ new Schema.Builder<ChangeData>()
+ .add(V89)
+ .addIndexedFields(ChangeField.FILE_COUNT)
+ .addSearchSpecs(ChangeField.FILE_COUNT_SPEC)
+ .build();
+
/**
* Name of the change index to be used when contacting index backends or loading configurations.
*/
diff --git a/java/com/google/gerrit/server/index/change/PendingIndexUpdate.java b/java/com/google/gerrit/server/index/change/PendingIndexUpdate.java
index db19fab..2c67a51 100644
--- a/java/com/google/gerrit/server/index/change/PendingIndexUpdate.java
+++ b/java/com/google/gerrit/server/index/change/PendingIndexUpdate.java
@@ -42,7 +42,7 @@
* with the JSON content of {@link Intent}.
*/
@Singleton
-public final class PendingIndexUpdate {
+public class PendingIndexUpdate {
record Intent(String project, int changeId, String operation) {}
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -56,8 +56,7 @@
final Path runningDir;
@Inject
- public PendingIndexUpdate(
- SitePaths sitePaths, ChangeIndexer indexer, @GerritServerConfig Config cfg) {
+ PendingIndexUpdate(SitePaths sitePaths, ChangeIndexer indexer, @GerritServerConfig Config cfg) {
intentDir = sitePaths.data_dir.resolve("pending-index");
buildingDir = intentDir.resolve("building");
runningDir = intentDir.resolve(PROCESS_MARKER);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index b85e449..0fa0f18 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -47,7 +47,7 @@
result.put(note.copy(), rn);
}
- return new RevisionNoteMap(noteMap, result.build());
+ return new RevisionNoteMap(noteMap, result.buildOrThrow());
}
static RevisionNoteMap emptyMap() {
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 1de12d7..b91b9fc 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -44,6 +44,10 @@
import java.util.Optional;
import java.util.Set;
import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.diff.EditList;
+import org.eclipse.jgit.diff.HistogramDiff;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
@@ -122,7 +126,7 @@
if (a.mode == FileMode.MISSING) {
throw new ResourceNotFoundException(String.format("File %s not found", fileName));
}
- FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements, true);
+ FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements, false);
PatchSide b =
new PatchSide(
null,
@@ -135,9 +139,26 @@
a.displayMethod,
a.fileMode);
+ RawText aRawText = new RawText(a.src.getContent());
+ RawText bRawText = new RawText(fixResult.text.getContent());
+ RawTextComparator cmp = comparatorFor(diffPrefs != null ? diffPrefs.ignoreWhitespace : null);
+ EditList edits = new HistogramDiff().diff(cmp, aRawText, bRawText);
+
+ ImmutableList<Edit> finalEdits;
+ if (diffPrefs == null
+ || diffPrefs.intralineDifference == null
+ || diffPrefs.intralineDifference) {
+ IntraLineDiff intraLineDiff =
+ IntraLineLoader.compute(
+ a.src, fixResult.text, ImmutableList.copyOf(edits), ImmutableSet.of());
+ finalEdits = ImmutableList.copyOf(intraLineDiff.getEdits());
+ } else {
+ finalEdits = ImmutableList.copyOf(edits);
+ }
+
PatchFileChange change =
new PatchFileChange(
- fixResult.edits,
+ finalEdits,
ImmutableSet.of(),
ImmutableList.of(),
fileName,
@@ -148,6 +169,24 @@
return build(a, b, change);
}
+ private static RawTextComparator comparatorFor(
+ @Nullable DiffPreferencesInfo.Whitespace whitespace) {
+ if (whitespace == null) {
+ return RawTextComparator.DEFAULT;
+ }
+ switch (whitespace) {
+ case IGNORE_ALL:
+ return RawTextComparator.WS_IGNORE_ALL;
+ case IGNORE_TRAILING:
+ return RawTextComparator.WS_IGNORE_TRAILING;
+ case IGNORE_LEADING_AND_TRAILING:
+ return RawTextComparator.WS_IGNORE_CHANGE;
+ case IGNORE_NONE:
+ default:
+ return RawTextComparator.DEFAULT;
+ }
+ }
+
private PatchSide resolveSideA(
Repository git, SidesResolver sidesResolver, String path, ObjectId baseId)
throws IOException {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 0516d43..8540b27 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -376,6 +376,7 @@
this.projectName = projectName;
this.baseConfig = baseConfig;
this.allProjectsName = allProjectsName;
+ this.contributorAgreements = new HashMap<>();
}
public void load(Repository repo) throws IOException, ConfigInvalidException {
@@ -695,7 +696,9 @@
this.project = p.build();
loadAccountsSection(rc);
- loadContributorAgreements(rc);
+ if (projectName.equals(allProjectsName)) {
+ loadContributorAgreements(rc);
+ }
loadAccessSections(rc);
loadBranchOrderSection(rc);
loadNotifySections(rc);
@@ -734,7 +737,7 @@
}
private void loadContributorAgreements(Config rc) {
- contributorAgreements = new HashMap<>();
+ contributorAgreements.clear();
for (String name : rc.getSubsections(CONTRIBUTOR_AGREEMENT)) {
ContributorAgreement.Builder ca = ContributorAgreement.builder(name);
ca.setDescription(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_DESCRIPTION));
@@ -1352,7 +1355,9 @@
Set<AccountGroup.UUID> keepGroups = new HashSet<>();
saveAccountsSection(rc, keepGroups);
- saveContributorAgreements(rc, keepGroups);
+ if (projectName.equals(allProjectsName)) {
+ saveContributorAgreements(rc, keepGroups);
+ }
saveAccessSections(rc, keepGroups);
saveNotifySections(rc, keepGroups);
savePluginSections(rc, keepGroups);
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 6331323..bb73016 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -17,10 +17,13 @@
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.git.ObjectIds;
@@ -279,6 +282,52 @@
}
/**
+ * Returns a predicate that matches changes that affect exactly the given number of files, or a
+ * range of file counts, in their latest patch set. The {@code count} parameter may be a plain
+ * integer (exact match) or a range expression such as {@code >2} or {@code <10}.
+ */
+ public static Predicate<ChangeData> filecount(String count) throws QueryParseException {
+ return new FileCountPredicate(count);
+ }
+
+ /**
+ * Returns a predicate that matches changes whose set of real (non-magic) files is exactly the
+ * comma-separated list of paths provided.
+ *
+ * <p>When {@code hasFileCountField} is true, builds {@code AND(path:f1, path:f2, …, filecount:N)}
+ * entirely from index predicates: each {@code path:} clause ensures the file is present, and
+ * {@code filecount:N} ensures no extra files exist.
+ *
+ * <p>When {@code hasFileCountField} is false (older schema versions that lack the {@code
+ * filecount:} field), falls back to {@code AND(path:f1, path:f2, …) +
+ * OnlyPathsPostFilterPredicate}.
+ */
+ public static Predicate<ChangeData> onlyPaths(String paths, boolean hasFileCountField) {
+ ImmutableSet<String> files =
+ Splitter.on(',')
+ .trimResults()
+ .omitEmptyStrings()
+ .splitToStream(paths)
+ .filter(f -> !Patch.isMagic(f))
+ .collect(toImmutableSet());
+
+ ImmutableList.Builder<Predicate<ChangeData>> clauses = ImmutableList.builder();
+ files.forEach(f -> clauses.add(path(f)));
+
+ if (hasFileCountField) {
+ try {
+ clauses.add(new FileCountPredicate(String.valueOf(files.size())));
+ } catch (QueryParseException e) {
+ throw new IllegalStateException("unreachable: files.size() is always a valid integer", e);
+ }
+ } else {
+ clauses.add(new OnlyPathsPostFilterPredicate(files));
+ }
+
+ return Predicate.and(clauses.build());
+ }
+
+ /**
* Returns a predicate that matches changes with the provided {@code footer} in their commit
* message.
*/
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index affda16..d5eba4d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -181,6 +181,7 @@
public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
public static final String FIELD_EXTENSION = "extension";
public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
+ public static final String FIELD_ONLY_PATHS = "onlypaths";
public static final String FIELD_FOOTER = "footer";
public static final String FIELD_FOOTER_NAME = "footernames";
public static final String FIELD_CONFLICTS = "conflicts";
@@ -192,6 +193,7 @@
public static final String FIELD_EXACTCOMMIT = "exactcommit";
public static final String FIELD_FILE = "file";
public static final String FIELD_FILEPART = "filepart";
+ public static final String FIELD_FILE_COUNT = "filecount";
public static final String FIELD_GROUP = "group";
public static final String FIELD_HASHTAG = "hashtag";
public static final String FIELD_LABEL = "label";
@@ -1048,6 +1050,11 @@
}
@Operator
+ public Predicate<ChangeData> filecount(String count) throws QueryParseException {
+ return ChangePredicates.filecount(count);
+ }
+
+ @Operator
public Predicate<ChangeData> ext(String ext) {
return extension(ext);
}
@@ -1068,6 +1075,15 @@
}
@Operator
+ public Predicate<ChangeData> onlypaths(String value) {
+ if (value.startsWith("^")) {
+ return new RegexOnlyPathsPredicate(value, args.regexCompiler);
+ }
+ return ChangePredicates.onlyPaths(
+ value, args.getSchema() != null && args.getSchema().hasField(ChangeField.FILE_COUNT_SPEC));
+ }
+
+ @Operator
public Predicate<ChangeData> footer(String footer) {
return ChangePredicates.footer(footer);
}
diff --git a/java/com/google/gerrit/server/query/change/FileCountPredicate.java b/java/com/google/gerrit/server/query/change/FileCountPredicate.java
new file mode 100644
index 0000000..ed3b07f
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FileCountPredicate.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class FileCountPredicate extends IntegerRangeChangePredicate {
+ public FileCountPredicate(String value) throws QueryParseException {
+ super(ChangeField.FILE_COUNT_SPEC, value);
+ }
+
+ @Override
+ protected Integer getValueInt(ChangeData changeData) {
+ return ChangeField.FILE_COUNT_SPEC.get(changeData);
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6c1f35e..ea833dc 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -129,11 +129,38 @@
@UsedAt(UsedAt.Project.GOOGLE)
public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
+ if (ids.isEmpty()) {
+ return Collections.emptyList();
+ }
+ int batchSize = indexConfig.maxTerms();
+ if (ids.size() <= batchSize) {
+ return query(byLegacyChangeIdsPredicate(indexConfig, ids));
+ }
+ List<Predicate<ChangeData>> queries = new ArrayList<>();
+ for (List<Change.Id> part : Iterables.partition(ids, batchSize)) {
+ queries.add(byLegacyChangeIdsPredicate(indexConfig, part));
+ }
+ Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(ids.size());
+ ImmutableList.Builder<ChangeData> result = ImmutableList.builder();
+ for (List<ChangeData> cds : query(queries)) {
+ for (ChangeData cd : cds) {
+ if (seen.add(cd.virtualId())) {
+ result.add(cd);
+ }
+ }
+ }
+ return result.build();
+ }
+
+ private static Predicate<ChangeData> byLegacyChangeIdsPredicate(
+ IndexConfig indexConfig, Collection<Change.Id> ids) {
+ int n = indexConfig.maxTerms();
+ checkArgument(ids.size() <= n, "cannot exceed %s change IDs", n);
List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
for (Change.Id id : ids) {
preds.add(ChangePredicates.idStr(id));
}
- return query(or(preds));
+ return or(preds);
}
@UsedAt(UsedAt.Project.GOOGLE)
@@ -258,10 +285,35 @@
return query(and(project(project), commit(hash)));
}
- public List<ChangeData> byProjectCommits(Project.NameKey project, List<String> hashes) {
+ public List<ChangeData> byProjectCommits(Project.NameKey project, Collection<String> hashes) {
+ if (hashes.isEmpty()) {
+ return Collections.emptyList();
+ }
+ int batchSize = indexConfig.maxTerms() - 1;
+ if (hashes.size() <= batchSize) {
+ return query(byProjectCommitsPredicate(indexConfig, project, hashes));
+ }
+ List<Predicate<ChangeData>> queries = new ArrayList<>();
+ for (List<String> part : Iterables.partition(hashes, batchSize)) {
+ queries.add(byProjectCommitsPredicate(indexConfig, project, part));
+ }
+ Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(hashes.size());
+ ImmutableList.Builder<ChangeData> result = ImmutableList.builder();
+ for (List<ChangeData> cds : query(queries)) {
+ for (ChangeData cd : cds) {
+ if (seen.add(cd.virtualId())) {
+ result.add(cd);
+ }
+ }
+ }
+ return result.build();
+ }
+
+ private static Predicate<ChangeData> byProjectCommitsPredicate(
+ IndexConfig indexConfig, Project.NameKey project, Collection<String> hashes) {
int n = indexConfig.maxTerms() - 1;
checkArgument(hashes.size() <= n, "cannot exceed %s commits", n);
- return query(and(project(project), or(commits(hashes))));
+ return and(project(project), or(commits(hashes)));
}
public List<ChangeData> byBranchCommit(String project, String branch, String hash) {
diff --git a/java/com/google/gerrit/server/query/change/OnlyPathsPostFilterPredicate.java b/java/com/google/gerrit/server/query/change/OnlyPathsPostFilterPredicate.java
new file mode 100644
index 0000000..33f7281
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/OnlyPathsPostFilterPredicate.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.index.query.PostFilterPredicate;
+
+/**
+ * Post-filter that passes only when the change's set of real (non-magic) files is exactly the
+ * expected list (sorted, deduplicated).
+ *
+ * <p>Used as a fallback when the {@code filecount:} index field is not available in the current
+ * schema version.
+ */
+@Deprecated
+class OnlyPathsPostFilterPredicate extends PostFilterPredicate<ChangeData> {
+ private final ImmutableSet<String> expectedFiles;
+
+ OnlyPathsPostFilterPredicate(ImmutableSet<String> files) {
+ super(ChangeQueryBuilder.FIELD_ONLY_PATHS, String.join(",", files));
+ this.expectedFiles = files;
+ }
+
+ @Override
+ public boolean match(ChangeData cd) {
+ ImmutableSet<String> realFiles =
+ cd.currentFilePaths().stream().filter(p -> !Patch.isMagic(p)).collect(toImmutableSet());
+ return realFiles.equals(expectedFiles);
+ }
+
+ @Override
+ public int getCost() {
+ return 3;
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/RegexOnlyPathsPredicate.java b/java/com/google/gerrit/server/query/change/RegexOnlyPathsPredicate.java
new file mode 100644
index 0000000..ca7aa32
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexOnlyPathsPredicate.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.server.ioutil.RegexCompiler;
+import com.google.gerrit.server.ioutil.RegexListSearcher;
+import java.util.List;
+
+/**
+ * Predicate matching changes where <em>every</em> real (non-magic) file matches the supplied regex
+ * — and no real files fall outside it.
+ *
+ * <p>Magic files ({@code /COMMIT_MSG}, {@code /MERGE_LIST}, {@code /PATCHSET_LEVEL}) are excluded
+ * from matching so that callers never need to mention them in queries.
+ *
+ * <p>Usage: {@code onlypaths:^src/.*\.java$}
+ */
+public class RegexOnlyPathsPredicate extends PostFilterPredicate<ChangeData> {
+ private final RegexListSearcher<String> searcher;
+
+ public RegexOnlyPathsPredicate(String re, RegexCompiler regexCompiler) {
+ super(ChangeQueryBuilder.FIELD_ONLY_PATHS, re);
+ this.searcher = RegexListSearcher.ofStrings(re, regexCompiler);
+ }
+
+ @Override
+ public boolean match(ChangeData cd) {
+ List<String> realFiles =
+ cd.currentFilePaths().stream().filter(p -> !Patch.isMagic(p)).sorted().toList();
+
+ if (realFiles.isEmpty()) {
+ return false;
+ }
+
+ // Every real file must match; a single mismatch disqualifies the change.
+ // RegexListSearcher expects a sorted list — realFiles is already sorted.
+ return realFiles.stream()
+ .allMatch(path -> searcher.search(List.of(path)).findAny().isPresent());
+ }
+
+ @Override
+ public int getCost() {
+ return 3;
+ }
+}
diff --git a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
index 29163a4..4782318 100644
--- a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
+++ b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -17,6 +17,7 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
@@ -29,12 +30,13 @@
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.server.index.group.GroupIndexCollection;
import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
-import java.util.stream.Collectors;
/**
* Query wrapper for the group index.
@@ -59,38 +61,162 @@
return getOnlyGroup(GroupPredicates.id(groupId), "group id '" + groupId + "'");
}
- public List<InternalGroup> byMember(Account.Id memberId) {
+ public Optional<InternalGroup> byUUID(AccountGroup.UUID uuid) {
+ return getOnlyGroup(GroupPredicates.uuid(uuid), "group UUID '" + uuid + "'");
+ }
+
+ public ImmutableList<InternalGroup> byUUIDs(Collection<AccountGroup.UUID> uuids) {
+ if (uuids.isEmpty()) {
+ return ImmutableList.of();
+ }
+ if (uuids.size() == 1) {
+ return query(GroupPredicates.uuid(uuids.iterator().next()));
+ }
+ int batchSize = Math.max(1, indexConfig.maxTerms() - 1);
+ if (uuids.size() <= batchSize) {
+ List<Predicate<InternalGroup>> predicates = new ArrayList<>(uuids.size());
+ for (AccountGroup.UUID uuid : uuids) {
+ predicates.add(GroupPredicates.uuid(uuid));
+ }
+ return query(Predicate.or(predicates));
+ }
+ List<Predicate<InternalGroup>> batchPredicates = new ArrayList<>();
+ for (List<AccountGroup.UUID> partition : Iterables.partition(uuids, batchSize)) {
+ if (partition.size() == 1) {
+ batchPredicates.add(GroupPredicates.uuid(partition.get(0)));
+ } else {
+ List<Predicate<InternalGroup>> predicates = new ArrayList<>(partition.size());
+ for (AccountGroup.UUID uuid : partition) {
+ predicates.add(GroupPredicates.uuid(uuid));
+ }
+ batchPredicates.add(Predicate.or(predicates));
+ }
+ }
+ ImmutableList.Builder<InternalGroup> result = ImmutableList.builder();
+ Set<AccountGroup.UUID> seen = new HashSet<>();
+ for (List<InternalGroup> batchResult : query(batchPredicates)) {
+ for (InternalGroup group : batchResult) {
+ if (seen.add(group.getGroupUUID())) {
+ result.add(group);
+ }
+ }
+ }
+ return result.build();
+ }
+
+ public ImmutableList<InternalGroup> byMember(Account.Id memberId) {
return query(GroupPredicates.member(memberId));
}
+ public ImmutableList<InternalGroup> byMembers(Collection<Account.Id> memberIds) {
+ if (memberIds.isEmpty()) {
+ return ImmutableList.of();
+ }
+ if (memberIds.size() == 1) {
+ return byMember(memberIds.iterator().next());
+ }
+ int batchSize = Math.max(1, indexConfig.maxTerms() - 1);
+ if (memberIds.size() <= batchSize) {
+ List<Predicate<InternalGroup>> predicates = new ArrayList<>(memberIds.size());
+ for (Account.Id id : memberIds) {
+ predicates.add(GroupPredicates.member(id));
+ }
+ return query(Predicate.or(predicates));
+ }
+ List<Predicate<InternalGroup>> batchPredicates = new ArrayList<>();
+ for (List<Account.Id> partition : Iterables.partition(memberIds, batchSize)) {
+ if (partition.size() == 1) {
+ batchPredicates.add(GroupPredicates.member(partition.get(0)));
+ } else {
+ List<Predicate<InternalGroup>> predicates = new ArrayList<>(partition.size());
+ for (Account.Id id : partition) {
+ predicates.add(GroupPredicates.member(id));
+ }
+ batchPredicates.add(Predicate.or(predicates));
+ }
+ }
+ ImmutableList.Builder<InternalGroup> result = ImmutableList.builder();
+ Set<AccountGroup.UUID> seen = new HashSet<>();
+ for (List<InternalGroup> batchResult : query(batchPredicates)) {
+ for (InternalGroup group : batchResult) {
+ if (seen.add(group.getGroupUUID())) {
+ result.add(group);
+ }
+ }
+ }
+ return result.build();
+ }
+
/**
* Get all immediate parents of the provided {@code subgroupIds}.
*
* @return map pointing from children to list of its immediate parents
*/
- public Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> bySubgroups(
+ public ImmutableMap<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> bySubgroups(
ImmutableSet<AccountGroup.UUID> subgroupIds) {
- List<Predicate<InternalGroup>> predicates =
- subgroupIds.stream().map(e -> GroupPredicates.subgroup(e)).collect(Collectors.toList());
- ImmutableList<InternalGroup> groups = query(Predicate.or(predicates));
+ if (subgroupIds.isEmpty()) {
+ return ImmutableMap.of();
+ }
+
+ ImmutableList<InternalGroup> groups;
+ int batchSize = Math.max(1, indexConfig.maxTerms() - 1);
+ if (subgroupIds.size() == 1) {
+ groups = query(GroupPredicates.subgroup(subgroupIds.iterator().next()));
+ } else if (subgroupIds.size() <= batchSize) {
+ List<Predicate<InternalGroup>> predicates = new ArrayList<>(subgroupIds.size());
+ for (AccountGroup.UUID e : subgroupIds) {
+ predicates.add(GroupPredicates.subgroup(e));
+ }
+ groups = query(Predicate.or(predicates));
+ } else {
+ List<Predicate<InternalGroup>> batchPredicates = new ArrayList<>();
+ for (List<AccountGroup.UUID> partition : Iterables.partition(subgroupIds, batchSize)) {
+ if (partition.size() == 1) {
+ batchPredicates.add(GroupPredicates.subgroup(partition.get(0)));
+ } else {
+ List<Predicate<InternalGroup>> predicates = new ArrayList<>(partition.size());
+ for (AccountGroup.UUID e : partition) {
+ predicates.add(GroupPredicates.subgroup(e));
+ }
+ batchPredicates.add(Predicate.or(predicates));
+ }
+ }
+ ImmutableList.Builder<InternalGroup> result = ImmutableList.builder();
+ Set<AccountGroup.UUID> seen = new HashSet<>();
+ for (List<InternalGroup> batchResult : query(batchPredicates)) {
+ for (InternalGroup g : batchResult) {
+ if (seen.add(g.getGroupUUID())) {
+ result.add(g);
+ }
+ }
+ }
+ groups = result.build();
+ }
Map<AccountGroup.UUID, Set<AccountGroup.UUID>> parentsByChild =
- Maps.newHashMapWithExpectedSize(groups.size());
- subgroupIds.stream().forEach(c -> parentsByChild.put(c, new HashSet<>()));
+ Maps.newHashMapWithExpectedSize(subgroupIds.size());
+ for (AccountGroup.UUID c : subgroupIds) {
+ parentsByChild.put(c, new HashSet<>());
+ }
for (InternalGroup parent : groups) {
for (AccountGroup.UUID child : parent.getSubgroups()) {
- if (subgroupIds.contains(child)) {
- parentsByChild.get(child).add(parent.getGroupUUID());
+ Set<AccountGroup.UUID> parents = parentsByChild.get(child);
+ if (parents != null) {
+ parents.add(parent.getGroupUUID());
}
}
}
- return parentsByChild.entrySet().stream()
- .collect(Collectors.toMap(Map.Entry::getKey, e -> ImmutableSet.copyOf(e.getValue())));
+ ImmutableMap.Builder<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> result =
+ ImmutableMap.builderWithExpectedSize(subgroupIds.size());
+ for (Map.Entry<AccountGroup.UUID, Set<AccountGroup.UUID>> entry : parentsByChild.entrySet()) {
+ result.put(entry.getKey(), ImmutableSet.copyOf(entry.getValue()));
+ }
+ return result.build();
}
private Optional<InternalGroup> getOnlyGroup(
Predicate<InternalGroup> predicate, String groupDescription) {
- ImmutableList<InternalGroup> groups = query(predicate);
+ ImmutableList<InternalGroup> groups = setLimit(2).query(predicate);
if (groups.isEmpty()) {
return Optional.empty();
}
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index d8be4c0..102e1b3 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -14,14 +14,11 @@
package com.google.gerrit.server.restapi.change;
-import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.server.CommentsUtil.COMMENT_INFO_ORDER;
-import static java.util.stream.Collectors.toList;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Streams;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
@@ -118,66 +115,85 @@
AccountLoader loader = fillAccounts ? accountLoaderFactory.get().create(true) : null;
Map<String, List<T>> out = new TreeMap<>();
+ int estimatedSize = (comments instanceof Collection) ? ((Collection<?>) comments).size() : 16;
+ List<T> allComments = fillCommentContext ? new ArrayList<>(estimatedSize) : null;
for (F c : comments) {
T o = toInfo(c, loader);
- List<T> list = out.get(o.path);
- if (list == null) {
- list = new ArrayList<>();
- out.put(o.path, list);
+ out.computeIfAbsent(o.path, k -> new ArrayList<>()).add(o);
+ if (fillCommentContext) {
+ allComments.add(o);
}
- list.add(o);
}
- out.values().forEach(l -> l.sort(COMMENT_INFO_ORDER));
+ for (List<T> list : out.values()) {
+ list.sort(COMMENT_INFO_ORDER);
+ }
if (loader != null) {
loader.fill();
}
- List<T> allComments = out.values().stream().flatMap(Collection::stream).collect(toList());
- if (fillCommentContext) {
+ if (fillCommentContext && allComments != null && !allComments.isEmpty()) {
addCommentContext(allComments);
}
- allComments.forEach(c -> c.path = null); // we don't need path since it exists in the map keys
+ for (List<T> list : out.values()) {
+ for (T c : list) {
+ c.path = null; // we don't need path since it exists in the map keys
+ }
+ }
return out;
}
public ImmutableList<T> formatAsList(Iterable<F> comments) throws PermissionBackendException {
AccountLoader loader = fillAccounts ? accountLoaderFactory.get().create(true) : null;
- ImmutableList<T> out =
- Streams.stream(comments)
- .map(c -> toInfo(c, loader))
- .sorted(COMMENT_INFO_ORDER)
- .collect(toImmutableList());
+ int estimatedSize = (comments instanceof Collection) ? ((Collection<?>) comments).size() : 16;
+ List<T> outList = new ArrayList<>(estimatedSize);
+ for (F c : comments) {
+ outList.add(toInfo(c, loader));
+ }
+ outList.sort(COMMENT_INFO_ORDER);
if (loader != null) {
loader.fill();
}
- if (fillCommentContext) {
- addCommentContext(out);
+ if (fillCommentContext && !outList.isEmpty()) {
+ addCommentContext(outList);
}
- return out;
+ return ImmutableList.copyOf(outList);
}
protected void addCommentContext(List<T> allComments) {
- List<CommentContextKey> keys =
- allComments.stream().map(this::createCommentContextKey).collect(toList());
+ if (allComments.isEmpty()) {
+ return;
+ }
+ List<CommentContextKey> keys = new ArrayList<>(allComments.size());
+ for (T c : allComments) {
+ keys.add(createCommentContextKey(c));
+ }
ImmutableMap<CommentContextKey, CommentContext> allContext =
commentContextCache.get().getAll(keys);
- for (T c : allComments) {
- CommentContextKey contextKey = createCommentContextKey(c);
+ for (int i = 0; i < allComments.size(); i++) {
+ T c = allComments.get(i);
+ CommentContextKey contextKey = keys.get(i);
CommentContext commentContext = allContext.get(contextKey);
- c.contextLines = toContextLineInfoList(commentContext);
- c.sourceContentType = commentContext.contentType();
+ if (commentContext != null) {
+ c.contextLines = toContextLineInfoList(commentContext);
+ c.sourceContentType = commentContext.contentType();
+ }
}
}
protected List<ContextLineInfo> toContextLineInfoList(CommentContext commentContext) {
- List<ContextLineInfo> result = new ArrayList<>();
+ if (commentContext == null
+ || commentContext.lines() == null
+ || commentContext.lines().isEmpty()) {
+ return new ArrayList<>();
+ }
+ List<ContextLineInfo> result = new ArrayList<>(commentContext.lines().size());
for (Map.Entry<Integer, String> e : commentContext.lines().entrySet()) {
result.add(new ContextLineInfo(e.getKey(), e.getValue()));
}
@@ -244,15 +260,24 @@
return null;
}
- return fixSuggestions.stream().map(this::toFixSuggestionInfo).collect(toList());
+ List<FixSuggestionInfo> result = new ArrayList<>(fixSuggestions.size());
+ for (FixSuggestion fixSuggestion : fixSuggestions) {
+ result.add(toFixSuggestionInfo(fixSuggestion));
+ }
+ return result;
}
private FixSuggestionInfo toFixSuggestionInfo(FixSuggestion fixSuggestion) {
FixSuggestionInfo fixSuggestionInfo = new FixSuggestionInfo();
fixSuggestionInfo.fixId = fixSuggestion.fixId;
fixSuggestionInfo.description = fixSuggestion.description;
- fixSuggestionInfo.replacements =
- fixSuggestion.replacements.stream().map(this::toFixReplacementInfo).collect(toList());
+ if (fixSuggestion.replacements != null) {
+ List<FixReplacementInfo> replacements = new ArrayList<>(fixSuggestion.replacements.size());
+ for (FixReplacement fixReplacement : fixSuggestion.replacements) {
+ replacements.add(toFixReplacementInfo(fixReplacement));
+ }
+ fixSuggestionInfo.replacements = replacements;
+ }
return fixSuggestionInfo;
}
diff --git a/java/com/google/gerrit/server/restapi/change/EvaluateChangeQueryExpression.java b/java/com/google/gerrit/server/restapi/change/EvaluateChangeQueryExpression.java
index 04cef70..6b028bd 100644
--- a/java/com/google/gerrit/server/restapi/change/EvaluateChangeQueryExpression.java
+++ b/java/com/google/gerrit/server/restapi/change/EvaluateChangeQueryExpression.java
@@ -84,10 +84,14 @@
// index, including submit requirement results.
List<ChangeData> changeDatas =
internalChangeQuery.get().byProjectChangeNumber(rsrc.getProject(), rsrc.getId());
+ if (changeDatas.isEmpty()) {
+ logger.atFine().log("Change %s not found in index; falling back to NoteDb", rsrc.getId());
+ return rsrc.getChangeData();
+ }
checkState(
changeDatas.size() == 1,
"Got %s matches for change %s, expected 1",
- changeDatas.size() == 1,
+ changeDatas.size(),
rsrc.getId());
return Iterables.getOnlyElement(changeDatas);
}
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 9129bfb..f8c066d 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -302,6 +302,7 @@
info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
info.instanceId = instanceId;
info.defaultBranch = config.getString("gerrit", null, "defaultBranch");
+ info.submitCommitUrl = config.getString("gerrit", null, "submitCommitUrl");
info.projectStatePredicateEnabled =
config.getBoolean("gerrit", null, "projectStatePredicateEnabled", true);
return info;
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java
index 44f7ba2..110032a 100644
--- a/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java
@@ -37,6 +37,7 @@
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.ObjectReader;
@@ -251,12 +252,29 @@
String.join(
" OR ",
lt.getRefPatterns().stream()
- .map(b -> "branch:\\\"" + b + "\\\"")
+ .map(MigrateLabelFunctionsToSubmitRequirement::toApplicableIfExpression)
.collect(Collectors.toList()))));
}
return builder.build();
}
+ private static String toApplicableIfExpression(String branchRef) {
+ // Reqex -> migrate as it is.
+ if (branchRef.startsWith("^")) {
+ return "branch:" + branchRef;
+ }
+ // Wildcard -> convert into gerrit regex
+ if (branchRef.endsWith("/*")) {
+ String prefix = branchRef.substring(0, branchRef.length() - 1);
+ String regex = "^" + Pattern.quote(prefix) + ".*";
+ return "branch:" + regex;
+ }
+ // branch name contains quote character -> escape quote
+ branchRef = branchRef.replace("\"", "\\\"");
+ // Other cases e.g. branch name containing # or " -> needs to be quoted
+ return "branch:\"" + branchRef + "\"";
+ }
+
private static boolean isBlockingOrRequiredLabel(LabelType lt) {
return switch (lt.getFunction()) {
case ANY_WITH_BLOCK, MAX_WITH_BLOCK, MAX_NO_BLOCK -> true;
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index eddee82..3c0cd00 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,9 +16,6 @@
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static com.google.gerrit.server.update.RetryableAction.ActionType.INDEX_QUERY;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
@@ -1166,47 +1163,22 @@
// The branch doesn't exist.
return;
}
- Project.NameKey project = branch.project();
- if (!experimentFeatures.isFeatureEnabled(
- GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE, project)) {
- return;
- }
if (submitType == SubmitType.CHERRY_PICK || submitType == SubmitType.REBASE_ALWAYS) {
return;
}
- boolean projectConfigRejectImplicitMerges =
+ Project.NameKey project = branch.project();
+ boolean rejectImplicitMerges =
projectCache
.get(project)
.orElseThrow(illegalState(project))
.is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES);
- boolean rejectImplicitMergesOnMerges =
- experimentFeatures.isFeatureEnabled(
- GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE, project)
- && (experimentFeatures.isFeatureEnabled(
- GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE, project)
- || projectConfigRejectImplicitMerges);
- try {
- if (hasImplicitMerges(branch, rw, commitsToSubmit, branchTip)) {
- if (rejectImplicitMergesOnMerges) {
- commitStatus.addImplicitMerge(project, branch);
- } else {
- String allCommits =
- commitsToSubmit.stream()
- .map(CodeReviewCommit::getId)
- .map(c -> ObjectId.toString(c))
- .collect(joining(", "));
- logger.atWarning().log(
- "Implicit merge was detected for the branch %s of the project %s. "
- + "Commits to be merged are: %s",
- branch.shortName(), project, allCommits);
- }
- }
- } catch (Exception e) {
- if (rejectImplicitMergesOnMerges) {
- throw e;
- }
- logger.atWarning().withCause(e).log("Error while checking for implicit merges");
+ if (!rejectImplicitMerges) {
+ return;
+ }
+
+ if (hasImplicitMerges(branch, rw, commitsToSubmit, branchTip)) {
+ commitStatus.addImplicitMerge(project, branch);
}
}
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index f20acb8..d0204b1 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -17,6 +17,7 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.base.Joiner;
+import com.google.common.base.Throwables;
import com.google.common.flogger.FluentLogger;
import com.google.common.util.concurrent.Atomics;
import com.google.gerrit.common.Nullable;
@@ -111,6 +112,9 @@
/** The task, as scheduled on a worker thread. */
private final AtomicReference<Future<?>> task;
+ /** Channel this command runs on; set when sshd destroys the command. */
+ private volatile ChannelSession channel;
+
/** Text of the command line which lead up to invoking this instance. */
private String commandName = "";
@@ -199,6 +203,7 @@
@Override
public void destroy(ChannelSession channel) {
+ this.channel = channel;
Future<?> future = task.getAndSet(null);
if (future != null && !future.isDone()) {
future.cancel(true);
@@ -353,11 +358,14 @@
|| //
(e.getClass() == SshException.class && "Already closed".equals(e.getMessage()))
|| //
- e.getClass() == InterruptedIOException.class) {
+ e.getClass() == InterruptedIOException.class
+ || //
+ isInterruptAfterClientDisconnect(e)) {
// This is sshd telling us the client just dropped off while
// we were waiting for a read or a write to complete. Either
// way its not really a fatal error. Don't log it.
//
+ logger.atFine().withCause(e).log("Client disconnected during %s", context.getCommandLine());
return 127;
}
@@ -401,6 +409,23 @@
return 128;
}
+ /**
+ * Returns true if this exception is the thread interrupt we sent from {@link
+ * #destroy(ChannelSession)} after the client disconnected.
+ *
+ * <p>Requires both that the channel is gone and that the interrupt is in the causal chain.
+ * Libraries wrap {@link InterruptedException} in unchecked exceptions, so the interrupt is rarely
+ * the top-level throwable. Checking the channel alone would suppress unrelated failures that
+ * merely happened to surface after a disconnect.
+ */
+ private boolean isInterruptAfterClientDisconnect(Throwable e) {
+ ChannelSession c = channel;
+ if (c == null || c.isOpen()) {
+ return false;
+ }
+ return Throwables.getCausalChain(e).stream().anyMatch(t -> t instanceof InterruptedException);
+ }
+
private void logCauseIfRelevant(Throwable e, StringBuilder message) {
String zeroLength = "length=0";
String streamAlreadyClosed = "stream is already closed";
diff --git a/javatests/com/google/gerrit/acceptance/api/change/EvaluateChangeQueryExpressionIT.java b/javatests/com/google/gerrit/acceptance/api/change/EvaluateChangeQueryExpressionIT.java
index 3fe15ff..dc4338c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/EvaluateChangeQueryExpressionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/EvaluateChangeQueryExpressionIT.java
@@ -22,6 +22,7 @@
import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.TestExtensions.TestSubmitRule;
import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.TestChange;
import com.google.gerrit.extensions.api.changes.ChangeIdentifier;
import com.google.gerrit.extensions.common.EvaluateChangeQueryExpressionResultInfo;
import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -311,4 +312,29 @@
}
assertThat(testSubmitRule.count()).isEqualTo(0);
}
+
+ @Test
+ public void evaluatingUsingIndexWhenChangeMissingFromIndexFallsBackToNoteDb() throws Exception {
+ ChangeIdentifier changeIdentifier = changeOperations.newChange().create();
+ changeOperations.change(changeIdentifier).newVote().codeReviewApproval().create();
+
+ TestChange testChange = changeOperations.change(changeIdentifier).get();
+ indexer.delete(testChange.project(), testChange.numericChangeId());
+
+ TestSubmitRule testSubmitRule = new TestSubmitRule();
+ try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+ EvaluateChangeQueryExpressionResultInfo info =
+ gApi.changes()
+ .id(changeIdentifier)
+ .evaluateChangeQueryExpression()
+ .withExpression("is:submittable")
+ .useIndex()
+ .get();
+ assertThat(info.status).isTrue();
+ assertThat(info.passingAtoms).containsExactly("is:submittable");
+ assertThat(info.failingAtoms).isEmpty();
+ assertThat(info.atomExplanations).isNull();
+ }
+ assertThat(testSubmitRule.count()).isEqualTo(1);
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
index b2765a4..a32e0c0 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -48,37 +48,10 @@
assertThat(experiments.keySet())
.containsAtLeast(
ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS,
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
- ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
- ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE,
ExperimentFeaturesConstants.SKIP_SUBMIT_RECORDS_WITHOUT_SUBMIT_REQUIREMENTS)
.inOrder();
- // "GerritBackendFeature__check_implicit_merges_on_merge",
- // "GerritBackendFeature__reject_implicit_merges_on_merge" and
- // "GerritBackendFeature__always_reject_implicit_merges_on_merge" are enabled via
- // AbstractDaemonTest#beforeTest
- assertThat(
- experiments.get(
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE)
- .enabled)
- .isTrue();
- assertThat(
- experiments.get(
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE)
- .enabled)
- .isTrue();
- assertThat(
- experiments.get(
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
- .enabled)
- .isTrue();
-
assertThat(
experiments.get(ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS).enabled)
.isFalse();
@@ -94,21 +67,12 @@
@GerritConfig(
name = "experiments.enabled",
values = {"GerritBackendFeature__attach_nonce_to_documentation"})
- // "GerritBackendFeature__check_implicit_merges_on_merge",
- // "GerritBackendFeature__reject_implicit_merges_on_merge" and
- // "GerritBackendFeature__always_reject_implicit_merges_on_merge" are enabled via
- // AbstractDaemonTest#beforeTest
public void listEnabled_noneEnabled() throws Exception {
ImmutableMap<String, ExperimentInfo> experiments =
gApi.config().server().listExperiments().enabledOnly().get();
assertThat(experiments.keySet())
.containsExactly(
- ExperimentFeaturesConstants
- .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
- ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
- ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
- ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
- .inOrder();
+ ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION);
for (ExperimentInfo experimentInfo : experiments.values()) {
assertThat(experimentInfo.enabled).isTrue();
}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index 2f3ef24..5396c83 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -19,6 +19,7 @@
import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
import static com.google.gerrit.truth.OptionalSubject.assertThat;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
@@ -105,6 +106,23 @@
}
@Test
+ public void batchEvictionByUuidUpdatesStaleUuidCache() throws Exception {
+ AccountGroup.UUID groupUuid1 = createGroup("group1");
+ AccountGroup.UUID groupUuid2 = createGroup("group2");
+ loadGroupToCache(groupUuid1);
+ loadGroupToCache(groupUuid2);
+ updateGroupWithoutCacheOrIndex(groupUuid1, newGroupDelta().setDescription("Modified1").build());
+ updateGroupWithoutCacheOrIndex(groupUuid2, newGroupDelta().setDescription("Modified2").build());
+
+ groupCache.evict(ImmutableList.of(groupUuid1, groupUuid2));
+
+ Optional<InternalGroup> updatedGroup1 = groupCache.get(groupUuid1);
+ assertThatGroup(updatedGroup1).value().description().isEqualTo("Modified1");
+ Optional<InternalGroup> updatedGroup2 = groupCache.get(groupUuid2);
+ assertThatGroup(updatedGroup2).value().description().isEqualTo("Modified2");
+ }
+
+ @Test
public void reindexingStaleGroupUpdatesTheIndex() throws Exception {
AccountGroup.UUID groupUuid = createGroup("users");
AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 09cd037..58f6605 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -1185,10 +1185,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.disabled",
- // The test intentionally create an implicit merge change.
- value = "GerritBackendFeature__reject_implicit_merges_on_merge")
@GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void commitsIncludedInRefsMergedChangeNonTipCommit() throws Exception {
String branchWithChange1 = R_HEADS + "branch-with-change1";
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
index 8635b15..10b7c2e 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
@@ -272,6 +272,37 @@
assertThat(diff).content().element(1).linesOfB().containsExactly("2nd line", "");
}
+ @Test
+ public void previewFixWithUnchangedLinesInsideReplacementRange() throws Exception {
+ // Replacement covers lines 3 to 5, where line 4 is unchanged.
+ String replacement = "Modified third line\nFourth line\nModified fifth line\n";
+ ApplyProvidedFixInput applyProvidedFixInput =
+ createApplyProvidedFixInput(FILE_NAME, replacement, 3, 0, 6, 0);
+
+ Map<String, DiffInfo> fixPreview =
+ gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+ DiffInfo diff = fixPreview.get(FILE_NAME);
+
+ // Should be split into two replacement hunks around the unchanged line 4.
+ assertThat(diff.content).hasSize(5);
+ assertThat(diff)
+ .content()
+ .element(0)
+ .commonLines()
+ .containsExactly("First line", "Second line");
+ assertThat(diff).content().element(1).linesOfA().containsExactly("Third line");
+ assertThat(diff).content().element(1).linesOfB().containsExactly("Modified third line");
+ assertThat(diff).content().element(2).commonLines().containsExactly("Fourth line");
+ assertThat(diff).content().element(3).linesOfA().containsExactly("Fifth line");
+ assertThat(diff).content().element(3).linesOfB().containsExactly("Modified fifth line");
+ assertThat(diff)
+ .content()
+ .element(4)
+ .commonLines()
+ .containsExactly(
+ "Sixth line", "Seventh line", "Eighth line", "Ninth line", "Tenth line", "");
+ }
+
private ApplyProvidedFixInput createApplyProvidedFixInput(
String file_name,
String replacement,
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitConfigIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitConfigIT.java
new file mode 100644
index 0000000..f72b7fc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitConfigIT.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.util.CommitMessageUtil.generateChangeId;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies that receive.rejectImplicitMerges controls implicit merge checks on submit.
+ *
+ * <p>All tests use the same commit graph, where the change targeted to stable has a parent from
+ * master.
+ */
+public class ImplicitMergeOnSubmitConfigIT extends AbstractImplicitMergeTest {
+ @ConfigSuite.Configs
+ public static ImmutableMap<String, Config> configs() {
+ ImmutableMap.Builder<String, Config> builder = ImmutableMap.builder();
+ for (SubmitType submitType : SubmitType.values()) {
+ if (submitType == SubmitType.INHERIT
+ || submitType == SubmitType.CHERRY_PICK
+ || submitType == SubmitType.REBASE_ALWAYS) {
+ continue;
+ }
+ Config cfg = new Config();
+ cfg.setString("test", null, "submitType", submitType.name());
+ builder.put(String.format("submitType=%s", submitType), cfg);
+ }
+ return builder.buildOrThrow();
+ }
+
+ private String implicitMergeChangeId;
+ private String explicitMergeChangeId;
+
+ @Before
+ public void setUp() throws Exception {
+ String submitTypeValue = cfg.getString("test", null, "submitType");
+ assume().that(submitTypeValue).isNotEmpty();
+ RevCommit base = repo().parseCommit(repo().exactRef("HEAD").getObjectId());
+ RevCommit stableBranchTip =
+ pushTo("refs/heads/stable", ImmutableMap.of("stable-content", "stable-first-line\n"), base)
+ .getCommit();
+ RevCommit masterBranchTip =
+ pushTo(
+ "refs/heads/master",
+ ImmutableMap.of("master-content", "master-first-line\n"),
+ stableBranchTip)
+ .getCommit();
+ implicitMergeChangeId = "I" + generateChangeId().name();
+ RevCommit implicitMergeChange =
+ createChangeWithoutPush(
+ implicitMergeChangeId,
+ ImmutableMap.of("master-content2", "added-by-implicit-merge\n"),
+ masterBranchTip);
+ explicitMergeChangeId =
+ pushTo(
+ "refs/for/stable",
+ ImmutableMap.of("stable-content", "stable-first-line\nadded-by-explicit-merge\n"),
+ implicitMergeChange,
+ stableBranchTip)
+ .getChangeId();
+ gApi.changes().id(implicitMergeChangeId).current().review(ReviewInput.approve());
+ gApi.changes().id(explicitMergeChangeId).current().review(ReviewInput.approve());
+ setSubmitType(SubmitType.valueOf(submitTypeValue));
+ }
+
+ @Test
+ public void implicitMergeRejectedByDefault() throws Exception {
+ assertThatImplicitMergeSubmitRejected();
+ }
+
+ @Test
+ public void explicitMergeAllowedByDefault() throws Exception {
+ assertThatExplicitMergeSubmitAllowed();
+ }
+
+ @Test
+ public void rejectImplicitMergesFalse_allowsImplicitMerge() throws Exception {
+ setRejectImplicitMerges(/* reject= */ false);
+ assertThatImplicitMergeSubmitAllowed();
+ }
+
+ @Test
+ public void rejectImplicitMergesFalse_allowsExplicitMerge() throws Exception {
+ setRejectImplicitMerges(/* reject= */ false);
+ assertThatExplicitMergeSubmitAllowed();
+ }
+
+ private void assertThatImplicitMergeSubmitRejected() throws Exception {
+ ResourceConflictException e =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(implicitMergeChangeId).current().submit());
+ assertThat(e.getMessage().toLowerCase()).contains("submit makes implicit merge to the branch");
+ ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
+ assertThat(ci.submitted).isNull();
+ assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+ .containsExactly("stable-content", "stable-first-line\n");
+ }
+
+ private void assertThatImplicitMergeSubmitAllowed() throws Exception {
+ gApi.changes().id(implicitMergeChangeId).current().submit();
+
+ ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
+ assertThat(ci.submitted).isNotNull();
+ assertThat(ci.submitter).isNotNull();
+ assertThat(ci.submitter._accountId)
+ .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
+
+ assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+ .containsExactly(
+ "master-content", "master-first-line\n",
+ "master-content2", "added-by-implicit-merge\n",
+ "stable-content", "stable-first-line\n");
+ }
+
+ private void assertThatExplicitMergeSubmitAllowed() throws Exception {
+ gApi.changes().id(explicitMergeChangeId).current().submit();
+
+ ChangeInfo ci = gApi.changes().id(explicitMergeChangeId).info();
+ assertThat(ci.submitted).isNotNull();
+ assertThat(ci.submitter).isNotNull();
+ assertThat(ci.submitter._accountId)
+ .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
+ assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+ .containsExactly(
+ "master-content", "master-first-line\n",
+ "master-content2", "added-by-implicit-merge\n",
+ "stable-content", "stable-first-line\nadded-by-explicit-merge\n");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java
deleted file mode 100644
index a974a92..0000000
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java
+++ /dev/null
@@ -1,335 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.util.CommitMessageUtil.generateChangeId;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.testing.ConfigSuite;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-/**
- * Verifies that gerrit correctly rejects or submits implicit merges depending on experiments.
- *
- * <p>All tests use the same commit configuration (master branch is one commit ahead of stable
- * branch):
- *
- * <pre>{@code
- * change[1] (target - stable, explicit merge of stable branch and master branches)
- * | \
- * | change[0] (target - stable, i.e. implicit merge of master and stable branches)
- * | |
- * | master
- * | |
- * stable <--- |
- * }</pre>
- */
-public class ImplicitMergeOnSubmitExperimentsIT extends AbstractImplicitMergeTest {
- @Override
- protected boolean enableExperimentsRejectImplicitMergesOnMerge() {
- // Tests uses own experiment setup.
- return false;
- }
-
- @ConfigSuite.Configs
- public static ImmutableMap<String, Config> configs() {
- // The @RunWith(Parameterized.class) can't be used, because AbstractDaemonClass already
- // uses @RunWith(ConfigSuite.class). Emulate parameters using configs.
- ImmutableMap.Builder<String, Config> builder = ImmutableMap.builder();
- for (SubmitType submitType : SubmitType.values()) {
- if (submitType == SubmitType.INHERIT
- || submitType == SubmitType.CHERRY_PICK
- || submitType == SubmitType.REBASE_ALWAYS) {
- continue;
- }
- Config cfg = new Config();
- cfg.setString("test", null, "submitType", submitType.name());
- builder.put(String.format("submitType=%s", submitType), cfg);
- }
- return builder.buildOrThrow();
- }
-
- private String implicitMergeChangeId;
- private String explicitMergeChangeId;
-
- private SubmitType submitType;
-
- @Before
- public void setUp() throws Exception {
- // The ConfigSuite runner always adds a default config. Ignore it (submitType is not set for
- // it).
- assume().that(cfg.getString("test", null, "submitType")).isNotEmpty();
- RevCommit base = repo().parseCommit(repo().exactRef("HEAD").getObjectId());
- RevCommit stableBranchTip =
- pushTo("refs/heads/stable", ImmutableMap.of("stable-content", "stable-first-line\n"), base)
- .getCommit();
- RevCommit masterBranchTip =
- pushTo(
- "refs/heads/master",
- ImmutableMap.of("master-content", "master-first-line\n"),
- stableBranchTip)
- .getCommit();
- implicitMergeChangeId = "I" + generateChangeId().name();
- RevCommit implicitMergeChange =
- createChangeWithoutPush(
- implicitMergeChangeId,
- ImmutableMap.of("master-content2", "added-by-implicit-merge\n"),
- masterBranchTip);
- explicitMergeChangeId =
- pushTo(
- "refs/for/stable",
- ImmutableMap.of("stable-content", "stable-first-line\nadded-by-explicit-merge\n"),
- implicitMergeChange,
- stableBranchTip)
- .getChangeId();
- gApi.changes().id(implicitMergeChangeId).current().review(ReviewInput.approve());
- gApi.changes().id(explicitMergeChangeId).current().review(ReviewInput.approve());
- submitType = SubmitType.valueOf(cfg.getString("test", null, "submitType"));
- setSubmitType(submitType);
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- "GerritBackendFeature__reject_implicit_merges_on_merge",
- "GerritBackendFeature__always_reject_implicit_merges_on_merge"
- })
- public void alwaysRejectOnMerge_rejectImplicitMergeFalse_rejectImplicitMergeOnSubmit()
- throws Exception {
- setRejectImplicitMerges(/* reject= */ false);
- assertThatImplicitMergeSubmitRejected();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- "GerritBackendFeature__reject_implicit_merges_on_merge",
- "GerritBackendFeature__always_reject_implicit_merges_on_merge"
- })
- public void alwaysRejectOnMerge_rejectImplicitMergeFalse_canSubmitExplicitMerge()
- throws Exception {
- setRejectImplicitMerges(/* reject= */ false);
- assertThatExcplicitMergeSubmitAllowed();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- "GerritBackendFeature__reject_implicit_merges_on_merge",
- "GerritBackendFeature__always_reject_implicit_merges_on_merge"
- })
- public void alwaysRejectOnMerge_rejectImplicitMergeTrue_rejectImplicitMergeOnSubmit()
- throws Exception {
- setRejectImplicitMerges(/* reject= */ true);
- assertThatImplicitMergeSubmitRejected();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- "GerritBackendFeature__reject_implicit_merges_on_merge",
- "GerritBackendFeature__always_reject_implicit_merges_on_merge"
- })
- public void alwaysRejectOnMerge_rejectImplicitMergeTrue_canSubmitExplicitMerge()
- throws Exception {
- setRejectImplicitMerges(/* reject= */ true);
- assertThatExcplicitMergeSubmitAllowed();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- "GerritBackendFeature__reject_implicit_merges_on_merge",
- })
- public void rejectOnMerge_rejectImplicitMergeFalse_canSubmitImplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ false);
- assertThatImplicitMergeSubmitAllowed();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- "GerritBackendFeature__reject_implicit_merges_on_merge",
- })
- public void rejectOnMerge_rejectImplicitMergeFalse_canSubmitExplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ false);
- assertThatExcplicitMergeSubmitAllowed();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- "GerritBackendFeature__reject_implicit_merges_on_merge",
- })
- public void rejectOnMerge_rejectImplicitMergeTrue_rejectImplicitMergeOnSubmit() throws Exception {
- setRejectImplicitMerges(/* reject= */ true);
- assertThatImplicitMergeSubmitRejected();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- "GerritBackendFeature__reject_implicit_merges_on_merge",
- })
- public void rejectOnMerge_rejectImplicitMergeTrue_canSubmitExplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ true);
- assertThatExcplicitMergeSubmitAllowed();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- })
- public void checkOnly_rejectImplicitMergeFalse_canSubmitImplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ false);
- assertThatImplicitMergeSubmitAllowed();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- })
- public void checkOnly_rejectImplicitMergeFalse_canSubmitExplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ false);
- assertThatExcplicitMergeSubmitAllowed();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- })
- public void checkOnly_rejectImplicitMergeTrue_canSubmitImplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ true);
- assertThatImplicitMergeSubmitAllowed();
- }
-
- @Test
- @GerritConfig(
- name = "experiments.enabled",
- values = {
- "GerritBackendFeature__check_implicit_merges_on_merge",
- })
- public void checkOnly_rejectImplicitMergeTrue_canSubmitExplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ true);
- assertThatExcplicitMergeSubmitAllowed();
- }
-
- @Test
- public void noExperiments_rejectImplicitMergeFalse_canSubmitImplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ false);
- assertThatImplicitMergeSubmitAllowed();
- }
-
- @Test
- public void noExperiments_rejectImplicitMergeFalse_canSubmitExplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ false);
- assertThatExcplicitMergeSubmitAllowed();
- }
-
- @Test
- public void noExperiments_rejectImplicitMergeTrue_canSubmitImplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ true);
- assertThatImplicitMergeSubmitAllowed();
- }
-
- @Test
- public void noExperiments_rejectImplicitMergeTrue_canSubmitExplicitMerge() throws Exception {
- setRejectImplicitMerges(/* reject= */ true);
- assertThatExcplicitMergeSubmitAllowed();
- }
-
- private void assertThatImplicitMergeSubmitRejected() throws Exception {
- ResourceConflictException e =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(implicitMergeChangeId).current().submit());
- assertThat(e.getMessage().toLowerCase()).contains("submit makes implicit merge to the branch");
- ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
- assertThat(ci.submitted).isNull();
- assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
- .containsExactly("stable-content", "stable-first-line\n");
- }
-
- private void assertThatImplicitMergeSubmitAllowed() throws Exception {
- gApi.changes().id(implicitMergeChangeId).current().submit();
-
- ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
- assertThat(ci.submitted).isNotNull();
- assertThat(ci.submitter).isNotNull();
- assertThat(ci.submitter._accountId)
- .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
-
- if (submitType != SubmitType.REBASE_ALWAYS) {
- assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
- .containsExactly(
- "master-content", "master-first-line\n",
- "master-content2", "added-by-implicit-merge\n",
- "stable-content", "stable-first-line\n");
- } else {
- assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
- .containsExactly(
- "master-content2", "added-by-implicit-merge\n",
- "stable-content", "stable-first-line\n");
- }
- }
-
- private void assertThatExcplicitMergeSubmitAllowed() throws Exception {
- gApi.changes().id(explicitMergeChangeId).current().submit();
-
- ChangeInfo ci = gApi.changes().id(explicitMergeChangeId).info();
- assertThat(ci.submitted).isNotNull();
- assertThat(ci.submitter).isNotNull();
- assertThat(ci.submitter._accountId)
- .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
- assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
- .containsExactly(
- "master-content", "master-first-line\n",
- "master-content2", "added-by-implicit-merge\n",
- "stable-content", "stable-first-line\nadded-by-explicit-merge\n");
- }
-}
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java
index 1c74165..b7c5743 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java
@@ -19,7 +19,6 @@
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.ChangeInfo;
@@ -42,10 +41,10 @@
* }</pre>
*
* Tests use only MergeAlways strategy. All other submit strategies (except cherry pick and rebase
- * always) use the same checks on submit. The {@link ImplicitMergeOnSubmitExperimentsIT} validates
- * that the implicit merge check is applied to all strategies (except cherry pick and rebase always)
- * and {@link ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT} contains tests for the cherry pick
- * and rebase always strategies.
+ * always) use the same checks on submit. The {@link ImplicitMergeOnSubmitConfigIT} validates that
+ * the implicit merge check is applied to all strategies (except cherry pick and rebase always), and
+ * {@link ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT} contains tests for the cherry pick and
+ * rebase always strategies.
*/
public class ImplicitMergeOnSubmitIT extends AbstractImplicitMergeTest {
private RevCommit masterTip;
@@ -55,6 +54,7 @@
@Before
public void setUp() throws Exception {
setSubmitType(SubmitType.MERGE_ALWAYS);
+ setRejectImplicitMerges(/* reject= */ false);
gApi.projects().name(project.get()).branch("other").create(new BranchInput());
baseCommit =
repo()
@@ -70,14 +70,12 @@
}
@Test
- @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void singleChangeImplicitMerge() throws Exception {
PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
}
@Test
- @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void chainOfChangesImplicitMerge() throws Exception {
PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
PushOneCommit.Result c1 = createApprovedChange("master", implicitMerge);
@@ -110,7 +108,6 @@
}
@Test
- @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void chainOfChangesNotOnTopOfTargetBranchTipWithImplicitMerge() throws Exception {
// Add one more commit to master branch.
pushTo("refs/heads/master", ImmutableMap.of(), masterTip);
@@ -123,7 +120,6 @@
}
@Test
- @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void chainOfChangesEndsWithExplicitMerge_onlyExplcitMergeCanBeSubmitted()
throws Exception {
PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
@@ -136,7 +132,6 @@
}
@Test
- @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void twoChainOfChangesSameTopic_oneChainImplicitMerge_rejectedOnSubmit() throws Exception {
cfg.setBoolean("change", null, "submitWholeTopic", true);
PushOneCommit.Result c1 = createApprovedChange("master", masterTip);
@@ -212,7 +207,6 @@
}
@Test
- @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void twoChainOfChangesEndsWithExplicitMergeSameTopicNotTipOfBranches_canBeSubmitted()
throws Exception {
cfg.setBoolean("change", null, "submitWholeTopic", true);
@@ -242,7 +236,6 @@
}
@Test
- @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void twoChainOfChangesDifferentBranchesSameTopic_oneChainImplicitMerge_rejectedOnSubmit()
throws Exception {
cfg.setBoolean("change", null, "submitWholeTopic", true);
@@ -268,7 +261,6 @@
}
@Test
- @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void explicitMergeOnTopOfChain_onlyTopSubmittable() throws Exception {
PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
PushOneCommit.Result im1 = createApprovedChange("master", implicitMerge);
@@ -282,7 +274,6 @@
}
@Test
- @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void explicitMergeOnTopOfChainParentIsNotBranchTip_onlyTopSubmittable() throws Exception {
// Add one more commit to master and other branches.
pushTo("refs/heads/master", ImmutableMap.of(), masterTip);
@@ -319,6 +310,7 @@
}
private void assertSubmitRejectedWithImplicitMerge(String changeId) throws Exception {
+ setRejectImplicitMerges();
ResourceConflictException e =
assertThrows(
ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
@@ -326,6 +318,7 @@
}
private void assertThatChangeSubmittable(String changeId) throws Exception {
+ setRejectImplicitMerges();
ChangeInfo ci = gApi.changes().id(changeId).current().submit();
assertThat(ci.submitted).isNotNull();
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 0f41219..88c58b8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -282,10 +282,6 @@
}
@Test
- @GerritConfig(
- name = "experiments.disabled",
- // The test intentionally create an implicit merge change.
- value = "GerritBackendFeature__reject_implicit_merges_on_merge")
@GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void submitWithMergedAncestorsOnOtherBranch() throws Throwable {
RevCommit initialHead = projectOperations.project(project).getHead("master");
@@ -336,10 +332,7 @@
}
@Test
- @GerritConfig(
- name = "experiments.disabled",
- // The test intentionally create an implicit merge change.
- value = "GerritBackendFeature__reject_implicit_merges_on_merge")
+ @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
public void submitWithOpenAncestorsOnOtherBranch() throws Throwable {
RevCommit initialHead = projectOperations.project(project).getHead("master");
PushOneCommit.Result change1 =
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index e172153..e011ffc 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -29,11 +29,6 @@
@Inject ExperimentFeatures experimentFeatures;
- @Override
- public boolean enableExperimentsRejectImplicitMergesOnMerge() {
- return false;
- }
-
@Test
public void emptyConfig_defaultFeatures_enabled() {
for (String defaultFeature : ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES) {
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/RefControlIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/RefControlIT.java
new file mode 100644
index 0000000..dc27bbc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/RefControlIT.java
@@ -0,0 +1,431 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.entities.Permission.READ;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Integration tests for ref-based permission settings.
+ *
+ * <p>Tests use {@link PermissionBackend#filter} directly to verify which refs are visible, covering
+ * scenarios not already tested by {@link com.google.gerrit.acceptance.git.RefAdvertisementIT}
+ * (which uses the git wire protocol) or {@link com.google.gerrit.server.permissions.RefControlTest}
+ * (which tests at the unit level).
+ */
+public class RefControlIT extends AbstractDaemonTest {
+
+ @Inject private ProjectOperations projectOperations;
+ @Inject private PermissionBackend permissionBackend;
+
+ private AccountGroup.UUID privileged;
+ private TestAccount privilegedUser;
+ private AccountGroup.UUID unprivileged;
+
+ @Before
+ public void setUpGroups() throws Exception {
+ privileged = AccountGroup.uuid(gApi.groups().create(name("privileged")).get().id);
+ privilegedUser = accountCreator.create(name("privileged-user"), "priv@test.com", "Priv", null);
+ gApi.groups().id(privileged.get()).addMembers(privilegedUser.username());
+
+ unprivileged = AccountGroup.uuid(gApi.groups().create(name("unprivileged")).get().id);
+ gApi.groups().id(unprivileged.get()).addMembers(user.username());
+
+ // Remove All-Projects default READ grants so each test controls ACLs precisely.
+ projectOperations
+ .project(allProjects)
+ .forUpdate()
+ .remove(permissionKey(READ).ref("refs/heads/*").group(ANONYMOUS_USERS))
+ .remove(permissionKey(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+ .remove(permissionKey(READ).ref("refs/meta/version").group(ANONYMOUS_USERS))
+ .update();
+ }
+
+ @Test
+ public void perProjectDeny_hidesProjectOnPublicServer() throws Exception {
+ // Simulate a public server: All-Projects grants READ to Anonymous Users.
+ // The per-project DENY on refs/* makes the project invisible to everyone
+ // except users with an explicit ALLOW in that project.
+ projectOperations
+ .project(allProjects)
+ .forUpdate()
+ .add(allow(READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .update();
+
+ Project.NameKey hidden = projectOperations.newProject().create();
+ gApi.projects().name(hidden.get()).branch("main").create(new BranchInput());
+
+ // Deny read for anonymous (= everyone) in the project itself.
+ projectOperations
+ .project(hidden)
+ .forUpdate()
+ .add(deny(READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .update();
+
+ // Regular user sees no refs.
+ assertThat(visibleRefs(hidden, user)).isEmpty();
+
+ // Granting READ explicitly in the same project still works.
+ projectOperations
+ .project(hidden)
+ .forUpdate()
+ .add(allow(READ).ref("refs/*").group(privileged))
+ .update();
+ assertThat(visibleRefs(hidden, privilegedUser)).contains("refs/heads/main");
+ }
+
+ @Test
+ public void blockAnonymousUsers_blocksEveryone_connotBeOverridenInChild() throws Exception {
+ // Blocking Anonymous Users blocks all users (registered too) since every
+ // user is a member of Anonymous Users. Without an ALLOW in the same section,
+ // no group can bypass the block.
+ projectOperations
+ .project(allProjects)
+ .forUpdate()
+ .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .update();
+
+ Project.NameKey p = projectOperations.newProject().create();
+ gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
+ assertThat(visibleRefs(p, user)).isEmpty();
+ assertThat(visibleRefs(p, privilegedUser)).isEmpty();
+ }
+
+ @Test
+ public void blockAnonymous_allowPrivileged_inSameSection_unblocks() throws Exception {
+ // ALLOW in the same AccessSection cancels the BLOCK for members of
+ // the allowed group.
+ Project.NameKey p = projectOperations.newProject().create();
+ gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .add(allow(READ).ref("refs/*").group(privileged))
+ .update();
+
+ // Unprivileged registered user is still blocked (ALLOW is only for privileged group).
+ assertThat(visibleRefs(p, user)).isEmpty();
+ // Privileged user: ALLOW in same section cancels the BLOCK.
+ assertThat(visibleRefs(p, privilegedUser)).contains("refs/heads/main");
+ }
+
+ @Test
+ public void blockWithExclusiveAllowOnMoreSpecificRef_unblocks() throws Exception {
+ // Documented example:
+ // [access "refs/*"] read = block group X
+ // [access "refs/heads/*"] exclusiveGroupPermissions = read
+ // read = group Y
+ // Members of Y can read refs/heads/* but not other refs.
+ Project.NameKey p = projectOperations.newProject().create();
+
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .add(allow(READ).ref("refs/heads/*").group(privileged))
+ .setExclusiveGroup(permissionKey(READ).ref("refs/heads/*"), true)
+ .update();
+
+ ImmutableList<String> visible = visibleRefs(p, privilegedUser);
+ // Branches are visible via the exclusive ALLOW.
+ assertThat(visible).containsExactlyElementsIn(List.of("HEAD", "refs/heads/master"));
+ assertThat(visibleRefs(p, user)).isEmpty();
+ }
+
+ @Test
+ public void blockWithNonExclusiveAllowOnMoreSpecificRef_doesNotUnblock() throws Exception {
+ // Without the exclusive flag on refs/heads/*, the ALLOW on the more specific
+ // ref does not override the parent BLOCK.
+ Project.NameKey p = projectOperations.newProject().create();
+ gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(block(READ).ref("refs/*").group(privileged))
+ .add(allow(READ).ref("refs/heads/*").group(privileged))
+ // NOTE: no setExclusiveGroup — non-exclusive ALLOW cannot unblock
+ .update();
+
+ assertThat(visibleRefs(p, privilegedUser)).isEmpty();
+ }
+
+ @Test
+ public void deny_onlyAffectsSpecificGroup_otherGroupUnaffected() throws Exception {
+ // DENY for unprivileged group on refs/heads/secret.
+ // All-Projects ALLOW for REGISTERED_USERS still applies to the privileged user.
+ Project.NameKey p = projectOperations.newProject().create();
+ gApi.projects().name(p.get()).branch("secret").create(new BranchInput());
+ gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+ .add(deny(READ).ref("refs/heads/secret").group(unprivileged))
+ .update();
+
+ // Privileged user can still see refs/heads/secret.
+ assertThat(visibleRefs(p, privilegedUser)).contains("refs/heads/secret");
+ // User has the DENY on refs/heads/secret but ALLOW on refs/*.
+ // The DENY cancels the ALLOW for the same (ref-pattern, group) via SeenRule,
+ // but the ALLOW on refs/* has a different ref pattern so it still applies.
+ assertThat(visibleRefs(p, user)).contains("refs/heads/secret");
+ }
+
+ @Test
+ public void deny_doesNotPreventAccessViaInheritedDifferentRefPattern() throws Exception {
+ // Doc: "DENY/ALLOW example" — child DENY on refs/heads/secret for REGISTERED_USERS,
+ // but the parent also has ALLOW on refs/heads/* for REGISTERED_USERS.
+ // The DENY only cancels (refs/heads/secret, REGISTERED_USERS) via SeenRule,
+ // but the parent ALLOW covers refs/heads/* which is a different ref pattern,
+ // so access is still granted.
+ Project.NameKey parent = projectOperations.newProject().create();
+ Project.NameKey child = projectOperations.newProject().parent(parent).create();
+ gApi.projects().name(child.get()).branch("secret").create(new BranchInput());
+
+ projectOperations
+ .project(parent)
+ .forUpdate()
+ .add(allow(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+ .update();
+ projectOperations
+ .project(child)
+ .forUpdate()
+ .add(deny(READ).ref("refs/heads/secret").group(REGISTERED_USERS))
+ .update();
+
+ // The parent ALLOW on refs/heads/* (different pattern) still applies.
+ assertThat(visibleRefs(child, user)).contains("refs/heads/secret");
+ }
+
+ @Test
+ public void grantReadOnRefsTagsOnly_doesNotMakeTagsVisible() throws Exception {
+ // Granting READ on refs/tags/* alone has no effect; tags are visible only
+ // when reachable from a readable branch.
+ Project.NameKey p = projectOperations.newProject().create();
+ gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+ gApi.projects().name(p.get()).tag("v1.0").create(new TagInput());
+
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(allow(READ).ref("refs/tags/*").group(REGISTERED_USERS))
+ .update();
+
+ // No branches are readable, so no tags are visible either.
+ assertThat(visibleRefs(p, user)).containsNoneIn(ImmutableList.of("refs/tags/v1.0"));
+ }
+
+ @Test
+ public void tagVisibleWhenReachableFromReadableBranch() throws Exception {
+ // A tag is visible if and only if it is reachable from a branch the user can read.
+ Project.NameKey p = projectOperations.newProject().create();
+ gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
+ // Create a tag pointing at HEAD
+ gApi.projects().name(p.get()).tag("v1.0").create(new TagInput());
+
+ assertThat(visibleRefs(p, user)).contains("refs/tags/v1.0");
+
+ // Now restrict READ to a subset of branches that does not include main.
+ // Remove the broad allow and grant only on refs/heads/other/*.
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .remove(permissionKey(READ).ref("refs/*").group(REGISTERED_USERS))
+ .add(allow(READ).ref("refs/heads/other/*").group(REGISTERED_USERS))
+ .update();
+
+ // Tag is no longer reachable from any visible ref, so it becomes invisible.
+ assertThat(visibleRefs(p, user)).doesNotContain("refs/tags/v1.0");
+ }
+
+ @Test
+ public void blockInParent_childCannotUnblockWithExclusive() throws Exception {
+ // An exclusive read access in a child project does not unblock
+ // read access blocked in a parent repository
+ Project.NameKey parent = projectOperations.newProject().create();
+ Project.NameKey child = projectOperations.newProject().parent(parent).create();
+ gApi.projects().name(child.get()).branch("main").create(new BranchInput());
+
+ projectOperations
+ .project(parent)
+ .forUpdate()
+ .add(block(READ).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ projectOperations
+ .project(child)
+ .forUpdate()
+ .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+ .setExclusiveGroup(permissionKey(READ).ref("refs/*"), true)
+ .update();
+
+ // The parent's BLOCK cannot be overridden by the child's exclusive ALLOW.
+ assertThat(visibleRefs(child, user)).isEmpty();
+ }
+
+ @Test
+ public void regexRefPattern_matchesOnlyMatchingBranches() throws Exception {
+ Project.NameKey p = projectOperations.newProject().create();
+ gApi.projects().name(p.get()).branch("short").create(new BranchInput());
+ gApi.projects().name(p.get()).branch("UPPERCASE").create(new BranchInput());
+ gApi.projects().name(p.get()).branch("verylongbranchname").create(new BranchInput());
+
+ // Allow read only on lowercase branches of 1-8 characters.
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(allow(READ).ref("^refs/heads/[a-z]{1,8}").group(REGISTERED_USERS))
+ .update();
+
+ ImmutableList<String> visible = visibleRefs(p, user);
+ assertThat(visible).contains("refs/heads/short");
+ assertThat(visible).doesNotContain("refs/heads/UPPERCASE");
+ assertThat(visible).doesNotContain("refs/heads/verylongbranchname");
+ }
+
+ @Test
+ public void usernamePattern_userSeesOnlyOwnBranch() throws Exception {
+ Project.NameKey p = projectOperations.newProject().create();
+ // Branch matching the regular user's username.
+ String userBranch = "sandbox/" + user.username() + "/feature";
+ // Branch matching the privileged user's username.
+ String privilegedBranch = "sandbox/" + privilegedUser.username() + "/feature";
+ gApi.projects().name(p.get()).branch(userBranch).create(new BranchInput());
+ gApi.projects().name(p.get()).branch(privilegedBranch).create(new BranchInput());
+
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(allow(READ).ref("refs/heads/sandbox/${username}/*").group(REGISTERED_USERS))
+ .update();
+
+ ImmutableList<String> visibleToUser = visibleRefs(p, user);
+ assertThat(visibleToUser).contains("refs/heads/" + userBranch);
+ assertThat(visibleToUser).doesNotContain("refs/heads/" + privilegedBranch);
+
+ ImmutableList<String> visibleToPrivileged = visibleRefs(p, privilegedUser);
+ assertThat(visibleToPrivileged).contains("refs/heads/" + privilegedBranch);
+ assertThat(visibleToPrivileged).doesNotContain("refs/heads/" + userBranch);
+ }
+
+ @Test
+ public void childAllow_moreSpecific_overridesNarrowerParentAllow() throws Exception {
+ // Parent allows only refs/heads/main; child additionally allows refs/heads/feature/*.
+ Project.NameKey parent = projectOperations.newProject().create();
+ Project.NameKey child = projectOperations.newProject().parent(parent).create();
+ gApi.projects().name(child.get()).branch("main").create(new BranchInput());
+ gApi.projects().name(child.get()).branch("feature/foo").create(new BranchInput());
+
+ projectOperations
+ .project(parent)
+ .forUpdate()
+ .add(allow(READ).ref("refs/heads/main").group(REGISTERED_USERS))
+ .update();
+ projectOperations
+ .project(child)
+ .forUpdate()
+ .add(allow(READ).ref("refs/heads/feature/*").group(REGISTERED_USERS))
+ .update();
+
+ ImmutableList<String> visible = visibleRefs(child, user);
+ assertThat(visible).contains("refs/heads/main");
+ assertThat(visible).contains("refs/heads/feature/foo");
+ }
+
+ @Test
+ public void exclusiveAllow_preventsOtherGroupsFromInheritingAccess() throws Exception {
+ // All-Projects ALLOW read for REGISTERED_USERS on refs/heads/*. Exclusive read
+ // permission is set for the privileged group on refs/heads/restricted/* in project.
+ // Regular registered users lose access to refs/heads/restricted/* because
+ // the exclusive flag stops the upward search before reaching REGISTERED_USERS.
+ Project.NameKey p = projectOperations.newProject().parent(allProjects).create();
+ gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+ gApi.projects().name(p.get()).branch("restricted/secret").create(new BranchInput());
+
+ // All-Projects grants broad READ to all registered users.
+ projectOperations
+ .project(allProjects)
+ .forUpdate()
+ .add(allow(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+ .update();
+ // Child grants exclusive READ on restricted/* only to privileged group.
+ // The exclusive flag stops the search before the parent ALLOW is reached.
+ projectOperations
+ .project(p)
+ .forUpdate()
+ .add(allow(READ).ref("refs/heads/restricted/*").group(privileged))
+ .setExclusiveGroup(permissionKey(READ).ref("refs/heads/restricted/*"), true)
+ .update();
+
+ // Regular user cannot see restricted branch (exclusive stops inherited ALLOW).
+ assertThat(visibleRefs(p, user)).doesNotContain("refs/heads/restricted/secret");
+ // Non-restricted branches are still accessible via inherited parent ALLOW.
+ assertThat(visibleRefs(p, user)).contains("refs/heads/main");
+ // Privileged user can see the restricted branch.
+ assertThat(visibleRefs(p, privilegedUser)).contains("refs/heads/restricted/secret");
+ }
+
+ private ImmutableList<String> visibleRefs(Project.NameKey project, TestAccount account)
+ throws Exception {
+ try (Repository repo = repoManager.openRepository(project)) {
+ return permissionBackend
+ .user(identifiedUserFactory.create(account.id()))
+ .project(project)
+ .filter(
+ repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+ .stream()
+ .map(Ref::getName)
+ .collect(toImmutableList());
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
index caec581..3a6be28 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
@@ -22,12 +22,19 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.Sandboxed;
import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.sshd.BaseCommand;
import com.google.gerrit.testing.ConfigSuite;
import com.google.inject.Module;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
import org.eclipse.jgit.lib.Config;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -71,6 +78,60 @@
}
}
+ @Test
+ public void clientDisconnectDoesNotLogInternalServerError() throws Exception {
+ List<LogRecord> severeRecords = new CopyOnWriteArrayList<>();
+ Handler captureHandler =
+ new Handler() {
+ @Override
+ public void publish(LogRecord r) {
+ if (r.getLevel().intValue() >= Level.SEVERE.intValue()) {
+ severeRecords.add(r);
+ }
+ }
+
+ @Override
+ public void flush() {}
+
+ @Override
+ public void close() {}
+ };
+ Logger baseCommandLogger = Logger.getLogger(BaseCommand.class.getName());
+ baseCommandLogger.addHandler(captureHandler);
+
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ Future<Integer> commandFuture =
+ executor.submit(() -> userSshSession.execAndReturnStatus("interrupted"));
+
+ // Wait until the command is running and parked in Thread.sleep().
+ InterruptedCommand.syncPoint.await(30, TimeUnit.SECONDS);
+
+ // Simulate the client dropping the connection. sshd calls destroy(), which
+ // interrupts the worker thread.
+ userSshSession.close();
+
+ // Positive signal: prove the interrupt actually reached the command and was
+ // rethrown wrapped, so a green test cannot mean "the path never ran".
+ assertThat(InterruptedCommand.threwWrapped.await(30, TimeUnit.SECONDS)).isTrue();
+
+ // handleError() runs just after the throw. Poll rather than sleeping a fixed
+ // interval: fail fast on a bad log, and do not burn wall-clock on success.
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (System.nanoTime() < deadline && severeRecords.isEmpty()) {
+ Thread.sleep(50);
+ }
+
+ // Surface any failure from the command thread rather than discarding it.
+ commandFuture.get(30, TimeUnit.SECONDS);
+ } finally {
+ baseCommandLogger.removeHandler(captureHandler);
+ executor.shutdownNow();
+ }
+
+ assertThat(severeRecords).isEmpty();
+ }
+
private Future<Integer> startCommand(ExecutorService executor, boolean graceful)
throws Exception {
Future<Integer> future =
diff --git a/javatests/com/google/gerrit/index/query/AndPredicateTest.java b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
index 0571ea5..95533b1 100644
--- a/javatests/com/google/gerrit/index/query/AndPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
@@ -88,9 +88,9 @@
final TestPredicate<String> b = f("author", "bob");
final TestPredicate<String> c = f("author", "charlie");
- assertTrue(new AndPredicate<>(a).hashCode() == new AndPredicate<>(a).hashCode());
- assertTrue(and(a, b).hashCode() == and(a, b).hashCode());
- assertTrue(and(a, b, c).hashCode() == and(a, b, c).hashCode());
+ assertEquals(new AndPredicate<>(a).hashCode(), new AndPredicate<>(a).hashCode());
+ assertEquals(and(a, b).hashCode(), and(a, b).hashCode());
+ assertEquals(and(a, b, c).hashCode(), and(a, b, c).hashCode());
assertFalse(and(a, c).hashCode() == and(a, b).hashCode());
assertFalse(and(a, b).hashCode() == new OrPredicate<>(a, b).hashCode());
}
diff --git a/javatests/com/google/gerrit/index/query/OrPredicateTest.java b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
index dbb8711..0db840a 100644
--- a/javatests/com/google/gerrit/index/query/OrPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
@@ -88,9 +88,9 @@
final TestPredicate<String> b = f("author", "bob");
final TestPredicate<String> c = f("author", "charlie");
- assertTrue(new OrPredicate<>(a).hashCode() == new OrPredicate<>(a).hashCode());
- assertTrue(or(a, b).hashCode() == or(a, b).hashCode());
- assertTrue(or(a, b, c).hashCode() == or(a, b, c).hashCode());
+ assertEquals(new OrPredicate<>(a).hashCode(), new OrPredicate<>(a).hashCode());
+ assertEquals(or(a, b).hashCode(), or(a, b).hashCode());
+ assertEquals(or(a, b, c).hashCode(), or(a, b, c).hashCode());
assertFalse(or(a, c).hashCode() == or(a, b).hashCode());
assertFalse(or(a, b).hashCode() == new AndPredicate<>(a, b).hashCode());
}
diff --git a/javatests/com/google/gerrit/index/query/PaginatingSourceTest.java b/javatests/com/google/gerrit/index/query/PaginatingSourceTest.java
new file mode 100644
index 0000000..5fed380
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/PaginatingSourceTest.java
@@ -0,0 +1,247 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeFalse;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.PaginationType;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.testing.ConfigSuite;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(ConfigSuite.class)
+public class PaginatingSourceTest extends PredicateTest {
+
+ @ConfigSuite.Parameter public Config config;
+
+ private static class TestPaginatedSource implements DataSource<String>, Paginated<String> {
+ private final List<String> allItems;
+ private final QueryOptions options;
+ private int restartCount = 0;
+ private int readCount = 0;
+
+ TestPaginatedSource(List<String> allItems, QueryOptions options) {
+ this.allItems = allItems;
+ this.options = options;
+ }
+
+ @Override
+ public QueryOptions getOptions() {
+ return options;
+ }
+
+ @Override
+ public ResultSet<String> read() {
+ readCount++;
+ return getSlice(0, options.pageSize());
+ }
+
+ @Override
+ public ResultSet<String> restart(int start) {
+ return restart(start, options.pageSize());
+ }
+
+ @Override
+ public ResultSet<String> restart(int start, int pageSize) {
+ restartCount++;
+ return getSlice(start, pageSize);
+ }
+
+ @Override
+ public ResultSet<String> restart(Object searchAfter, int pageSize) {
+ restartCount++;
+ int start = searchAfter == null ? 0 : ((Integer) searchAfter) + 1;
+ return getSlice(start, pageSize);
+ }
+
+ private ResultSet<String> getSlice(int start, int pageSize) {
+ if (start >= allItems.size()) {
+ return new ListResultSet<>(ImmutableList.of());
+ }
+ int end = Math.min(start + pageSize, allItems.size());
+ List<String> slice = new ArrayList<>(allItems.subList(start, end));
+ return new ListResultSet<String>(slice) {
+ @Override
+ public Object searchAfter() {
+ return end - 1;
+ }
+ };
+ }
+
+ @Override
+ public int getCardinality() {
+ return allItems.size();
+ }
+
+ @Override
+ public ResultSet<FieldBundle> readRaw() {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ private QueryOptions createOptions(IndexConfig indexConfig, int pageSize, int limit) {
+ return QueryOptions.create(
+ indexConfig,
+ 0,
+ null,
+ pageSize,
+ indexConfig.pageSizeMultiplier(),
+ limit,
+ /* allowIncompleteResults= */ false,
+ ImmutableSet.of());
+ }
+
+ @Test
+ public void read_doesNotRestartWhenFirstPageMeetsLimit() {
+ IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+ assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+ List<String> items =
+ IntStream.range(0, 100).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+ // limit 26 (e.g. 25 + 1 probe), pageSize 26
+ QueryOptions options = createOptions(indexConfig, 26, 26);
+ TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+ PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 0, indexConfig);
+ ImmutableList<String> results = paginatingSource.read().toList();
+
+ assertThat(results).hasSize(26);
+ assertThat(source.readCount).isEqualTo(1);
+ // Crucial check: no second query issued since the first query already returned 26 items
+ assertThat(source.restartCount).isEqualTo(0);
+ }
+
+ @Test
+ public void read_doesNotRestartWhenResultsFewerThanLimit() {
+ IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+ assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+ List<String> items =
+ IntStream.range(0, 10).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+ QueryOptions options = createOptions(indexConfig, 26, 26);
+ TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+ PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 0, indexConfig);
+ ImmutableList<String> results = paginatingSource.read().toList();
+
+ assertThat(results).hasSize(10);
+ assertThat(source.readCount).isEqualTo(1);
+ assertThat(source.restartCount).isEqualTo(0);
+ }
+
+ @Test
+ public void read_restartsToBackfillWhenVisibleResultsLessThanLimit() {
+ IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+ assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+ // 100 items, only even-indexed items match
+ List<String> items =
+ IntStream.range(0, 100).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+ // pageSize 10, limit 15
+ QueryOptions options = createOptions(indexConfig, 10, 15);
+ TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+ Predicate<String> filter = item -> Integer.parseInt(item.substring("item-".length())) % 2 == 0;
+
+ PaginatingSource<String> paginatingSource =
+ new PaginatingSource<>(source, 0, indexConfig) {
+ @Override
+ protected boolean match(String object) {
+ return filter.test(object);
+ }
+ };
+
+ ImmutableList<String> results = paginatingSource.read().toList();
+
+ // Page 1: 10 items (0..9) -> 5 match (item-0, 2, 4, 6, 8)
+ // Page 2: 10 items (10..19) -> 5 match (item-10, 12, 14, 16, 18) [total: 10]
+ // Page 3: 10 items (20..29) -> 5 match (item-20, 22, 24, 26, 28) [total: 15] -> limit reached!
+ assertThat(results).hasSize(15);
+ assertThat(source.readCount).isEqualTo(1);
+ // Restarted exactly 2 times to reach 15 items, never issued an unnecessary 3rd restart
+ assertThat(source.restartCount).isEqualTo(2);
+ }
+
+ @Test
+ public void read_breaksEarlyOnSubsequentPageWhenLimitReached() {
+ IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+ assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+ List<String> items =
+ IntStream.range(0, 100).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+ // pageSize 10, limit 15 (page 1 gives 10 items, page 2 only needs to yield 5 items)
+ QueryOptions options = createOptions(indexConfig, 10, 15);
+ TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+ PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 0, indexConfig);
+ ImmutableList<String> results = paginatingSource.read().toList();
+
+ assertThat(results).hasSize(15);
+ assertThat(source.readCount).isEqualTo(1);
+ assertThat(source.restartCount).isEqualTo(1);
+ }
+
+ @Test
+ public void read_withStartOffsetDoesNotRestartWhenLimitMet() {
+ IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+ assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+ List<String> items =
+ IntStream.range(0, 100).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+ // start=10, limit=25 + 1 probe = 26. convertForBackend gives limit=36, pageSize=36
+ QueryOptions options = createOptions(indexConfig, 36, 36);
+ TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+ PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 10, indexConfig);
+ ImmutableList<String> results = paginatingSource.read().toList();
+
+ // 36 items read, start=10 dropped, leaving 26 items
+ assertThat(results).hasSize(26);
+ assertThat(source.readCount).isEqualTo(1);
+ assertThat(source.restartCount).isEqualTo(0);
+ }
+
+ @Test
+ public void read_noLimitQueryPaginatesUntilExhaustion() {
+ IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+ assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+ List<String> items =
+ IntStream.range(0, 30).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+ // pageSize 10, limit Integer.MAX_VALUE
+ QueryOptions options = createOptions(indexConfig, 10, Integer.MAX_VALUE);
+ TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+ PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 0, indexConfig);
+ ImmutableList<String> results = paginatingSource.read().toList();
+
+ assertThat(results).hasSize(30);
+ assertThat(source.readCount).isEqualTo(1);
+ // Page 1: 0..9 (10 items), restart 1: 10..19 (10 items), restart 2: 20..29 (10 items), restart
+ // 3: empty (0 items)
+ assertThat(source.restartCount).isEqualTo(3);
+ }
+}
diff --git a/javatests/com/google/gerrit/server/comment/CommentContextCacheImplTest.java b/javatests/com/google/gerrit/server/comment/CommentContextCacheImplTest.java
new file mode 100644
index 0000000..56c755d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/comment/CommentContextCacheImplTest.java
@@ -0,0 +1,304 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.comment;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.AbstractLoadingCache;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class CommentContextCacheImplTest {
+
+ private static final Project.NameKey PROJECT = Project.nameKey("my-project");
+ private static final Change.Id CHANGE_ID = Change.id(123);
+
+ private CommentContextKey createKey(String id, String path, int patchset, int padding) {
+ return CommentContextKey.builder()
+ .project(PROJECT)
+ .changeId(CHANGE_ID)
+ .id(id)
+ .path(path)
+ .patchset(patchset)
+ .contextPadding(padding)
+ .build();
+ }
+
+ @Test
+ public void getAll_deduplicatesInputKeys() {
+ CommentContextKey key1 = createKey("c1", "FileA.java", 1, 3);
+ CommentContextKey key1Duplicate = createKey("c1", "FileA.java", 1, 3);
+ CommentContextKey key2 = createKey("c2", "FileB.java", 1, 3);
+
+ CommentContext ctx1 = CommentContext.create(ImmutableMap.of(10, "line 10"), "text/x-java");
+ CommentContext ctx2 = CommentContext.create(ImmutableMap.of(20, "line 20"), "text/x-java");
+
+ List<CommentContextKey> requestedKeys = new ArrayList<>();
+ LoadingCache<CommentContextKey, CommentContext> loadingCache =
+ new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+ @Override
+ public CommentContext get(CommentContextKey key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public CommentContext getIfPresent(Object key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ImmutableMap<CommentContextKey, CommentContext> getAll(
+ Iterable<? extends CommentContextKey> keys) {
+ ImmutableMap.Builder<CommentContextKey, CommentContext> builder =
+ ImmutableMap.builder();
+ for (CommentContextKey k : keys) {
+ requestedKeys.add(k);
+ if (k.id().equals("c1")) {
+ builder.put(k, ctx1);
+ } else if (k.id().equals("c2")) {
+ builder.put(k, ctx2);
+ }
+ }
+ return builder.build();
+ }
+ };
+
+ CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+ ImmutableMap<CommentContextKey, CommentContext> result =
+ cache.getAll(ImmutableList.of(key1, key1Duplicate, key2));
+
+ // Verify cache was queried with only 2 unique keys
+ assertThat(requestedKeys).hasSize(2);
+
+ assertThat(result).hasSize(2);
+ assertThat(result.get(key1)).isEqualTo(ctx1);
+ assertThat(result.get(key2)).isEqualTo(ctx2);
+ }
+
+ @Test
+ public void getAll_handlesMissingCacheEntries() {
+ CommentContextKey key1 = createKey("c1", "FileA.java", 1, 3);
+ CommentContextKey key2 = createKey("c2", "FileB.java", 1, 3);
+
+ CommentContext ctx1 = CommentContext.create(ImmutableMap.of(10, "line 10"), "text/x-java");
+
+ LoadingCache<CommentContextKey, CommentContext> loadingCache =
+ new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+ @Override
+ public CommentContext get(CommentContextKey key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public CommentContext getIfPresent(Object key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ImmutableMap<CommentContextKey, CommentContext> getAll(
+ Iterable<? extends CommentContextKey> keys) {
+ ImmutableMap.Builder<CommentContextKey, CommentContext> builder =
+ ImmutableMap.builder();
+ for (CommentContextKey k : keys) {
+ if (k.id().equals("c1")) {
+ builder.put(k, ctx1);
+ }
+ }
+ return builder.build();
+ }
+ };
+
+ CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+ ImmutableMap<CommentContextKey, CommentContext> result =
+ cache.getAll(ImmutableList.of(key1, key2));
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(key1)).isEqualTo(ctx1);
+ assertThat(result.containsKey(key2)).isFalse();
+ }
+
+ @Test
+ public void getAll_adjustsNegativeContextPaddingToZero() {
+ CommentContextKey key = createKey("c1", "FileA.java", 1, -5);
+
+ List<CommentContextKey> requestedKeys = new ArrayList<>();
+ LoadingCache<CommentContextKey, CommentContext> loadingCache =
+ new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+ @Override
+ public CommentContext get(CommentContextKey key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public CommentContext getIfPresent(Object key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ImmutableMap<CommentContextKey, CommentContext> getAll(
+ Iterable<? extends CommentContextKey> keys) {
+ Iterables.addAll(requestedKeys, keys);
+ return ImmutableMap.of();
+ }
+ };
+
+ CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+ ImmutableMap<CommentContextKey, CommentContext> result = cache.getAll(ImmutableList.of(key));
+
+ assertThat(result).isEmpty();
+ assertThat(requestedKeys).hasSize(1);
+ assertThat(requestedKeys.get(0).contextPadding()).isEqualTo(0);
+ }
+
+ @Test
+ public void getAll_adjustsExcessiveContextPaddingToMax() {
+ CommentContextKey key =
+ createKey("c1", "FileA.java", 1, CommentContextCacheImpl.MAX_CONTEXT_PADDING + 20);
+
+ List<CommentContextKey> requestedKeys = new ArrayList<>();
+ LoadingCache<CommentContextKey, CommentContext> loadingCache =
+ new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+ @Override
+ public CommentContext get(CommentContextKey key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public CommentContext getIfPresent(Object key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ImmutableMap<CommentContextKey, CommentContext> getAll(
+ Iterable<? extends CommentContextKey> keys) {
+ Iterables.addAll(requestedKeys, keys);
+ return ImmutableMap.of();
+ }
+ };
+
+ CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+ ImmutableMap<CommentContextKey, CommentContext> result = cache.getAll(ImmutableList.of(key));
+
+ assertThat(result).isEmpty();
+ assertThat(requestedKeys).hasSize(1);
+ assertThat(requestedKeys.get(0).contextPadding())
+ .isEqualTo(CommentContextCacheImpl.MAX_CONTEXT_PADDING);
+ }
+
+ @Test
+ public void get_singleKey() {
+ CommentContextKey key = createKey("c1", "FileA.java", 1, 3);
+ CommentContext ctx = CommentContext.create(ImmutableMap.of(5, "code line"), "text/x-java");
+
+ LoadingCache<CommentContextKey, CommentContext> loadingCache =
+ new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+ @Override
+ public CommentContext get(CommentContextKey key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public CommentContext getIfPresent(Object key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ImmutableMap<CommentContextKey, CommentContext> getAll(
+ Iterable<? extends CommentContextKey> keys) {
+ ImmutableMap.Builder<CommentContextKey, CommentContext> builder =
+ ImmutableMap.builder();
+ for (CommentContextKey k : keys) {
+ builder.put(k, ctx);
+ }
+ return builder.build();
+ }
+ };
+
+ CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+ CommentContext result = cache.get(key);
+ assertThat(result).isEqualTo(ctx);
+ }
+
+ @Test
+ public void commentContextSerializer_roundTrip_multiLineContext() {
+ CommentContext original =
+ CommentContext.create(
+ ImmutableMap.of(
+ 1, "public class Foo {",
+ 2, " public void bar() {",
+ 3, " return;",
+ 4, " }",
+ 5, "}"),
+ "text/x-java");
+
+ byte[] serialized =
+ CommentContextCacheImpl.CommentContextSerializer.INSTANCE.serialize(original);
+ assertThat(serialized).isNotEmpty();
+
+ CommentContext deserialized =
+ CommentContextCacheImpl.CommentContextSerializer.INSTANCE.deserialize(serialized);
+ assertThat(deserialized).isEqualTo(original);
+ assertThat(deserialized.lines()).isEqualTo(original.lines());
+ assertThat(deserialized.contentType()).isEqualTo("text/x-java");
+ }
+
+ @Test
+ public void commentContextSerializer_roundTrip_emptyContext() {
+ CommentContext original = CommentContext.empty();
+
+ byte[] serialized =
+ CommentContextCacheImpl.CommentContextSerializer.INSTANCE.serialize(original);
+ assertThat(serialized).isNotNull();
+
+ CommentContext deserialized =
+ CommentContextCacheImpl.CommentContextSerializer.INSTANCE.deserialize(serialized);
+ assertThat(deserialized).isEqualTo(original);
+ assertThat(deserialized.lines()).isEmpty();
+ assertThat(deserialized.contentType()).isEmpty();
+ }
+
+ @Test
+ public void commentContextSerializer_roundTrip_emptyLinesAndWhitespace() {
+ CommentContext original =
+ CommentContext.create(
+ ImmutableMap.of(
+ 10, "",
+ 11, " ",
+ 12, "\t\t",
+ 13, "non-empty line"),
+ "text/plain");
+
+ byte[] serialized =
+ CommentContextCacheImpl.CommentContextSerializer.INSTANCE.serialize(original);
+ assertThat(serialized).isNotEmpty();
+
+ CommentContext deserialized =
+ CommentContextCacheImpl.CommentContextSerializer.INSTANCE.deserialize(serialized);
+ assertThat(deserialized).isEqualTo(original);
+ assertThat(deserialized.lines()).isEqualTo(original.lines());
+ assertThat(deserialized.contentType()).isEqualTo("text/plain");
+ }
+}
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 33698fe..0884bb7 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -616,6 +616,20 @@
}
@Test
+ public void regexWithEscapedDotMatchesLiteralDotOnly() throws Exception {
+ projectOperations
+ .project(localKey)
+ .forUpdate()
+ .add(allow(READ).ref("^refs/heads/.*foo\\.bar").group(DEVS))
+ .update();
+
+ ProjectControl u = user(localKey, DEVS);
+ assertCanRead("refs/heads/bar-foo.bar", u);
+ // Without the escaping, '.' would be a wildcard and this would also match.
+ assertCannotRead("refs/heads/bar-fooXbar", u);
+ }
+
+ @Test
public void blockRule_ParentBlocksChild() throws Exception {
projectOperations
.project(localKey)
diff --git a/javatests/com/google/gerrit/server/project/MigrateLabelFunctionsToSubmitRequirementTest.java b/javatests/com/google/gerrit/server/project/MigrateLabelFunctionsToSubmitRequirementTest.java
new file mode 100644
index 0000000..89c3a77
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/MigrateLabelFunctionsToSubmitRequirementTest.java
@@ -0,0 +1,283 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.restapi.project.MigrateLabelFunctionsToSubmitRequirement;
+import com.google.gerrit.server.restapi.project.MigrateLabelFunctionsToSubmitRequirement.Status;
+import com.google.gerrit.server.schema.UpdateUI;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.TestUpdateUI;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests full migration behavior of {@link MigrateLabelFunctionsToSubmitRequirement}. */
+public class MigrateLabelFunctionsToSubmitRequirementTest {
+ private static final String LABEL_NAME = "Foo";
+
+ private static final ImmutableList<LabelValue> STANDARD_VALUES =
+ ImmutableList.of(
+ LabelValue.create((short) -1, "Looks Bad"),
+ LabelValue.create((short) 0, "No Score"),
+ LabelValue.create((short) 1, "Looks Good"));
+
+ private InMemoryRepositoryManager repoManager;
+ private MigrateLabelFunctionsToSubmitRequirement migrator;
+
+ @Before
+ public void setUp() {
+ repoManager = new InMemoryRepositoryManager();
+ migrator = new MigrateLabelFunctionsToSubmitRequirement(null, repoManager);
+ }
+
+ private record TestProjectConfig(
+ ProjectConfig config,
+ Map<String, LabelType> labels,
+ Map<String, SubmitRequirement> submitRequirements) {}
+
+ private TestProjectConfig newConfig() {
+ ProjectConfig config = mock(ProjectConfig.class);
+ Map<String, LabelType> labels = new LinkedHashMap<>();
+ Map<String, SubmitRequirement> submitRequirements = new LinkedHashMap<>();
+ when(config.getLabelSections()).thenReturn(labels);
+ when(config.getSubmitRequirementSections()).thenReturn(submitRequirements);
+ return new TestProjectConfig(config, labels, submitRequirements);
+ }
+
+ private LabelType.Builder labelBuilder(LabelFunction function) {
+ return LabelType.builder(LABEL_NAME, STANDARD_VALUES).setFunction(function);
+ }
+
+ private void createRepository(Project.NameKey project) throws Exception {
+ var repo = repoManager.createRepository(project);
+ assertThat(repo).isNotNull();
+ }
+
+ private SubmitRequirement requirementFor(ProjectConfig config) {
+ Map<String, SubmitRequirement> srs = config.getSubmitRequirementSections();
+ assertThat(srs).containsKey(LABEL_NAME);
+ return srs.get(LABEL_NAME);
+ }
+
+ @Test
+ public void maxWithBlock_createsSr_andResetsLabelFunction() throws Exception {
+ Project.NameKey project = Project.nameKey("p-max");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels().put(LABEL_NAME, labelBuilder(LabelFunction.MAX_WITH_BLOCK).build());
+ UpdateUI ui = mock(UpdateUI.class);
+
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.MIGRATED);
+ assertThat(requirementFor(c.config()).submittabilityExpression().expressionString())
+ .isEqualTo("label:Foo=MAX AND -label:Foo=MIN");
+ assertThat(c.labels().get(LABEL_NAME).getFunction()).isEqualTo(LabelFunction.NO_BLOCK);
+ }
+
+ @Test
+ public void noBlock_doesNotCreateSr_andReturnsNoChange() throws Exception {
+ Project.NameKey project = Project.nameKey("p-noblock");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels().put(LABEL_NAME, labelBuilder(LabelFunction.NO_BLOCK).build());
+ UpdateUI ui = mock(UpdateUI.class);
+
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.NO_CHANGE);
+ assertThat(c.submitRequirements()).isEmpty();
+ verifyNoInteractions(ui);
+ }
+
+ @Test
+ public void noOp_resetsToNoBlock_withoutSr() throws Exception {
+ Project.NameKey project = Project.nameKey("p-noop");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels().put(LABEL_NAME, labelBuilder(LabelFunction.NO_OP).build());
+ UpdateUI ui = mock(UpdateUI.class);
+
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.MIGRATED);
+ assertThat(c.submitRequirements()).isEmpty();
+ assertThat(c.labels().get(LABEL_NAME).getFunction()).isEqualTo(LabelFunction.NO_BLOCK);
+ }
+
+ @Test
+ public void existingSrWithSameName_isNotOverwritten_andWarningEmitted() throws Exception {
+ Project.NameKey project = Project.nameKey("p-existing");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels().put(LABEL_NAME, labelBuilder(LabelFunction.MAX_WITH_BLOCK).build());
+ c.submitRequirements()
+ .put(
+ LABEL_NAME,
+ SubmitRequirement.builder()
+ .setName(LABEL_NAME)
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("project:foo"))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+
+ TestUpdateUI ui = new TestUpdateUI();
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.MIGRATED);
+ assertThat(c.labels().get(LABEL_NAME).getFunction()).isEqualTo(LabelFunction.NO_BLOCK);
+ assertThat(c.submitRequirements().get(LABEL_NAME).submittabilityExpression().expressionString())
+ .isEqualTo("project:foo");
+ assertThat(ui.getOutput()).contains("Warning");
+ }
+
+ @Test
+ public void branchPattern_regex_usedAsIs() throws Exception {
+ Project.NameKey project = Project.nameKey("p-regex");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels()
+ .put(
+ LABEL_NAME,
+ labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+ .setRefPatterns(ImmutableList.of("^refs/heads/main-.*"))
+ .build());
+ UpdateUI ui = mock(UpdateUI.class);
+
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.MIGRATED);
+ assertThat(
+ requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+ .isEqualTo("branch:^refs/heads/main-.*");
+ }
+
+ @Test
+ public void branchPattern_wildcard_convertedToRegex() throws Exception {
+ Project.NameKey project = Project.nameKey("p-wildcard");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels()
+ .put(
+ LABEL_NAME,
+ labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+ .setRefPatterns(ImmutableList.of("refs/heads/release/*"))
+ .build());
+ UpdateUI ui = mock(UpdateUI.class);
+
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.MIGRATED);
+ assertThat(
+ requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+ .isEqualTo("branch:^\\Qrefs/heads/release/\\E.*");
+ }
+
+ @Test
+ public void branchPattern_plain_wrappedInQuotes() throws Exception {
+ Project.NameKey project = Project.nameKey("p-plain");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels()
+ .put(
+ LABEL_NAME,
+ labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+ .setRefPatterns(ImmutableList.of("refs/heads/master"))
+ .build());
+ UpdateUI ui = mock(UpdateUI.class);
+
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.MIGRATED);
+ assertThat(
+ requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+ .isEqualTo("branch:\"refs/heads/master\"");
+ }
+
+ @Test
+ public void branchPattern_plain_withQuote_isEscapedAndQuoted() throws Exception {
+ Project.NameKey project = Project.nameKey("p-quote");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels()
+ .put(
+ LABEL_NAME,
+ labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+ .setRefPatterns(ImmutableList.of("refs/heads/gerr\"it"))
+ .build());
+ UpdateUI ui = mock(UpdateUI.class);
+
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.MIGRATED);
+ assertThat(
+ requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+ .isEqualTo("branch:\"refs/heads/gerr\\\"it\"");
+ }
+
+ @Test
+ public void branchPattern_plain_withHash_isQuoted() throws Exception {
+ Project.NameKey project = Project.nameKey("p-hash");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels()
+ .put(
+ LABEL_NAME,
+ labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+ .setRefPatterns(ImmutableList.of("refs/heads/gerr#it"))
+ .build());
+ UpdateUI ui = mock(UpdateUI.class);
+
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.MIGRATED);
+ assertThat(
+ requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+ .isEqualTo("branch:\"refs/heads/gerr#it\"");
+ }
+
+ @Test
+ public void branchPattern_multiple_joinedWithOr() throws Exception {
+ Project.NameKey project = Project.nameKey("p-multi");
+ createRepository(project);
+ TestProjectConfig c = newConfig();
+ c.labels()
+ .put(
+ LABEL_NAME,
+ labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+ .setRefPatterns(ImmutableList.of("refs/heads/master", "^refs/heads/main-.*"))
+ .build());
+ UpdateUI ui = mock(UpdateUI.class);
+
+ Status status = migrator.updateConfig(project, c.config(), ui);
+
+ assertThat(status).isEqualTo(Status.MIGRATED);
+ assertThat(
+ requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+ .isEqualTo("branch:\"refs/heads/master\" OR branch:^refs/heads/main-.*");
+ }
+}
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 1d7e7ab..bd1cb94 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -132,7 +132,7 @@
+ " agreementUrl = http://www.example.com/agree\n")
.create();
- ProjectConfig cfg = read(rev);
+ ProjectConfig cfg = read(ALL_PROJECTS, rev);
assertThat(cfg.getAccountsSection().getSameGroupVisibility()).hasSize(2);
ContributorAgreement ca = cfg.getContributorAgreement("Individual");
assertThat(ca.getName()).isEqualTo("Individual");
@@ -167,6 +167,32 @@
}
@Test
+ public void readConfigWithEscapedDotInRegexAccessSectionRef() throws Exception {
+ // In git config subsection syntax, "\\" encodes a literal backslash, so
+ // the file text [access "^refs/heads/.*foo\\.bar"] yields subsection name
+ // "^refs/heads/.*foo\.bar", which includes a literal dot match.
+ RevCommit rev =
+ tr.commit()
+ .add("groups", group(developers))
+ .add(
+ "project.config",
+ "[access \"^refs/heads/.*foo\\\\.bar\"]\n" + " read = group Developers\n")
+ .create();
+ update(rev);
+
+ ProjectConfig cfg = read(rev);
+ assertThat(cfg.getAccessSection("^refs/heads/.*foo\\.bar")).isNotNull();
+ // Without proper escaping the dot would not be literal, so the unescaped
+ // form must not resolve to the same section.
+ assertThat(cfg.getAccessSection("^refs/heads/.*foo.bar")).isNull();
+
+ // Round-trip: the backslash must survive a write-back.
+ rev = commit(cfg);
+ assertThat(text(rev, "project.config"))
+ .isEqualTo("[access \"^refs/heads/.*foo\\\\.bar\"]\n" + " read = group Developers\n");
+ }
+
+ @Test
public void readConfigLabelDefaultValue() throws Exception {
RevCommit rev =
tr.commit()
@@ -439,7 +465,7 @@
.create();
update(rev);
- ProjectConfig cfg = read(rev);
+ ProjectConfig cfg = read(ALL_PROJECTS, rev);
cfg.upsertAccessSection(
"refs/heads/*",
section -> {
@@ -877,7 +903,7 @@
.create();
update(rev);
- ProjectConfig cfg = read(rev);
+ ProjectConfig cfg = read(ALL_PROJECTS, rev);
ContributorAgreement.Builder section = cfg.getContributorAgreement("Individual").toBuilder();
section.setAccepted(ImmutableList.of());
cfg.upsertContributorAgreement(section.build());
@@ -890,6 +916,20 @@
}
@Test
+ public void contributorSectionIsIgnoredIfSetOnRegularProject() throws Exception {
+ RevCommit rev =
+ tr.commit()
+ .add(
+ "project.config",
+ "[contributor-agreement \"Individual\"]\n" + " accepted = group Developers\n")
+ .create();
+ update(rev);
+
+ ProjectConfig cfg = read(rev);
+ assertThat(cfg.getContributorAgreement("Individual")).isNull();
+ }
+
+ @Test
public void notifySectionIsUnsetIfNoNotificationsAreSet() throws Exception {
RevCommit rev =
tr.commit()
@@ -1020,7 +1060,12 @@
}
private ProjectConfig read(RevCommit rev) throws IOException, ConfigInvalidException {
- ProjectConfig cfg = factory.create(Project.nameKey("test"));
+ return read(Project.nameKey("test"), rev);
+ }
+
+ private ProjectConfig read(Project.NameKey projectNameKey, RevCommit rev)
+ throws IOException, ConfigInvalidException {
+ ProjectConfig cfg = factory.create(projectNameKey);
cfg.load(db, rev);
return cfg;
}
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 0bbc5bb..87bbdfe 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -2264,6 +2264,81 @@
}
@Test
+ public void byOnlyPathsLiteral() throws Exception {
+ Project.NameKey project = Project.nameKey("repo");
+ repo = createAndOpenProject(project);
+ Change oneFile = insert(project, newChangeWithFiles(repo, "src/Foo.java"));
+ Change twoFiles = insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java"));
+ Change otherFile = insert(project, newChangeWithFiles(repo, "src/Bar.java"));
+ Change threeFiles =
+ insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java", "src/Baz.java"));
+
+ // Single-file exact match
+ assertQuery("onlypaths:src/Foo.java", oneFile);
+ assertQuery("onlypaths:src/Bar.java", otherFile);
+
+ // Two-file exact match — query order must not matter
+ assertQuery("onlypaths:src/Foo.java,src/Bar.java", twoFiles);
+ assertQuery("onlypaths:src/Bar.java,src/Foo.java", twoFiles);
+
+ // Three-file exact match
+ assertQuery("onlypaths:src/Foo.java,src/Bar.java,src/Baz.java", threeFiles);
+
+ // Superset must NOT match
+ assertQuery("onlypaths:src/Foo.java,src/Bar.java,src/Baz.java,src/Extra.java");
+
+ // Inverse
+ assertQuery("-onlypaths:src/Foo.java", threeFiles, otherFile, twoFiles);
+ }
+
+ @Test
+ public void byOnlyPathsRegex() throws Exception {
+ Project.NameKey project = Project.nameKey("repo");
+ repo = createAndOpenProject(project);
+ Change allJava = insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java"));
+ Change mixed = insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Foo.kt"));
+ Change allKt = insert(project, newChangeWithFiles(repo, "src/Foo.kt"));
+
+ // Only changes where every file matches the regex are returned.
+ // allJava: both files are .java — matches
+ // mixed: has a .kt file — must not match
+ // allKt: only .kt file — must not match
+ assertQuery("onlypaths:{^src/.*\\.java$}", allJava);
+
+ // Only changes where every file matches the regex are returned.
+ // allKt: only .kt file — matches
+ // mixed: has a .java file — must not match
+ // allJava: both files are .java — must not match
+ assertQuery("onlypaths:{^src/.*\\.kt$}", allKt);
+
+ // Regex covering both extensions matches all three changes
+ assertQuery("onlypaths:{^src/.*\\.(java|kt)$}", allKt, mixed, allJava);
+
+ // No real file matches
+ assertQuery("onlypaths:^test/.*");
+ }
+
+ @Test
+ public void byFileCount() throws Exception {
+ assume().that(getSchema().hasField(ChangeField.FILE_COUNT_SPEC)).isTrue();
+
+ Project.NameKey project = Project.nameKey("repo");
+ repo = createAndOpenProject(project);
+ Change oneFile = insert(project, newChangeWithFiles(repo, "src/Foo.java"));
+ Change twoFiles = insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java"));
+ Change threeFiles =
+ insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java", "src/Baz.java"));
+
+ assertQuery("filecount:1", oneFile);
+ assertQuery("filecount:2", twoFiles);
+ assertQuery("filecount:3", threeFiles);
+ assertQuery("filecount:>1", threeFiles, twoFiles);
+ assertQuery("filecount:<2", oneFile);
+ assertQuery("filecount:<5", threeFiles, twoFiles, oneFile);
+ assertQuery("filecount:>3");
+ }
+
+ @Test
public void byFooter() throws Exception {
Project.NameKey project = Project.nameKey("repo");
repo = createAndOpenProject(project);
@@ -5265,4 +5340,119 @@
private ChangeApi getChangeApi(Change change) throws RestApiException {
return gApi.changes().id(change.getProject().get(), change.getChangeId());
}
+
+ @Test
+ public void byLegacyChangeIds() throws Exception {
+ Project.NameKey project = Project.nameKey("repo");
+ repo = createAndOpenProject(project);
+ Change change1 = insert(project, newChange(repo));
+ Change change2 = insert(project, newChange(repo));
+ Change change3 = insert(project, newChange(repo));
+
+ // Empty list
+ assertThat(queryProvider.get().byLegacyChangeIds(ImmutableList.of())).isEmpty();
+
+ // Single ID
+ List<ChangeData> cds = queryProvider.get().byLegacyChangeIds(ImmutableList.of(change1.getId()));
+ assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+ .containsExactly(change1.getId());
+
+ // Multiple IDs
+ cds =
+ queryProvider
+ .get()
+ .byLegacyChangeIds(ImmutableList.of(change1.getId(), change2.getId(), change3.getId()));
+ assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+ .containsExactly(change1.getId(), change2.getId(), change3.getId());
+
+ // Non-existent ID mixed with valid ID
+ cds =
+ queryProvider.get().byLegacyChangeIds(ImmutableList.of(change1.getId(), Change.id(999999)));
+ assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+ .containsExactly(change1.getId());
+
+ // Duplicate IDs in input are deduplicated
+ cds =
+ queryProvider
+ .get()
+ .byLegacyChangeIds(ImmutableList.of(change1.getId(), change1.getId(), change2.getId()));
+ assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+ .containsExactly(change1.getId(), change2.getId());
+ }
+
+ @Test
+ public void byProjectCommits() throws Exception {
+ Project.NameKey project = Project.nameKey("repo");
+ repo = createAndOpenProject(project);
+ ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+ Change change1 = insert(project, ins1);
+ ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+ Change change2 = insert(project, ins2);
+ ChangeInserter ins3 = newChangeWithStatus(repo, Change.Status.ABANDONED);
+ Change change3 = insert(project, ins3);
+
+ String c1 = ins1.getCommitId().name();
+ String c2 = ins2.getCommitId().name();
+ String c3 = ins3.getCommitId().name();
+
+ // Empty list
+ assertThat(queryProvider.get().byProjectCommits(project, ImmutableList.of())).isEmpty();
+
+ // Single commit
+ List<ChangeData> cds = queryProvider.get().byProjectCommits(project, ImmutableList.of(c1));
+ assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+ .containsExactly(change1.getId());
+
+ // All commits
+ cds = queryProvider.get().byProjectCommits(project, ImmutableList.of(c1, c2, c3));
+ assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+ .containsExactly(change1.getId(), change2.getId(), change3.getId());
+
+ // Duplicate commit hashes are deduplicated
+ cds = queryProvider.get().byProjectCommits(project, ImmutableList.of(c1, c1, c2));
+ assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+ .containsExactly(change1.getId(), change2.getId());
+
+ // Other project returns empty
+ Project.NameKey otherProject = Project.nameKey("other-repo");
+ createProject(otherProject);
+ assertThat(queryProvider.get().byProjectCommits(otherProject, ImmutableList.of(c1, c2, c3)))
+ .isEmpty();
+ }
+
+ @Test
+ public void byLegacyChangeIdsAndByProjectCommitsPartitioning() throws Exception {
+ Project.NameKey project = Project.nameKey("partition-repo");
+ repo = createAndOpenProject(project);
+ ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+ Change change1 = insert(project, ins1);
+ ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+ Change change2 = insert(project, ins2);
+
+ String c1 = ins1.getCommitId().name();
+ String c2 = ins2.getCommitId().name();
+
+ int maxTerms = indexConfig.maxTerms();
+ List<Change.Id> largeIdList = new ArrayList<>(maxTerms + 50);
+ largeIdList.add(change1.getId());
+ largeIdList.add(change2.getId());
+ for (int i = 0; i < maxTerms + 48; i++) {
+ largeIdList.add(Change.id(1000000 + i));
+ }
+
+ List<ChangeData> cds = queryProvider.get().byLegacyChangeIds(largeIdList);
+ assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+ .containsExactly(change1.getId(), change2.getId());
+
+ List<String> largeHashList = new ArrayList<>(maxTerms + 50);
+ largeHashList.add(c1);
+ largeHashList.add(c2);
+ for (int i = 0; i < maxTerms + 48; i++) {
+ largeHashList.add(String.format("%040x", i + 1));
+ }
+
+ cds = queryProvider.get().byProjectCommits(project, largeHashList);
+ assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+ .containsExactly(change1.getId(), change2.getId());
+ }
}
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index f0873c1..4ece494 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -144,6 +144,30 @@
@Test
@UseClockStep
+ public void queryDoesNotPaginateWhenLimitMetByVisibleChanges() throws Exception {
+ Project.NameKey project = Project.nameKey("repo");
+ try (TestRepository<Repository> testRepo = createAndOpenProject(project)) {
+ insert(project, newChange(testRepo));
+ insert(project, newChange(testRepo));
+ insert(project, newChange(testRepo));
+ insert(project, newChange(testRepo));
+ }
+
+ AbstractFakeIndex<?, ?, ?> idx =
+ (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
+ idx.resetQueryCount();
+ List<ChangeInfo> queryResult = newQuery("status:new").withLimit(2).get();
+ assertThat(queryResult).hasSize(2);
+ assertThat(queryResult.get(queryResult.size() - 1)._moreChanges).isTrue();
+
+ // Since the limit is 2, the initial index query asks for limit + 1 = 3 changes.
+ // Because all 3 changes returned are visible, the limit and the probe row are satisfied.
+ // A secondary pagination query must not be executed.
+ assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
+ }
+
+ @Test
+ @UseClockStep
public void noLimitQueryPaginates() throws Exception {
assumeFalse(PaginationType.NONE == getCurrentPaginationType());
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index d8339e7..7f47530 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -22,6 +22,8 @@
import static org.junit.Assert.fail;
import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
@@ -70,6 +72,7 @@
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import java.util.Optional;
import org.junit.After;
import org.junit.Before;
@@ -113,6 +116,9 @@
@Inject private GroupIndexCollection groupIndexes;
+ @Inject protected Provider<InternalGroupQuery> internalGroupQueryProvider;
+ @Inject protected Provider<GroupQueryProcessor> queryProcessorProvider;
+
protected LifecycleManager lifecycle;
protected Injector injector;
protected AccountInfo currentUserInfo;
@@ -410,6 +416,139 @@
assertQuery(query);
}
+ @Test
+ public void groupCacheBatchEvictionByUuid() throws Exception {
+ GroupInfo group = createGroup(name("cacheGroup"));
+ AccountGroup.UUID uuid = AccountGroup.uuid(group.id);
+
+ assertThat(groupCache.get(uuid)).isPresent();
+
+ groupsUpdateProvider
+ .get()
+ .updateGroupInNoteDb(uuid, GroupDelta.builder().setDescription("Modified").build());
+
+ groupCache.evict(ImmutableList.of(uuid));
+
+ assertThat(groupCache.get(uuid).map(InternalGroup::getDescription)).hasValue("Modified");
+ }
+
+ @Test
+ public void byUuidInternalQuery() throws Exception {
+ GroupInfo group1 = createGroup(name("group1"));
+ GroupInfo group2 = createGroup(name("group2"));
+ AccountGroup.UUID uuid1 = AccountGroup.uuid(group1.id);
+ AccountGroup.UUID uuid2 = AccountGroup.uuid(group2.id);
+
+ Optional<InternalGroup> found = internalGroupQueryProvider.get().byUUID(uuid1);
+ assertThat(found).isPresent();
+ assertThat(found.get().getGroupUUID()).isEqualTo(uuid1);
+
+ ImmutableList<InternalGroup> foundBatch =
+ internalGroupQueryProvider.get().byUUIDs(ImmutableList.of(uuid1, uuid2));
+ assertThat(foundBatch.stream().map(InternalGroup::getGroupUUID).collect(toList()))
+ .containsExactly(uuid1, uuid2);
+
+ assertThat(internalGroupQueryProvider.get().byUUIDs(ImmutableList.of())).isEmpty();
+ }
+
+ @Test
+ public void byUuidInternalQueryWithMaxTermsOne() throws Exception {
+ GroupInfo group1 = createGroup(name("group1"));
+ GroupInfo group2 = createGroup(name("group2"));
+ AccountGroup.UUID uuid1 = AccountGroup.uuid(group1.id);
+ AccountGroup.UUID uuid2 = AccountGroup.uuid(group2.id);
+
+ IndexConfig indexConfig = IndexConfig.builder().maxTerms(1).build();
+ InternalGroupQuery query =
+ new InternalGroupQuery(queryProcessorProvider.get(), indexes, indexConfig);
+ ImmutableList<InternalGroup> foundBatch = query.byUUIDs(ImmutableList.of(uuid1, uuid2));
+ assertThat(foundBatch.stream().map(InternalGroup::getGroupUUID).collect(toList()))
+ .containsExactly(uuid1, uuid2);
+ }
+
+ @Test
+ public void byNameInternalQuery() throws Exception {
+ GroupInfo group = createGroup(name("group1"));
+ AccountGroup.NameKey name = AccountGroup.nameKey(group.name);
+
+ Optional<InternalGroup> found = internalGroupQueryProvider.get().byName(name);
+ assertThat(found).isPresent();
+ assertThat(found.get().getNameKey()).isEqualTo(name);
+ }
+
+ @Test
+ public void byMemberInternalQuery() throws Exception {
+ assume().that(getSchemaVersion() >= 4).isTrue();
+
+ AccountInfo user1 = createAccount("user1", "User1", "user1@example.com");
+ AccountInfo user2 = createAccount("user2", "User2", "user2@example.com");
+ Account.Id userId1 = Account.id(user1._accountId);
+ Account.Id userId2 = Account.id(user2._accountId);
+
+ GroupInfo group1 = createGroup(name("group1"), user1);
+ GroupInfo group2 = createGroup(name("group2"), user2);
+ GroupInfo group3 = createGroup(name("group3"), user1);
+
+ ImmutableList<InternalGroup> groupsUser1 = internalGroupQueryProvider.get().byMember(userId1);
+ assertThat(groupsUser1.stream().map(g -> g.getGroupUUID().get()).collect(toList()))
+ .containsExactly(group1.id, group3.id);
+
+ ImmutableList<InternalGroup> groupsBatch =
+ internalGroupQueryProvider.get().byMembers(ImmutableList.of(userId1, userId2));
+ assertThat(groupsBatch.stream().map(g -> g.getGroupUUID().get()).collect(toList()))
+ .containsExactly(group1.id, group2.id, group3.id);
+
+ assertThat(internalGroupQueryProvider.get().byMembers(ImmutableList.of())).isEmpty();
+ }
+
+ @Test
+ public void bySubgroupsInternalQuery() throws Exception {
+ assume().that(getSchemaVersion() >= 4).isTrue();
+
+ assertThat(internalGroupQueryProvider.get().bySubgroups(ImmutableSet.of())).isEmpty();
+
+ GroupInfo superParentGroup = createGroup(name("superParentGroup"));
+ GroupInfo parentGroup1 = createGroup(name("parentGroup1"));
+ GroupInfo parentGroup2 = createGroup(name("parentGroup2"));
+ GroupInfo subGroup = createGroup(name("subGroup"));
+
+ gApi.groups().id(superParentGroup.id).addGroups(parentGroup1.id, parentGroup2.id);
+ gApi.groups().id(parentGroup1.id).addGroups(subGroup.id);
+ gApi.groups().id(parentGroup2.id).addGroups(subGroup.id);
+
+ AccountGroup.UUID subUuid = AccountGroup.uuid(subGroup.id);
+ AccountGroup.UUID parent1Uuid = AccountGroup.uuid(parentGroup1.id);
+ AccountGroup.UUID parent2Uuid = AccountGroup.uuid(parentGroup2.id);
+
+ assertThat(internalGroupQueryProvider.get().bySubgroups(ImmutableSet.of(subUuid)))
+ .containsExactly(subUuid, ImmutableSet.of(parent1Uuid, parent2Uuid));
+ }
+
+ @Test
+ public void groupCacheCrossPopulation() throws Exception {
+ GroupInfo group = createGroup(name("cacheGroup"));
+ AccountGroup.UUID uuid = AccountGroup.uuid(group.id);
+ AccountGroup.NameKey nameKey = AccountGroup.nameKey(group.name);
+ AccountGroup.Id groupId = AccountGroup.id(group.groupId);
+
+ // Evict all to start clean
+ groupCache.evict(uuid);
+ groupCache.evict(nameKey);
+ groupCache.evict(groupId);
+
+ // Batch loading by UUID populates in-memory cache for Name and ID
+ Map<AccountGroup.UUID, InternalGroup> loaded = groupCache.get(ImmutableList.of(uuid));
+ assertThat(loaded).containsKey(uuid);
+
+ Optional<InternalGroup> byName = groupCache.get(nameKey);
+ assertThat(byName).isPresent();
+ assertThat(byName.get().getGroupUUID()).isEqualTo(uuid);
+
+ Optional<InternalGroup> byId = groupCache.get(groupId);
+ assertThat(byId).isPresent();
+ assertThat(byId.get().getGroupUUID()).isEqualTo(uuid);
+ }
+
private Account.Id createAccountOutsideRequestContext(
String username, String fullName, String email, boolean active) throws Exception {
try (ManualRequestContext ctx = oneOffRequestContext.open()) {
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentJsonTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentJsonTest.java
new file mode 100644
index 0000000..3cdcf06
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentJsonTest.java
@@ -0,0 +1,225 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.comment.CommentContextCache;
+import com.google.gerrit.server.comment.CommentContextKey;
+import com.google.inject.util.Providers;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class CommentJsonTest {
+
+ private static final Project.NameKey PROJECT = Project.nameKey("test-project");
+ private static final Change.Id CHANGE_ID = Change.id(12345);
+ private static final ObjectId COMMIT_ID =
+ ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+ private AccountLoader.Factory accountLoaderFactory;
+ private AccountLoader accountLoader;
+ private CommentContextCache commentContextCache;
+ private final AtomicInteger cacheGetAllCount = new AtomicInteger(0);
+
+ @Before
+ public void setUp() {
+ accountLoaderFactory = mock(AccountLoader.Factory.class);
+ accountLoader = mock(AccountLoader.class);
+ when(accountLoaderFactory.create(true)).thenReturn(accountLoader);
+ when(accountLoader.get(any()))
+ .thenAnswer(inv -> new AccountInfo(((Account.Id) inv.getArgument(0)).get()));
+
+ commentContextCache = mock(CommentContextCache.class);
+ when(commentContextCache.getAll(any()))
+ .thenAnswer(
+ inv -> {
+ cacheGetAllCount.incrementAndGet();
+ Iterable<CommentContextKey> keys = inv.getArgument(0);
+ ImmutableMap.Builder<CommentContextKey, CommentContext> builder =
+ ImmutableMap.builder();
+ for (CommentContextKey key : keys) {
+ builder.put(
+ key,
+ CommentContext.create(
+ ImmutableMap.of(1, "line 1 context", 2, "line 2 context"), "text/x-java"));
+ }
+ return builder.build();
+ });
+ }
+
+ private CommentJson newCommentJson() {
+ return new CommentJson(Providers.of(accountLoaderFactory), Providers.of(commentContextCache))
+ .setProjectKey(PROJECT)
+ .setChangeId(CHANGE_ID);
+ }
+
+ private HumanComment newComment(
+ String uuid, String filename, int patchSetId, int line, String message) {
+ Comment.Key key = new Comment.Key(uuid, filename, patchSetId);
+ HumanComment comment =
+ new HumanComment(
+ key,
+ Account.id(1001),
+ Instant.ofEpochMilli(1000000L),
+ (short) 1,
+ message,
+ "serverId",
+ /* unresolved= */ false);
+ comment.setCommitId(COMMIT_ID);
+ comment.lineNbr = line;
+ return comment;
+ }
+
+ @Test
+ public void formatSingleComment() throws Exception {
+ CommentJson commentJson = newCommentJson().setFillAccounts(true).setFillPatchSet(true);
+ HumanComment comment = newComment("c1", "file1.txt", 1, 10, "test message");
+
+ CommentInfo info = commentJson.newHumanCommentFormatter().format(comment);
+
+ assertThat(info.id).isEqualTo("c1");
+ assertThat(info.path).isEqualTo("file1.txt");
+ assertThat(info.patchSet).isEqualTo(1);
+ assertThat(info.line).isEqualTo(10);
+ assertThat(info.message).isEqualTo("test message");
+ assertThat(info.author).isNotNull();
+ assertThat(info.author._accountId).isEqualTo(1001);
+ }
+
+ @Test
+ public void formatMapGroupingAndSorting() throws Exception {
+ CommentJson commentJson = newCommentJson().setFillAccounts(false).setFillPatchSet(true);
+ HumanComment c1 = newComment("c1", "fileA.txt", 1, 20, "msg2");
+ HumanComment c2 = newComment("c2", "fileA.txt", 1, 10, "msg1");
+ HumanComment c3 = newComment("c3", "fileB.txt", 1, 5, "msg3");
+
+ Map<String, List<CommentInfo>> result =
+ commentJson.newHumanCommentFormatter().format(ImmutableList.of(c1, c2, c3));
+
+ assertThat(result.keySet()).containsExactly("fileA.txt", "fileB.txt").inOrder();
+ assertThat(result.get("fileA.txt")).hasSize(2);
+ assertThat(result.get("fileA.txt").get(0).id).isEqualTo("c2");
+ assertThat(result.get("fileA.txt").get(0).line).isEqualTo(10);
+ assertThat(result.get("fileA.txt").get(0).path).isNull(); // Path nulled out for map
+ assertThat(result.get("fileA.txt").get(1).id).isEqualTo("c1");
+ assertThat(result.get("fileA.txt").get(1).line).isEqualTo(20);
+ assertThat(result.get("fileA.txt").get(1).path).isNull();
+
+ assertThat(result.get("fileB.txt")).hasSize(1);
+ assertThat(result.get("fileB.txt").get(0).id).isEqualTo("c3");
+ assertThat(result.get("fileB.txt").get(0).path).isNull();
+ }
+
+ @Test
+ public void formatWithCommentContext() throws Exception {
+ CommentJson commentJson =
+ newCommentJson()
+ .setFillAccounts(false)
+ .setFillPatchSet(true)
+ .setFillCommentContext(true)
+ .setContextPadding(3);
+ HumanComment c1 = newComment("c1", "fileA.txt", 1, 10, "msg1");
+ HumanComment c2 = newComment("c2", "fileB.txt", 1, 20, "msg2");
+
+ Map<String, List<CommentInfo>> result =
+ commentJson.newHumanCommentFormatter().format(ImmutableList.of(c1, c2));
+
+ assertThat(cacheGetAllCount.get()).isEqualTo(1);
+ CommentInfo info1 = result.get("fileA.txt").get(0);
+ assertThat(info1.contextLines).hasSize(2);
+ assertThat(info1.contextLines.get(0).lineNumber).isEqualTo(1);
+ assertThat(info1.contextLines.get(0).contextLine).isEqualTo("line 1 context");
+ assertThat(info1.sourceContentType).isEqualTo("text/x-java");
+ assertThat(info1.path).isNull();
+
+ CommentInfo info2 = result.get("fileB.txt").get(0);
+ assertThat(info2.contextLines).hasSize(2);
+ assertThat(info2.contextLines.get(0).lineNumber).isEqualTo(1);
+ assertThat(info2.contextLines.get(0).contextLine).isEqualTo("line 1 context");
+ assertThat(info2.sourceContentType).isEqualTo("text/x-java");
+ assertThat(info2.path).isNull();
+ }
+
+ @Test
+ public void formatAsListWithCommentContext() throws Exception {
+ CommentJson commentJson =
+ newCommentJson()
+ .setFillAccounts(false)
+ .setFillPatchSet(true)
+ .setFillCommentContext(true)
+ .setContextPadding(2);
+ HumanComment c1 = newComment("c1", "fileB.txt", 1, 20, "msg2");
+ HumanComment c2 = newComment("c2", "fileA.txt", 1, 10, "msg1");
+
+ ImmutableList<CommentInfo> result =
+ commentJson.newHumanCommentFormatter().formatAsList(ImmutableList.of(c1, c2));
+
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).id).isEqualTo("c2");
+ assertThat(result.get(0).path).isEqualTo("fileA.txt"); // Path preserved in list
+ assertThat(result.get(0).contextLines).hasSize(2);
+ assertThat(result.get(1).id).isEqualTo("c1");
+ assertThat(result.get(1).path).isEqualTo("fileB.txt");
+ assertThat(result.get(1).contextLines).hasSize(2);
+ }
+
+ @Test
+ public void formatWithFixSuggestions() throws Exception {
+ CommentJson commentJson = newCommentJson().setFillAccounts(false).setFillPatchSet(true);
+ HumanComment c1 = newComment("c1", "fileA.txt", 1, 10, "msg1");
+
+ Comment.Range range = new Comment.Range(10, 2, 10, 8);
+
+ FixReplacement replacement = new FixReplacement("fileA.txt", range, "replacement text");
+ FixSuggestion suggestion =
+ new FixSuggestion("fix-1", "Fix description", ImmutableList.of(replacement));
+ c1.fixSuggestions = ImmutableList.of(suggestion);
+
+ CommentInfo info = commentJson.newHumanCommentFormatter().format(c1);
+
+ assertThat(info.fixSuggestions).hasSize(1);
+ assertThat(info.fixSuggestions.get(0).fixId).isEqualTo("fix-1");
+ assertThat(info.fixSuggestions.get(0).description).isEqualTo("Fix description");
+ assertThat(info.fixSuggestions.get(0).replacements).hasSize(1);
+ assertThat(info.fixSuggestions.get(0).replacements.get(0).path).isEqualTo("fileA.txt");
+ assertThat(info.fixSuggestions.get(0).replacements.get(0).replacement)
+ .isEqualTo("replacement text");
+ }
+}
diff --git a/lib/BUILD b/lib/BUILD
index 5c7efa2..5648007 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -221,7 +221,7 @@
name = "blame-cache",
data = ["//lib:LICENSE-Apache2.0"],
visibility = ["//visibility:public"],
- exports = ["@external_deps//:com_google_gitiles_blame_cache"],
+ exports = ["@gitiles//java/com/google/gitiles/blame/cache"],
)
java_library(
diff --git a/lib/gitiles/BUILD b/lib/gitiles/BUILD
index 3457828..ad7ad06 100644
--- a/lib/gitiles/BUILD
+++ b/lib/gitiles/BUILD
@@ -10,8 +10,6 @@
":gfm-tables",
":gitiles-servlet",
":prettify",
- "//lib/commons:lang3",
- "//lib/commons:text",
],
)
@@ -47,7 +45,7 @@
name = "gitiles-servlet",
data = ["//lib:LICENSE-Apache2.0"],
visibility = ["//visibility:public"],
- exports = ["@external_deps//:com_google_gitiles_gitiles_servlet"],
+ exports = ["@gitiles//java/com/google/gitiles:servlet"],
)
java_library(
diff --git a/modules/gitiles b/modules/gitiles
new file mode 160000
index 0000000..0b8ab6c
--- /dev/null
+++ b/modules/gitiles
@@ -0,0 +1 @@
+Subproject commit 0b8ab6c71efb23845cfab57a588ac902bc6ad5aa
diff --git a/modules/jgit b/modules/jgit
index bf0f0ad..941c6f0 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit bf0f0ad1cc2ce422604383272a4a4b8b3947b465
+Subproject commit 941c6f04f992e66ddb78528edcf56d079ff2ccab
diff --git a/plugins/gitiles b/plugins/gitiles
index 0e3ccb9..af35ead 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 0e3ccb926a23b972d9f4472e3ba7d874fac481c8
+Subproject commit af35ead2d86007665c9ae93a95f5abbd52e59fe7
diff --git a/plugins/package.json b/plugins/package.json
index 9e4737b..189a9d1 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -3,18 +3,18 @@
"description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
"browser": true,
"dependencies": {
- "@codemirror/autocomplete": "^6.20.1",
- "@codemirror/commands": "^6.10.3",
+ "@codemirror/autocomplete": "^6.20.3",
+ "@codemirror/commands": "^6.11.0",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
- "@codemirror/lang-html": "^6.4.11",
+ "@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
- "@codemirror/lang-jinja": "^6.0.0",
+ "@codemirror/lang-jinja": "^6.0.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
- "@codemirror/lang-markdown": "^6.5.0",
+ "@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
@@ -22,14 +22,14 @@
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-xml": "^6.1.0",
- "@codemirror/lang-yaml": "^6.1.2",
- "@codemirror/language": "^6.12.2",
+ "@codemirror/lang-yaml": "^6.1.3",
+ "@codemirror/language": "^6.12.4",
"@codemirror/language-data": "^6.5.2",
- "@codemirror/legacy-modes": "^6.5.2",
- "@codemirror/lint": "^6.9.5",
- "@codemirror/search": "^6.6.0",
- "@codemirror/state": "^6.6.0",
- "@codemirror/view": "^6.40.0",
+ "@codemirror/legacy-modes": "^6.5.3",
+ "@codemirror/lint": "^6.9.7",
+ "@codemirror/search": "^6.7.1",
+ "@codemirror/state": "^6.7.1",
+ "@codemirror/view": "^6.43.9",
"@lezer/highlight": "^1.2.3",
"@gerritcodereview/typescript-api": "3.14.0",
"@material/web": "^2.4.1",
diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml
index 1c99c8c..8713e49 100644
--- a/plugins/pnpm-lock.yaml
+++ b/plugins/pnpm-lock.yaml
@@ -9,11 +9,11 @@
.:
dependencies:
'@codemirror/autocomplete':
- specifier: ^6.20.1
- version: 6.20.1
+ specifier: ^6.20.3
+ version: 6.20.3
'@codemirror/commands':
- specifier: ^6.10.3
- version: 6.10.3
+ specifier: ^6.11.0
+ version: 6.11.0
'@codemirror/lang-cpp':
specifier: ^6.0.3
version: 6.0.3
@@ -24,8 +24,8 @@
specifier: ^6.0.1
version: 6.0.1
'@codemirror/lang-html':
- specifier: ^6.4.11
- version: 6.4.11
+ specifier: ^6.4.12
+ version: 6.4.12
'@codemirror/lang-java':
specifier: ^6.0.2
version: 6.0.2
@@ -33,8 +33,8 @@
specifier: ^6.2.5
version: 6.2.5
'@codemirror/lang-jinja':
- specifier: ^6.0.0
- version: 6.0.0
+ specifier: ^6.0.1
+ version: 6.0.1
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
@@ -42,8 +42,8 @@
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-markdown':
- specifier: ^6.5.0
- version: 6.5.0
+ specifier: ^6.5.2
+ version: 6.5.2
'@codemirror/lang-php':
specifier: ^6.0.2
version: 6.0.2
@@ -66,29 +66,29 @@
specifier: ^6.1.0
version: 6.1.0
'@codemirror/lang-yaml':
- specifier: ^6.1.2
- version: 6.1.2
+ specifier: ^6.1.3
+ version: 6.1.3
'@codemirror/language':
- specifier: ^6.12.2
- version: 6.12.2
+ specifier: ^6.12.4
+ version: 6.12.4
'@codemirror/language-data':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/legacy-modes':
- specifier: ^6.5.2
- version: 6.5.2
+ specifier: ^6.5.3
+ version: 6.5.3
'@codemirror/lint':
- specifier: ^6.9.5
- version: 6.9.5
+ specifier: ^6.9.7
+ version: 6.9.7
'@codemirror/search':
- specifier: ^6.6.0
- version: 6.6.0
+ specifier: ^6.7.1
+ version: 6.7.1
'@codemirror/state':
- specifier: ^6.6.0
- version: 6.6.0
+ specifier: ^6.7.1
+ version: 6.7.1
'@codemirror/view':
- specifier: ^6.40.0
- version: 6.40.0
+ specifier: ^6.43.9
+ version: 6.43.9
'@gerritcodereview/typescript-api':
specifier: 3.14.0
version: 3.14.0
@@ -133,11 +133,11 @@
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
- '@codemirror/autocomplete@6.20.1':
- resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==}
+ '@codemirror/autocomplete@6.20.3':
+ resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
- '@codemirror/commands@6.10.3':
- resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==}
+ '@codemirror/commands@6.11.0':
+ resolution: {integrity: sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==}
'@codemirror/lang-angular@0.1.4':
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
@@ -151,8 +151,8 @@
'@codemirror/lang-go@6.0.1':
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
- '@codemirror/lang-html@6.4.11':
- resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
+ '@codemirror/lang-html@6.4.12':
+ resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==}
'@codemirror/lang-java@6.0.2':
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
@@ -160,8 +160,8 @@
'@codemirror/lang-javascript@6.2.5':
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
- '@codemirror/lang-jinja@6.0.0':
- resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==}
+ '@codemirror/lang-jinja@6.0.1':
+ resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
@@ -172,8 +172,8 @@
'@codemirror/lang-liquid@6.3.2':
resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
- '@codemirror/lang-markdown@6.5.0':
- resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
+ '@codemirror/lang-markdown@6.5.2':
+ resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==}
'@codemirror/lang-php@6.0.2':
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
@@ -199,29 +199,29 @@
'@codemirror/lang-xml@6.1.0':
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
- '@codemirror/lang-yaml@6.1.2':
- resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
+ '@codemirror/lang-yaml@6.1.3':
+ resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
'@codemirror/language-data@6.5.2':
resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
- '@codemirror/language@6.12.2':
- resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==}
+ '@codemirror/language@6.12.4':
+ resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
- '@codemirror/legacy-modes@6.5.2':
- resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
+ '@codemirror/legacy-modes@6.5.3':
+ resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==}
- '@codemirror/lint@6.9.5':
- resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==}
+ '@codemirror/lint@6.9.7':
+ resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
- '@codemirror/search@6.6.0':
- resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
+ '@codemirror/search@6.7.1':
+ resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==}
- '@codemirror/state@6.6.0':
- resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
+ '@codemirror/state@6.7.1':
+ resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
- '@codemirror/view@6.40.0':
- resolution: {integrity: sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==}
+ '@codemirror/view@6.43.9':
+ resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==}
'@esbuild/aix-ppc64@0.25.9':
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
@@ -398,14 +398,14 @@
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
- '@lezer/common@1.5.1':
- resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
+ '@lezer/common@1.5.2':
+ resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
- '@lezer/cpp@1.1.5':
- resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==}
+ '@lezer/cpp@1.1.6':
+ resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==}
- '@lezer/css@1.3.1':
- resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==}
+ '@lezer/css@1.3.6':
+ resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==}
'@lezer/go@1.0.1':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
@@ -425,17 +425,17 @@
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
- '@lezer/lr@1.4.8':
- resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
+ '@lezer/lr@1.4.10':
+ resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
- '@lezer/markdown@1.6.3':
- resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
+ '@lezer/markdown@1.7.2':
+ resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==}
'@lezer/php@1.0.5':
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
- '@lezer/python@1.1.18':
- resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
+ '@lezer/python@1.1.19':
+ resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==}
'@lezer/rust@1.0.2':
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
@@ -459,8 +459,8 @@
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
hasBin: true
- '@marijn/find-cluster-break@1.0.2':
- resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
+ '@marijn/find-cluster-break@1.0.4':
+ resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
'@material/web@2.4.1':
resolution: {integrity: sha512-0sk9t25acJ72Qv3r0n9r0lgDbPaAKnpm0p+QmEAAwYyZomHxuVbgrrAdtNXaRm7jFyGh+WsTr8bhtvCnpPRFjw==}
@@ -1041,8 +1041,8 @@
resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
engines: {node: '>= 0.8'}
- crelt@1.0.6:
- resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+ crelt@1.0.7:
+ resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@@ -2130,190 +2130,193 @@
'@babel/helper-validator-identifier@7.27.1': {}
- '@codemirror/autocomplete@6.20.1':
+ '@codemirror/autocomplete@6.20.3':
dependencies:
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- '@lezer/common': 1.5.1
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ '@lezer/common': 1.5.2
- '@codemirror/commands@6.10.3':
+ '@codemirror/commands@6.11.0':
dependencies:
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- '@lezer/common': 1.5.1
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ '@lezer/common': 1.5.2
'@codemirror/lang-angular@0.1.4':
dependencies:
- '@codemirror/lang-html': 6.4.11
+ '@codemirror/lang-html': 6.4.12
'@codemirror/lang-javascript': 6.2.5
- '@codemirror/language': 6.12.2
- '@lezer/common': 1.5.1
+ '@codemirror/language': 6.12.4
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@codemirror/lang-cpp@6.0.3':
dependencies:
- '@codemirror/language': 6.12.2
- '@lezer/cpp': 1.1.5
+ '@codemirror/language': 6.12.4
+ '@lezer/cpp': 1.1.6
'@codemirror/lang-css@6.3.1':
dependencies:
- '@codemirror/autocomplete': 6.20.1
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@lezer/common': 1.5.1
- '@lezer/css': 1.3.1
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@lezer/common': 1.5.2
+ '@lezer/css': 1.3.6
'@codemirror/lang-go@6.0.1':
dependencies:
- '@codemirror/autocomplete': 6.20.1
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@lezer/common': 1.5.1
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@lezer/common': 1.5.2
'@lezer/go': 1.0.1
- '@codemirror/lang-html@6.4.11':
+ '@codemirror/lang-html@6.4.12':
dependencies:
- '@codemirror/autocomplete': 6.20.1
+ '@codemirror/autocomplete': 6.20.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- '@lezer/common': 1.5.1
- '@lezer/css': 1.3.1
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ '@lezer/common': 1.5.2
+ '@lezer/css': 1.3.6
'@lezer/html': 1.3.13
'@codemirror/lang-java@6.0.2':
dependencies:
- '@codemirror/language': 6.12.2
+ '@codemirror/language': 6.12.4
'@lezer/java': 1.1.3
'@codemirror/lang-javascript@6.2.5':
dependencies:
- '@codemirror/autocomplete': 6.20.1
- '@codemirror/language': 6.12.2
- '@codemirror/lint': 6.9.5
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- '@lezer/common': 1.5.1
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/language': 6.12.4
+ '@codemirror/lint': 6.9.7
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ '@lezer/common': 1.5.2
'@lezer/javascript': 1.5.4
- '@codemirror/lang-jinja@6.0.0':
+ '@codemirror/lang-jinja@6.0.1':
dependencies:
- '@codemirror/lang-html': 6.4.11
- '@codemirror/language': 6.12.2
- '@lezer/common': 1.5.1
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/lang-html': 6.4.12
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@codemirror/lang-json@6.0.2':
dependencies:
- '@codemirror/language': 6.12.2
+ '@codemirror/language': 6.12.4
'@lezer/json': 1.0.3
'@codemirror/lang-less@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
- '@codemirror/language': 6.12.2
- '@lezer/common': 1.5.1
+ '@codemirror/language': 6.12.4
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@codemirror/lang-liquid@6.3.2':
dependencies:
- '@codemirror/autocomplete': 6.20.1
- '@codemirror/lang-html': 6.4.11
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- '@lezer/common': 1.5.1
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/lang-html': 6.4.12
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
- '@codemirror/lang-markdown@6.5.0':
+ '@codemirror/lang-markdown@6.5.2':
dependencies:
- '@codemirror/autocomplete': 6.20.1
- '@codemirror/lang-html': 6.4.11
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- '@lezer/common': 1.5.1
- '@lezer/markdown': 1.6.3
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/lang-html': 6.4.12
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ '@lezer/common': 1.5.2
+ '@lezer/markdown': 1.7.2
'@codemirror/lang-php@6.0.2':
dependencies:
- '@codemirror/lang-html': 6.4.11
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@lezer/common': 1.5.1
+ '@codemirror/lang-html': 6.4.12
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@lezer/common': 1.5.2
'@lezer/php': 1.0.5
'@codemirror/lang-python@6.2.1':
dependencies:
- '@codemirror/autocomplete': 6.20.1
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@lezer/common': 1.5.1
- '@lezer/python': 1.1.18
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@lezer/common': 1.5.2
+ '@lezer/python': 1.1.19
'@codemirror/lang-rust@6.0.2':
dependencies:
- '@codemirror/language': 6.12.2
+ '@codemirror/language': 6.12.4
'@lezer/rust': 1.0.2
'@codemirror/lang-sass@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@lezer/common': 1.5.1
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@lezer/common': 1.5.2
'@lezer/sass': 1.1.0
'@codemirror/lang-sql@6.10.0':
dependencies:
- '@codemirror/autocomplete': 6.20.1
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@lezer/common': 1.5.1
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@codemirror/lang-vue@0.1.3':
dependencies:
- '@codemirror/lang-html': 6.4.11
+ '@codemirror/lang-html': 6.4.12
'@codemirror/lang-javascript': 6.2.5
- '@codemirror/language': 6.12.2
- '@lezer/common': 1.5.1
+ '@codemirror/language': 6.12.4
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@codemirror/lang-wast@6.0.2':
dependencies:
- '@codemirror/language': 6.12.2
- '@lezer/common': 1.5.1
+ '@codemirror/language': 6.12.4
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@codemirror/lang-xml@6.1.0':
dependencies:
- '@codemirror/autocomplete': 6.20.1
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- '@lezer/common': 1.5.1
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ '@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
- '@codemirror/lang-yaml@6.1.2':
+ '@codemirror/lang-yaml@6.1.3':
dependencies:
- '@codemirror/autocomplete': 6.20.1
- '@codemirror/language': 6.12.2
- '@codemirror/state': 6.6.0
- '@lezer/common': 1.5.1
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
'@codemirror/language-data@6.5.2':
@@ -2322,14 +2325,14 @@
'@codemirror/lang-cpp': 6.0.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-go': 6.0.1
- '@codemirror/lang-html': 6.4.11
+ '@codemirror/lang-html': 6.4.12
'@codemirror/lang-java': 6.0.2
'@codemirror/lang-javascript': 6.2.5
- '@codemirror/lang-jinja': 6.0.0
+ '@codemirror/lang-jinja': 6.0.1
'@codemirror/lang-json': 6.0.2
'@codemirror/lang-less': 6.0.2
'@codemirror/lang-liquid': 6.3.2
- '@codemirror/lang-markdown': 6.5.0
+ '@codemirror/lang-markdown': 6.5.2
'@codemirror/lang-php': 6.0.2
'@codemirror/lang-python': 6.2.1
'@codemirror/lang-rust': 6.0.2
@@ -2338,43 +2341,43 @@
'@codemirror/lang-vue': 0.1.3
'@codemirror/lang-wast': 6.0.2
'@codemirror/lang-xml': 6.1.0
- '@codemirror/lang-yaml': 6.1.2
- '@codemirror/language': 6.12.2
- '@codemirror/legacy-modes': 6.5.2
+ '@codemirror/lang-yaml': 6.1.3
+ '@codemirror/language': 6.12.4
+ '@codemirror/legacy-modes': 6.5.3
- '@codemirror/language@6.12.2':
+ '@codemirror/language@6.12.4':
dependencies:
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- '@lezer/common': 1.5.1
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
style-mod: 4.1.3
- '@codemirror/legacy-modes@6.5.2':
+ '@codemirror/legacy-modes@6.5.3':
dependencies:
- '@codemirror/language': 6.12.2
+ '@codemirror/language': 6.12.4
- '@codemirror/lint@6.9.5':
+ '@codemirror/lint@6.9.7':
dependencies:
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- crelt: 1.0.6
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ crelt: 1.0.7
- '@codemirror/search@6.6.0':
+ '@codemirror/search@6.7.1':
dependencies:
- '@codemirror/state': 6.6.0
- '@codemirror/view': 6.40.0
- crelt: 1.0.6
+ '@codemirror/state': 6.7.1
+ '@codemirror/view': 6.43.9
+ crelt: 1.0.7
- '@codemirror/state@6.6.0':
+ '@codemirror/state@6.7.1':
dependencies:
- '@marijn/find-cluster-break': 1.0.2
+ '@marijn/find-cluster-break': 1.0.4
- '@codemirror/view@6.40.0':
+ '@codemirror/view@6.43.9':
dependencies:
- '@codemirror/state': 6.6.0
- crelt: 1.0.6
+ '@codemirror/state': 6.7.1
+ crelt: 1.0.7
style-mod: 4.1.3
w3c-keyname: 2.2.8
@@ -2473,98 +2476,98 @@
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
- '@lezer/common@1.5.1': {}
+ '@lezer/common@1.5.2': {}
- '@lezer/cpp@1.1.5':
+ '@lezer/cpp@1.1.6':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
- '@lezer/css@1.3.1':
+ '@lezer/css@1.3.6':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/go@1.0.1':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/highlight@1.2.3':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/html@1.3.13':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/java@1.1.3':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/javascript@1.5.4':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/json@1.0.3':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
- '@lezer/lr@1.4.8':
+ '@lezer/lr@1.4.10':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
- '@lezer/markdown@1.6.3':
+ '@lezer/markdown@1.7.2':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/php@1.0.5':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
- '@lezer/python@1.1.18':
+ '@lezer/python@1.1.19':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/rust@1.0.2':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/sass@1.1.0':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/xml@1.0.6':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lezer/yaml@1.0.4':
dependencies:
- '@lezer/common': 1.5.1
+ '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.8
+ '@lezer/lr': 1.4.10
'@lit-labs/ssr-dom-shim@1.4.0': {}
@@ -2588,7 +2591,7 @@
- supports-color
optional: true
- '@marijn/find-cluster-break@1.0.2': {}
+ '@marijn/find-cluster-break@1.0.4': {}
'@material/web@2.4.1':
dependencies:
@@ -3305,7 +3308,7 @@
depd: 2.0.0
keygrip: 1.1.0
- crelt@1.0.6: {}
+ crelt@1.0.7: {}
cross-spawn@7.0.6:
dependencies:
diff --git a/plugins/replication b/plugins/replication
index 186d15a..36581c0 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 186d15ad334e6da0cf40cdf59f7c3ebe4ef59a14
+Subproject commit 36581c07b5fc46b6ab7811b14af0aa308ad398d3
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 26b3007..9a504e0 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -16,23 +16,23 @@
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8"
integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
-"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.20.1", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1":
- version "6.20.1"
- resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz#4cfbc8b2e1e25f890ec34a081037e58b4e44143e"
- integrity sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==
+"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.20.3", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1":
+ version "6.20.3"
+ resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz#696b740312c6a962e14567b49a3661b5924bc5ae"
+ integrity sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==
dependencies:
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.17.0"
"@lezer/common" "^1.0.0"
-"@codemirror/commands@^6.10.3":
- version "6.10.3"
- resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.10.3.tgz#01877060befdec352e8300dec1f185489c300635"
- integrity sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==
+"@codemirror/commands@^6.11.0":
+ version "6.11.0"
+ resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.11.0.tgz#2194d6fcad9ed787dcc42667db0e0543fab2e0ef"
+ integrity sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==
dependencies:
"@codemirror/language" "^6.0.0"
- "@codemirror/state" "^6.6.0"
+ "@codemirror/state" "^6.7.0"
"@codemirror/view" "^6.27.0"
"@lezer/common" "^1.1.0"
@@ -78,10 +78,10 @@
"@lezer/common" "^1.0.0"
"@lezer/go" "^1.0.0"
-"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.11":
- version "6.4.11"
- resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.11.tgz#c46ba46ae642fd567cf05c4129005d2913ac248d"
- integrity sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==
+"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.12":
+ version "6.4.12"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.12.tgz#ca5dc0f741c1e819182bce9d03b073552172b1b7"
+ integrity sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==
dependencies:
"@codemirror/autocomplete" "^6.0.0"
"@codemirror/lang-css" "^6.0.0"
@@ -114,13 +114,16 @@
"@lezer/common" "^1.0.0"
"@lezer/javascript" "^1.0.0"
-"@codemirror/lang-jinja@^6.0.0":
- version "6.0.0"
- resolved "https://registry.yarnpkg.com/@codemirror/lang-jinja/-/lang-jinja-6.0.0.tgz#cc02cd1e45d1fed1226e3c3b44615503f794c904"
- integrity sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==
+"@codemirror/lang-jinja@^6.0.0", "@codemirror/lang-jinja@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz#01d128a7e0756b2714cd453ad16c1dffb20188f0"
+ integrity sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==
dependencies:
+ "@codemirror/autocomplete" "^6.0.0"
"@codemirror/lang-html" "^6.0.0"
"@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.0.0"
"@lezer/common" "^1.2.0"
"@lezer/highlight" "^1.2.0"
"@lezer/lr" "^1.4.0"
@@ -158,10 +161,10 @@
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.3.1"
-"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.5.0":
- version "6.5.0"
- resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz#29df87310a555b007beba8e12893363956a26e8e"
- integrity sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==
+"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.5.2":
+ version "6.5.2"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz#530b91442c035ca5d4ea5135c0499b2c15c39872"
+ integrity sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==
dependencies:
"@codemirror/autocomplete" "^6.7.1"
"@codemirror/lang-html" "^6.0.0"
@@ -258,10 +261,10 @@
"@lezer/common" "^1.0.0"
"@lezer/xml" "^1.0.0"
-"@codemirror/lang-yaml@^6.0.0", "@codemirror/lang-yaml@^6.1.2":
- version "6.1.2"
- resolved "https://registry.yarnpkg.com/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz#c84280c68fa7af456a355d91183b5e537e9b7038"
- integrity sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==
+"@codemirror/lang-yaml@^6.0.0", "@codemirror/lang-yaml@^6.1.3":
+ version "6.1.3"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz#4d4127e8339984639715d1e3f8edca1ea5bfabfb"
+ integrity sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==
dependencies:
"@codemirror/autocomplete" "^6.0.0"
"@codemirror/language" "^6.0.0"
@@ -300,10 +303,10 @@
"@codemirror/language" "^6.0.0"
"@codemirror/legacy-modes" "^6.4.0"
-"@codemirror/language@^6.0.0", "@codemirror/language@^6.12.2", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0":
- version "6.12.2"
- resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.2.tgz#7db5a46757411cf251e8f450474c05710c27d42c"
- integrity sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==
+"@codemirror/language@^6.0.0", "@codemirror/language@^6.12.4", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0":
+ version "6.12.4"
+ resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.4.tgz#01e70fd5aa3a8a067ff1dfec75d5b6394cdfa058"
+ integrity sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==
dependencies:
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.23.0"
@@ -312,44 +315,44 @@
"@lezer/lr" "^1.0.0"
style-mod "^4.0.0"
-"@codemirror/legacy-modes@^6.4.0", "@codemirror/legacy-modes@^6.5.2":
- version "6.5.2"
- resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz#7e2976c79007cd3fa9ed8a1d690892184a7f5ecf"
- integrity sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==
+"@codemirror/legacy-modes@^6.4.0", "@codemirror/legacy-modes@^6.5.3":
+ version "6.5.3"
+ resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz#5dcac7cdc430d32ad8af1c2c9bce392fad1b5fcb"
+ integrity sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==
dependencies:
"@codemirror/language" "^6.0.0"
-"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.9.5":
- version "6.9.5"
- resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.9.5.tgz#c7da006f3335a33014799a7375c82df558e89f90"
- integrity sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==
+"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.9.7":
+ version "6.9.7"
+ resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.9.7.tgz#841fc733674389d91fe49a1c34027ad3babdf105"
+ integrity sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==
dependencies:
"@codemirror/state" "^6.0.0"
- "@codemirror/view" "^6.35.0"
+ "@codemirror/view" "^6.42.0"
crelt "^1.0.5"
-"@codemirror/search@^6.6.0":
- version "6.6.0"
- resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.6.0.tgz#3b83a1e35391e1575a83a3b485e3f95263ddaa0b"
- integrity sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==
+"@codemirror/search@^6.7.1":
+ version "6.7.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.7.1.tgz#2523a762871d18ad982edc2d4b2fa1da483b0392"
+ integrity sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==
dependencies:
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.37.0"
crelt "^1.0.5"
-"@codemirror/state@^6.0.0", "@codemirror/state@^6.6.0":
- version "6.6.0"
- resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.6.0.tgz#b88dbdc14aea4ace3c6d67bb77fe28bb84e4394e"
- integrity sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==
+"@codemirror/state@^6.0.0", "@codemirror/state@^6.7.0", "@codemirror/state@^6.7.1":
+ version "6.7.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.7.1.tgz#9e88a17448c1dbc7b50acbeeec979ed7ccf1d6fc"
+ integrity sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==
dependencies:
"@marijn/find-cluster-break" "^1.0.0"
-"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.40.0":
- version "6.40.0"
- resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.40.0.tgz#97198fd717ebf471ef594a5bd557a9f2d1d4d165"
- integrity sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.42.0", "@codemirror/view@^6.43.9":
+ version "6.43.9"
+ resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.9.tgz#85c44ad1bc5fc930e5642e7313643dab7dc866a4"
+ integrity sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==
dependencies:
- "@codemirror/state" "^6.6.0"
+ "@codemirror/state" "^6.7.0"
crelt "^1.0.6"
style-mod "^4.1.0"
w3c-keyname "^2.2.4"
@@ -520,23 +523,23 @@
"@jridgewell/sourcemap-codec" "^1.4.14"
"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0", "@lezer/common@^1.2.1", "@lezer/common@^1.3.0", "@lezer/common@^1.5.0":
- version "1.5.1"
- resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.5.1.tgz#6e8c114ff5d36a41148e146a253734d3bb8807d3"
- integrity sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==
+ version "1.5.2"
+ resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.5.2.tgz#d6840db13779e3f1b42e70c9a97c4086d12fae22"
+ integrity sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==
"@lezer/cpp@^1.0.0":
- version "1.1.5"
- resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.5.tgz#de5b0352b4e0825b5cb62334f6a69f8ddc6ec734"
- integrity sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.6.tgz#4408c66f0ce4fb47759a3b83dbfdd780a49315aa"
+ integrity sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==
dependencies:
"@lezer/common" "^1.2.0"
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.0.0"
"@lezer/css@^1.1.0", "@lezer/css@^1.1.7":
- version "1.3.1"
- resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.3.1.tgz#583e0119768021c58a731d38e56a91c700b57e14"
- integrity sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==
+ version "1.3.6"
+ resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.3.6.tgz#2cdae5b532beaa5cf1e7dccb918d8b190b6c6d14"
+ integrity sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==
dependencies:
"@lezer/common" "^1.2.0"
"@lezer/highlight" "^1.0.0"
@@ -595,16 +598,16 @@
"@lezer/lr" "^1.0.0"
"@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1", "@lezer/lr@^1.3.3", "@lezer/lr@^1.4.0":
- version "1.4.8"
- resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.8.tgz#333de9bc9346057323ff09beb4cda47ccc38a498"
- integrity sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==
+ version "1.4.10"
+ resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.10.tgz#b3acc36e5ad049b74ddb7719594e7e74d9161ff5"
+ integrity sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==
dependencies:
"@lezer/common" "^1.0.0"
"@lezer/markdown@^1.0.0":
- version "1.6.3"
- resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.6.3.tgz#04beb444f656c2319ddf23554b1e4b0edf536071"
- integrity sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==
+ version "1.7.2"
+ resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.7.2.tgz#dfe0249813dc8faa60b4659a4ca2b8da6dcaf753"
+ integrity sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==
dependencies:
"@lezer/common" "^1.5.0"
"@lezer/highlight" "^1.0.0"
@@ -619,9 +622,9 @@
"@lezer/lr" "^1.1.0"
"@lezer/python@^1.1.4":
- version "1.1.18"
- resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.18.tgz#fa02fbf492741c82dc2dc98a0a042bd0d4d7f1d3"
- integrity sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==
+ version "1.1.19"
+ resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.19.tgz#7843d44ff27c980439a82e87c18b28172e85c478"
+ integrity sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==
dependencies:
"@lezer/common" "^1.2.0"
"@lezer/highlight" "^1.0.0"
@@ -691,9 +694,9 @@
tar "^6.1.11"
"@marijn/find-cluster-break@^1.0.0":
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8"
- integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz#42c2aea61cda307cdb1347444792452d7b5dbfb4"
+ integrity sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==
"@material/web@^2.4.1":
version "2.4.1"
@@ -1745,9 +1748,9 @@
keygrip "~1.1.0"
crelt@^1.0.5, crelt@^1.0.6:
- version "1.0.6"
- resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
- integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.7.tgz#3b441b2ddfa73161d6a2770aa4cd677f895eaf28"
+ integrity sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==
cross-spawn@^7.0.3:
version "7.0.6"
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index f738b35..d7524948 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -277,6 +277,7 @@
show_newline_warning_left?: boolean;
show_newline_warning_right?: boolean;
use_new_image_diff_ui?: boolean;
+ is_edit_mode?: boolean;
}
/**
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 08b99a1..85dcbc3 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -287,6 +287,7 @@
revert?: ActionInfo;
revert_submission?: ActionInfo;
abandon?: ActionInfo;
+ restore?: ActionInfo;
submit?: ActionInfo;
topic?: ActionInfo;
hashtags?: ActionInfo;
@@ -794,6 +795,7 @@
primary_weblink_name?: string;
instance_id?: string;
default_branch?: string;
+ submit_commit_url?: string;
}
export type GitRef = BrandType<string, '_gitRef'>;
@@ -1240,6 +1242,16 @@
}
/**
+ * The SubmittedTogetherInfo entity contains information about a collection of
+ * changes that would be submitted together.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together-info
+ */
+export declare interface SubmittedTogetherInfo {
+ changes: ChangeInfo[];
+ non_visible_changes: number;
+}
+
+/**
* The SuggestInfo entity contains information about Gerritconfiguration from
* the suggest section.
* https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index fcc1b32..f9bf9f6 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -100,12 +100,22 @@
REVIEWERS = 'Reviewers',
REPO = 'Repo',
BRANCH = 'Branch',
+ HASHTAGS = 'Hashtags',
UPDATED = 'Updated',
SIZE = 'Size',
STATUS = 'Status',
}
/**
+ * Columns that are shown when the user has not customized their change table
+ * preferences. Columns that are opt-in (such as Hashtags) are excluded here,
+ * but still appear in the settings editor via `ColumnNames`.
+ */
+export const DEFAULT_VISIBLE_COLUMNS: string[] = Object.values(
+ ColumnNames
+).filter(col => col !== ColumnNames.HASHTAGS);
+
+/**
* @description Modes for gr-diff-cursor
* The scroll behavior for the cursor. Values are 'never' and
* 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 32e77ae..96bbcfd 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -99,6 +99,8 @@
PREVIEW_FIX_LOAD = 'PreviewFixLoad',
// Time to apply fix for a user suggested edit or a fix from checks
APPLY_FIX_LOAD = 'ApplyFixLoad',
+ // Time to revert a delta hunk in diff edit mode
+ REVERT_DELTA_LOAD = 'RevertDeltaLoad',
// Time to copy target to clipboard
COPY_TO_CLIPBOARD = 'CopyToClipboard',
// Time to autocomplete a comment
@@ -188,6 +190,8 @@
FLOW_CREATED = 'flow-created',
// AI Chat interaction request failures
AI_CHAT_FAILURE = 'ai-chat-failure',
+ // Revert hunk clicked in diff edit mode
+ REVERT_DELTA_CLICKED = 'revert-delta-clicked',
}
/**
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 970a6de..ef9294b 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -165,6 +165,8 @@
<gr-copy-clipboard
id="uuid"
.text=${this.getGroupUUID()}
+ buttonTitle="Copy Group UUID to clipboard"
+ copyTargetName="Group UUID"
></gr-copy-clipboard>
</fieldset>
`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index 8b4192f..3d5051f 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -57,7 +57,12 @@
<fieldset>
<h3 class="heading-3" id="groupUUID">Group UUID</h3>
<fieldset>
- <gr-copy-clipboard id="uuid"> </gr-copy-clipboard>
+ <gr-copy-clipboard
+ buttontitle="Copy Group UUID to clipboard"
+ copytargetname="Group UUID"
+ id="uuid"
+ >
+ </gr-copy-clipboard>
</fieldset>
<h3 class="heading-3" id="groupName">Group Name</h3>
<fieldset>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index bc48aac..e4fc88d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -512,8 +512,8 @@
return html`
<section>
<span class="title">
- Reject implicit merges when changes are pushed for review</span
- >
+ Reject implicit merges when changes are uploaded or submitted
+ </span>
<span class="value">
<md-outlined-select
id="rejectImplicitMergesSelect"
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 2f1bf8b..b0085b2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -561,7 +561,7 @@
</section>
<section>
<span class="title">
- Reject implicit merges when changes are pushed for review
+ Reject implicit merges when changes are uploaded or submitted
</span>
<span class="value">
<md-outlined-select
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
index 98781a0..3f8520e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
@@ -45,13 +45,15 @@
}
override render() {
+ const restore = this.isRestoreMode();
+ const action = restore ? 'restore' : 'abandon';
return html`
<gr-button
- id="abandon"
+ id=${action}
flatten
.disabled=${!this.isEnabled()}
@click=${() => this.actionModal.showModal()}
- >Abandon</gr-button
+ >${restore ? 'Restore' : 'Abandon'}</gr-button
>
<dialog id="actionModal" tabindex="-1">
<gr-dialog
@@ -62,7 +64,7 @@
.cancelLabel=${'Close'}
>
<div slot="header">
- ${this.selectedChanges.length} changes to abandon
+ ${this.selectedChanges.length} changes to ${action}
</div>
<div slot="main">
<table>
@@ -97,7 +99,24 @@
: ProgressStatus.NOT_STARTED;
}
+ /**
+ * The button acts as a toggle: when every selected change is abandoned it
+ * offers to restore them, otherwise it offers to abandon them. Changes that
+ * are already in the target state are skipped by the model.
+ */
+ private isRestoreMode() {
+ return (
+ this.selectedChanges.length > 0 &&
+ this.selectedChanges.every(
+ change => change.status === ChangeStatus.ABANDONED
+ )
+ );
+ }
+
private isEnabled() {
+ if (this.isRestoreMode()) {
+ return this.selectedChanges.every(change => !!change.actions?.restore);
+ }
return this.selectedChanges.every(
change =>
!!change.actions?.abandon || change.status === ChangeStatus.ABANDONED
@@ -129,7 +148,10 @@
const errFn = (changeNum: NumericChangeId) => {
throw new Error(`request for ${changeNum} failed`);
};
- const promises = this.getBulkActionsModel().abandonChanges('', errFn);
+ const model = this.getBulkActionsModel();
+ const promises = this.isRestoreMode()
+ ? model.restoreChanges('', errFn)
+ : model.abandonChanges('', errFn);
for (let index = 0; index < promises.length; index++) {
const changeNum = this.selectedChanges[index]._number;
promises[index]
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
index d99a7c4..dc9db6f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -143,7 +143,69 @@
assert.isTrue(queryAndAssert<GrButton>(element, '#abandon').disabled);
});
- test('abandon button is enabled if change is already abandoned', async () => {
+ test('mixed open/abandoned selection shows Abandon', async () => {
+ const changes: ChangeInfo[] = [
+ {...change1, actions: {abandon: {}}},
+ {...change2, actions: {restore: {}}, status: ChangeStatus.ABANDONED},
+ ];
+ getChangesStub.returns(changes);
+ model.sync(changes);
+ await waitUntilObserved(
+ model.loadingState$,
+ state => state === LoadingState.LOADED
+ );
+ await selectChange(change1);
+ await selectChange(change2);
+ await element.updateComplete;
+
+ assert.isNotOk(query(element, '#restore'));
+ const button = queryAndAssert<GrButton>(element, '#abandon');
+ assert.isFalse(button.disabled);
+ assert.equal(button.innerText.trim(), 'Abandon');
+ });
+
+ test('restore button is shown if all changes are abandoned', async () => {
+ const changes: ChangeInfo[] = [
+ {...change1, actions: {restore: {}}, status: ChangeStatus.ABANDONED},
+ ];
+ getChangesStub.returns(changes);
+ model.sync(changes);
+ await waitUntilObserved(
+ model.loadingState$,
+ state => state === LoadingState.LOADED
+ );
+ await selectChange(change1);
+ await element.updateComplete;
+
+ assert.isNotOk(query(element, '#abandon'));
+ const button = queryAndAssert<GrButton>(element, '#restore');
+ assert.isFalse(button.disabled);
+ assert.equal(button.innerText.trim(), 'Restore');
+ assert.equal(
+ queryAndAssert<HTMLDivElement>(element, 'div[slot="header"]')
+ .innerText.replace(/\s+/g, ' ')
+ .trim(),
+ '1 changes to restore'
+ );
+
+ const executeChangeAction = stubRestApi('executeChangeAction').returns(
+ Promise.resolve(new Response())
+ );
+
+ queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+ await waitUntil(
+ () =>
+ queryAndAssert<HTMLTableDataCellElement>(
+ element,
+ '#status'
+ ).innerText.trim() === `Status: ${ProgressStatus.SUCCESSFUL}`
+ );
+ assert.equal(executeChangeAction.callCount, 1);
+ assert.equal(executeChangeAction.lastCall.args[2], '/restore');
+ });
+
+ test('restore button is disabled without restore permission', async () => {
const changes: ChangeInfo[] = [
{...change1, actions: {}, status: ChangeStatus.ABANDONED},
];
@@ -156,17 +218,7 @@
await selectChange(change1);
await element.updateComplete;
- assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
-
- queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
-
- await waitUntil(
- () =>
- queryAndAssert<HTMLTableDataCellElement>(
- element,
- '#status'
- ).innerText.trim() === `Status: ${ProgressStatus.SUCCESSFUL}`
- );
+ assert.isTrue(queryAndAssert<GrButton>(element, '#restore').disabled);
});
test('progress updates as request is resolved', async () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index f914b55..8df9de1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -22,6 +22,7 @@
import {
AccountInfo,
ChangeInfo,
+ Hashtag,
NumericChangeId,
ServerInfo,
Timestamp,
@@ -243,6 +244,9 @@
.requirements {
white-space: nowrap;
}
+ .hashtags a.hashtag:not(:last-of-type) {
+ margin-right: var(--spacing-s);
+ }
.reviewers {
--account-max-length: 70px;
}
@@ -349,9 +353,9 @@
${this.renderCellNumber(changeUrl)} ${this.renderCellSubject(changeUrl)}
${this.renderCellOwner()} ${this.renderCellReviewers()}
${this.renderCellRepo()} ${this.renderCellBranch()}
- ${this.renderCellUpdated()} ${this.renderCellSubmitted()}
- ${this.renderCellWaiting()} ${this.renderCellSize()}
- ${this.renderCellRequirements()}
+ ${this.renderCellHashtags()} ${this.renderCellUpdated()}
+ ${this.renderCellSubmitted()} ${this.renderCellWaiting()}
+ ${this.renderCellSize()} ${this.renderCellRequirements()}
${this.labelNames?.map(labelNames => this.renderChangeLabels(labelNames))}
${this.dynamicCellEndpoints?.map(pluginEndpointName =>
this.renderChangePluginEndpoint(pluginEndpointName)
@@ -588,6 +592,30 @@
`;
}
+ private renderCellHashtags() {
+ if (this.computeIsColumnHidden(ColumnNames.HASHTAGS)) return;
+
+ return html`
+ <td class="cell hashtags">
+ ${(this.change?.hashtags ?? []).map(hashtag =>
+ this.renderChangeHashtag(hashtag)
+ )}
+ </td>
+ `;
+ }
+
+ private renderChangeHashtag(hashtag: Hashtag) {
+ return html`
+ <a class="hashtag" href=${this.computeHashtagUrl(hashtag)}>
+ <gr-limited-text .limit=${25} .text=${hashtag}></gr-limited-text>
+ </a>
+ `;
+ }
+
+ private computeHashtagUrl(hashtag: Hashtag) {
+ return createSearchUrl({hashtag, statuses: ['open', 'merged']});
+ }
+
private renderChangeLabels(labelName: string) {
return html` <td class="cell label requirement">
<gr-change-list-column-requirement
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index c62d185..d8d5f54 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -30,6 +30,7 @@
AccountId,
BranchName,
ChangeInfo,
+ Hashtag,
RepoName,
TopicName,
} from '../../../types/common';
@@ -90,6 +91,7 @@
ColumnNames.UPDATED,
ColumnNames.SIZE,
ColumnNames.STATUS,
+ ColumnNames.HASHTAGS,
];
await element.updateComplete;
@@ -219,6 +221,7 @@
ColumnNames.UPDATED,
ColumnNames.SIZE,
ColumnNames.STATUS,
+ ColumnNames.HASHTAGS,
];
await element.updateComplete;
@@ -233,6 +236,56 @@
}
});
+ test('hashtags cell not rendered when column is not visible', async () => {
+ element.visibleChangeTableColumns = [
+ ColumnNames.SUBJECT,
+ ColumnNames.OWNER,
+ ColumnNames.REVIEWERS,
+ ColumnNames.REPO,
+ ColumnNames.BRANCH,
+ ColumnNames.UPDATED,
+ ColumnNames.SIZE,
+ ColumnNames.STATUS,
+ ];
+ element.change = {
+ ...createChange(),
+ hashtags: ['runway' as Hashtag, 'stability' as Hashtag],
+ };
+
+ await element.updateComplete;
+
+ assert.isNotOk(query(element, '.hashtags'));
+ });
+
+ test('renders hashtags as links to hashtag search', async () => {
+ element.visibleChangeTableColumns = [
+ ColumnNames.SUBJECT,
+ ColumnNames.HASHTAGS,
+ ];
+ element.change = {
+ ...createChange(),
+ hashtags: ['runway' as Hashtag, 'stability' as Hashtag],
+ };
+
+ await element.updateComplete;
+
+ const cell = queryAndAssert(element, '.cell.hashtags');
+ const links = cell.querySelectorAll<HTMLAnchorElement>('a.hashtag');
+ assert.equal(links.length, 2);
+ assert.equal(
+ links[0].getAttribute('href'),
+ '/q/hashtag:"runway"+(status:open OR status:merged)'
+ );
+ assert.equal(
+ links[1].getAttribute('href'),
+ '/q/hashtag:"stability"+(status:open OR status:merged)'
+ );
+ const texts = cell.querySelectorAll('gr-limited-text');
+ assert.equal(texts.length, 2);
+ assert.equal(texts[0].text, 'runway');
+ assert.equal(texts[1].text, 'stability');
+ });
+
function checkComputeReviewers(
userId: number | undefined,
reviewerIds: number[],
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
index 16fc638..ae2d8bb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -77,7 +77,7 @@
</md-checkbox>
</td>
#
- SubjectOwnerReviewersRepoBranchUpdatedSizeStatus
+ SubjectOwnerReviewersRepoBranchHashtagsUpdatedSizeStatus
<gr-change-list-item
aria-label="Test subject, section: test"
role="button"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 1ce9a3b..b56a80b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -18,7 +18,11 @@
UserId,
} from '../../../types/common';
import {fire, fireReload} from '../../../utils/event-util';
-import {ColumnNames, ScrollMode} from '../../../constants/constants';
+import {
+ ColumnNames,
+ DEFAULT_VISIBLE_COLUMNS,
+ ScrollMode,
+} from '../../../constants/constants';
import {
getRequirements,
orderSubmitRequirementNames,
@@ -361,7 +365,7 @@
this.changeTableColumns = Object.values(ColumnNames);
this.showNumber = false;
- this.visibleChangeTableColumns = Object.values(ColumnNames);
+ this.visibleChangeTableColumns = [...DEFAULT_VISIBLE_COLUMNS];
if (this.loggedInUser && this.preferences) {
this.showNumber = !!this.preferences?.legacycid_in_change_table;
const prefColumns = changeTablePrefs(this.preferences);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index 55a954f..3419ee0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -466,8 +466,8 @@
assert.isTrue(element.showNumber);
});
- test('all columns visible', () => {
- for (const column of element.changeTableColumns!) {
+ test('all default columns visible', () => {
+ for (const column of element.visibleChangeTableColumns!) {
const elementClass = '.' + column.trim().toLowerCase();
const section = queryAndAssert(element, 'gr-change-list-section');
assert.isFalse(
@@ -475,6 +475,15 @@
);
}
});
+
+ test('hashtags column is not visible by default', () => {
+ assert.notInclude(
+ element.visibleChangeTableColumns!,
+ ColumnNames.HASHTAGS
+ );
+ const section = queryAndAssert(element, 'gr-change-list-section');
+ assert.isNotOk(query<HTMLElement>(section, '.hashtags'));
+ });
});
suite('full column preference', () => {
@@ -497,6 +506,7 @@
'Branch',
'Updated',
'Size',
+ 'Hashtags',
],
};
element.config = createServerInfo();
@@ -533,6 +543,7 @@
'Branch',
'Updated',
'Size',
+ 'Hashtags',
],
};
element.config = createServerInfo();
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index b35a0ab..91a22f0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -185,7 +185,7 @@
__key: 'chat',
__type: ActionType.CHANGE,
enabled: true,
- label: 'Review Agent',
+ label: 'Agent Chat',
};
function isQuickApproveAction(
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 0104595..2a8ec71 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -1326,6 +1326,8 @@
class="changeCopyClipboard"
hideInput=""
text=${this.computeCopyTextForTitle()}
+ buttonTitle="Copy change subject and URL to clipboard"
+ copyTargetName="Change subject and URL"
>
</gr-copy-clipboard>
</div>`;
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
index be82140..632666c 100644
--- a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
@@ -151,6 +151,8 @@
id=${`${id}-copy-clipboard`}
nowrap
?multiline=${!!multiline}
+ copyTargetName=${label}
+ buttonTitle=${`Copy ${label} to clipboard`}
${index === 0 && ref(this.copyClipboardRef)}
></gr-copy-clipboard>
</div>`;
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
index b589115..864e33e 100644
--- a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
@@ -45,6 +45,8 @@
<div class="dropdown-content">
<div class="copy-link-row">
<gr-copy-clipboard
+ buttontitle="Copy Change ID to clipboard"
+ copytargetname="Change ID"
id="Change_ID-field-copy-clipboard"
label="Change ID"
nowrap=""
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 4dca08d..53ef217 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -6,6 +6,8 @@
import '../../../styles/gr-a11y-styles';
import '../../../styles/shared-styles';
import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer';
+import type {GrDiffMarkdownViewer} from '../../diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer';
import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
import '../../shared/gr-button/gr-button';
@@ -36,14 +38,14 @@
import {customElement, property, query, state} from 'lit/decorators.js';
import {
BasePatchSetNum,
- EDIT,
FileInfo,
NumericChangeId,
PARENT,
PatchRange,
RevisionPatchSetNum,
} from '../../../types/common';
-import {DiffPreferencesInfo} from '../../../types/diff';
+import {isMarkdownDiff} from '../../../utils/diff-util';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
@@ -51,7 +53,6 @@
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
import {Interaction, Timing} from '../../../constants/reporting';
-import {RevisionInfo} from '../../shared/revision-info/revision-info';
import {select} from '../../../utils/observable-util';
import {resolve} from '../../../models/dependency';
import {browserModelToken} from '../../../models/browser/browser-model';
@@ -271,6 +272,13 @@
@state()
expandedFiles: Set<string> = new Set();
+ @state()
+ private diffsByPath = new Map<string, DiffInfo>();
+
+ // Private but used in tests.
+ @state()
+ richMarkdownFiles: Set<string> = new Set();
+
// Private but used in tests.
@state()
showSizeBars = true;
@@ -278,8 +286,9 @@
// For merge commits vs Auto Merge, an extra file row is shown detailing the
// files that were merged without conflict. These files are also passed to any
// plugins.
+ // Private but used in tests.
@state()
- private cleanlyMergedPaths: string[] = [];
+ cleanlyMergedPaths: string[] = [];
// Private but used in tests.
@state()
@@ -310,8 +319,6 @@
private readonly reporting = getAppContext().reportingService;
- private readonly restApiService = getAppContext().restApiService;
-
private readonly getPluginLoader = resolve(this, pluginLoaderToken);
private readonly getUserModel = resolve(this, userModelToken);
@@ -519,6 +526,25 @@
margin-left: var(--spacing-s);
width: 1.9em;
}
+ .richMarkdownToggle {
+ align-items: center;
+ display: inline-flex;
+ justify-content: flex-end;
+ margin-right: var(--spacing-s);
+ opacity: 0;
+ }
+ .row:hover .richMarkdownToggle,
+ .row:focus-within .richMarkdownToggle,
+ .row.expanded .richMarkdownToggle {
+ opacity: 100;
+ }
+ .richMarkdownToggle gr-button {
+ --gr-button-padding: 0 var(--spacing-s);
+ }
+ .richMarkdownToggle gr-icon {
+ font-size: 16px;
+ margin-right: var(--spacing-xs);
+ }
.fileListButton {
margin: var(--spacing-m);
}
@@ -874,6 +900,20 @@
);
subscribe(
this,
+ () => this.getFilesModel().cleanlyMergedPaths$,
+ paths => {
+ this.cleanlyMergedPaths = paths;
+ }
+ );
+ subscribe(
+ this,
+ () => this.getFilesModel().cleanlyMergedOldPaths$,
+ paths => {
+ this.cleanlyMergedOldPaths = paths;
+ }
+ );
+ subscribe(
+ this,
() => this.getBrowserModel().diffViewMode$,
diffView => {
this.diffViewMode = diffView;
@@ -1196,22 +1236,43 @@
)}
<!-- endpoint: change-view-file-list-content -->
${this.renderReviewed(file)} ${this.renderFileControls(file)}
- ${this.renderShowHide(file)}
+ ${this.renderRichMarkdownToggle(file)} ${this.renderShowHide(file)}
</div>
${when(
this.isFileExpanded(file.__path),
() => html`
- <gr-diff-host
- ?noAutoRender=${true}
- ?showLoadFailure=${true}
- .changeNum=${this.changeNum}
- .change=${this.change}
- .patchRange=${this.patchRange}
- .file=${patchSetFile}
- .path=${file.__path}
- .projectName=${this.change?.project}
- ?noRenderOnPrefsChange=${true}
- ></gr-diff-host>
+ ${when(
+ this.isShowingRichMarkdown(file.__path),
+ () => html`
+ <gr-diff-markdown-viewer
+ .diff=${this.getDiffForPath(file.__path)}
+ .path=${file.__path}
+ .patchRange=${this.patchRange}
+ .loggedIn=${this.loggedIn}
+ ></gr-diff-markdown-viewer>
+ `
+ )}
+ <div ?hidden=${this.isShowingRichMarkdown(file.__path)}>
+ <gr-diff-host
+ ?hidden=${this.isShowingRichMarkdown(file.__path)}
+ ?disabledThreads=${this.isShowingRichMarkdown(file.__path)}
+ ?noAutoRender=${true}
+ ?showLoadFailure=${true}
+ .changeNum=${this.changeNum}
+ .change=${this.change}
+ .patchRange=${this.patchRange}
+ .file=${patchSetFile}
+ .path=${file.__path}
+ .projectName=${this.change?.project}
+ ?noRenderOnPrefsChange=${true}
+ @diff-changed=${(e: CustomEvent<{value?: DiffInfo}>) => {
+ if (e.detail.value) {
+ this.diffsByPath.set(file.__path, e.detail.value);
+ this.requestUpdate();
+ }
+ }}
+ ></gr-diff-host>
+ </div>
`
)}
</div>`;
@@ -1354,6 +1415,8 @@
<gr-copy-clipboard
?hideInput=${true}
.text=${file.__path}
+ buttonTitle="Copy file path to clipboard"
+ copyTargetName="File path"
></gr-copy-clipboard>
</a>
${when(
@@ -1376,6 +1439,8 @@
<gr-copy-clipboard
?hideInput=${true}
.text=${file.old_path}
+ buttonTitle="Copy old file path to clipboard"
+ copyTargetName="Old file path"
></gr-copy-clipboard>
</div>
`
@@ -1610,6 +1675,86 @@
</div>`;
}
+ isShowingRichMarkdown(path?: string): boolean {
+ if (!path || !isMarkdownDiff(path)) return false;
+ return this.richMarkdownFiles.has(path);
+ }
+
+ async toggleRichMarkdown(path: string, e?: Event) {
+ if (e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ const isRich = this.richMarkdownFiles.has(path);
+ if (isRich) {
+ const viewers = Array.from(
+ this.shadowRoot?.querySelectorAll<GrDiffMarkdownViewer>(
+ 'gr-diff-markdown-viewer'
+ ) ?? []
+ );
+ const viewer = viewers.find(v => v.path === path);
+ await viewer?.autoSaveDrafts();
+ } else {
+ const diffHosts = Array.from(
+ this.shadowRoot?.querySelectorAll<GrDiffHost>('gr-diff-host') ?? []
+ );
+ const diffHost = this.findDiffByPath(path, diffHosts);
+ await diffHost?.autoSaveDrafts();
+ }
+ const newSet = new Set(this.richMarkdownFiles);
+ if (newSet.has(path)) {
+ newSet.delete(path);
+ } else {
+ newSet.add(path);
+ if (!this.isFileExpanded(path)) {
+ const newExpanded = new Set(this.expandedFiles);
+ newExpanded.add(path);
+ this.expandedFiles = newExpanded;
+ }
+ }
+ this.richMarkdownFiles = newSet;
+ }
+
+ getDiffForPath(path: string): DiffInfo | undefined {
+ const cached = this.diffsByPath.get(path);
+ if (cached) return cached;
+ const diffHosts = Array.from(
+ this.shadowRoot?.querySelectorAll<GrDiffHost>('gr-diff-host') ?? []
+ );
+ const diffHost = this.findDiffByPath(path, diffHosts);
+ if (diffHost?.diff) {
+ this.diffsByPath.set(path, diffHost.diff);
+ return diffHost.diff;
+ }
+ return undefined;
+ }
+
+ private renderRichMarkdownToggle(file: NormalizedFileInfo) {
+ if (!isMarkdownDiff(file.__path)) return nothing;
+ const isRich = this.isShowingRichMarkdown(file.__path);
+ return html`
+ <div class="richMarkdownToggle" role="gridcell">
+ <gr-tooltip-content
+ has-tooltip
+ title=${isRich
+ ? 'View source diff'
+ : 'View rich rendered markdown diff'}
+ >
+ <gr-button
+ link
+ class="toggleRichMarkdown"
+ @click=${(e: MouseEvent) => this.toggleRichMarkdown(file.__path, e)}
+ >
+ <gr-icon icon=${isRich ? 'code' : 'preview'} filled></gr-icon>
+ <span class="richToggleLabel"
+ >${isRich ? 'Source diff' : 'Rich diff'}</span
+ >
+ </gr-button>
+ </gr-tooltip-content>
+ </div>
+ `;
+ }
+
private renderShowHide(file: NormalizedFileInfo) {
const expanded = this.isFileExpanded(file.__path);
return html` <div class="show-hide" role="gridcell">
@@ -1871,43 +2016,6 @@
this.reporting.fileListDisplayed();
}
- // TODO: Move into files-model.
- // visible for testing
- async updateCleanlyMergedPaths() {
- // When viewing Auto Merge base vs a patchset, add an additional row that
- // knows how many files were cleanly merged. This requires an additional RPC
- // for the diffs between target parent and the patch set. The cleanly merged
- // files are all the files in the target RPC that weren't in the Auto Merge
- // RPC.
- if (
- this.change &&
- this.changeNum &&
- this.patchNum &&
- new RevisionInfo(this.change).isMergeCommit(this.patchNum) &&
- this.basePatchNum === PARENT &&
- this.patchNum !== EDIT
- ) {
- const allFilesByPath = await this.restApiService.getChangeOrEditFiles(
- this.changeNum,
- {
- basePatchNum: -1 as BasePatchSetNum, // -1 is first (target) parent
- patchNum: this.patchNum,
- }
- );
- if (!allFilesByPath) return;
- const conflictingPaths = this.files.map(f => f.__path);
- this.cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
- path => !conflictingPaths.includes(path)
- );
- this.cleanlyMergedOldPaths = this.cleanlyMergedPaths
- .map(path => allFilesByPath[path].old_path)
- .filter((oldPath): oldPath is string => !!oldPath);
- } else {
- this.cleanlyMergedPaths = [];
- this.cleanlyMergedOldPaths = [];
- }
- }
-
private detectChromiteButler() {
const hasButler = !!document.getElementById('butler-suggested-owners');
if (hasButler) {
@@ -2237,6 +2345,24 @@
private handleNewComment() {
this.classList.remove('hideComments');
+ const viewers = Array.from(
+ this.shadowRoot?.querySelectorAll<GrDiffMarkdownViewer>(
+ 'gr-diff-markdown-viewer'
+ ) ?? []
+ );
+ const viewerWithSelection = viewers.find(v => v.hasActiveSelection());
+ if (viewerWithSelection) {
+ viewerWithSelection.createCommentFromSelectionOrHover();
+ return;
+ }
+ const currentPath = this.files[this.fileCursor.index]?.__path;
+ if (currentPath && this.isShowingRichMarkdown(currentPath)) {
+ const currentViewer = viewers.find(v => v.path === currentPath);
+ if (currentViewer) {
+ currentViewer.createCommentFromSelectionOrHover();
+ return;
+ }
+ }
this.diffCursor?.createCommentInPlace();
}
@@ -2433,7 +2559,6 @@
async filesChanged() {
if (this.expandedFiles.size > 0) this.expandedFiles = new Set();
- await this.updateCleanlyMergedPaths();
if (!this.files || this.files.length === 0) return;
await this.updateComplete;
this.fileCursor.stops = Array.from(
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index dfff003..e2dddf3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -18,6 +18,7 @@
waitEventLoop,
waitUntil,
} from '../../../test/test-utils';
+import type {GrDiffMarkdownViewer} from '../../diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer';
import {
BasePatchSetNum,
CommitId,
@@ -207,7 +208,12 @@
<span class="truncatedFileName" title="path/file0">
…/file0
</span>
- <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+ <gr-copy-clipboard
+ buttontitle="Copy file path to clipboard"
+ copytargetname="File path"
+ hideinput=""
+ >
+ </gr-copy-clipboard>
</a>
</span>
<div role="gridcell">
@@ -327,7 +333,12 @@
<span class="truncatedFileName" title="path/file0">
…/file0
</span>
- <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+ <gr-copy-clipboard
+ buttontitle="Copy file path to clipboard"
+ copytargetname="File path"
+ hideinput=""
+ >
+ </gr-copy-clipboard>
</a>
</span>
`
@@ -345,7 +356,12 @@
<span class="truncatedFileName" title="path/file1">
…/file1
</span>
- <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+ <gr-copy-clipboard
+ buttontitle="Copy file path to clipboard"
+ copytargetname="File path"
+ hideinput=""
+ >
+ </gr-copy-clipboard>
</a>
</span>
`
@@ -1618,18 +1634,10 @@
});
suite('for merge commits', () => {
- let filesStub: sinon.SinonStub;
-
setup(async () => {
element.files = [
normalize({size: 0, size_delta: 0}, 'conflictingFile.js'),
];
- filesStub = stubRestApi('getChangeOrEditFiles')
- .onFirstCall()
- .resolves({
- 'conflictingFile.js': {size: 0, size_delta: 0},
- 'cleanlyMergedFile.js': {size: 0, size_delta: 0},
- });
stubRestApi('getReviewedFiles').resolves([]);
stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
const changeWithMultipleParents = {
@@ -1656,6 +1664,8 @@
});
test('displays cleanly merged file count', async () => {
+ element.cleanlyMergedPaths = ['cleanlyMergedFile.js'];
+ await element.updateComplete;
await waitUntil(() => !!query(element, '.cleanlyMergedText'));
const message = queryAndAssert<HTMLSpanElement>(
@@ -1666,15 +1676,10 @@
});
test('displays plural cleanly merged file count', async () => {
- filesStub.restore();
- stubRestApi('getChangeOrEditFiles')
- .onFirstCall()
- .resolves({
- 'conflictingFile.js': {size: 0, size_delta: 0},
- 'cleanlyMergedFile.js': {size: 0, size_delta: 0},
- 'anotherCleanlyMergedFile.js': {size: 0, size_delta: 0},
- });
- await element.updateCleanlyMergedPaths();
+ element.cleanlyMergedPaths = [
+ 'cleanlyMergedFile.js',
+ 'anotherCleanlyMergedFile.js',
+ ];
await element.updateComplete;
await waitUntil(() => !!query(element, '.cleanlyMergedText'));
@@ -1686,34 +1691,17 @@
});
test('displays button for navigating to parent 1 base', async () => {
+ element.cleanlyMergedPaths = ['cleanlyMergedFile.js'];
+ await element.updateComplete;
await waitUntil(() => !!query(element, '.showParentButton'));
queryAndAssert(element, '.showParentButton');
});
- test('computes old paths for cleanly merged files', async () => {
- filesStub.restore();
- stubRestApi('getChangeOrEditFiles')
- .onFirstCall()
- .resolves({
- 'conflictingFile.js': {size: 0, size_delta: 0},
- 'cleanlyMergedFile.js': {
- old_path: 'cleanlyMergedFileOldName.js',
- size: 0,
- size_delta: 0,
- },
- });
- await element.updateCleanlyMergedPaths();
-
- assert.deepEqual(element.cleanlyMergedOldPaths, [
- 'cleanlyMergedFileOldName.js',
- ]);
- });
-
test('not shown for non-Auto Merge base parents', async () => {
+ element.cleanlyMergedPaths = [];
element.basePatchNum = 1 as BasePatchSetNum;
element.patchNum = 2 as RevisionPatchSetNum;
- await element.updateCleanlyMergedPaths();
await element.updateComplete;
assert.notOk(query(element, '.cleanlyMergedText'));
@@ -1721,9 +1709,9 @@
});
test('not shown in edit mode', async () => {
+ element.cleanlyMergedPaths = [];
element.basePatchNum = 1 as BasePatchSetNum;
element.patchNum = EDIT;
- await element.updateCleanlyMergedPaths();
await element.updateComplete;
assert.notOk(query(element, '.cleanlyMergedText'));
@@ -2408,4 +2396,121 @@
assert.equal(element.computeClass('', 'file.java'), '');
});
});
+
+ suite('rich markdown diff', () => {
+ setup(async () => {
+ stubRestApi('getDiffComments').returns(Promise.resolve({}));
+ stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+ stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
+ stubElement('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
+ stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
+
+ element = await fixture(html`<gr-file-list></gr-file-list>`);
+ element.numFilesShown = 5;
+ element.files = [normalize({}, 'README.md'), normalize({}, 'file.ts')];
+ await element.updateComplete;
+ });
+
+ test('toggle button rendered only for markdown files', () => {
+ const rows = queryAll(element, '.file-row');
+ assert.equal(rows.length, 2);
+
+ const mdToggle = rows[0].querySelector('.toggleRichMarkdown');
+ assert.isOk(mdToggle);
+
+ const nonMdToggle = rows[1].querySelector('.toggleRichMarkdown');
+ assert.isNotOk(nonMdToggle);
+ });
+
+ test('clicking toggleRichMarkdown expands file and enables rich mode', async () => {
+ assert.isFalse(element.isFileExpanded('README.md'));
+ assert.isFalse(element.isShowingRichMarkdown('README.md'));
+
+ element.toggleRichMarkdown('README.md');
+ await element.updateComplete;
+
+ assert.isTrue(element.isFileExpanded('README.md'));
+ assert.isTrue(element.isShowingRichMarkdown('README.md'));
+
+ const viewer = query(element, 'gr-diff-markdown-viewer');
+ assert.isOk(viewer);
+
+ // Toggle off
+ element.toggleRichMarkdown('README.md');
+ await element.updateComplete;
+
+ assert.isFalse(element.isShowingRichMarkdown('README.md'));
+ const viewerAfter = query(element, 'gr-diff-markdown-viewer');
+ assert.isNotOk(viewerAfter);
+ });
+
+ test('handleNewComment delegates to viewer when viewer has active selection', async () => {
+ element.loggedIn = true;
+ element.toggleRichMarkdown('README.md');
+ await element.updateComplete;
+
+ const viewer = queryAndAssert<GrDiffMarkdownViewer>(
+ element,
+ 'gr-diff-markdown-viewer'
+ );
+ assert.isTrue(viewer.loggedIn);
+
+ sinon.stub(viewer, 'hasActiveSelection').returns(true);
+ const commentSpy = sinon.spy(viewer, 'createCommentFromSelectionOrHover');
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (element as any).handleNewComment();
+
+ assert.isTrue(commentSpy.calledOnce);
+ });
+
+ test('c and C shortcuts delegate to viewer when cursor is on rich markdown file', async () => {
+ element.loggedIn = true;
+ element.toggleRichMarkdown('README.md');
+ await element.updateComplete;
+
+ const viewer = queryAndAssert<GrDiffMarkdownViewer>(
+ element,
+ 'gr-diff-markdown-viewer'
+ );
+ const commentSpy = sinon.spy(viewer, 'createCommentFromSelectionOrHover');
+
+ element.fileCursor.setCursorAtIndex(0);
+ assert.equal(element.files[element.fileCursor.index].__path, 'README.md');
+
+ pressKey(element, 'c');
+ assert.isTrue(commentSpy.calledOnce);
+
+ pressKey(element, 'C');
+ assert.isTrue(commentSpy.calledTwice);
+ });
+
+ test('toggling rich to source flushes drafts for matching file viewer', async () => {
+ element.files = [normalize({}, 'README.md'), normalize({}, 'DOCS.md')];
+ await element.updateComplete;
+
+ element.toggleRichMarkdown('README.md');
+ element.toggleRichMarkdown('DOCS.md');
+ await element.updateComplete;
+
+ const viewers = Array.from(
+ queryAll<GrDiffMarkdownViewer>(element, 'gr-diff-markdown-viewer')
+ );
+ assert.equal(viewers.length, 2);
+
+ const readmeViewer = viewers.find(v => v.path === 'README.md')!;
+ const docsViewer = viewers.find(v => v.path === 'DOCS.md')!;
+ assert.isOk(readmeViewer);
+ assert.isOk(docsViewer);
+
+ const readmeSaveSpy = sinon.spy(readmeViewer, 'autoSaveDrafts');
+ const docsSaveSpy = sinon.spy(docsViewer, 'autoSaveDrafts');
+
+ await element.toggleRichMarkdown('DOCS.md');
+ await element.updateComplete;
+
+ assert.isFalse(readmeSaveSpy.called);
+ assert.isTrue(docsSaveSpy.calledOnce);
+ });
+ });
});
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 8c3bd77..304d542 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -5,8 +5,13 @@
*/
import '../gr-label-score-row/gr-label-score-row';
import '../../../styles/shared-styles';
+
import {css, html, LitElement, nothing} from 'lit';
import {customElement, property} from 'lit/decorators.js';
+
+import {LabelNameToValuesMap} from '../../../api/rest-api';
+import {ChangeStatus} from '../../../constants/constants';
+import {fontStyles} from '../../../styles/gr-font-styles';
import {
AccountInfo,
ChangeInfo,
@@ -19,11 +24,9 @@
getApplicableLabels,
getDefaultValue,
getTriggerVotes,
+ getVoteForAccount,
Label,
} from '../../../utils/label-util';
-import {ChangeStatus} from '../../../constants/constants';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {LabelNameToValuesMap} from '../../../api/rest-api';
@customElement('gr-label-scores')
export class GrLabelScores extends LitElement {
@@ -136,11 +139,13 @@
}
private renderErrorMessages() {
+ const mergedMessage =
+ 'Because this change has been merged, votes may not be decreased. You can still reply to comments without changing your vote.';
return html`<div
class="mergedMessage"
?hidden=${this.change?.status !== ChangeStatus.MERGED}
>
- Because this change has been merged, votes may not be decreased.
+ ${mergedMessage}
</div>
<div
class="abandonedMessage"
@@ -169,7 +174,13 @@
if (selectedVal === undefined) continue;
const defValNum = getDefaultValue(this.change?.labels, label);
- if (includeDefaults || selectedVal !== defValNum) {
+ // The user's previous vote from the change labels.
+ const prevValStr = getVoteForAccount(label, this.account, this.change);
+ const prevValNum = prevValStr !== null ? Number(prevValStr) : defValNum;
+
+ // If includeDefaults is true, include the label.
+ // Otherwise, ONLY include it if the user actually changed their vote.
+ if (includeDefaults || selectedVal !== prevValNum) {
labels[label] = selectedVal;
}
}
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
index 1de151e..0def9d8 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
@@ -79,6 +79,8 @@
});
test('render', () => {
+ const mergedMessage =
+ 'Because this change has been merged, votes may not be decreased. You can still reply to comments without changing your vote.';
assert.shadowDom.equal(
element,
/* HTML */ `
@@ -87,9 +89,7 @@
</div>
<gr-label-score-row name="Code-Review"> </gr-label-score-row>
<gr-label-score-row name="Verified"> </gr-label-score-row>
- <div class="mergedMessage" hidden="">
- Because this change has been merged, votes may not be decreased.
- </div>
+ <div class="mergedMessage" hidden="">${mergedMessage}</div>
<div class="abandonedMessage" hidden="">
Because this change has been abandoned, you cannot vote.
</div>
@@ -128,6 +128,27 @@
assert.deepEqual(element.getLabelValues(false), {});
});
+ test('getLabelValues with previous vote and includeDefaults=false', async () => {
+ // Setup gives account +1 on Code-Review and +1 on Verified.
+ const row = queryAndAssert<GrLabelScoreRow>(
+ element,
+ 'gr-label-score-row[name="Code-Review"]'
+ );
+ // User changes their vote to +2
+ row.setSelectedValue('+2');
+ await element.updateComplete;
+
+ // includeDefaults=false should OMIT Verified (since it is unchanged at
+ // +1) but should INCLUDE Code-Review (since it changed to +2).
+ assert.deepEqual(element.getLabelValues(false), {'Code-Review': 2});
+
+ // Changing back to +1 (original vote) makes it unchanged again, so it's
+ // omitted.
+ row.setSelectedValue('+1');
+ await element.updateComplete;
+ assert.deepEqual(element.getLabelValues(false), {});
+ });
+
test('getVoteForAccount', () => {
const labelName = 'Code-Review';
assert.strictEqual(
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index c675ff7..a6c7ce3 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -51,6 +51,7 @@
import {ChangeMessageDeletedEventDetail} from '../../../types/events';
import {configModelToken} from '../../../models/config/config-model';
import {userModelToken} from '../../../models/user/user-model';
+import {computeMainCodeBrowserWeblink} from '../../../utils/weblink-util';
import {subscribe} from '../../lit/subscription-controller';
import {LABEL_TITLE_SCORE_PATTERN} from '../../../utils/message-util';
@@ -687,7 +688,62 @@
}
return line;
});
- return mappedLines.join('\n').trim();
+ let result = mappedLines.join('\n').trim();
+ if (isExpanded) {
+ result = this.linkifyCommitHashes(result);
+ }
+ return result;
+ }
+
+ /**
+ * Converts commit SHAs in "submitted as <sha>" and "cherry-picked as <sha>"
+ * messages into markdown links using the configured code browser weblinks.
+ */
+ private linkifyCommitHashes(text: string): string {
+ return text.replace(
+ /((?:submitted|cherry-picked) as )([0-9a-f]{40}|[0-9a-f]{64})\b/g,
+ (_match, prefix: string, sha: string) => {
+ const url = this.getCommitUrl(sha);
+ if (url) {
+ return `${prefix}[${sha}](${url})`;
+ }
+ return `${prefix}${sha}`;
+ }
+ );
+ }
+
+ private getCommitUrl(sha: string): string | undefined {
+ // Prefer the explicit submitCommitUrl from gerrit.config.
+ const submitCommitUrl = this.config?.gerrit?.submit_commit_url;
+ if (submitCommitUrl) {
+ const urlWithCommit = submitCommitUrl.includes('${commit}')
+ ? submitCommitUrl.replace('${commit}', sha)
+ : `${submitCommitUrl.replace(/\/+$/, '')}/${sha}`;
+ try {
+ const url = new URL(urlWithCommit);
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
+ return url.toString();
+ }
+ } catch {
+ // Fall back to the revision's weblinks.
+ }
+ }
+ // Fall back to deriving a URL from the revision's weblinks.
+ if (this.change?.revisions) {
+ for (const rev of Object.values(this.change.revisions)) {
+ const weblink = computeMainCodeBrowserWeblink(
+ rev.commit?.web_links,
+ this.config
+ );
+ if (weblink?.url) {
+ const revSha = rev.commit?.commit;
+ if (revSha && weblink.url.includes(revSha)) {
+ return weblink.url.replace(revSha, sha);
+ }
+ }
+ }
+ }
+ return undefined;
}
// private but used in tests
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 445f41f..6fa6587 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -34,6 +34,7 @@
ReviewInputTag,
RevisionPatchSetNum,
SavingState,
+ ServerInfo,
Timestamp,
UrlEncodedCommentId,
} from '../../../types/common';
@@ -685,6 +686,86 @@
);
assert.equal(actual, expected);
});
+
+ suite('submitted commit links', () => {
+ const sha = '0123456789abcdef0123456789abcdef01234567';
+ const message = `Change has been successfully rebased and submitted as ${sha}`;
+
+ test('uses configured URL placeholder', () => {
+ element.config = {
+ gerrit: {
+ submit_commit_url: 'https://example.com/commit/${commit}',
+ },
+ } as ServerInfo;
+
+ assert.equal(
+ element.computeMessageContent(true, message),
+ `Change has been successfully rebased and submitted as [${sha}](https://example.com/commit/${sha})`
+ );
+ });
+
+ test('links cherry-picked commit', () => {
+ element.config = {
+ gerrit: {
+ submit_commit_url: 'https://example.com/commit/${commit}',
+ },
+ } as ServerInfo;
+ const cherryPickedMessage = `Change has been successfully cherry-picked as ${sha}`;
+
+ assert.equal(
+ element.computeMessageContent(true, cherryPickedMessage),
+ `Change has been successfully cherry-picked as [${sha}](https://example.com/commit/${sha})`
+ );
+ });
+
+ test('links SHA-256 commit', () => {
+ element.config = {
+ gerrit: {
+ submit_commit_url: 'https://example.com/commit/${commit}',
+ },
+ } as ServerInfo;
+ const sha256 = `${sha}0123456789abcdef01234567`;
+ const sha256Message = `Change has been successfully rebased and submitted as ${sha256}`;
+
+ assert.equal(
+ element.computeMessageContent(true, sha256Message),
+ `Change has been successfully rebased and submitted as [${sha256}](https://example.com/commit/${sha256})`
+ );
+ });
+
+ test('appends commit to configured URL without trailing slash', () => {
+ element.config = {
+ gerrit: {submit_commit_url: 'https://example.com/commit'},
+ } as ServerInfo;
+
+ assert.equal(
+ element.computeMessageContent(true, message),
+ `Change has been successfully rebased and submitted as [${sha}](https://example.com/commit/${sha})`
+ );
+ });
+
+ test('rejects configured non-HTTP URL', () => {
+ element.config = {
+ gerrit: {submit_commit_url: 'javascript:${commit}'},
+ } as ServerInfo;
+
+ assert.equal(element.computeMessageContent(true, message), message);
+ });
+
+ test('only links hashes in submitted commit messages', () => {
+ element.config = {
+ gerrit: {
+ submit_commit_url: 'https://example.com/commit/${commit}',
+ },
+ } as ServerInfo;
+
+ const unrelatedMessage = `Tree ID: ${sha}`;
+ assert.equal(
+ element.computeMessageContent(true, unrelatedMessage),
+ unrelatedMessage
+ );
+ });
+ });
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 19348be..b5ee72e 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -17,6 +17,7 @@
ChangeInfo,
CommitId,
PatchSetNumber,
+ PreferencesInfo,
RelatedChangeAndCommitInfo,
RevisionPatchSetNum,
SubmittedTogetherInfo,
@@ -29,7 +30,12 @@
import {createChangeUrl} from '../../../models/views/change';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
-import {changeModelToken} from '../../../models/change/change-model';
+import {
+ changeModelToken,
+ urlBaseForCommit,
+} from '../../../models/change/change-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {createDefaultPreferences} from '../../../constants/constants';
import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
export interface ChangeMarkersInList {
@@ -73,6 +79,9 @@
@state()
sameTopicChanges: ChangeInfo[] = [];
+ @state()
+ preferences?: PreferencesInfo;
+
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getRelatedChangesModel = resolve(
@@ -80,6 +89,8 @@
relatedChangesModelToken
);
+ private readonly getUserModel = resolve(this, userModelToken);
+
constructor() {
super();
subscribe(
@@ -117,6 +128,11 @@
() => this.getRelatedChangesModel().sameTopicChanges$,
x => (this.sameTopicChanges = x ?? [])
);
+ subscribe(
+ this,
+ () => this.getUserModel().preferences$,
+ x => (this.preferences = x)
+ );
}
static override get styles() {
@@ -307,6 +323,7 @@
repo: change.project,
usp: 'related-change',
patchNum: change._revision_number as RevisionPatchSetNum,
+ basePatchNum: this.computeRelatedChangeBase(change),
})
: ''}
show-change-status
@@ -676,6 +693,21 @@
return aNum === bNum;
}
+ /**
+ * The base for the link of a change in the relation chain: merge commits are
+ * linked with the base that the `default_base_for_merges` preference picks,
+ * spelled out in the URL, so that the link keeps pointing at the same diff
+ * for whoever it is shared with.
+ */
+ // private but used in tests
+ computeRelatedChangeBase(change: RelatedChangeAndCommitInfo) {
+ const isMergeCommit = (change.commit.parents?.length ?? 0) > 1;
+ return urlBaseForCommit(
+ isMergeCommit,
+ this.preferences ?? createDefaultPreferences()
+ );
+ }
+
/*
* A list of commit ids connected to change to understand if other change
* is direct or indirect ancestor / descendant.
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 8e799cf..e0263be 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -29,6 +29,11 @@
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../types/types';
import {getChangeNumber} from '../../../utils/change-util';
+import {
+ createDefaultPreferences,
+ DefaultBase,
+} from '../../../constants/constants';
+import {userModelToken} from '../../../models/user/user-model';
import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import './gr-related-changes-list';
@@ -37,6 +42,7 @@
GrRelatedChangesList,
Section,
} from './gr-related-changes-list';
+import {GrRelatedChange} from './gr-related-change';
import {GrRelatedCollapse} from './gr-related-collapse';
suite('gr-related-changes-list', () => {
@@ -601,4 +607,67 @@
assert.strictEqual(hookEl!.change, element.change);
});
});
+
+ suite('relation chain base', () => {
+ function relatedChange(numParents: number): RelatedChangeAndCommitInfo {
+ return {
+ ...createRelatedChangeAndCommitInfo(),
+ _change_number: 123 as NumericChangeId,
+ _revision_number: 2,
+ commit: {
+ ...createCommitInfoWithRequiredCommit(),
+ parents: Array.from({length: numParents}, (_, i) => {
+ return {
+ commit: `parent${i}` as CommitId,
+ subject: 'parent',
+ };
+ }),
+ },
+ };
+ }
+
+ async function href(change: RelatedChangeAndCommitInfo) {
+ element.change = createParsedChange();
+ element.latestPatchNum = 1 as PatchSetNumber;
+ element.relatedChanges = [change];
+ await element.updateComplete;
+ return queryAndAssert<GrRelatedChange>(
+ queryAndAssert<HTMLElement>(element, '#relatedChanges'),
+ 'gr-related-change'
+ ).href;
+ }
+
+ test('single parent commit has no base in the URL', async () => {
+ testResolver(userModelToken).setPreferences({
+ ...createDefaultPreferences(),
+ default_base_for_merges: DefaultBase.FIRST_PARENT,
+ });
+ assert.equal(
+ await href(relatedChange(1)),
+ '/c/test-project/+/123/2?usp=related-change'
+ );
+ });
+
+ test('merge commit is linked with the auto-merge base', async () => {
+ testResolver(userModelToken).setPreferences({
+ ...createDefaultPreferences(),
+ default_base_for_merges: DefaultBase.AUTO_MERGE,
+ });
+ assert.equal(
+ await href(relatedChange(2)),
+ '/c/test-project/+/123/0..2?usp=related-change'
+ );
+ });
+
+ test('merge commit is linked with the first parent base', async () => {
+ testResolver(userModelToken).setPreferences({
+ ...createDefaultPreferences(),
+ default_base_for_merges: DefaultBase.FIRST_PARENT,
+ });
+ assert.equal(
+ await href(relatedChange(2)),
+ '/c/test-project/+/123/-1..2?usp=related-change'
+ );
+ });
+ });
});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index a56c329..cdcd85d 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -882,6 +882,7 @@
)}
>
</gr-account-list>
+ <gr-endpoint-slot name="middle"></gr-endpoint-slot>
<gr-endpoint-slot name="right"></gr-endpoint-slot>
</div>
`;
@@ -1517,7 +1518,9 @@
// visible for testing
async send(includeComments: boolean, startReview: boolean) {
- const labels = this.getLabelScores().getLabelValues();
+ const includeDefaults =
+ !this.change || this.change.status !== ChangeStatus.MERGED;
+ const labels = this.getLabelScores().getLabelValues(includeDefaults);
if (labels[StandardLabels.CODE_REVIEW] === 2) {
this.reporting.reportInteraction(Interaction.CODE_REVIEW_APPROVAL);
}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index eed15b9..dd168b3 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -231,6 +231,7 @@
<div class="peopleList">
<div class="peopleListLabel">Reviewers</div>
<gr-account-list id="reviewers"> </gr-account-list>
+ <gr-endpoint-slot name="middle"> </gr-endpoint-slot>
<gr-endpoint-slot name="right"> </gr-endpoint-slot>
</div>
<gr-endpoint-slot name="below"> </gr-endpoint-slot>
@@ -373,6 +374,7 @@
<div class="peopleList">
<div class="peopleListLabel">Reviewers</div>
<gr-account-list id="reviewers"> </gr-account-list>
+ <gr-endpoint-slot name="middle"> </gr-endpoint-slot>
<gr-endpoint-slot name="right"> </gr-endpoint-slot>
</div>
<gr-endpoint-slot name="below"> </gr-endpoint-slot>
@@ -428,6 +430,7 @@
<div class="peopleList">
<div class="peopleListLabel">Reviewers</div>
<gr-account-list id="reviewers"> </gr-account-list>
+ <gr-endpoint-slot name="middle"> </gr-endpoint-slot>
<gr-endpoint-slot name="right"> </gr-endpoint-slot>
</div>
<gr-endpoint-slot name="below"> </gr-endpoint-slot>
@@ -2537,6 +2540,28 @@
assert.isTrue(element.isSendDisabled());
});
+ test('send sets includeDefaults based on change status', async () => {
+ stubSaveReview(() => {});
+ const getLabelValuesStub = sinon
+ .stub(element.getLabelScores(), 'getLabelValues')
+ .returns({});
+
+ element.change = {
+ ...createChange(),
+ status: ChangeStatus.NEW,
+ };
+ await element.send(false, false);
+ assert.isTrue(getLabelValuesStub.calledWith(true));
+
+ getLabelValuesStub.resetHistory();
+ element.change = {
+ ...createChange(),
+ status: ChangeStatus.MERGED,
+ };
+ await element.send(false, false);
+ assert.isTrue(getLabelValuesStub.calledWith(false));
+ });
+
test('sending patchset level comment', async () => {
const patchsetLevelComment = queryAndAssert<GrComment>(
element,
diff --git a/polygerrit-ui/app/elements/chat-panel/message-actions.ts b/polygerrit-ui/app/elements/chat-panel/message-actions.ts
index 78f6b66..86f6980 100644
--- a/polygerrit-ui/app/elements/chat-panel/message-actions.ts
+++ b/polygerrit-ui/app/elements/chat-panel/message-actions.ts
@@ -87,6 +87,8 @@
.text=${this.getGeminiMessageText()}
hideInput
.smallIcon=${false}
+ buttonTitle="Copy response to clipboard"
+ copyTargetName="Response"
></gr-copy-clipboard>
<md-icon-button
diff --git a/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts b/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts
index 123a76d..c70c976 100644
--- a/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts
@@ -82,7 +82,12 @@
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-copy-clipboard class="copy-button" hideinput="">
+ <gr-copy-clipboard
+ buttontitle="Copy response to clipboard"
+ class="copy-button"
+ copytargetname="Response"
+ hideinput=""
+ >
</gr-copy-clipboard>
<md-icon-button
class="regenerate-button"
@@ -102,7 +107,13 @@
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-copy-clipboard class="copy-button" hidden="" hideinput="">
+ <gr-copy-clipboard
+ buttontitle="Copy response to clipboard"
+ class="copy-button"
+ copytargetname="Response"
+ hidden=""
+ hideinput=""
+ >
</gr-copy-clipboard>
<md-icon-button
class="regenerate-button"
diff --git a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
index 8b9676e..35eb92c 100644
--- a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
@@ -91,6 +91,7 @@
'message:',
'onlyexts:',
'onlyextensions:',
+ 'onlypaths:',
'owner:',
'ownerin:',
'parentof:',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 0a6538b..7b71854 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../shared/gr-comment-thread/gr-comment-thread';
+import type {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
import '../../checks/gr-diff-check-result';
import '../../../embed/diff/gr-diff/gr-diff';
import {
@@ -11,8 +12,12 @@
isImageDiff,
isLineUnchanged,
} from '../../../utils/diff-util';
+import {isMagicPath} from '../../../utils/path-list-util';
import {getAppContext} from '../../../services/app-context';
import {
+ computeAllPatchSets,
+ computeLatestPatchNum,
+ findEdit,
getParentIndex,
isAParent,
isMergeParent,
@@ -35,10 +40,12 @@
PARENT,
PatchRange,
PatchSetNum,
+ PatchSetNumber,
PreferencesInfo,
RepoName,
RevisionPatchSetNum,
} from '../../../types/common';
+import {GrDiffGroup} from '../../../embed/diff/gr-diff/gr-diff-group';
import {
DiffInfo,
DiffPreferencesInfo,
@@ -46,7 +53,12 @@
WebLinkInfo,
} from '../../../types/diff';
import {GrDiff} from '../../../embed/diff/gr-diff/gr-diff';
-import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
+import {
+ ChangeStatus,
+ CommentSide,
+ DiffViewMode,
+ Side,
+} from '../../../constants/constants';
import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
import {KnownExperimentId} from '../../../services/flags/flags';
import {
@@ -57,8 +69,9 @@
waitForEventOnce,
} from '../../../utils/event-util';
import {assertIsDefined} from '../../../utils/common-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
import {Subscription} from 'rxjs';
import {
@@ -96,6 +109,12 @@
} from '../../../utils/async-util';
import {subscribe} from '../../lit/subscription-controller';
import {userModelToken} from '../../../models/user/user-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {
+ changeViewModelToken,
+ createApplyFixUrl,
+} from '../../../models/views/change';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {keyed} from 'lit/directives/keyed.js';
import {repeat} from 'lit/directives/repeat.js';
@@ -103,7 +122,10 @@
import {Shortcut} from '../../lit/shortcut-controller';
import {shortcutsServiceToken} from '../../../services/shortcuts/shortcuts-service';
import {toComment} from '../../../models/checks/checks-util';
-import {lineNumberToNumber} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {
+ createRevertFixSuggestion,
+ lineNumberToNumber,
+} from '../../../embed/diff/gr-diff/gr-diff-utils';
const EMPTY_BLAME = 'No blame information for this diff.';
@@ -222,6 +244,9 @@
@property({type: Boolean})
showLoadFailure?: boolean;
+ @property({type: Boolean})
+ disabledThreads = false;
+
@state()
private loggedIn = false;
@@ -311,8 +336,22 @@
private readonly getChecksModel = resolve(this, checksModelToken);
+ private readonly getChangeModel = resolve(this, changeModelToken);
+
+ private readonly getChangeViewModel = resolve(this, changeViewModelToken);
+
+ private readonly getNavigation = resolve(this, navigationToken);
+
private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+ @state()
+ editMode = false;
+
+ @state()
+ latestPatchNum?: PatchSetNumber;
+
+ private isReverting = false;
+
// visible for testing
readonly reporting = getAppContext().reportingService;
@@ -358,6 +397,12 @@
this.reload(false);
}
});
+ this.addEventListener(
+ 'revert-delta',
+ (e: CustomEvent<{group: GrDiffGroup; onComplete?: () => void}>) => {
+ this.handleRevertDelta(e.detail.group, e.detail.onComplete);
+ }
+ );
subscribe(
this,
() => this.getBrowserModel().diffViewMode$,
@@ -384,6 +429,16 @@
);
subscribe(
this,
+ () => this.getChangeModel().editMode$,
+ editMode => (this.editMode = editMode)
+ );
+ subscribe(
+ this,
+ () => this.getChangeModel().latestPatchNum$,
+ latestPatchNum => (this.latestPatchNum = latestPatchNum)
+ );
+ subscribe(
+ this,
() => this.getPluginLoader().pluginsModel.pluginsLoaded$,
async pluginsLoaded => {
if (pluginsLoaded) {
@@ -393,6 +448,33 @@
);
}
+ // visible for testing
+ isRevertAllowed(): boolean {
+ if (!this.loggedIn) return false;
+ if (
+ this.patchRange?.basePatchNum === undefined ||
+ !isAParent(this.patchRange.basePatchNum)
+ ) {
+ return false;
+ }
+ if (isMagicPath(this.path)) return false;
+ if (this.diff?.binary || isImageDiff(this.diff)) return false;
+ if (
+ this.change?.status === ChangeStatus.MERGED ||
+ this.change?.status === ChangeStatus.ABANDONED
+ ) {
+ return false;
+ }
+ const isEditMode = this.editMode || this.patchRange?.patchNum === EDIT;
+ if (!isEditMode) return false;
+ const patchNum = this.patchRange?.patchNum;
+ if (patchNum === EDIT) return true;
+ const latestPatchNum =
+ this.latestPatchNum ??
+ computeLatestPatchNum(computeAllPatchSets(this.change));
+ return patchNum !== undefined && patchNum === latestPatchNum;
+ }
+
override connectedCallback() {
super.connectedCallback();
this.subscribeToChecks();
@@ -422,13 +504,16 @@
if (
changedProperties.has('changeComments') ||
changedProperties.has('patchRange') ||
- changedProperties.has('file')
+ changedProperties.has('file') ||
+ changedProperties.has('disabledThreads')
) {
- this.threads = this.computeFileThreads(
- this.changeComments,
- this.patchRange,
- this.file
- );
+ this.threads = this.disabledThreads
+ ? []
+ : this.computeFileThreads(
+ this.changeComments,
+ this.patchRange,
+ this.file
+ );
}
if (
changedProperties.has('noRenderOnPrefsChange') ||
@@ -498,6 +583,14 @@
}
}
+ async autoSaveDrafts(): Promise<void> {
+ const threadElements = Array.from(
+ this.shadowRoot?.querySelectorAll<GrCommentThread>('gr-comment-thread') ??
+ []
+ );
+ await Promise.all(threadElements.map(thread => thread.autoSave()));
+ }
+
override render() {
const showNewlineWarningLeft =
this.hasTrailingNewlines(this.diff, true) === false;
@@ -506,6 +599,10 @@
const useNewImageDiffUi = this.flags.isEnabled(
KnownExperimentId.NEW_IMAGE_DIFF_UI
);
+ const renderPrefs: RenderPreferences = {
+ ...this.renderPrefs,
+ is_edit_mode: this.isRevertAllowed(),
+ };
return keyed(
this.grDiffKey,
@@ -516,7 +613,7 @@
.path=${this.path}
.prefs=${this.prefs}
.noRenderOnPrefsChange=${this.noRenderOnPrefsChange}
- .renderPrefs=${this.renderPrefs}
+ .renderPrefs=${renderPrefs}
.lineWrapping=${this.lineWrapping}
.viewMode=${this.viewMode}
.lineOfInterest=${this.lineOfInterest}
@@ -1364,6 +1461,100 @@
if (!lines) return null;
return lines[lines.length - 1] === '';
}
+
+ async handleRevertDelta(group: GrDiffGroup, onComplete?: () => void) {
+ if (!this.changeNum || !this.patchRange || !this.path) {
+ onComplete?.();
+ return;
+ }
+ if (!this.isRevertAllowed()) {
+ onComplete?.();
+ return;
+ }
+ if (this.isReverting) {
+ onComplete?.();
+ return;
+ }
+
+ const fixSuggestion = createRevertFixSuggestion(
+ this.path,
+ group,
+ this.diffElement?.groups ?? []
+ );
+ if (!fixSuggestion) {
+ onComplete?.();
+ return;
+ }
+
+ let patchNum: RevisionPatchSetNum | undefined = this.patchRange.patchNum;
+ if (patchNum === undefined) {
+ onComplete?.();
+ return;
+ }
+
+ if (patchNum === EDIT) {
+ const editRev = findEdit(Object.values(this.change?.revisions ?? {}));
+ patchNum =
+ (editRev?.basePatchNum as PatchSetNumber | undefined) ??
+ this.latestPatchNum ??
+ computeLatestPatchNum(computeAllPatchSets(this.change));
+ }
+ if (patchNum === undefined) {
+ onComplete?.();
+ return;
+ }
+
+ this.isReverting = true;
+ fireAlert(this, 'Reverting change...');
+ this.reporting.reportInteraction(Interaction.REVERT_DELTA_CLICKED, {
+ path: this.path,
+ });
+ this.reporting.time(Timing.REVERT_DELTA_LOAD);
+ let res: Response | undefined;
+ let errorText = '';
+ try {
+ res = await this.restApiService.applyFixSuggestion(
+ this.changeNum,
+ patchNum,
+ fixSuggestion.replacements,
+ undefined,
+ throwingErrorCallback
+ );
+ } catch (error) {
+ if (error instanceof Error) {
+ errorText = error.message;
+ }
+ fireAlert(this, `Reverting change failed: ${errorText}`);
+ } finally {
+ this.isReverting = false;
+ onComplete?.();
+ this.reporting.timeEnd(Timing.REVERT_DELTA_LOAD, {
+ success: res?.ok ?? false,
+ status: res?.status,
+ });
+ }
+
+ if (res?.ok) {
+ fireAlert(this, 'Change reverted.');
+ const currentChildView = this.getChangeViewModel().getState()?.childView;
+ const hasEdit =
+ !!findEdit(Object.values(this.change?.revisions ?? {})) ||
+ this.patchRange?.patchNum === EDIT;
+ this.getNavigation().setUrl(
+ createApplyFixUrl({
+ change: this.change,
+ changeNum: this.changeNum,
+ repo: this.change?.project ?? this.projectName ?? ('' as RepoName),
+ basePatchNum: PARENT,
+ patchNum: EDIT,
+ forceReload: !hasEdit,
+ filePath: this.path,
+ currentChildView,
+ })
+ );
+ await this.reload(true);
+ }
+ }
}
declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_screenshot_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_screenshot_test.ts
new file mode 100644
index 0000000..81484e8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_screenshot_test.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-host';
+import {GrDiffHost} from './gr-diff-host';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-expect-error
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {stubRestApi, visualDiffDarkTheme} from '../../../test/test-utils';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {
+ createChange,
+ createPatchRange,
+} from '../../../test/test-data-generators';
+import {EDIT, NumericChangeId} from '../../../types/common';
+import {DiffInfo, DiffViewMode} from '../../../api/diff';
+
+suite('gr-diff-host screenshot tests', () => {
+ let element: GrDiffHost;
+
+ setup(async () => {
+ const diff: DiffInfo = {
+ meta_a: {
+ name: 'sample.ts',
+ content_type: 'application/typescript',
+ lines: 10,
+ },
+ meta_b: {
+ name: 'sample.ts',
+ content_type: 'application/typescript',
+ lines: 10,
+ },
+ change_type: 'MODIFIED',
+ intraline_status: 'OK',
+ content: [
+ {
+ ab: [
+ '// Copyright 2026 Google LLC',
+ 'import {LitElement, html} from "lit";',
+ '',
+ ],
+ },
+ {
+ a: [
+ 'export function calculateSum(a: number, b: number): number {',
+ ' return a + b;',
+ '}',
+ ],
+ b: [
+ 'export function calculateSum(x: number, y: number): number {',
+ ' // Updated implementation',
+ ' return x + y;',
+ '}',
+ ],
+ },
+ {
+ ab: [
+ '',
+ 'export function helper(): void {',
+ ' console.log("ready");',
+ ],
+ },
+ {
+ a: [' console.log("old debug line");'],
+ },
+ {
+ ab: ['}'],
+ },
+ {
+ b: ['', '// Added at end of file', 'export const VERSION = 2;'],
+ },
+ ],
+ };
+
+ stubRestApi('getDiff').resolves(diff);
+ element = await fixture<GrDiffHost>(html`<gr-diff-host
+ .changeNum=${42 as NumericChangeId}
+ .path=${'sample.ts'}
+ .change=${createChange()}
+ .patchRange=${{
+ ...createPatchRange(),
+ patchNum: EDIT,
+ }}
+ .prefs=${createDefaultDiffPrefs()}
+ ></gr-diff-host>`);
+ await element.reload(true);
+ await element.updateComplete;
+ });
+
+ test('edit mode diff with revert buttons', async () => {
+ await visualDiff(element, 'gr-diff-host-edit-mode-revert');
+ await visualDiffDarkTheme(element, 'gr-diff-host-edit-mode-revert');
+ });
+
+ test('unified edit mode diff with revert buttons', async () => {
+ element.viewMode = DiffViewMode.UNIFIED;
+ await element.updateComplete;
+
+ await visualDiff(element, 'gr-diff-host-edit-mode-revert-unified');
+ await visualDiffDarkTheme(element, 'gr-diff-host-edit-mode-revert-unified');
+ });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 958d2fb..3189f03 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -7,6 +7,7 @@
import '../../../test/common-test-setup';
import './gr-diff-host';
import {
+ ChangeStatus,
CommentSide,
createDefaultDiffPrefs,
Side,
@@ -18,7 +19,9 @@
createComment,
createCommentThread,
createDiff,
+ createEditRevision,
createPatchRange,
+ createRevision,
createRunResult,
} from '../../../test/test-data-generators';
import {
@@ -41,17 +44,31 @@
NumericChangeId,
PARENT,
PatchSetNum,
+ PatchSetNumber,
+ RevisionInfo,
RevisionPatchSetNum,
} from '../../../types/common';
import {CoverageType} from '../../../types/types';
import {GrDiffHost} from './gr-diff-host';
-import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
+import {
+ DiffInfo,
+ DiffViewMode,
+ GrDiffLineType,
+ IgnoreWhitespaceType,
+} from '../../../api/diff';
+import {
+ GrDiffGroup,
+ GrDiffGroupType,
+} from '../../../embed/diff/gr-diff/gr-diff-group';
+import {GrDiffLine} from '../../../embed/diff/gr-diff/gr-diff-line';
+import {Interaction, Timing} from '../../../constants/reporting';
import {ErrorCallback} from '../../../api/rest';
import {SinonStub, SinonStubbedMember} from 'sinon';
import {RunResult} from '../../../models/checks/checks-model';
import {assertIsDefined} from '../../../utils/common-util';
import {assert, fixture, html} from '@open-wc/testing';
import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {UserModel, userModelToken} from '../../../models/user/user-model';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -60,6 +77,7 @@
CommentsModel,
commentsModelToken,
} from '../../../models/comments/comments-model';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
suite('gr-diff-host tests', () => {
let element: GrDiffHost;
@@ -850,6 +868,43 @@
`
);
});
+
+ test('threads are cleared when disabledThreads is true and restored when false', async () => {
+ const thread: CommentThread = {
+ ...createCommentThread([createComment()]),
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ sinon.stub(element as any, 'computeFileThreads').returns([thread]);
+
+ element.disabledThreads = false;
+ element.threads = [thread];
+ await element.updateComplete;
+ assert.equal(element.threads.length, 1);
+
+ element.disabledThreads = true;
+ await element.updateComplete;
+ assert.equal(element.threads.length, 0);
+
+ element.disabledThreads = false;
+ await element.updateComplete;
+ assert.equal(element.threads.length, 1);
+ });
+
+ test('autoSaveDrafts calls autoSave on all comment threads', async () => {
+ const thread: CommentThread = {
+ ...createCommentThread([createComment()]),
+ };
+ element.threads = [thread];
+ await element.updateComplete;
+
+ const threadEl = element.shadowRoot!.querySelector('gr-comment-thread');
+ assert.isNotNull(threadEl);
+ const autoSaveStub = sinon.stub(threadEl, 'autoSave').resolves();
+
+ await element.autoSaveDrafts();
+
+ assert.isTrue(autoSaveStub.calledOnce);
+ });
});
suite('render check elements', () => {
@@ -1545,4 +1600,389 @@
assert.isTrue(computeSpy.called);
});
});
+
+ suite('revert change in edit mode', () => {
+ setup(() => {
+ userModel.setAccount(account);
+ });
+
+ test('is_edit_mode is passed in renderPrefs only in edit mode', async () => {
+ element.patchRange = createPatchRange();
+ await element.updateComplete;
+ const grDiff = element.shadowRoot?.querySelector('gr-diff');
+ assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ await element.updateComplete;
+ assert.isTrue(grDiff?.renderPrefs?.is_edit_mode);
+ });
+
+ test('handleRevertDelta applies fix suggestion and reloads diff', async () => {
+ const applyFixStub = stubRestApi('applyFixSuggestion').returns(
+ Promise.resolve(new Response('', {status: 200}))
+ );
+ const reloadStub = sinon.stub(element, 'reload').resolves();
+
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ element.latestPatchNum = 1 as PatchSetNumber;
+ element.path = 'foo.ts';
+ element.changeNum = 42 as NumericChangeId;
+
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+ removeLine.text = 'old code';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+ addLine.text = 'new code';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+
+ const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+ const reportStub = sinon.stub(element.reporting, 'reportInteraction');
+ const timeEndStub = sinon.stub(element.reporting, 'timeEnd');
+
+ await element.handleRevertDelta(group);
+
+ assert.isTrue(setUrlStub.calledOnce);
+ assert.include(setUrlStub.firstCall.args[0], '/+/42/edit');
+ assert.notInclude(setUrlStub.firstCall.args[0], '..edit');
+ assert.notInclude(setUrlStub.firstCall.args[0], 'forceReload=true');
+ assert.isTrue(
+ reportStub.calledWith(Interaction.REVERT_DELTA_CLICKED, {
+ path: 'foo.ts',
+ })
+ );
+ assert.isTrue(
+ timeEndStub.calledWith(
+ Timing.REVERT_DELTA_LOAD,
+ sinon.match({success: true})
+ )
+ );
+ assert.isTrue(applyFixStub.calledOnce);
+ assert.equal(applyFixStub.firstCall.args[0], 42 as NumericChangeId);
+ assert.equal(applyFixStub.firstCall.args[1], 1 as RevisionPatchSetNum);
+ assert.isUndefined(applyFixStub.firstCall.args[3]);
+ assert.equal(applyFixStub.firstCall.args[4], throwingErrorCallback);
+ assert.deepEqual(applyFixStub.firstCall.args[2], [
+ {
+ path: 'foo.ts',
+ range: {
+ start_line: 10,
+ start_character: 0,
+ end_line: 10,
+ end_character: 8,
+ },
+ replacement: 'old code',
+ },
+ ]);
+ assert.isTrue(reloadStub.lastCall.calledWith(true));
+ });
+
+ test('is_edit_mode is passed in renderPrefs when editMode is true', async () => {
+ element.patchRange = createPatchRange();
+ element.latestPatchNum = 1 as PatchSetNumber;
+ element.editMode = false;
+ await element.updateComplete;
+ let grDiff = element.shadowRoot?.querySelector('gr-diff');
+ assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+
+ element.editMode = true;
+ await element.updateComplete;
+ grDiff = element.shadowRoot?.querySelector('gr-diff');
+ assert.isTrue(grDiff?.renderPrefs?.is_edit_mode);
+ });
+
+ test('handleRevertDelta applies fix suggestion when in editMode with numeric patchset', async () => {
+ const applyFixStub = stubRestApi('applyFixSuggestion').returns(
+ Promise.resolve(new Response('', {status: 200}))
+ );
+ sinon.stub(element, 'reload').resolves();
+ const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+ element.patchRange = createPatchRange(); // numeric patchNum: 1
+ element.latestPatchNum = 1 as PatchSetNumber;
+ element.editMode = true;
+ element.path = 'foo.ts';
+ element.changeNum = 42 as NumericChangeId;
+
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+ removeLine.text = 'old code';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+ addLine.text = 'new code';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+
+ await element.handleRevertDelta(group);
+
+ assert.isTrue(setUrlStub.calledOnce);
+ assert.include(setUrlStub.firstCall.args[0], '/+/42/edit');
+ assert.notInclude(setUrlStub.firstCall.args[0], '..edit');
+ assert.include(setUrlStub.firstCall.args[0], 'forceReload=true');
+ assert.isTrue(applyFixStub.calledOnce);
+ assert.equal(applyFixStub.firstCall.args[0], 42 as NumericChangeId);
+ assert.equal(applyFixStub.firstCall.args[1], 1 as RevisionPatchSetNum);
+ });
+
+ test('handleRevertDelta applies fix suggestion when patchNum is EDIT', async () => {
+ const applyFixStub = stubRestApi('applyFixSuggestion').returns(
+ Promise.resolve(new Response('', {status: 200}))
+ );
+ sinon.stub(element, 'reload').resolves();
+ const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ element.latestPatchNum = 3 as PatchSetNumber;
+ element.editMode = true;
+ element.path = 'foo.ts';
+ element.changeNum = 42 as NumericChangeId;
+
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+ removeLine.text = 'old code';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+ addLine.text = 'new code';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+
+ await element.handleRevertDelta(group);
+
+ assert.isTrue(setUrlStub.calledOnce);
+ assert.include(setUrlStub.firstCall.args[0], '/+/42/edit');
+ assert.notInclude(setUrlStub.firstCall.args[0], 'forceReload=true');
+ assert.isTrue(applyFixStub.calledOnce);
+ assert.equal(applyFixStub.firstCall.args[0], 42 as NumericChangeId);
+ assert.equal(applyFixStub.firstCall.args[1], 3 as RevisionPatchSetNum);
+ assert.isUndefined(applyFixStub.firstCall.args[3]);
+ });
+
+ test('handleRevertDelta resolves base patchset from edit revision', async () => {
+ const applyFixStub = stubRestApi('applyFixSuggestion').returns(
+ Promise.resolve(new Response('', {status: 200}))
+ );
+ sinon.stub(element, 'reload').resolves();
+ const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ element.change = {
+ ...createChange(),
+ revisions: {
+ r1: createRevision(1),
+ r2: createRevision(2),
+ rEdit: createEditRevision(2) as unknown as RevisionInfo,
+ },
+ };
+ element.latestPatchNum = 3 as PatchSetNumber;
+ element.editMode = true;
+ element.path = 'foo.ts';
+ element.changeNum = 42 as NumericChangeId;
+
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+ removeLine.text = 'old code';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+ addLine.text = 'new code';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+
+ await element.handleRevertDelta(group);
+
+ assert.isTrue(setUrlStub.calledOnce);
+ assert.notInclude(setUrlStub.firstCall.args[0], 'forceReload=true');
+ assert.isTrue(applyFixStub.calledOnce);
+ assert.equal(applyFixStub.firstCall.args[0], 42 as NumericChangeId);
+ assert.equal(applyFixStub.firstCall.args[1], 2 as RevisionPatchSetNum);
+ });
+
+ test('handleRevertDelta calls onComplete callback on success', async () => {
+ stubRestApi('applyFixSuggestion').returns(
+ Promise.resolve(new Response('', {status: 200}))
+ );
+ sinon.stub(element, 'reload').resolves();
+ sinon.stub(testResolver(navigationToken), 'setUrl');
+
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ element.latestPatchNum = 1 as PatchSetNumber;
+ element.editMode = true;
+ element.path = 'foo.ts';
+ element.changeNum = 42 as NumericChangeId;
+
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+ removeLine.text = 'old code';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+ addLine.text = 'new code';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+
+ const onCompleteSpy = sinon.spy();
+ await element.handleRevertDelta(group, onCompleteSpy);
+
+ assert.isTrue(onCompleteSpy.calledOnce);
+ });
+
+ test('handleRevertDelta calls onComplete callback on failure', async () => {
+ stubRestApi('applyFixSuggestion').returns(
+ Promise.reject(new Error('Network error'))
+ );
+ sinon.stub(element, 'reload').resolves();
+
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ element.latestPatchNum = 1 as PatchSetNumber;
+ element.editMode = true;
+ element.path = 'foo.ts';
+ element.changeNum = 42 as NumericChangeId;
+
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+ removeLine.text = 'old code';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+ addLine.text = 'new code';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+
+ const onCompleteSpy = sinon.spy();
+ await element.handleRevertDelta(group, onCompleteSpy);
+
+ assert.isTrue(onCompleteSpy.calledOnce);
+ });
+
+ test('handleRevertDelta does not apply fix if not in edit mode', async () => {
+ const applyFixStub = stubRestApi('applyFixSuggestion');
+ element.patchRange = createPatchRange();
+ element.editMode = false;
+ element.path = 'foo.ts';
+ element.changeNum = 42 as NumericChangeId;
+
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+ removeLine.text = 'old code';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+ addLine.text = 'new code';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+
+ await element.handleRevertDelta(group);
+
+ assert.isFalse(applyFixStub.called);
+ });
+
+ test('is_edit_mode is false when in editMode but viewing older patchset', async () => {
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: 1 as RevisionPatchSetNum,
+ };
+ element.latestPatchNum = 2 as PatchSetNumber;
+ element.editMode = true;
+ await element.updateComplete;
+
+ const grDiff = element.shadowRoot?.querySelector('gr-diff');
+ assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+ assert.isFalse(element.isRevertAllowed());
+ });
+
+ test('is_edit_mode is false for commit message and merge list', async () => {
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ element.editMode = true;
+
+ element.path = '/COMMIT_MSG';
+ await element.updateComplete;
+ let grDiff = element.shadowRoot?.querySelector('gr-diff');
+ assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+ assert.isFalse(element.isRevertAllowed());
+
+ element.path = '/MERGE_LIST';
+ await element.updateComplete;
+ grDiff = element.shadowRoot?.querySelector('gr-diff');
+ assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+ assert.isFalse(element.isRevertAllowed());
+ });
+
+ test('is_edit_mode is false for binary and image diffs', async () => {
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ element.editMode = true;
+ element.path = 'image.png';
+ element.diff = {
+ ...createDiff(),
+ binary: true,
+ };
+ await element.updateComplete;
+ assert.isFalse(element.isRevertAllowed());
+ });
+
+ test('is_edit_mode is false for merged or abandoned changes', async () => {
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ element.editMode = true;
+ element.path = 'foo.ts';
+ element.change = {
+ ...createChange(),
+ status: ChangeStatus.MERGED,
+ };
+ await element.updateComplete;
+ assert.isFalse(element.isRevertAllowed());
+
+ element.change = {
+ ...createChange(),
+ status: ChangeStatus.ABANDONED,
+ };
+ await element.updateComplete;
+ assert.isFalse(element.isRevertAllowed());
+ });
+
+ test('isRevertAllowed is false when logged out', async () => {
+ userModel.setAccount(undefined);
+ element.patchRange = {
+ ...createPatchRange(),
+ patchNum: EDIT,
+ };
+ element.editMode = true;
+ element.path = 'foo.ts';
+ await element.updateComplete;
+ assert.isFalse(element.isRevertAllowed());
+ });
+
+ test('isRevertAllowed is false when basePatchNum is not a parent', async () => {
+ element.patchRange = {
+ basePatchNum: 1 as BasePatchSetNum,
+ patchNum: EDIT,
+ };
+ element.editMode = true;
+ element.path = 'foo.ts';
+ await element.updateComplete;
+ assert.isFalse(element.isRevertAllowed());
+ });
+ });
});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer.ts
new file mode 100644
index 0000000..be9d15b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer.ts
@@ -0,0 +1,1142 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-comment-thread/gr-comment-thread';
+import type {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {DiffInfo} from '../../../types/diff';
+import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
+import {
+ CommentThread,
+ DraftInfo,
+ EDIT,
+ PARENT,
+ PatchRange,
+ PatchSetNum,
+ RevisionPatchSetNum,
+} from '../../../types/common';
+import {sanitizeHtmlToFragment} from '../../../utils/inner-html-util';
+import {resolve} from '../../../models/dependency';
+import {browserModelToken} from '../../../models/browser/browser-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {subscribe} from '../../lit/subscription-controller';
+import {createNew} from '../../../utils/comment-util';
+import {
+ getParentIndex,
+ isAParent,
+ isMergeParent,
+} from '../../../utils/patch-set-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire, fireAlert} from '../../../utils/event-util';
+import {
+ AlignedDiffRow,
+ AlignedDiffRowWithThreads,
+ alignMarkdownTokens,
+ attachThreadsToRows,
+ getThreadDiffSide,
+ parseMarkdownBlocks,
+ reconstructFileContent,
+} from './markdown-diff-util';
+
+@customElement('gr-diff-markdown-viewer')
+export class GrDiffMarkdownViewer extends LitElement {
+ @property({type: Object}) diff?: DiffInfo;
+
+ @property({type: String}) path?: string;
+
+ @property({type: Object}) patchRange?: PatchRange;
+
+ @property({type: Array}) threads?: CommentThread[];
+
+ @property({type: String}) viewMode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+
+ @state() alignedRows: AlignedDiffRow[] = [];
+
+ @state() private internalThreads: CommentThread[] = [];
+
+ @state() private changeComments?: ChangeComments;
+
+ @property({type: Boolean}) loggedIn = false;
+
+ @query('.selection-action-box')
+ private selectionActionBox?: HTMLElement;
+
+ private selectionActionBoxVisible = false;
+
+ private selectionBoxPositionBelow = false;
+
+ private selectionBoxTop = 0;
+
+ private selectionBoxLeft = 0;
+
+ private selectedSide?: Side;
+
+ private selectedLine?: number;
+
+ private hoveredSide?: Side;
+
+ private hoveredLine?: number;
+
+ private readonly getBrowserModel = resolve(this, browserModelToken);
+
+ private readonly getCommentsModel = resolve(this, commentsModelToken);
+
+ private readonly getUserModel = resolve(this, userModelToken);
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getBrowserModel().diffViewMode$,
+ mode => {
+ if (mode) this.viewMode = mode;
+ }
+ );
+ subscribe(
+ this,
+ () => this.getCommentsModel().changeComments$,
+ changeComments => {
+ this.changeComments = changeComments;
+ this.updateInternalThreads();
+ }
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().loggedIn$,
+ loggedIn => {
+ this.loggedIn = loggedIn;
+ }
+ );
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ document.addEventListener('selectionchange', this.handleSelectionChange);
+ window.addEventListener('keydown', this.handleKeyDown);
+ }
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ document.removeEventListener('selectionchange', this.handleSelectionChange);
+ window.removeEventListener('keydown', this.handleKeyDown);
+ }
+
+ get effectiveThreads(): CommentThread[] {
+ return this.threads ?? this.internalThreads;
+ }
+
+ get filePath(): string | undefined {
+ return this.path ?? this.diff?.meta_b?.name ?? this.diff?.meta_a?.name;
+ }
+
+ private updateInternalThreads() {
+ if (!this.changeComments || !this.patchRange || !this.filePath) return;
+ this.internalThreads = this.changeComments.getThreadsBySideForFile(
+ {path: this.filePath},
+ this.patchRange
+ );
+ }
+
+ static override get styles() {
+ return [
+ css`
+ :host {
+ display: block;
+ position: relative;
+ background-color: var(--view-background-color, #ffffff);
+ color: var(--primary-text-color, #202124);
+ font-family: var(--font-family, Roboto, sans-serif);
+ font-size: var(--font-size-normal, 14px);
+ line-height: var(--line-height-normal, 1.5);
+ }
+ .file-level-threads {
+ padding: var(--spacing-m, 12px) var(--spacing-l, 16px);
+ background-color: var(--background-color-secondary, #f8f9fa);
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-s, 8px);
+ }
+ .file-level-title {
+ font-size: var(--font-size-small, 12px);
+ font-weight: var(--font-weight-bold, 600);
+ color: var(--deemphasized-text-color, #5f6368);
+ text-transform: uppercase;
+ }
+ .column-headers {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ column-gap: var(--spacing-l, 16px);
+ padding: var(--spacing-xs, 4px) var(--spacing-m, 12px);
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+ background-color: var(--background-color-secondary, #f8f9fa);
+ color: var(--deemphasized-text-color, #5f6368);
+ font-size: var(--font-size-small, 12px);
+ font-weight: var(--font-weight-bold, 600);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+ .diff-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ column-gap: var(--spacing-l, 16px);
+ row-gap: var(--spacing-m, 12px);
+ padding: var(--spacing-m, 12px);
+ }
+ .diff-cell {
+ position: relative;
+ min-width: 0;
+ overflow-wrap: break-word;
+ padding: var(--spacing-xxs, 2px) var(--spacing-s, 8px);
+ padding-right: 90px;
+ border-left: 3px solid transparent;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ }
+ .diff-cell.empty {
+ background-color: var(--diff-blank-background-color, transparent);
+ min-height: 24px;
+ }
+ .diff-cell.added,
+ .diff-cell.modified-right {
+ border-left-color: var(--positive-green-text-color, #2da44e);
+ background-color: var(--light-add-highlight-color, #d8fed8);
+ border-radius: 0 4px 4px 0;
+ }
+ .diff-cell.deleted,
+ .diff-cell.modified-left {
+ border-left-color: var(--negative-red-text-color, #cf222e);
+ background-color: var(--light-remove-highlight-color, #ffebee);
+ border-radius: 0 4px 4px 0;
+ }
+ .unified-container {
+ box-sizing: border-box;
+ max-width: 100%;
+ padding: var(--spacing-m, 12px) var(--spacing-l, 16px);
+ }
+ .unified-block {
+ position: relative;
+ border-left: 3px solid transparent;
+ box-sizing: border-box;
+ margin-bottom: var(--spacing-s, 8px);
+ overflow-wrap: break-word;
+ padding: var(--spacing-xxs, 2px) var(--spacing-s, 8px);
+ padding-right: 90px;
+ display: flex;
+ flex-direction: column;
+ }
+ .unified-block.added {
+ border-left-color: var(--positive-green-text-color, #2da44e);
+ background-color: var(--light-add-highlight-color, #d8fed8);
+ border-radius: 0 4px 4px 0;
+ }
+ .unified-block.deleted {
+ border-left-color: var(--negative-red-text-color, #cf222e);
+ background-color: var(--light-remove-highlight-color, #ffebee);
+ border-radius: 0 4px 4px 0;
+ }
+ .unified-block.unchanged {
+ border-left-color: transparent;
+ }
+ .cell-action-bar {
+ position: absolute;
+ top: 4px;
+ right: 8px;
+ z-index: 10;
+ pointer-events: none;
+ }
+ .add-comment-btn {
+ pointer-events: auto;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.15s ease-in-out, background-color 0.15s;
+ background-color: var(--background-color-primary, #ffffff);
+ color: var(--primary-text-color, #202124);
+ border: 1px solid var(--border-color, #dadce0);
+ border-radius: 16px;
+ padding: 2px 8px;
+ font-size: var(--font-size-small, 12px);
+ font-weight: var(--font-weight-medium, 500);
+ box-shadow: var(--elevation-level-1, 0 1px 3px rgba(60, 64, 67, 0.3));
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ user-select: none;
+ line-height: 18px;
+ }
+ .add-comment-btn gr-icon {
+ --gr-icon-size: 16px;
+ color: var(--primary-text-color, #202124);
+ }
+ .diff-cell:hover .add-comment-btn,
+ .unified-block:hover .add-comment-btn {
+ opacity: 1;
+ visibility: visible;
+ }
+ .add-comment-btn:hover {
+ background: linear-gradient(
+ var(--hover-background-color, rgba(161, 194, 250, 0.2)),
+ var(--hover-background-color, rgba(161, 194, 250, 0.2))
+ ),
+ var(--background-color-primary, #ffffff);
+ box-shadow: var(--elevation-level-2, 0 2px 6px rgba(60, 64, 67, 0.3));
+ }
+ .add-comment-btn:active {
+ background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
+ var(--background-color-primary, #ffffff);
+ }
+ .selection-action-box {
+ position: absolute;
+ z-index: 500;
+ transform: translate(-50%, -100%);
+ margin-top: -6px;
+ }
+ .selection-action-box.below {
+ transform: translate(-50%, 0);
+ margin-top: 6px;
+ }
+ .selection-comment-btn {
+ background-color: var(--background-color-primary, #ffffff);
+ color: var(--primary-text-color, #202124);
+ border: 1px solid var(--border-color, #dadce0);
+ border-radius: 16px;
+ padding: 4px 12px;
+ font-size: var(--font-size-small, 12px);
+ font-weight: var(--font-weight-medium, 500);
+ box-shadow: var(--elevation-level-2, 0 2px 6px rgba(60, 64, 67, 0.3));
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ white-space: nowrap;
+ user-select: none;
+ }
+ .selection-comment-btn:hover {
+ background: linear-gradient(
+ var(--hover-background-color, rgba(161, 194, 250, 0.2)),
+ var(--hover-background-color, rgba(161, 194, 250, 0.2))
+ ),
+ var(--background-color-primary, #ffffff);
+ box-shadow: var(--elevation-level-3, 0 4px 8px rgba(60, 64, 67, 0.3));
+ }
+ .selection-comment-btn:active {
+ background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
+ var(--background-color-primary, #ffffff);
+ }
+ .selection-comment-btn gr-icon {
+ --gr-icon-size: 16px;
+ color: var(--primary-text-color, #202124);
+ }
+ .comment-thread {
+ display: block;
+ max-width: 100%;
+ }
+ .cell-content {
+ min-width: 0;
+ }
+ .threads-container {
+ margin-top: var(--spacing-s, 8px);
+ padding-top: var(--spacing-xs, 4px);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-s, 8px);
+ }
+ .diff-highlight-add,
+ ins {
+ background-color: var(--dark-add-highlight-color, #aaf2aa);
+ text-decoration: none;
+ border-radius: 2px;
+ padding: 1px 2px;
+ }
+ .diff-highlight-del,
+ del {
+ background-color: var(--dark-remove-highlight-color, #ffcdd2);
+ text-decoration: line-through;
+ border-radius: 2px;
+ padding: 1px 2px;
+ }
+ .diff-cell.deleted code,
+ .diff-cell.modified-left code,
+ .unified-block.deleted code,
+ del code {
+ background-color: rgba(0, 0, 0, 0.05);
+ }
+ .diff-cell.added code,
+ .diff-cell.modified-right code,
+ .unified-block.added code,
+ ins code {
+ background-color: rgba(0, 0, 0, 0.05);
+ }
+ .diff-cell.deleted pre,
+ .diff-cell.modified-left pre,
+ .unified-block.deleted pre {
+ background-color: rgba(0, 0, 0, 0.03);
+ border-color: rgba(207, 34, 46, 0.2);
+ }
+ .diff-cell.added pre,
+ .diff-cell.modified-right pre,
+ .unified-block.added pre {
+ background-color: rgba(0, 0, 0, 0.03);
+ border-color: rgba(46, 160, 67, 0.2);
+ }
+ pre .diff-highlight-del {
+ display: inline-block;
+ width: 100%;
+ box-sizing: border-box;
+ }
+ pre .diff-highlight-add {
+ display: inline-block;
+ width: 100%;
+ box-sizing: border-box;
+ }
+ .diff-cell.deleted th,
+ .diff-cell.modified-left th,
+ .unified-block.deleted th {
+ background-color: rgba(0, 0, 0, 0.04);
+ }
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ margin-top: 0;
+ margin-bottom: var(--spacing-xs, 4px);
+ color: var(--primary-text-color, #202124);
+ }
+ h1 {
+ font-size: 1.6em;
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+ padding-bottom: 4px;
+ }
+ h2 {
+ font-size: 1.3em;
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+ padding-bottom: 4px;
+ }
+ h3 {
+ font-size: 1.15em;
+ }
+ p {
+ margin-top: 0;
+ margin-bottom: var(--spacing-xs, 4px);
+ }
+ code {
+ font-family: var(--monospace-font-family, 'Roboto Mono', monospace);
+ font-size: var(--font-size-code, 12px);
+ background-color: var(--background-color-secondary, #f1f3f4);
+ padding: 2px 4px;
+ border-radius: 3px;
+ }
+ pre {
+ background-color: var(--background-color-secondary, #f8f9fa);
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 4px;
+ padding: var(--spacing-s, 8px);
+ overflow-x: auto;
+ margin: 0;
+ }
+ pre code {
+ background-color: transparent;
+ padding: 0;
+ font-size: var(--font-size-code, 12px);
+ display: block;
+ white-space: pre;
+ }
+ blockquote {
+ border-left: 4px solid var(--border-color, #d0d7de);
+ margin: 0 0 var(--spacing-s, 8px) 0;
+ padding: 0 var(--spacing-m, 12px);
+ color: var(--deemphasized-text-color, #5f6368);
+ }
+ ul,
+ ol {
+ margin-top: 0;
+ margin-bottom: var(--spacing-xs, 4px);
+ padding-left: var(--spacing-xl, 24px);
+ }
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ margin-bottom: var(--spacing-s, 8px);
+ }
+ th,
+ td {
+ border: 1px solid var(--border-color, #e0e0e0);
+ padding: 6px 12px;
+ text-align: left;
+ }
+ th {
+ background-color: var(--background-color-secondary, #f8f9fa);
+ font-weight: var(--font-weight-bold, 600);
+ }
+ `,
+ ];
+ }
+
+ override willUpdate(changedProperties: PropertyValues) {
+ if (changedProperties.has('diff')) {
+ this.recomputeAlignedRows();
+ }
+ if (
+ changedProperties.has('changeComments') ||
+ changedProperties.has('patchRange') ||
+ changedProperties.has('path')
+ ) {
+ this.updateInternalThreads();
+ }
+ }
+
+ private recomputeAlignedRows() {
+ if (!this.diff) {
+ this.alignedRows = [];
+ return;
+ }
+ const textA = reconstructFileContent(this.diff, Side.LEFT);
+ const textB = reconstructFileContent(this.diff, Side.RIGHT);
+ const tokensA = parseMarkdownBlocks(textA);
+ const tokensB = parseMarkdownBlocks(textB);
+ this.alignedRows = alignMarkdownTokens(tokensA, tokensB);
+ }
+
+ private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
+ if (!this.loggedIn) {
+ fire(this, 'show-auth-required', {});
+ return false;
+ }
+ if (!this.patchRange) {
+ fireAlert(this, 'Cannot create comment. patchRange undefined.');
+ return false;
+ }
+
+ const isEdit = patchNum === EDIT;
+ const isEditBase = patchNum === PARENT && this.patchRange.patchNum === EDIT;
+
+ if (isEdit) {
+ fireAlert(this, 'You cannot comment on an edit.');
+ return false;
+ }
+ if (isEditBase) {
+ fireAlert(this, 'You cannot comment on the base patchset of an edit.');
+ return false;
+ }
+ return true;
+ }
+
+ private computeParentIndex() {
+ if (!this.patchRange) return null;
+ return isMergeParent(this.patchRange.basePatchNum)
+ ? getParentIndex(this.patchRange.basePatchNum)
+ : null;
+ }
+
+ createComment(side: Side, lineNum?: number) {
+ if (!this.patchRange) {
+ fireAlert(this, 'Cannot create comment. patchRange undefined.');
+ return;
+ }
+
+ const patchNum =
+ side === Side.LEFT && !isAParent(this.patchRange.basePatchNum)
+ ? this.patchRange.basePatchNum
+ : this.patchRange.patchNum;
+ const commentSide =
+ side === Side.LEFT && isAParent(this.patchRange.basePatchNum)
+ ? CommentSide.PARENT
+ : CommentSide.REVISION;
+
+ if (!this.canCommentOnPatchSetNum(patchNum)) return;
+ const path = this.filePath;
+ assertIsDefined(path, 'path');
+
+ const basePath = this.diff?.meta_a?.name;
+ const effectivePath =
+ basePath && side === Side.LEFT && commentSide === CommentSide.REVISION
+ ? basePath
+ : path;
+
+ let effectiveLine = lineNum;
+ if (effectiveLine === undefined) {
+ const fallbackRow = this.alignedRows.find(r =>
+ side === Side.LEFT
+ ? r.leftStartLine !== undefined
+ : r.rightStartLine !== undefined
+ );
+ effectiveLine =
+ side === Side.LEFT
+ ? fallbackRow?.leftStartLine
+ : fallbackRow?.rightStartLine ?? 1;
+ }
+
+ const parentIndex = this.computeParentIndex();
+ const draft: DraftInfo = {
+ ...createNew('', true),
+ patch_set: patchNum as RevisionPatchSetNum,
+ side: commentSide,
+ parent: parentIndex ?? undefined,
+ path: effectivePath,
+ line: typeof effectiveLine === 'number' ? effectiveLine : undefined,
+ };
+ this.getCommentsModel().addNewDraft(draft);
+ }
+
+ private handleCellMouseEnter(side: Side, lineNum?: number) {
+ this.hoveredSide = side;
+ this.hoveredLine = lineNum;
+ }
+
+ private handleCellMouseLeave() {
+ this.hoveredSide = undefined;
+ this.hoveredLine = undefined;
+ }
+
+ private clearSelection() {
+ (this.renderRoot as ShadowRoot)?.getSelection?.()?.removeAllRanges();
+ window.getSelection()?.removeAllRanges();
+ this.selectedSide = undefined;
+ this.selectedLine = undefined;
+ }
+
+ hasActiveSelection(): boolean {
+ const selection = this.getActiveSelection();
+ if (!selection || selection.isCollapsed || !selection.rangeCount) {
+ return false;
+ }
+ const range = selection.getRangeAt(0);
+ const container = range.commonAncestorContainer;
+ const elementNode =
+ container.nodeType === Node.TEXT_NODE
+ ? container.parentElement
+ : (container as Element);
+ return !!(
+ elementNode &&
+ this.renderRoot.contains(elementNode) &&
+ elementNode.closest('.diff-cell, .unified-block')
+ );
+ }
+
+ private getActiveSelection(): Selection | null {
+ const shadowSelection = (this.renderRoot as ShadowRoot)?.getSelection?.();
+ if (
+ shadowSelection &&
+ !shadowSelection.isCollapsed &&
+ shadowSelection.rangeCount > 0
+ ) {
+ return shadowSelection;
+ }
+ const docSelection = window.getSelection();
+ if (
+ docSelection &&
+ !docSelection.isCollapsed &&
+ docSelection.rangeCount > 0
+ ) {
+ return docSelection;
+ }
+ return null;
+ }
+
+ private handleSelectionChange = () => {
+ const selection = this.getActiveSelection();
+ if (!selection || selection.isCollapsed || !selection.rangeCount) {
+ if (this.selectionActionBoxVisible) {
+ this.selectionActionBoxVisible = false;
+ if (this.selectionActionBox) {
+ this.selectionActionBox.style.display = 'none';
+ }
+ }
+ return;
+ }
+ const range = selection.getRangeAt(0);
+ const container = range.commonAncestorContainer;
+ const elementNode =
+ container.nodeType === Node.TEXT_NODE
+ ? container.parentElement
+ : (container as Element);
+ if (!elementNode || !this.renderRoot.contains(elementNode)) {
+ if (this.selectionActionBoxVisible) {
+ this.selectionActionBoxVisible = false;
+ if (this.selectionActionBox) {
+ this.selectionActionBox.style.display = 'none';
+ }
+ }
+ return;
+ }
+ const cell = elementNode.closest('.diff-cell, .unified-block');
+ if (!cell) {
+ if (this.selectionActionBoxVisible) {
+ this.selectionActionBoxVisible = false;
+ if (this.selectionActionBox) {
+ this.selectionActionBox.style.display = 'none';
+ }
+ }
+ return;
+ }
+
+ const rangeRect = range.getBoundingClientRect();
+ const hostRect = this.getBoundingClientRect();
+
+ const spaceAbove = rangeRect.top - hostRect.top;
+ if (spaceAbove < 40) {
+ this.selectionBoxTop = rangeRect.bottom - hostRect.top + this.scrollTop;
+ this.selectionBoxPositionBelow = true;
+ } else {
+ this.selectionBoxTop = rangeRect.top - hostRect.top + this.scrollTop;
+ this.selectionBoxPositionBelow = false;
+ }
+ this.selectionBoxLeft =
+ rangeRect.left - hostRect.left + rangeRect.width / 2 + this.scrollLeft;
+ const sideStr = cell.getAttribute('data-side');
+ this.selectedSide = sideStr === 'left' ? Side.LEFT : Side.RIGHT;
+ const lineStr = cell.getAttribute('data-line');
+ this.selectedLine = lineStr ? Number(lineStr) : undefined;
+ this.selectionActionBoxVisible = true;
+ if (this.selectionActionBox) {
+ this.selectionActionBox.style.display = 'block';
+ this.selectionActionBox.style.top = `${this.selectionBoxTop}px`;
+ this.selectionActionBox.style.left = `${this.selectionBoxLeft}px`;
+ this.selectionActionBox.classList.toggle(
+ 'below',
+ this.selectionBoxPositionBelow
+ );
+ }
+ };
+
+ private handleSelectionCommentClick(e: Event) {
+ e.stopPropagation();
+ if (this.selectedSide !== undefined) {
+ this.createComment(this.selectedSide, this.selectedLine);
+ this.selectionActionBoxVisible = false;
+ if (this.selectionActionBox) {
+ this.selectionActionBox.style.display = 'none';
+ }
+ this.clearSelection();
+ }
+ }
+
+ private handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'c' || e.key === 'C') {
+ if (e.ctrlKey || e.metaKey || e.altKey) {
+ return;
+ }
+ const target = e.composedPath()[0] as HTMLElement;
+ if (
+ target?.tagName === 'INPUT' ||
+ target?.tagName === 'TEXTAREA' ||
+ target?.tagName === 'GR-TEXTAREA' ||
+ target?.isContentEditable
+ ) {
+ return;
+ }
+ if (this.hasActiveSelection() || this.hoveredSide !== undefined) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.createCommentFromSelectionOrHover();
+ }
+ }
+ };
+
+ createCommentFromSelectionOrHover() {
+ const selection = this.getActiveSelection();
+ if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
+ const range = selection.getRangeAt(0);
+ const container = range.commonAncestorContainer;
+ const elementNode =
+ container.nodeType === Node.TEXT_NODE
+ ? container.parentElement
+ : (container as Element);
+ if (elementNode && this.renderRoot.contains(elementNode)) {
+ const cell = elementNode.closest('.diff-cell, .unified-block');
+ if (cell) {
+ const sideStr = cell.getAttribute('data-side');
+ const side = sideStr === 'left' ? Side.LEFT : Side.RIGHT;
+ const lineStr = cell.getAttribute('data-line');
+ const line = lineStr ? Number(lineStr) : undefined;
+ this.createComment(side, line);
+ this.selectionActionBoxVisible = false;
+ if (this.selectionActionBox) {
+ this.selectionActionBox.style.display = 'none';
+ }
+ this.clearSelection();
+ return;
+ }
+ }
+ }
+ if (this.selectedSide !== undefined) {
+ this.createComment(this.selectedSide, this.selectedLine);
+ this.selectionActionBoxVisible = false;
+ if (this.selectionActionBox) {
+ this.selectionActionBox.style.display = 'none';
+ }
+ this.clearSelection();
+ return;
+ }
+ if (this.hoveredSide !== undefined) {
+ this.createComment(this.hoveredSide, this.hoveredLine);
+ return;
+ }
+ const firstRow = this.alignedRows.find(r => r.rightStartLine !== undefined);
+ if (firstRow) {
+ this.createComment(Side.RIGHT, firstRow.rightStartLine);
+ } else {
+ this.createComment(Side.RIGHT, 1);
+ }
+ }
+
+ async autoSaveDrafts(): Promise<void> {
+ const threadElements = Array.from(
+ this.shadowRoot?.querySelectorAll<GrCommentThread>('gr-comment-thread') ??
+ []
+ );
+ await Promise.all(threadElements.map(thread => thread.autoSave()));
+ }
+
+ override render() {
+ if (this.viewMode === DiffViewMode.UNIFIED) {
+ return this.renderUnifiedView();
+ }
+ return this.renderSideBySideView();
+ }
+
+ private renderThread(thread: CommentThread, side?: Side) {
+ const diffSide = side ?? getThreadDiffSide(thread, this.patchRange);
+ return html`
+ <gr-comment-thread
+ class="comment-thread"
+ .rootId=${thread.rootId}
+ .thread=${thread}
+ .showPatchset=${false}
+ .showPortedComment=${!!thread.ported}
+ diff-side=${diffSide}
+ line-num=${thread.line ?? 'FILE'}
+ >
+ </gr-comment-thread>
+ `;
+ }
+
+ private renderCommentButton(side: Side, lineNum?: number) {
+ if (lineNum === undefined) return nothing;
+ return html`
+ <div class="cell-action-bar">
+ <button
+ type="button"
+ class="add-comment-btn"
+ title="Add comment (line ${lineNum})"
+ aria-label="Add comment (line ${lineNum})"
+ @click=${(e: Event) => {
+ e.stopPropagation();
+ this.createComment(side, lineNum);
+ }}
+ >
+ <gr-icon icon="add_comment" filled></gr-icon>
+ <span>Comment</span>
+ </button>
+ </div>
+ `;
+ }
+
+ private renderSelectionActionBox() {
+ return html`
+ <div
+ class="selection-action-box ${this.selectionBoxPositionBelow
+ ? 'below'
+ : ''}"
+ style="display: ${this.selectionActionBoxVisible
+ ? 'block'
+ : 'none'}; top: ${this.selectionBoxTop}px; left: ${this
+ .selectionBoxLeft}px;"
+ >
+ <button
+ type="button"
+ class="selection-comment-btn"
+ @mousedown=${(e: MouseEvent) => {
+ e.preventDefault();
+ }}
+ @click=${this.handleSelectionCommentClick}
+ >
+ <gr-icon icon="add_comment" filled></gr-icon>
+ <span>Comment (c)</span>
+ </button>
+ </div>
+ `;
+ }
+
+ private renderSideBySideView() {
+ const {rowsWithThreads, fileLevelThreads} = attachThreadsToRows(
+ this.alignedRows,
+ this.effectiveThreads,
+ this.patchRange
+ );
+
+ return html`
+ ${when(
+ fileLevelThreads.length > 0,
+ () => html`
+ <div class="file-level-threads">
+ <div class="file-level-title">File Comments</div>
+ ${fileLevelThreads.map(t => this.renderThread(t))}
+ </div>
+ `
+ )}
+ <div class="column-headers">
+ <div class="column-header left">Base</div>
+ <div class="column-header right">Revision</div>
+ </div>
+ <div class="diff-grid" role="region" aria-label="Rich Markdown Diff">
+ ${rowsWithThreads.map(row => this.renderRow(row))}
+ </div>
+ ${this.renderSelectionActionBox()}
+ `;
+ }
+
+ private renderUnifiedView() {
+ const {rowsWithThreads, fileLevelThreads} = attachThreadsToRows(
+ this.alignedRows,
+ this.effectiveThreads,
+ this.patchRange
+ );
+
+ return html`
+ ${when(
+ fileLevelThreads.length > 0,
+ () => html`
+ <div class="file-level-threads">
+ <div class="file-level-title">File Comments</div>
+ ${fileLevelThreads.map(t => this.renderThread(t))}
+ </div>
+ `
+ )}
+ <div
+ class="unified-container"
+ role="region"
+ aria-label="Rich Markdown Diff"
+ >
+ ${rowsWithThreads.map(row => this.renderUnifiedRow(row))}
+ </div>
+ ${this.renderSelectionActionBox()}
+ `;
+ }
+
+ private renderUnifiedRow(row: AlignedDiffRowWithThreads) {
+ if (row.status === 'unchanged') {
+ const allThreads = [...row.leftThreads, ...row.rightThreads];
+ return html`
+ <div
+ class="unified-block unchanged"
+ data-side="right"
+ data-line=${ifDefined(row.rightStartLine)}
+ @mouseenter=${() =>
+ this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)}
+ @mouseleave=${() => this.handleCellMouseLeave()}
+ >
+ ${this.renderCommentButton(Side.RIGHT, row.rightStartLine)}
+ <div class="cell-content">
+ ${sanitizeHtmlToFragment(row.leftHtml ?? row.rightHtml ?? '')}
+ </div>
+ ${when(
+ allThreads.length > 0,
+ () => html`
+ <div class="threads-container">
+ ${allThreads.map(t => this.renderThread(t))}
+ </div>
+ `
+ )}
+ </div>
+ `;
+ }
+ if (row.status === 'deleted') {
+ return html`
+ <div
+ class="unified-block deleted"
+ data-side="left"
+ data-line=${ifDefined(row.leftStartLine)}
+ @mouseenter=${() =>
+ this.handleCellMouseEnter(Side.LEFT, row.leftStartLine)}
+ @mouseleave=${() => this.handleCellMouseLeave()}
+ >
+ ${this.renderCommentButton(Side.LEFT, row.leftStartLine)}
+ <div class="cell-content">
+ ${sanitizeHtmlToFragment(row.leftHtml!)}
+ </div>
+ ${when(
+ row.leftThreads.length > 0,
+ () => html`
+ <div class="threads-container">
+ ${row.leftThreads.map(t => this.renderThread(t, Side.LEFT))}
+ </div>
+ `
+ )}
+ </div>
+ `;
+ }
+ if (row.status === 'added') {
+ return html`
+ <div
+ class="unified-block added"
+ data-side="right"
+ data-line=${ifDefined(row.rightStartLine)}
+ @mouseenter=${() =>
+ this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)}
+ @mouseleave=${() => this.handleCellMouseLeave()}
+ >
+ ${this.renderCommentButton(Side.RIGHT, row.rightStartLine)}
+ <div class="cell-content">
+ ${sanitizeHtmlToFragment(row.rightHtml!)}
+ </div>
+ ${when(
+ row.rightThreads.length > 0,
+ () => html`
+ <div class="threads-container">
+ ${row.rightThreads.map(t => this.renderThread(t, Side.RIGHT))}
+ </div>
+ `
+ )}
+ </div>
+ `;
+ }
+ // modified: render deleted (base) then added (revision)
+ return html`
+ <div
+ class="unified-block deleted"
+ data-side="left"
+ data-line=${ifDefined(row.leftStartLine)}
+ @mouseenter=${() =>
+ this.handleCellMouseEnter(Side.LEFT, row.leftStartLine)}
+ @mouseleave=${() => this.handleCellMouseLeave()}
+ >
+ ${this.renderCommentButton(Side.LEFT, row.leftStartLine)}
+ <div class="cell-content">${sanitizeHtmlToFragment(row.leftHtml!)}</div>
+ ${when(
+ row.leftThreads.length > 0,
+ () => html`
+ <div class="threads-container">
+ ${row.leftThreads.map(t => this.renderThread(t, Side.LEFT))}
+ </div>
+ `
+ )}
+ </div>
+ <div
+ class="unified-block added"
+ data-side="right"
+ data-line=${ifDefined(row.rightStartLine)}
+ @mouseenter=${() =>
+ this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)}
+ @mouseleave=${() => this.handleCellMouseLeave()}
+ >
+ ${this.renderCommentButton(Side.RIGHT, row.rightStartLine)}
+ <div class="cell-content">
+ ${sanitizeHtmlToFragment(row.rightHtml!)}
+ </div>
+ ${when(
+ row.rightThreads.length > 0,
+ () => html`
+ <div class="threads-container">
+ ${row.rightThreads.map(t => this.renderThread(t, Side.RIGHT))}
+ </div>
+ `
+ )}
+ </div>
+ `;
+ }
+
+ private renderRow(row: AlignedDiffRowWithThreads) {
+ return html` ${this.renderLeftCell(row)} ${this.renderRightCell(row)} `;
+ }
+
+ private renderLeftCell(row: AlignedDiffRowWithThreads) {
+ const isDeleted = row.status === 'deleted';
+ const isModified = row.status === 'modified';
+ const isEmpty = row.status === 'added' || !row.leftHtml;
+
+ const classes = {
+ 'diff-cell': true,
+ left: true,
+ deleted: isDeleted,
+ 'modified-left': isModified,
+ empty: isEmpty,
+ };
+
+ return html`
+ <div
+ class=${classMap(classes)}
+ data-side="left"
+ data-line=${ifDefined(row.leftStartLine)}
+ @mouseenter=${() =>
+ !isEmpty && this.handleCellMouseEnter(Side.LEFT, row.leftStartLine)}
+ @mouseleave=${() => this.handleCellMouseLeave()}
+ >
+ ${when(!isEmpty, () =>
+ this.renderCommentButton(Side.LEFT, row.leftStartLine)
+ )}
+ <div class="cell-content">
+ ${when(!isEmpty, () => sanitizeHtmlToFragment(row.leftHtml!))}
+ </div>
+ ${when(
+ row.leftThreads.length > 0,
+ () => html`
+ <div class="threads-container">
+ ${row.leftThreads.map(t => this.renderThread(t, Side.LEFT))}
+ </div>
+ `
+ )}
+ </div>
+ `;
+ }
+
+ private renderRightCell(row: AlignedDiffRowWithThreads) {
+ const isAdded = row.status === 'added';
+ const isModified = row.status === 'modified';
+ const isEmpty = row.status === 'deleted' || !row.rightHtml;
+
+ const classes = {
+ 'diff-cell': true,
+ right: true,
+ added: isAdded,
+ 'modified-right': isModified,
+ empty: isEmpty,
+ };
+
+ return html`
+ <div
+ class=${classMap(classes)}
+ data-side="right"
+ data-line=${ifDefined(row.rightStartLine)}
+ @mouseenter=${() =>
+ !isEmpty && this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)}
+ @mouseleave=${() => this.handleCellMouseLeave()}
+ >
+ ${when(!isEmpty, () =>
+ this.renderCommentButton(Side.RIGHT, row.rightStartLine)
+ )}
+ <div class="cell-content">
+ ${when(!isEmpty, () => sanitizeHtmlToFragment(row.rightHtml!))}
+ </div>
+ ${when(
+ row.rightThreads.length > 0,
+ () => html`
+ <div class="threads-container">
+ ${row.rightThreads.map(t => this.renderThread(t, Side.RIGHT))}
+ </div>
+ `
+ )}
+ </div>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-markdown-viewer': GrDiffMarkdownViewer;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer_test.ts
new file mode 100644
index 0000000..e164913
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer_test.ts
@@ -0,0 +1,610 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert, fixture, html} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import './gr-diff-markdown-viewer';
+import {GrDiffMarkdownViewer} from './gr-diff-markdown-viewer';
+import {DiffInfo} from '../../../types/diff';
+import {CommentSide, DiffViewMode} from '../../../constants/constants';
+import {
+ createAccountDetailWithId,
+ createComment,
+ createCommentThread,
+ createDiff,
+ createPatchRange,
+} from '../../../test/test-data-generators';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+ CommentsModel,
+ commentsModelToken,
+} from '../../../models/comments/comments-model';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+ CommentThread,
+ DraftInfo,
+ RevisionPatchSetNum,
+} from '../../../types/common';
+import sinon from 'sinon';
+
+suite('gr-diff-markdown-viewer tests', () => {
+ let element: GrDiffMarkdownViewer;
+ let commentsModel: CommentsModel;
+ let userModel: UserModel;
+
+ setup(async () => {
+ commentsModel = testResolver(commentsModelToken);
+ userModel = testResolver(userModelToken);
+ userModel.setAccount(createAccountDetailWithId(1));
+
+ element = await fixture<GrDiffMarkdownViewer>(
+ html`<gr-diff-markdown-viewer></gr-diff-markdown-viewer>`
+ );
+ });
+
+ test('renders empty when no diff is provided', () => {
+ const grid = element.shadowRoot!.querySelector('.diff-grid');
+ assert.isNotNull(grid);
+ assert.equal(grid.children.length, 0);
+ });
+
+ test('renders aligned markdown rows', async () => {
+ const diff: DiffInfo = {
+ ...createDiff(),
+ content: [
+ {ab: ['# Title', '']},
+ {a: ['Old paragraph.'], b: ['New paragraph.']},
+ {b: ['', '- Added bullet']},
+ ],
+ };
+
+ element.diff = diff;
+ await element.updateComplete;
+
+ const grid = element.shadowRoot!.querySelector('.diff-grid');
+ assert.isNotNull(grid);
+ // At least 3 rows = 6 cells (left and right)
+ const cells = grid.querySelectorAll('.diff-cell');
+ assert.isAtLeast(cells.length, 4);
+
+ // Verify left and right column headers
+ const leftHeader = element.shadowRoot!.querySelector('.column-header.left');
+ const rightHeader = element.shadowRoot!.querySelector(
+ '.column-header.right'
+ );
+ assert.equal(leftHeader!.textContent!.trim(), 'Base');
+ assert.equal(rightHeader!.textContent!.trim(), 'Revision');
+ });
+
+ test('highlights modified and added cells correctly', async () => {
+ const diff: DiffInfo = {
+ ...createDiff(),
+ content: [{a: ['Deleted text.'], b: ['Added text.']}],
+ };
+
+ element.diff = diff;
+ await element.updateComplete;
+
+ const modifiedLeft = element.shadowRoot!.querySelector<HTMLElement>(
+ '.diff-cell.modified-left'
+ );
+ const modifiedRight = element.shadowRoot!.querySelector<HTMLElement>(
+ '.diff-cell.modified-right'
+ );
+ assert.isNotNull(modifiedLeft);
+ assert.isNotNull(modifiedRight);
+ const styleLeft = window.getComputedStyle(modifiedLeft);
+ const styleRight = window.getComputedStyle(modifiedRight);
+ assert.isOk(styleLeft.backgroundColor);
+ assert.notEqual(styleLeft.backgroundColor, 'rgba(0, 0, 0, 0)');
+ assert.notEqual(styleLeft.backgroundColor, 'transparent');
+ assert.isOk(styleRight.backgroundColor);
+ assert.notEqual(styleRight.backgroundColor, 'rgba(0, 0, 0, 0)');
+ assert.notEqual(styleRight.backgroundColor, 'transparent');
+ });
+
+ test('renders deleted block with red background in side-by-side mode', async () => {
+ const diff: DiffInfo = {
+ ...createDiff(),
+ content: [{a: ['Only deleted paragraph.']}],
+ };
+
+ element.diff = diff;
+ await element.updateComplete;
+
+ const deletedCell =
+ element.shadowRoot!.querySelector<HTMLElement>('.diff-cell.deleted');
+ assert.isNotNull(deletedCell);
+ const style = window.getComputedStyle(deletedCell);
+ assert.isOk(style.backgroundColor);
+ assert.notEqual(style.backgroundColor, 'rgba(0, 0, 0, 0)');
+ assert.notEqual(style.backgroundColor, 'transparent');
+ });
+
+ test('renders unified diff mode correctly', async () => {
+ const diff: DiffInfo = {
+ ...createDiff(),
+ content: [
+ {ab: ['# Unchanged title', '']},
+ {a: ['Deleted block.'], b: ['Added block.']},
+ ],
+ };
+
+ element.diff = diff;
+ element.viewMode = DiffViewMode.UNIFIED;
+ await element.updateComplete;
+
+ const unifiedContainer =
+ element.shadowRoot!.querySelector('.unified-container');
+ assert.isNotNull(unifiedContainer);
+
+ const headers = element.shadowRoot!.querySelector('.column-headers');
+ assert.isNull(headers);
+
+ const unchangedBlock = element.shadowRoot!.querySelector(
+ '.unified-block.unchanged'
+ );
+ assert.isNotNull(unchangedBlock);
+
+ const deletedBlock = element.shadowRoot!.querySelector(
+ '.unified-block.deleted'
+ );
+ assert.isNotNull(deletedBlock);
+
+ const addedBlock = element.shadowRoot!.querySelector(
+ '.unified-block.added'
+ );
+ assert.isNotNull(addedBlock);
+
+ // Switch back to side-by-side (split) mode
+ element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+ await element.updateComplete;
+
+ assert.isNull(element.shadowRoot!.querySelector('.unified-container'));
+ assert.isNotNull(element.shadowRoot!.querySelector('.diff-grid'));
+ assert.isNotNull(element.shadowRoot!.querySelector('.column-headers'));
+ });
+
+ suite('comment threads rendering and creation', () => {
+ const diff: DiffInfo = {
+ ...createDiff(),
+ content: [
+ {ab: ['# Title', '']},
+ {a: ['Old paragraph.'], b: ['New paragraph.']},
+ ],
+ };
+
+ test('renders file-level and block-level threads in side-by-side mode', async () => {
+ const fileThread: CommentThread = createCommentThread([
+ {...createComment(), line: undefined, message: 'File comment'},
+ ]);
+ const titleThread: CommentThread = createCommentThread([
+ {
+ ...createComment(),
+ line: 1,
+ message: 'Title comment',
+ patch_set: 1 as RevisionPatchSetNum,
+ },
+ ]);
+ const baseParagraphThread: CommentThread = createCommentThread([
+ {
+ ...createComment(),
+ line: 3,
+ message: 'Old paragraph comment',
+ side: CommentSide.PARENT,
+ },
+ ]);
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ element.threads = [fileThread, titleThread, baseParagraphThread];
+ await element.updateComplete;
+
+ // File level threads section
+ const fileSection = element.shadowRoot!.querySelector(
+ '.file-level-threads'
+ );
+ assert.isNotNull(fileSection);
+ const fileThreads = fileSection.querySelectorAll('gr-comment-thread');
+ assert.equal(fileThreads.length, 1);
+
+ // Block threads inside diff cells
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ // Row 0 left: Title (unchanged, line 1)
+ // Row 0 right: Title (unchanged, line 1) -> contains titleThread
+ const rightTitleCell = cells[1];
+ const rightTitleThreads =
+ rightTitleCell.querySelectorAll('gr-comment-thread');
+ assert.equal(rightTitleThreads.length, 1);
+
+ // Row 1 left: Old paragraph (modified-left, line 3) -> contains baseParagraphThread
+ const leftOldCell = cells[2];
+ const leftOldThreads = leftOldCell.querySelectorAll('gr-comment-thread');
+ assert.equal(leftOldThreads.length, 1);
+ });
+
+ test('clicking add comment button calls commentsModel.addNewDraft', async () => {
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ // Row 0 right (Title, line 1)
+ const rightBtn = cells[1].querySelector<HTMLElement>('.add-comment-btn');
+ assert.isNotNull(rightBtn);
+ rightBtn.click();
+
+ assert.isTrue(addDraftSpy.calledOnce);
+ const draft1: DraftInfo = addDraftSpy.firstCall.firstArg;
+ assert.equal(draft1.path, 'test.md');
+ assert.equal(draft1.side, CommentSide.REVISION);
+ assert.equal(draft1.line, 1);
+
+ // Row 1 left (Old paragraph, line 3)
+ const leftBtn = cells[2].querySelector<HTMLElement>('.add-comment-btn');
+ assert.isNotNull(leftBtn);
+ leftBtn.click();
+
+ assert.isTrue(addDraftSpy.calledTwice);
+ const draft2: DraftInfo = addDraftSpy.secondCall.firstArg;
+ assert.equal(draft2.path, 'test.md');
+ assert.equal(draft2.side, CommentSide.PARENT);
+ assert.equal(draft2.line, 3);
+ });
+
+ test('prevents commenting when logged out and fires show-auth-required', async () => {
+ userModel.setAccount(undefined);
+ await element.updateComplete;
+
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+ let authFired = false;
+ element.addEventListener('show-auth-required', () => {
+ authFired = true;
+ });
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ const btn =
+ element.shadowRoot!.querySelector<HTMLElement>('.add-comment-btn');
+ assert.isNotNull(btn);
+ btn.click();
+
+ assert.isFalse(addDraftSpy.called);
+ assert.isTrue(authFired);
+ });
+
+ test('renders threads and handles commenting in unified mode', async () => {
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+ const thread: CommentThread = createCommentThread([
+ {
+ ...createComment(),
+ line: 1,
+ message: 'Unified comment',
+ patch_set: 1 as RevisionPatchSetNum,
+ },
+ ]);
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ element.threads = [thread];
+ element.viewMode = DiffViewMode.UNIFIED;
+ await element.updateComplete;
+
+ const unifiedContainer =
+ element.shadowRoot!.querySelector('.unified-container');
+ assert.isNotNull(unifiedContainer);
+
+ const threads = unifiedContainer.querySelectorAll('gr-comment-thread');
+ assert.equal(threads.length, 1);
+
+ const btn =
+ unifiedContainer.querySelector<HTMLElement>('.add-comment-btn');
+ assert.isNotNull(btn);
+ btn.click();
+
+ assert.isTrue(addDraftSpy.calledOnce);
+ const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+ assert.equal(draft.path, 'test.md');
+ assert.equal(draft.line, 1);
+ });
+
+ test('pressing c when cell is hovered creates draft on that line and side', async () => {
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ // Row 1 left (line 3, Side.LEFT)
+ const leftCell = cells[2];
+ leftCell.dispatchEvent(new MouseEvent('mouseenter'));
+
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'c', bubbles: true})
+ );
+
+ assert.isTrue(addDraftSpy.calledOnce);
+ const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+ assert.equal(draft.path, 'test.md');
+ assert.equal(draft.side, CommentSide.PARENT);
+ assert.equal(draft.line, 3);
+ });
+
+ test('selecting text displays selection action box and clicking creates draft', async () => {
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ const rightCell = cells[1];
+ const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+ assert.isNotNull(textNode);
+
+ const range = document.createRange();
+ range.selectNodeContents(textNode!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ document.dispatchEvent(new Event('selectionchange'));
+ await element.updateComplete;
+
+ const actionBox = element.shadowRoot!.querySelector(
+ '.selection-action-box'
+ );
+ assert.isNotNull(actionBox);
+ const actionBoxStyle = window.getComputedStyle(actionBox);
+ assert.equal(actionBoxStyle.zIndex, '500');
+
+ const commentBtn = actionBox.querySelector<HTMLElement>(
+ '.selection-comment-btn'
+ );
+ assert.isNotNull(commentBtn);
+ const btnStyle = window.getComputedStyle(commentBtn);
+ assert.isOk(btnStyle.backgroundColor);
+ assert.notEqual(btnStyle.backgroundColor, 'transparent');
+ assert.notEqual(btnStyle.backgroundColor, 'rgba(0, 0, 0, 0)');
+
+ commentBtn.click();
+
+ assert.isTrue(addDraftSpy.calledOnce);
+ const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+ assert.equal(draft.path, 'test.md');
+ assert.equal(draft.side, CommentSide.REVISION);
+ assert.equal(draft.line, 1);
+ });
+
+ test('text selection is preserved after selectionchange event', async () => {
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ const rightCell = cells[1];
+ const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+ assert.isNotNull(textNode);
+
+ const range = document.createRange();
+ range.selectNodeContents(textNode!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ document.dispatchEvent(new Event('selectionchange'));
+ await element.updateComplete;
+
+ assert.isFalse(selection.isCollapsed);
+ assert.equal(selection.rangeCount, 1);
+ assert.isTrue(
+ element.shadowRoot!.contains(selection.getRangeAt(0).startContainer)
+ );
+ });
+
+ test('pressing c with active text selection creates draft', async () => {
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ const leftCell = cells[2];
+ const textNode = leftCell.querySelector('.cell-content')?.firstChild;
+ assert.isNotNull(textNode);
+
+ const range = document.createRange();
+ range.selectNodeContents(textNode!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ document.dispatchEvent(new Event('selectionchange'));
+ await element.updateComplete;
+
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'c', bubbles: true})
+ );
+
+ assert.isTrue(addDraftSpy.calledOnce);
+ const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+ assert.equal(draft.path, 'test.md');
+ assert.equal(draft.side, CommentSide.PARENT);
+ assert.equal(draft.line, 3);
+ });
+
+ test('pressing C (uppercase) with active text selection creates draft', async () => {
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ const leftCell = cells[2];
+ const textNode = leftCell.querySelector('.cell-content')?.firstChild;
+ assert.isNotNull(textNode);
+
+ const range = document.createRange();
+ range.selectNodeContents(textNode!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ document.dispatchEvent(new Event('selectionchange'));
+ await element.updateComplete;
+
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'C', bubbles: true})
+ );
+
+ assert.isTrue(addDraftSpy.calledOnce);
+ const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+ assert.equal(draft.path, 'test.md');
+ assert.equal(draft.side, CommentSide.PARENT);
+ assert.equal(draft.line, 3);
+ });
+
+ test('pressing c and C in unified mode with active selection creates draft', async () => {
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ element.viewMode = DiffViewMode.UNIFIED;
+ await element.updateComplete;
+
+ const block = element.shadowRoot!.querySelector('.unified-block.added');
+ assert.isNotNull(block);
+ const textNode = block.querySelector('.cell-content')?.firstChild;
+ assert.isNotNull(textNode);
+
+ const range = document.createRange();
+ range.selectNodeContents(textNode!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ document.dispatchEvent(new Event('selectionchange'));
+ await element.updateComplete;
+
+ assert.isTrue(element.hasActiveSelection());
+
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'C', bubbles: true})
+ );
+
+ assert.isTrue(addDraftSpy.calledOnce);
+ const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+ assert.equal(draft.path, 'test.md');
+ assert.equal(draft.side, CommentSide.REVISION);
+ assert.equal(draft.line, 3);
+ });
+
+ test('pressing Ctrl+C or Meta+C with selection does not create draft', async () => {
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ const rightCell = cells[1];
+ const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+ assert.isNotNull(textNode);
+
+ const range = document.createRange();
+ range.selectNodeContents(textNode!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ document.dispatchEvent(new Event('selectionchange'));
+ await element.updateComplete;
+
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'c', ctrlKey: true, bubbles: true})
+ );
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'c', metaKey: true, bubbles: true})
+ );
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'C', ctrlKey: true, bubbles: true})
+ );
+
+ assert.isFalse(addDraftSpy.called);
+ });
+
+ test('hasActiveSelection returns true when selection is inside diff and false otherwise', async () => {
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ assert.isFalse(element.hasActiveSelection());
+
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ const rightCell = cells[1];
+ const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+ assert.isNotNull(textNode);
+
+ const range = document.createRange();
+ range.selectNodeContents(textNode!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ assert.isTrue(element.hasActiveSelection());
+
+ selection.removeAllRanges();
+ assert.isFalse(element.hasActiveSelection());
+ });
+
+ test('createCommentFromSelectionOrHover creates comment on selection', async () => {
+ const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+ element.diff = diff;
+ element.patchRange = createPatchRange();
+ element.path = 'test.md';
+ await element.updateComplete;
+
+ const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+ const rightCell = cells[1];
+ const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+ assert.isNotNull(textNode);
+
+ const range = document.createRange();
+ range.selectNodeContents(textNode!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ element.createCommentFromSelectionOrHover();
+
+ assert.isTrue(addDraftSpy.calledOnce);
+ const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+ assert.equal(draft.path, 'test.md');
+ assert.equal(draft.side, CommentSide.REVISION);
+ assert.equal(draft.line, 1);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util.ts b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util.ts
new file mode 100644
index 0000000..2e5752f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util.ts
@@ -0,0 +1,511 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {DiffInfo} from '../../../types/diff';
+import {CommentSide, Side} from '../../../constants/constants';
+import {CommentThread, PatchRange} from '../../../types/common';
+import {isInBaseOfPatchRange} from '../../../utils/comment-util';
+import {getDiffLines} from '../../../utils/diff-util';
+import {htmlEscape} from '../../../utils/inner-html-util';
+import {Marked, Token, Tokens} from 'marked';
+
+export type DiffBlockStatus = 'unchanged' | 'added' | 'deleted' | 'modified';
+
+export type MarkdownToken = Token & {
+ startLine?: number;
+ endLine?: number;
+};
+
+export interface AlignedDiffRow {
+ status: DiffBlockStatus;
+ leftToken?: MarkdownToken;
+ rightToken?: MarkdownToken;
+ leftHtml?: string;
+ rightHtml?: string;
+ leftStartLine?: number;
+ leftEndLine?: number;
+ rightStartLine?: number;
+ rightEndLine?: number;
+}
+
+export interface AlignedDiffRowWithThreads extends AlignedDiffRow {
+ leftThreads: CommentThread[];
+ rightThreads: CommentThread[];
+}
+
+export interface InlineDiffSegment {
+ text: string;
+ type: 'common' | 'added' | 'deleted';
+}
+
+/** Reconstruct the entire file content from diff chunks for the given side. */
+export function reconstructFileContent(diff: DiffInfo, side: Side): string {
+ return getDiffLines(diff, side).join('\n');
+}
+
+/** Parse markdown text into top-level block tokens with line number metadata. */
+export function parseMarkdownBlocks(markdown: string): MarkdownToken[] {
+ if (!markdown) return [];
+ const marked = new Marked();
+ const tokens = marked.lexer(markdown);
+ const result: MarkdownToken[] = [];
+ let curLine = 1;
+ for (const t of tokens) {
+ const raw = t.raw;
+ const newlineCount = (raw.match(/\n/g) || []).length;
+ const startLine = curLine;
+ const endLine = curLine + newlineCount - (raw.endsWith('\n') ? 1 : 0);
+ curLine += newlineCount;
+ if (t.type === 'space') {
+ continue;
+ }
+ (t as MarkdownToken).startLine = startLine;
+ (t as MarkdownToken).endLine = Math.max(startLine, endLine);
+ result.push(t as MarkdownToken);
+ }
+ return result;
+}
+
+/** Tokenize text into words, whitespace, and punctuation for fine-grained inline diffing. */
+export function tokenizeWords(text: string): string[] {
+ return text.match(/\s+|[^\s\w]+|\w+/g) || [];
+}
+
+/** Compute LCS-based diff between two arrays of strings (e.g. words or lines). */
+export function computeSequenceDiff(
+ seqA: string[],
+ seqB: string[]
+): InlineDiffSegment[] {
+ const m = seqA.length;
+ const n = seqB.length;
+ // DP table for LCS lengths
+ const dp: number[][] = Array.from({length: m + 1}, () =>
+ new Array(n + 1).fill(0)
+ );
+
+ for (let i = 0; i < m; i++) {
+ for (let j = 0; j < n; j++) {
+ if (seqA[i] === seqB[j]) {
+ dp[i + 1][j + 1] = dp[i][j] + 1;
+ } else {
+ dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
+ }
+ }
+ }
+
+ // Backtrack to build diff segments
+ const segments: InlineDiffSegment[] = [];
+ let i = m;
+ let j = n;
+
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && seqA[i - 1] === seqB[j - 1]) {
+ segments.unshift({text: seqA[i - 1], type: 'common'});
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ segments.unshift({text: seqB[j - 1], type: 'added'});
+ j--;
+ } else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
+ segments.unshift({text: seqA[i - 1], type: 'deleted'});
+ i--;
+ }
+ }
+
+ return segments;
+}
+
+/** Render a single marked token to standard HTML using marked. */
+export function renderTokenToHtml(token?: Token): string {
+ if (!token) return '';
+ const marked = new Marked();
+ return marked.parse(token.raw, {async: false}) || '';
+}
+
+/** Render an inline diff for modified text in headings or paragraphs. */
+export function renderInlineTextDiff(
+ textA: string,
+ textB: string,
+ wrapperTag = 'p'
+): {leftHtml: string; rightHtml: string} {
+ const wordsA = tokenizeWords(textA);
+ const wordsB = tokenizeWords(textB);
+ const diff = computeSequenceDiff(wordsA, wordsB);
+
+ // Merge consecutive segments of the same type
+ const mergedSegments: InlineDiffSegment[] = [];
+ for (const seg of diff) {
+ const last = mergedSegments[mergedSegments.length - 1];
+ if (last && last.type === seg.type) {
+ last.text += seg.text;
+ } else {
+ mergedSegments.push({...seg});
+ }
+ }
+
+ let leftContent = '';
+ let rightContent = '';
+
+ for (const seg of mergedSegments) {
+ const escaped = htmlEscape(seg.text).toString();
+ if (seg.type === 'common') {
+ leftContent += escaped;
+ rightContent += escaped;
+ } else if (seg.type === 'deleted') {
+ leftContent += `<del class="diff-highlight-del">${escaped}</del>`;
+ } else if (seg.type === 'added') {
+ rightContent += `<ins class="diff-highlight-add">${escaped}</ins>`;
+ }
+ }
+
+ return {
+ leftHtml: `<${wrapperTag}>${leftContent}</${wrapperTag}>`,
+ rightHtml: `<${wrapperTag}>${rightContent}</${wrapperTag}>`,
+ };
+}
+
+/** Render a modified code block with line-by-line diff highlights. */
+export function renderCodeBlockDiff(
+ tokenA: Tokens.Code,
+ tokenB: Tokens.Code
+): {leftHtml: string; rightHtml: string} {
+ const linesA = tokenA.text.split('\n');
+ const linesB = tokenB.text.split('\n');
+ const lineDiff = computeSequenceDiff(linesA, linesB);
+
+ let leftLines = '';
+ let rightLines = '';
+
+ for (const seg of lineDiff) {
+ const escaped = htmlEscape(seg.text).toString();
+ if (seg.type === 'common') {
+ leftLines += `${escaped}\n`;
+ rightLines += `${escaped}\n`;
+ } else if (seg.type === 'deleted') {
+ leftLines += `<span class="diff-highlight-del">${escaped}</span>\n`;
+ } else if (seg.type === 'added') {
+ rightLines += `<span class="diff-highlight-add">${escaped}</span>\n`;
+ }
+ }
+
+ const langClass = tokenB.lang
+ ? ` class="language-${htmlEscape(tokenB.lang)}"`
+ : '';
+ return {
+ leftHtml: `<pre><code${langClass}>${leftLines.trimEnd()}</code></pre>`,
+ rightHtml: `<pre><code${langClass}>${rightLines.trimEnd()}</code></pre>`,
+ };
+}
+
+/**
+ * Align base (A) and revision (B) markdown block tokens into rows.
+ * Matches exact blocks as anchors, pairs modified blocks of compatible types,
+ * and leaves unmatched blocks as added or deleted with empty partner cells.
+ */
+export function alignMarkdownTokens(
+ tokensA: MarkdownToken[],
+ tokensB: MarkdownToken[]
+): AlignedDiffRow[] {
+ // Filter out whitespace-only 'space' tokens between blocks
+ const blocksA = tokensA.filter(t => t.type !== 'space');
+ const blocksB = tokensB.filter(t => t.type !== 'space');
+
+ const m = blocksA.length;
+ const n = blocksB.length;
+
+ // DP table to find LCS of exact matches
+ const dp: number[][] = Array.from({length: m + 1}, () =>
+ new Array(n + 1).fill(0)
+ );
+
+ for (let i = 0; i < m; i++) {
+ for (let j = 0; j < n; j++) {
+ if (blocksA[i].raw === blocksB[j].raw) {
+ dp[i + 1][j + 1] = dp[i][j] + 1;
+ } else {
+ dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
+ }
+ }
+ }
+
+ // Backtrack to extract exact match pairs
+ const anchorPairs: {aIdx: number; bIdx: number}[] = [];
+ let i = m;
+ let j = n;
+ while (i > 0 && j > 0) {
+ if (blocksA[i - 1].raw === blocksB[j - 1].raw) {
+ anchorPairs.unshift({aIdx: i - 1, bIdx: j - 1});
+ i--;
+ j--;
+ } else if (dp[i][j - 1] >= dp[i - 1][j]) {
+ j--;
+ } else {
+ i--;
+ }
+ }
+
+ const rows: AlignedDiffRow[] = [];
+ let lastA = 0;
+ let lastB = 0;
+
+ function processInterval(endA: number, endB: number) {
+ const unalignedA = blocksA.slice(lastA, endA);
+ const unalignedB = blocksB.slice(lastB, endB);
+
+ let idxA = 0;
+ let idxB = 0;
+
+ // Greedily pair up adjacent tokens of compatible type as 'modified'
+ while (idxA < unalignedA.length && idxB < unalignedB.length) {
+ const tokA = unalignedA[idxA];
+ const tokB = unalignedB[idxB];
+
+ const canPair =
+ tokA.type === tokB.type ||
+ (tokA.type === 'paragraph' && tokB.type === 'paragraph') ||
+ (tokA.type === 'heading' && tokB.type === 'heading') ||
+ (tokA.type === 'code' && tokB.type === 'code');
+
+ if (canPair) {
+ let leftHtml: string;
+ let rightHtml: string;
+
+ if (tokA.type === 'code' && tokB.type === 'code') {
+ const diff = renderCodeBlockDiff(
+ tokA as Tokens.Code,
+ tokB as Tokens.Code
+ );
+ leftHtml = diff.leftHtml;
+ rightHtml = diff.rightHtml;
+ } else if (tokA.type === 'heading' && tokB.type === 'heading') {
+ const depth = (tokB as Tokens.Heading).depth;
+ const diff = renderInlineTextDiff(tokA.text, tokB.text, `h${depth}`);
+ leftHtml = diff.leftHtml;
+ rightHtml = diff.rightHtml;
+ } else if (tokA.type === 'paragraph' && tokB.type === 'paragraph') {
+ const diff = renderInlineTextDiff(tokA.text, tokB.text, 'p');
+ leftHtml = diff.leftHtml;
+ rightHtml = diff.rightHtml;
+ } else {
+ leftHtml = renderTokenToHtml(tokA);
+ rightHtml = renderTokenToHtml(tokB);
+ }
+
+ rows.push({
+ status: 'modified',
+ leftToken: tokA,
+ rightToken: tokB,
+ leftHtml,
+ rightHtml,
+ leftStartLine: tokA.startLine,
+ leftEndLine: tokA.endLine,
+ rightStartLine: tokB.startLine,
+ rightEndLine: tokB.endLine,
+ });
+ idxA++;
+ idxB++;
+ } else {
+ // Output deleted block on left
+ rows.push({
+ status: 'deleted',
+ leftToken: tokA,
+ leftHtml: renderTokenToHtml(tokA),
+ leftStartLine: tokA.startLine,
+ leftEndLine: tokA.endLine,
+ });
+ idxA++;
+ }
+ }
+
+ // Remaining unmatched in A
+ while (idxA < unalignedA.length) {
+ const tokA = unalignedA[idxA];
+ rows.push({
+ status: 'deleted',
+ leftToken: tokA,
+ leftHtml: renderTokenToHtml(tokA),
+ leftStartLine: tokA.startLine,
+ leftEndLine: tokA.endLine,
+ });
+ idxA++;
+ }
+
+ // Remaining unmatched in B
+ while (idxB < unalignedB.length) {
+ const tokB = unalignedB[idxB];
+ rows.push({
+ status: 'added',
+ rightToken: tokB,
+ rightHtml: renderTokenToHtml(tokB),
+ rightStartLine: tokB.startLine,
+ rightEndLine: tokB.endLine,
+ });
+ idxB++;
+ }
+ }
+
+ // Interleave intervals between anchor pairs
+ for (const anchor of anchorPairs) {
+ processInterval(anchor.aIdx, anchor.bIdx);
+ const tokA = blocksA[anchor.aIdx];
+ const tokB = blocksB[anchor.bIdx];
+ const htmlA = renderTokenToHtml(tokA);
+ const htmlB = renderTokenToHtml(tokB);
+ rows.push({
+ status: 'unchanged',
+ leftToken: tokA,
+ rightToken: tokB,
+ leftHtml: htmlA,
+ rightHtml: htmlB,
+ leftStartLine: tokA.startLine,
+ leftEndLine: tokA.endLine,
+ rightStartLine: tokB.startLine,
+ rightEndLine: tokB.endLine,
+ });
+ lastA = anchor.aIdx + 1;
+ lastB = anchor.bIdx + 1;
+ }
+
+ // Trailing interval
+ processInterval(m, n);
+
+ return rows;
+}
+
+/** Determine which side of the diff (LEFT or RIGHT) a comment thread belongs to. */
+export function getThreadDiffSide(
+ thread: CommentThread,
+ patchRange?: PatchRange
+): Side {
+ if (!patchRange) {
+ return thread.commentSide === CommentSide.PARENT ? Side.LEFT : Side.RIGHT;
+ }
+ const commentProps = {
+ patch_set: thread.patchNum,
+ side: thread.commentSide,
+ parent: thread.mergeParentNum,
+ };
+ if (isInBaseOfPatchRange(commentProps, patchRange)) {
+ return Side.LEFT;
+ }
+ return Side.RIGHT;
+}
+
+/**
+ * Assigns comment threads to their corresponding markdown diff rows based on line
+ * numbers and diff side. File-level comments (or threads without line numbers) are
+ * grouped separately.
+ */
+export function attachThreadsToRows(
+ rows: AlignedDiffRow[],
+ threads: CommentThread[] = [],
+ patchRange?: PatchRange
+): {
+ rowsWithThreads: AlignedDiffRowWithThreads[];
+ fileLevelThreads: CommentThread[];
+} {
+ const rowsWithThreads: AlignedDiffRowWithThreads[] = rows.map(r => {
+ return {
+ ...r,
+ leftThreads: [],
+ rightThreads: [],
+ };
+ });
+
+ const fileLevelThreads: CommentThread[] = [];
+
+ if (rowsWithThreads.length === 0) {
+ return {
+ rowsWithThreads,
+ fileLevelThreads: [...threads],
+ };
+ }
+
+ for (const thread of threads) {
+ const line = thread.line;
+ if (line === undefined || line === 'FILE') {
+ fileLevelThreads.push(thread);
+ continue;
+ }
+
+ const lineNum = typeof line === 'number' ? line : Number(line);
+ if (isNaN(lineNum)) {
+ fileLevelThreads.push(thread);
+ continue;
+ }
+
+ const side = getThreadDiffSide(thread, patchRange);
+
+ if (side === Side.LEFT) {
+ const leftRows = rowsWithThreads.filter(
+ r => r.leftStartLine !== undefined
+ );
+ if (leftRows.length === 0) {
+ rowsWithThreads[0].leftThreads.push(thread);
+ continue;
+ }
+
+ const exactRow = leftRows.find(
+ r => lineNum >= r.leftStartLine! && lineNum <= r.leftEndLine!
+ );
+ if (exactRow) {
+ exactRow.leftThreads.push(thread);
+ continue;
+ }
+
+ if (lineNum < leftRows[0].leftStartLine!) {
+ leftRows[0].leftThreads.push(thread);
+ continue;
+ }
+
+ let targetRow = leftRows[leftRows.length - 1];
+ for (let i = 0; i < leftRows.length - 1; i++) {
+ if (
+ lineNum > leftRows[i].leftEndLine! &&
+ lineNum < leftRows[i + 1].leftStartLine!
+ ) {
+ targetRow = leftRows[i];
+ break;
+ }
+ }
+ targetRow.leftThreads.push(thread);
+ } else {
+ const rightRows = rowsWithThreads.filter(
+ r => r.rightStartLine !== undefined
+ );
+ if (rightRows.length === 0) {
+ rowsWithThreads[0].rightThreads.push(thread);
+ continue;
+ }
+
+ const exactRow = rightRows.find(
+ r => lineNum >= r.rightStartLine! && lineNum <= r.rightEndLine!
+ );
+ if (exactRow) {
+ exactRow.rightThreads.push(thread);
+ continue;
+ }
+
+ if (lineNum < rightRows[0].rightStartLine!) {
+ rightRows[0].rightThreads.push(thread);
+ continue;
+ }
+
+ let targetRow = rightRows[rightRows.length - 1];
+ for (let i = 0; i < rightRows.length - 1; i++) {
+ if (
+ lineNum > rightRows[i].rightEndLine! &&
+ lineNum < rightRows[i + 1].rightStartLine!
+ ) {
+ targetRow = rightRows[i];
+ break;
+ }
+ }
+ targetRow.rightThreads.push(thread);
+ }
+ }
+
+ return {rowsWithThreads, fileLevelThreads};
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util_test.ts
new file mode 100644
index 0000000..dd112a4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util_test.ts
@@ -0,0 +1,229 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {DiffInfo} from '../../../types/diff';
+import {CommentSide, Side} from '../../../constants/constants';
+import {
+ createComment,
+ createCommentThread,
+ createDiff,
+} from '../../../test/test-data-generators';
+import {CommentThread, UrlEncodedCommentId} from '../../../types/common';
+import {
+ alignMarkdownTokens,
+ attachThreadsToRows,
+ computeSequenceDiff,
+ parseMarkdownBlocks,
+ reconstructFileContent,
+ tokenizeWords,
+} from './markdown-diff-util';
+
+suite('markdown-diff-util tests', () => {
+ test('reconstructFileContent', () => {
+ const diff: DiffInfo = {
+ ...createDiff(),
+ content: [
+ {ab: ['# Header', '']},
+ {a: ['Old line'], b: ['New line']},
+ {ab: ['', 'Footer']},
+ ],
+ };
+
+ assert.equal(
+ reconstructFileContent(diff, Side.LEFT),
+ '# Header\n\nOld line\n\nFooter'
+ );
+ assert.equal(
+ reconstructFileContent(diff, Side.RIGHT),
+ '# Header\n\nNew line\n\nFooter'
+ );
+ });
+
+ test('parseMarkdownBlocks', () => {
+ const md = '# Title\n\nParagraph text.\n\n```js\nconsole.log(1);\n```';
+ const tokens = parseMarkdownBlocks(md);
+ assert.equal(tokens.length, 3);
+ assert.equal(tokens[0].type, 'heading');
+ assert.equal(tokens[1].type, 'paragraph');
+ assert.equal(tokens[2].type, 'code');
+ });
+
+ test('tokenizeWords', () => {
+ const words = tokenizeWords('Hello world! How are you?');
+ assert.deepEqual(words, [
+ 'Hello',
+ ' ',
+ 'world',
+ '!',
+ ' ',
+ 'How',
+ ' ',
+ 'are',
+ ' ',
+ 'you',
+ '?',
+ ]);
+ });
+
+ test('computeSequenceDiff', () => {
+ const seqA = ['a', 'b', 'c'];
+ const seqB = ['a', 'x', 'c'];
+ const diff = computeSequenceDiff(seqA, seqB);
+ assert.deepEqual(diff, [
+ {text: 'a', type: 'common'},
+ {text: 'b', type: 'deleted'},
+ {text: 'x', type: 'added'},
+ {text: 'c', type: 'common'},
+ ]);
+ });
+
+ suite('alignMarkdownTokens', () => {
+ test('identical documents produce unchanged rows', () => {
+ const doc = '# Title\n\nParagraph 1.\n\nParagraph 2.';
+ const tokensA = parseMarkdownBlocks(doc);
+ const tokensB = parseMarkdownBlocks(doc);
+
+ const rows = alignMarkdownTokens(tokensA, tokensB);
+ assert.equal(rows.length, 3);
+ assert.isTrue(rows.every(r => r.status === 'unchanged'));
+ });
+
+ test('inserted block creates added row with empty left', () => {
+ const docA = '# Title\n\nFooter.';
+ const docB = '# Title\n\nInserted paragraph.\n\nFooter.';
+ const tokensA = parseMarkdownBlocks(docA);
+ const tokensB = parseMarkdownBlocks(docB);
+
+ const rows = alignMarkdownTokens(tokensA, tokensB);
+ assert.equal(rows.length, 3);
+ assert.equal(rows[0].status, 'unchanged');
+ assert.equal(rows[1].status, 'added');
+ assert.isUndefined(rows[1].leftToken);
+ assert.isDefined(rows[1].rightToken);
+ assert.include(rows[1].rightHtml!, 'Inserted paragraph');
+ assert.equal(rows[2].status, 'unchanged');
+ });
+
+ test('deleted block creates deleted row with empty right', () => {
+ const docA = '# Title\n\nDeleted paragraph.\n\nFooter.';
+ const docB = '# Title\n\nFooter.';
+ const tokensA = parseMarkdownBlocks(docA);
+ const tokensB = parseMarkdownBlocks(docB);
+
+ const rows = alignMarkdownTokens(tokensA, tokensB);
+ assert.equal(rows.length, 3);
+ assert.equal(rows[0].status, 'unchanged');
+ assert.equal(rows[1].status, 'deleted');
+ assert.isDefined(rows[1].leftToken);
+ assert.isUndefined(rows[1].rightToken);
+ assert.include(rows[1].leftHtml!, 'Deleted paragraph');
+ assert.equal(rows[2].status, 'unchanged');
+ });
+
+ test('modified paragraph generates inline diff highlights', () => {
+ const docA = 'Follow this structure:';
+ const docB = 'Follow this structure. Only the first block is required:';
+ const tokensA = parseMarkdownBlocks(docA);
+ const tokensB = parseMarkdownBlocks(docB);
+
+ const rows = alignMarkdownTokens(tokensA, tokensB);
+ assert.equal(rows.length, 1);
+ assert.equal(rows[0].status, 'modified');
+ assert.include(rows[0].leftHtml!, 'Follow this structure');
+ assert.include(rows[0].rightHtml!, 'diff-highlight-add');
+ assert.include(rows[0].rightHtml!, 'Only the first block is required');
+ });
+
+ test('modified code block generates line diff highlights', () => {
+ const docA = '```bash\nline 1\nold code\nline 3\n```';
+ const docB = '```bash\nline 1\nnew code\nline 3\n```';
+ const tokensA = parseMarkdownBlocks(docA);
+ const tokensB = parseMarkdownBlocks(docB);
+
+ const rows = alignMarkdownTokens(tokensA, tokensB);
+ assert.equal(rows.length, 1);
+ assert.equal(rows[0].status, 'modified');
+ assert.include(rows[0].leftHtml!, 'diff-highlight-del');
+ assert.include(rows[0].leftHtml!, 'old code');
+ assert.include(rows[0].rightHtml!, 'diff-highlight-add');
+ assert.include(rows[0].rightHtml!, 'new code');
+ });
+
+ test('line numbers are correctly assigned to tokens and rows', () => {
+ const docA = '# Header\n\nLine 1\nLine 2';
+ const docB = '# Header\n\nLine 1\nLine 2 modified';
+ const tokensA = parseMarkdownBlocks(docA);
+ const tokensB = parseMarkdownBlocks(docB);
+
+ assert.equal(tokensA[0].startLine, 1);
+ assert.equal(tokensA[0].endLine, 1);
+ assert.equal(tokensA[1].startLine, 3);
+ assert.equal(tokensA[1].endLine, 4);
+
+ const rows = alignMarkdownTokens(tokensA, tokensB);
+ assert.equal(rows[0].leftStartLine, 1);
+ assert.equal(rows[0].leftEndLine, 1);
+ assert.equal(rows[0].rightStartLine, 1);
+ assert.equal(rows[0].rightEndLine, 1);
+
+ assert.equal(rows[1].leftStartLine, 3);
+ assert.equal(rows[1].leftEndLine, 4);
+ assert.equal(rows[1].rightStartLine, 3);
+ assert.equal(rows[1].rightEndLine, 4);
+ });
+ });
+
+ suite('attachThreadsToRows', () => {
+ test('attaches line threads to matching rows and separates file-level threads', () => {
+ const docA = '# Header\n\nParagraph 1\n\nParagraph 2';
+ const docB = '# Header\n\nParagraph 1 modified\n\nParagraph 2';
+ const tokensA = parseMarkdownBlocks(docA);
+ const tokensB = parseMarkdownBlocks(docB);
+ const rows = alignMarkdownTokens(tokensA, tokensB);
+
+ const fileThread: CommentThread = createCommentThread([
+ {
+ ...createComment(),
+ id: 'file-1' as UrlEncodedCommentId,
+ line: undefined,
+ side: CommentSide.REVISION,
+ },
+ ]);
+ const rightThread: CommentThread = createCommentThread([
+ {
+ ...createComment(),
+ id: 'right-1' as UrlEncodedCommentId,
+ line: 3,
+ side: CommentSide.REVISION,
+ },
+ ]);
+ const leftThread: CommentThread = createCommentThread([
+ {
+ ...createComment(),
+ id: 'left-1' as UrlEncodedCommentId,
+ line: 3,
+ side: CommentSide.PARENT,
+ },
+ ]);
+
+ const result = attachThreadsToRows(rows, [
+ fileThread,
+ rightThread,
+ leftThread,
+ ]);
+ assert.equal(result.fileLevelThreads.length, 1);
+ assert.equal(result.fileLevelThreads[0].rootId, 'file-1');
+
+ // Row 1 is "Paragraph 1 modified" which spans lines 3-3
+ assert.equal(result.rowsWithThreads[1].rightThreads.length, 1);
+ assert.equal(result.rowsWithThreads[1].rightThreads[0].rootId, 'right-1');
+
+ assert.equal(result.rowsWithThreads[1].leftThreads.length, 1);
+ assert.equal(result.rowsWithThreads[1].leftThreads[0].rootId, 'left-1');
+ });
+ });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 611d285..188e3e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -40,6 +40,7 @@
BasePatchSetNum,
Comment,
CommentMap,
+ CommentThread,
DropdownLink,
EDIT,
NumericChangeId,
@@ -103,7 +104,9 @@
FileNameToNormalizedFileInfoMap,
filesModelToken,
} from '../../../models/change/files-model';
-import {isImageDiff} from '../../../utils/diff-util';
+import {isImageDiff, isMarkdownDiff} from '../../../utils/diff-util';
+import '../gr-diff-markdown-viewer/gr-diff-markdown-viewer';
+import type {GrDiffMarkdownViewer} from '../gr-diff-markdown-viewer/gr-diff-markdown-viewer';
import {formStyles} from '../../../styles/form-styles';
import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
import {configModelToken} from '../../../models/config/config-model';
@@ -140,9 +143,15 @@
@query('#diffHost')
diffHost?: GrDiffHost;
+ @query('#markdownViewer')
+ markdownViewer?: GrDiffMarkdownViewer;
+
@state()
reviewed = false;
+ @state()
+ showRichMarkdown = false;
+
@query('#downloadModal')
downloadModal?: HTMLDialogElement;
@@ -169,6 +178,16 @@
}
// Private but used in tests.
+ get threadsForFile(): CommentThread[] {
+ if (!this.changeComments || !this.path || !this.patchRange) return [];
+ const file = this.files?.changeFilesByPath?.[this.path];
+ return this.changeComments.getThreadsBySideForFile(
+ {path: this.path, basePath: file?.old_path},
+ this.patchRange
+ );
+ }
+
+ // Private but used in tests.
@state()
patchNum?: RevisionPatchSetNum;
@@ -828,27 +847,37 @@
) {
this.initCursor();
}
+ if (changedProperties.has('showRichMarkdown') && !this.showRichMarkdown) {
+ this.reInitCursor();
+ }
if (
changedProperties.has('change') ||
changedProperties.has('changeComments') ||
changedProperties.has('path') ||
changedProperties.has('patchNum') ||
changedProperties.has('basePatchNum') ||
- changedProperties.has('files')
+ changedProperties.has('files') ||
+ changedProperties.has('showRichMarkdown')
) {
if (this.change && this.changeComments && this.path && this.patchRange) {
assertIsDefined(this.diffHost, 'diffHost');
const file = this.files?.changeFilesByPath?.[this.path];
- this.diffHost.updateComplete.then(() => {
- assertIsDefined(this.path);
- assertIsDefined(this.patchRange);
- assertIsDefined(this.diffHost);
- assertIsDefined(this.changeComments);
- this.diffHost.threads = this.changeComments.getThreadsBySideForFile(
- {path: this.path, basePath: file?.old_path},
- this.patchRange
- );
- });
+ if (!this.isShowingRichMarkdown()) {
+ this.diffHost.disabledThreads = false;
+ this.diffHost.updateComplete.then(() => {
+ assertIsDefined(this.path);
+ assertIsDefined(this.patchRange);
+ assertIsDefined(this.diffHost);
+ assertIsDefined(this.changeComments);
+ this.diffHost.threads = this.changeComments.getThreadsBySideForFile(
+ {path: this.path, basePath: file?.old_path},
+ this.patchRange
+ );
+ });
+ } else {
+ this.diffHost.disabledThreads = true;
+ this.diffHost.threads = [];
+ }
}
}
if (
@@ -900,9 +929,27 @@
hidden: !!this.file?.diffs_too_expensive_to_compute,
})}
>
- <gr-endpoint-decorator name="diff-content">
+ ${when(
+ this.isShowingRichMarkdown(),
+ () => html`
+ <gr-diff-markdown-viewer
+ id="markdownViewer"
+ .diff=${this.diff}
+ .path=${this.path}
+ .patchRange=${this.patchRange}
+ .threads=${this.threadsForFile}
+ .loggedIn=${this.loggedIn}
+ ></gr-diff-markdown-viewer>
+ `
+ )}
+ <gr-endpoint-decorator
+ name="diff-content"
+ ?hidden=${this.isShowingRichMarkdown()}
+ >
<gr-diff-host
id="diffHost"
+ ?hidden=${this.isShowingRichMarkdown()}
+ ?disabledThreads=${this.isShowingRichMarkdown()}
.changeNum=${this.changeNum}
.change=${this.change}
.patchRange=${this.patchRange}
@@ -1181,7 +1228,7 @@
name="diff-header-controls"
></gr-endpoint-decorator>
${this.renderSidebarTriggers()} ${this.renderShowEntireFileButton()}
- ${this.renderBlameButton()}
+ ${this.renderBlameButton()} ${this.renderRichMarkdownToggle()}
${when(
this.computeCanEdit(),
() => html`
@@ -1289,6 +1336,45 @@
</span>`;
}
+ private renderRichMarkdownToggle() {
+ if (!isMarkdownDiff(this.path, this.diff)) return nothing;
+ return html`<span class="separator"></span
+ ><span class="richMarkdownToggle">
+ <gr-tooltip-content
+ has-tooltip=""
+ position-below=""
+ title=${this.showRichMarkdown
+ ? 'Switch to source diff'
+ : 'Switch to rich rendered Markdown diff'}
+ >
+ <gr-button
+ link=""
+ id="toggleRichMarkdown"
+ @click=${this.toggleRichMarkdown}
+ >
+ <gr-icon
+ icon=${this.showRichMarkdown ? 'code' : 'preview'}
+ filled=""
+ ></gr-icon>
+ ${this.showRichMarkdown ? 'Source diff' : 'Rich diff'}
+ </gr-button>
+ </gr-tooltip-content>
+ </span>`;
+ }
+
+ async toggleRichMarkdown() {
+ if (this.isShowingRichMarkdown()) {
+ await this.markdownViewer?.autoSaveDrafts();
+ } else {
+ await this.diffHost?.autoSaveDrafts();
+ }
+ this.showRichMarkdown = !this.showRichMarkdown;
+ }
+
+ private isShowingRichMarkdown(): boolean {
+ return this.showRichMarkdown && isMarkdownDiff(this.path, this.diff);
+ }
+
private renderDialogs() {
return html`
<gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
@@ -1408,6 +1494,10 @@
private handleNewComment() {
this.classList.remove('hideComments');
this.classList.remove('hideCheckCodePointers');
+ if (this.isShowingRichMarkdown()) {
+ this.markdownViewer?.createCommentFromSelectionOrHover();
+ return;
+ }
this.cursor?.createCommentInPlace();
}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index e19a98c..d015b62 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -28,6 +28,7 @@
createConfig,
createDiff,
createDiffViewState,
+ createDraft,
createFileInfo,
createParsedChange,
createRange,
@@ -40,6 +41,7 @@
import {
BasePatchSetNum,
CommentInfo,
+ DraftInfo,
EDIT,
NumericChangeId,
PARENT,
@@ -64,6 +66,8 @@
import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
import {assert, fixture, html} from '@open-wc/testing';
import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrComment} from '../../shared/gr-comment/gr-comment';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
import {testResolver} from '../../../test/common-test-setup';
import {UserModel, userModelToken} from '../../../models/user/user-model';
import {
@@ -81,6 +85,7 @@
import {MdCheckbox} from '@material/web/checkbox/checkbox';
import {FileNameToNormalizedFileInfoMap} from '../../../models/change/files-model';
import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {createNew} from '../../../utils/comment-util';
import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
import {LoadingStatus} from '../../../types/types';
import {RunResult} from '../../../models/checks/checks-model';
@@ -1955,6 +1960,287 @@
});
});
+ suite('rich markdown diff', () => {
+ test('toggle rich markdown diff', async () => {
+ element.path = 'README.md';
+ element.diff = {
+ ...createDiff(),
+ content: [{ab: ['# Title']}, {a: ['Old line'], b: ['New line']}],
+ };
+ await element.updateComplete;
+
+ const toggleBtn = element.shadowRoot!.querySelector<GrButton>(
+ '#toggleRichMarkdown'
+ );
+ assert.isNotNull(toggleBtn);
+ assert.isFalse(element.showRichMarkdown);
+
+ await element.toggleRichMarkdown();
+ await element.updateComplete;
+
+ assert.isTrue(element.showRichMarkdown);
+ const markdownViewer =
+ element.shadowRoot!.querySelector('#markdownViewer');
+ assert.isNotNull(markdownViewer);
+
+ await element.toggleRichMarkdown();
+ await element.updateComplete;
+
+ assert.isFalse(element.showRichMarkdown);
+ });
+
+ test('toggle is hidden for non-markdown files', async () => {
+ element.path = 'foo.txt';
+ element.diff = createDiff();
+ await element.updateComplete;
+
+ const toggleBtn = element.shadowRoot!.querySelector(
+ '#toggleRichMarkdown'
+ );
+ assert.isNull(toggleBtn);
+ });
+
+ test('handleNewComment delegates to markdownViewer when rich markdown is active', async () => {
+ element.path = 'README.md';
+ element.diff = {
+ ...createDiff(),
+ content: [{ab: ['# Title']}],
+ };
+ element.showRichMarkdown = true;
+ await element.updateComplete;
+
+ assert.isDefined(element.markdownViewer);
+ const createCommentSpy = sinon.spy(
+ element.markdownViewer,
+ 'createCommentFromSelectionOrHover'
+ );
+
+ // Simulate new comment shortcut/action
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (element as any).handleNewComment();
+
+ assert.isTrue(createCommentSpy.calledOnce);
+ });
+
+ test('c and C shortcuts delegate to markdownViewer when rich markdown is active', async () => {
+ element.path = 'README.md';
+ element.diff = {
+ ...createDiff(),
+ content: [{ab: ['# Title']}],
+ };
+ element.showRichMarkdown = true;
+ await element.updateComplete;
+
+ assert.isDefined(element.markdownViewer);
+ const createCommentSpy = sinon.spy(
+ element.markdownViewer,
+ 'createCommentFromSelectionOrHover'
+ );
+
+ pressKey(element, 'c');
+ assert.isTrue(createCommentSpy.calledOnce);
+
+ pressKey(element, 'C');
+ assert.isTrue(createCommentSpy.calledTwice);
+ });
+
+ test('toggleRichMarkdown flushes diffHost drafts before switching to rich mode', async () => {
+ element.path = 'README.md';
+ element.diff = {
+ ...createDiff(),
+ content: [{ab: ['# Title']}],
+ };
+ element.showRichMarkdown = false;
+ await element.updateComplete;
+
+ assert.isDefined(element.diffHost);
+ const autoSaveSpy = sinon.spy(element.diffHost, 'autoSaveDrafts');
+
+ await element.toggleRichMarkdown();
+ await element.updateComplete;
+
+ assert.isTrue(autoSaveSpy.calledOnce);
+ assert.isTrue(element.showRichMarkdown);
+ });
+
+ test('toggleRichMarkdown flushes markdownViewer drafts before switching to source mode', async () => {
+ element.path = 'README.md';
+ element.diff = {
+ ...createDiff(),
+ content: [{ab: ['# Title']}],
+ };
+ element.showRichMarkdown = true;
+ await element.updateComplete;
+
+ assert.isDefined(element.markdownViewer);
+ const autoSaveSpy = sinon.spy(element.markdownViewer, 'autoSaveDrafts');
+
+ await element.toggleRichMarkdown();
+ await element.updateComplete;
+
+ assert.isTrue(autoSaveSpy.calledOnce);
+ assert.isFalse(element.showRichMarkdown);
+ });
+
+ test('toggling from rich markdown back to source diff updates diffHost threads', async () => {
+ element.path = 'README.md';
+ element.diff = {
+ ...createDiff(),
+ content: [{ab: ['# Title']}],
+ };
+ element.showRichMarkdown = true;
+ await element.updateComplete;
+
+ commentsModel.setState({
+ comments: {},
+ drafts: {
+ 'README.md': [
+ createDraft({
+ id: 'draft_1' as UrlEncodedCommentId,
+ line: 1,
+ message: 'Draft from rich mode',
+ patch_set: 1 as RevisionPatchSetNum,
+ }),
+ ],
+ },
+ portedComments: {},
+ portedDrafts: {},
+ discardedDrafts: [],
+ });
+ await element.updateComplete;
+ await element.diffHost?.updateComplete;
+
+ // Toggle back to source diff
+ element.showRichMarkdown = false;
+ await element.updateComplete;
+ await element.diffHost?.updateComplete;
+
+ assert.isDefined(element.diffHost);
+ assert.equal(element.diffHost.threads.length, 1);
+ assert.equal(element.diffHost.threads[0].line, 1);
+ });
+
+ test('toggling from rich markdown back to source diff preserves saved draft without empty composer', async () => {
+ element.path = 'README.md';
+ element.diff = {
+ ...createDiff(),
+ content: [{ab: ['# Title']}],
+ };
+ element.showRichMarkdown = true;
+ await element.updateComplete;
+
+ assert.isDefined(element.diffHost);
+ assert.isTrue(element.diffHost.hidden);
+ assert.equal(element.diffHost.threads.length, 0);
+
+ commentsModel.setState({
+ comments: {},
+ drafts: {
+ 'README.md': [
+ createDraft({
+ id: 'draft_1' as UrlEncodedCommentId,
+ line: 7,
+ message: '123',
+ patch_set: 1 as RevisionPatchSetNum,
+ }),
+ ],
+ },
+ portedComments: {},
+ portedDrafts: {},
+ discardedDrafts: [],
+ });
+ await element.updateComplete;
+ await element.diffHost?.updateComplete;
+
+ // Toggle back to source diff
+ element.showRichMarkdown = false;
+ await element.updateComplete;
+ await element.diffHost?.updateComplete;
+
+ assert.isFalse(element.diffHost.hidden);
+ assert.equal(element.diffHost.threads.length, 1);
+ assert.equal(element.diffHost.threads[0].line, 7);
+ assert.equal(element.diffHost.threads[0].comments[0].message, '123');
+ });
+
+ test('toggling rich to source once renders saved draft in view mode, not editing composer', async () => {
+ stubRestApi('saveDiffDraft').callsFake((_changeNum, _patchNum, draft) =>
+ Promise.resolve({
+ ok: true,
+ text: () =>
+ Promise.resolve(
+ ")]}'\n" +
+ JSON.stringify({
+ ...draft,
+ id: 'draft_saved_1',
+ updated: '2026-09-03 12:21:00.000000000',
+ })
+ ),
+ } as Response)
+ );
+
+ element.path = 'README.md';
+ element.diff = {
+ ...createDiff(),
+ content: [{ab: ['# Title']}],
+ };
+ element.showRichMarkdown = true;
+ await element.updateComplete;
+
+ // 1. User adds a draft while in rich markdown diff mode
+ const draft: DraftInfo = {
+ ...createNew('', true),
+ path: 'README.md',
+ patch_set: 1 as RevisionPatchSetNum,
+ line: 7,
+ message: '',
+ };
+ commentsModel.addNewDraft(draft);
+ await element.updateComplete;
+ await element.diffHost?.updateComplete;
+
+ // While in rich markdown, diffHost is hidden and must not render threads
+ assert.isTrue(element.diffHost?.hidden);
+ assert.equal(element.diffHost?.threads.length, 0);
+
+ // 2. User saves the draft in rich markdown diff mode
+ await commentsModel.saveDraft({
+ ...draft,
+ message: 'some full line',
+ });
+ await element.updateComplete;
+ await element.diffHost?.updateComplete;
+
+ // Still hidden and no threads in diffHost
+ assert.equal(element.diffHost?.threads.length, 0);
+
+ // 3. User toggles from rich to source ONCE
+ element.showRichMarkdown = false;
+ await element.updateComplete;
+ await element.diffHost?.updateComplete;
+
+ // 4. Assert diffHost renders the saved comment, and it is NOT in edit mode
+ assertIsDefined(element.diffHost);
+ assert.isFalse(element.diffHost.hidden);
+ assert.equal(element.diffHost.threads.length, 1);
+ assert.equal(element.diffHost.threads[0].line, 7);
+ assert.equal(
+ element.diffHost.threads[0].comments[0].message,
+ 'some full line'
+ );
+
+ const threadEl = queryAndAssert<GrCommentThread>(
+ element.diffHost,
+ 'gr-comment-thread'
+ );
+ await threadEl.updateComplete;
+ const commentEl = queryAndAssert<GrComment>(threadEl, 'gr-comment');
+ await commentEl.updateComplete;
+
+ assert.isFalse(commentEl.editing);
+ });
+ });
+
suite('editMode behavior', () => {
setup(async () => {
element.loggedIn = true;
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 59a0f4a..fa8ee18 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -32,6 +32,9 @@
initGlobalVariables(createAppContext(), true);
+export const SCROLL_PADDING_TOP_CALC =
+ 'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))';
+
@customElement('gr-app')
export class GrApp extends LitElement {
private finalizables: Finalizable[] = [];
@@ -82,16 +85,63 @@
if (!this.serviceWorkerInstaller) {
this.serviceWorkerInstaller = resolver(serviceWorkerInstallerToken);
}
+
+ // Defines top optimal viewing region for PageDown/PageUp keyboard paging
+ // and anchor jumps when sticky headers are active.
+ document.documentElement.style.setProperty(
+ 'scroll-padding-top',
+ SCROLL_PADDING_TOP_CALC
+ );
+ document.addEventListener('focusin', this.handleFocusIn);
+ document.addEventListener('focusout', this.handleFocusOut);
}
override disconnectedCallback() {
+ document.removeEventListener('focusin', this.handleFocusIn);
+ document.removeEventListener('focusout', this.handleFocusOut);
for (const f of this.finalizables) {
f.finalize();
}
this.finalizables = [];
+ document.documentElement.style.removeProperty('scroll-padding-top');
super.disconnectedCallback();
}
+ private readonly handleFocusIn = (e: FocusEvent) => {
+ const path = e.composedPath();
+ if (this.isInsideStickyContainer(path)) {
+ document.documentElement.style.setProperty('scroll-padding-top', '0px');
+ } else {
+ document.documentElement.style.setProperty(
+ 'scroll-padding-top',
+ SCROLL_PADDING_TOP_CALC
+ );
+ }
+ };
+
+ private readonly handleFocusOut = (e: FocusEvent) => {
+ if (!e.relatedTarget) {
+ document.documentElement.style.setProperty(
+ 'scroll-padding-top',
+ SCROLL_PADDING_TOP_CALC
+ );
+ }
+ };
+
+ private isInsideStickyContainer(path: EventTarget[]): boolean {
+ for (const target of path) {
+ if (!(target instanceof HTMLElement)) continue;
+ if (target === document.body || target === document.documentElement) {
+ break;
+ }
+ const style = window.getComputedStyle(target);
+ if (style.position === 'sticky' && style.top !== 'auto') {
+ return true;
+ }
+ }
+ return false;
+ }
+
override render() {
return html`<gr-app-element id="app-element"></gr-app-element>`;
}
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index d33f990..17c64d7 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -87,4 +87,121 @@
grAppElement.paramsChanged();
assert.ok(grAppElement.lastSearchPage);
});
+
+ test('scroll-padding-top is set when connected', () => {
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ 'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))'
+ );
+ });
+
+ test('scroll-padding-top is removed when disconnected', () => {
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ 'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))'
+ );
+ grApp.remove();
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ ''
+ );
+ });
+
+ test('scroll-padding-top drops to 0px on focus inside sticky header and restores on blur', () => {
+ const grAppElement = queryAndAssert<GrAppElement>(grApp, '#app-element');
+ const mainHeader = queryAndAssert(grAppElement, 'gr-main-header');
+
+ const searchBar = queryAndAssert(mainHeader, 'gr-smart-search');
+ const searchAutocomplete = queryAndAssert(
+ searchBar,
+ 'gr-search-autocomplete'
+ );
+ const autocomplete = queryAndAssert(searchAutocomplete, 'gr-autocomplete');
+ const input = queryAndAssert(autocomplete, '#input');
+
+ // 1. Focus inside nested search bar input in sticky mainHeader
+ input.dispatchEvent(
+ new FocusEvent('focusin', {bubbles: true, composed: true})
+ );
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ '0px'
+ );
+
+ // 2. Focus moves to non-sticky content
+ grAppElement.dispatchEvent(
+ new FocusEvent('focusin', {bubbles: true, composed: true})
+ );
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ 'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))'
+ );
+
+ // 3. Re-focus inside sticky header
+ mainHeader.dispatchEvent(
+ new FocusEvent('focusin', {bubbles: true, composed: true})
+ );
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ '0px'
+ );
+
+ // 4. Focus leaves the window (blur, relatedTarget: null)
+ mainHeader.dispatchEvent(
+ new FocusEvent('focusout', {
+ bubbles: true,
+ composed: true,
+ relatedTarget: null,
+ })
+ );
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ 'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))'
+ );
+ });
+
+ test('focus transition between sticky elements maintains 0px without intermediate reset', () => {
+ const grAppElement = queryAndAssert<GrAppElement>(grApp, '#app-element');
+ const mainHeader = queryAndAssert(grAppElement, 'gr-main-header');
+
+ // Create a second sticky element to simulate another sticky header or sibling
+ const secondSticky = document.createElement('div');
+ secondSticky.style.position = 'sticky';
+ secondSticky.style.top = '48px';
+ grAppElement.shadowRoot!.appendChild(secondSticky);
+
+ // Focus first sticky element
+ mainHeader.dispatchEvent(
+ new FocusEvent('focusin', {bubbles: true, composed: true})
+ );
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ '0px'
+ );
+
+ // Focusout from mainHeader transferring to secondSticky
+ mainHeader.dispatchEvent(
+ new FocusEvent('focusout', {
+ bubbles: true,
+ composed: true,
+ relatedTarget: secondSticky,
+ })
+ );
+ // Because relatedTarget is non-null, focusout does not prematurely reset
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ '0px'
+ );
+
+ // Focusin on secondSticky
+ secondSticky.dispatchEvent(
+ new FocusEvent('focusin', {bubbles: true, composed: true})
+ );
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ '0px'
+ );
+
+ secondSticky.remove();
+ });
});
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index ef2a680..9029a5c 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -104,6 +104,14 @@
</tr>
<tr>
<td>
+ <label for="Hashtags"> Hashtags </label>
+ </td>
+ <td class="checkboxContainer">
+ <md-checkbox id="Hashtags" name="Hashtags"> </md-checkbox>
+ </td>
+ </tr>
+ <tr>
+ <td>
<label for="Updated"> Updated </label>
</td>
<td class="checkboxContainer">
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index d22f02b..e247022 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -528,7 +528,12 @@
${href
? html`<a href=${href}>${displayPath}</a>`
: html`<span>${displayPath}</span>`}
- <gr-copy-clipboard hideInput .text=${displayPath}></gr-copy-clipboard>
+ <gr-copy-clipboard
+ hideInput
+ .text=${displayPath}
+ buttonTitle="Copy file path to clipboard"
+ copyTargetName="File path"
+ ></gr-copy-clipboard>
</div>
`;
}
@@ -773,6 +778,10 @@
this.draftElement!.edit();
}
+ async autoSave(): Promise<void> {
+ await this.draftElement?.autoSave();
+ }
+
private async addQuote(quote: string) {
await waitUntil(
() => !!this.draftElement,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index c70bf60..67801de 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -123,7 +123,12 @@
<a href="/c/test-repo-name/+/1/1/test-path-comment-thread">
test-path-comment-thread
</a>
- <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+ <gr-copy-clipboard
+ buttontitle="Copy file path to clipboard"
+ copytargetname="File path"
+ hideinput=""
+ >
+ </gr-copy-clipboard>
</div>
<div class="pathInfo">
<a href="/c/test-repo-name/+/1/1/test-path-comment-thread#314">
@@ -160,7 +165,12 @@
<a href="/c/test-repo-name/+/1/1/test-path-comment-thread">
test-path-comment-thread
</a>
- <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+ <gr-copy-clipboard
+ buttontitle="Copy file path to clipboard"
+ copytargetname="File path"
+ hideinput=""
+ >
+ </gr-copy-clipboard>
</div>
<div class="pathInfo">
<a href="/c/test-repo-name/+/1/1/test-path-comment-thread#314">
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 238e1b9..8e8ec47 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -202,7 +202,7 @@
() => html`<span class="shortcut">${this.shortcut}</span>`
)}
<gr-tooltip-content
- ?has-tooltip=${this.hasTooltip}
+ ?has-tooltip=${this.hasTooltip || !!this.buttonTitle}
title=${ifDefined(this.buttonTitle)}
>
<gr-button
@@ -210,7 +210,7 @@
link=""
class="copyToClipboard"
@click=${this.copyToClipboard}
- aria-label="copy"
+ aria-label=${this.buttonTitle ?? 'copy'}
aria-description="Click to copy to clipboard"
>
<div>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index acc1180..9ea0ee2 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -165,4 +165,26 @@
queryAndAssert<GrButton>(element, '.copyToClipboard').click();
assert.isFalse(clickStub.called);
});
+
+ test('buttonTitle enables tooltip and sets aria attributes', async () => {
+ element.buttonTitle = 'Copy custom item to clipboard';
+ await element.updateComplete;
+
+ const tooltipContent = queryAndAssert(element, 'gr-tooltip-content');
+ assert.isTrue(tooltipContent.hasAttribute('has-tooltip'));
+ assert.equal(
+ tooltipContent.getAttribute('title'),
+ 'Copy custom item to clipboard'
+ );
+
+ const button = queryAndAssert<GrButton>(element, '.copyToClipboard');
+ assert.equal(
+ button.getAttribute('aria-label'),
+ 'Copy custom item to clipboard'
+ );
+ assert.equal(
+ button.getAttribute('aria-description'),
+ 'Click to copy to clipboard'
+ );
+ });
});
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index c42fdd1..352c8fd 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -335,6 +335,8 @@
?hidden=${!this.showCopyForTriggerText}
hideInput
.text=${this.text}
+ buttonTitle="Copy to clipboard"
+ copyTargetName="Text"
></gr-copy-clipboard>
</gr-button>
<div class="dropdown-menu">
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
index 5fb5ffc..a67461f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -68,7 +68,13 @@
>
<span id="triggerText" class="desktopText"> Button Text 2 </span>
<span id="triggerText" class="mobileText"> Button Text 2 </span>
- <gr-copy-clipboard class="copyClipboard" hidden="" hideinput="">
+ <gr-copy-clipboard
+ buttontitle="Copy to clipboard"
+ class="copyClipboard"
+ copytargetname="Text"
+ hidden=""
+ hideinput=""
+ >
</gr-copy-clipboard>
</gr-button>
<div class="dropdown-menu">
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions_screenshot_test.ts
new file mode 100644
index 0000000..ff40c2b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions_screenshot_test.ts
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-fix-suggestions';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-ignore
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {GrFixSuggestions} from './gr-fix-suggestions';
+import {
+ createComment,
+ createFixSuggestionInfo,
+} from '../../../test/test-data-generators';
+import {NumericChangeId, RevisionPatchSetNum} from '../../../api/rest-api';
+import {stubFlags, visualDiffDarkTheme} from '../../../test/test-utils';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {testResolver} from '../../../test/common-test-setup';
+import * as sinon from 'sinon';
+import {highlightedStringToRanges} from '../../../utils/syntax-util';
+import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
+import {PatchSetNumber} from '../../../types/common';
+
+suite('gr-fix-suggestions screenshot tests', () => {
+ let element: GrFixSuggestions;
+
+ setup(async () => {
+ stubFlags('isEnabled').returns(true);
+ const highlightService = testResolver(highlightServiceToken);
+ const leftRanges: SyntaxLayerLine[] = highlightedStringToRanges(
+ '<span class="keyword">export</span> <span class="keyword">class</span> <span class="title">Test</span> {\n' +
+ ' <span class="keyword">private</span> <span class="title function_">oldMethod</span>() {\n' +
+ ' <span class="variable">console</span>.<span class="title function_">log</span>(<span class="string">"old"</span>);\n' +
+ ' }\n' +
+ '}'
+ );
+ const rightRanges: SyntaxLayerLine[] = highlightedStringToRanges(
+ '<span class="keyword">export</span> <span class="keyword">class</span> <span class="title">Test</span> {\n' +
+ ' <span class="keyword">private</span> <span class="title function_">newMethod</span>() {\n' +
+ ' <span class="variable">console</span>.<span class="title function_">log</span>(<span class="string">"new"</span>);\n' +
+ ' }\n' +
+ '}'
+ );
+ sinon.stub(highlightService, 'highlight').callsFake(async (_lang, code) => {
+ if (code?.includes('oldMethod')) return leftRanges;
+ if (code?.includes('newMethod')) return rightRanges;
+ return [];
+ });
+
+ element = await fixture<GrFixSuggestions>(
+ html`<gr-fix-suggestions
+ .generated_fix_suggestions=${[createFixSuggestionInfo()]}
+ .comment=${{
+ ...createComment(),
+ id: '1',
+ patch_set: 1 as PatchSetNumber,
+ }}
+ ></gr-fix-suggestions>`
+ );
+ await element.updateComplete;
+ });
+
+ test('ai fix suggestion with syntax highlighting', async () => {
+ // mock preview because it's calculated on backend
+ element.suggestionDiffPreview!.previewLoadedFor = {
+ fixSuggestionInfo: createFixSuggestionInfo(),
+ changeNum: 42 as NumericChangeId,
+ patchSet: 1 as RevisionPatchSetNum,
+ };
+ element.suggestionDiffPreview!.preview = {
+ filepath: 'test.ts',
+ preview: {
+ meta_a: {
+ name: 'test.ts',
+ content_type: 'application/typescript',
+ lines: 6,
+ },
+ meta_b: {
+ name: 'test.ts',
+ content_type: 'application/typescript',
+ lines: 6,
+ },
+ intraline_status: 'OK',
+ change_type: 'MODIFIED',
+ content: [
+ {
+ ab: ['export class Test {'],
+ },
+ {
+ a: [' private oldMethod() {', ' console.log("old");', ' }'],
+ b: [' private newMethod() {', ' console.log("new");', ' }'],
+ edit_a: [
+ [24, 2],
+ [23, 2],
+ [27, 2],
+ ],
+ edit_b: [],
+ },
+ {
+ ab: ['}'],
+ },
+ ],
+ },
+ };
+ element.requestUpdate();
+ await element.updateComplete;
+ await element.suggestionDiffPreview!.updateComplete;
+ // Allow syntax worker promise and notify to apply annotations
+ await new Promise(r => setTimeout(r, 100));
+ await document.fonts?.ready;
+
+ await visualDiff(element, 'gr-fix-suggestions');
+ await visualDiffDarkTheme(element, 'gr-fix-suggestions');
+ });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 0b46706..3f46a76 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -39,6 +39,7 @@
import {throwingErrorCallback} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
import {ReportSource} from '../../../services/suggestions/suggestions-service';
import {replacementsToString} from '../../../utils/comment-util';
+import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
import {GrTextarea} from '../../../embed/gr-textarea';
export interface PreviewLoadedDetail {
@@ -84,8 +85,17 @@
@property({type: Boolean, reflect: true})
editable = false;
+ // visible for testing
+ readonly syntaxLayer = new GrSyntaxLayerWorker(
+ resolve(this, highlightServiceToken),
+ () => getAppContext().reportingService
+ );
+
+ // visible for testing
+ readonly tokenHighlightLayer = new TokenHighlightLayer(this);
+
@state()
- layers: DiffLayer[] = [];
+ layers: DiffLayer[] = [this.syntaxLayer];
/**
* The fix suggestion info that the preview is loaded for.
@@ -134,15 +144,21 @@
private readonly getViewModel = resolve(this, changeViewModelToken);
- private readonly syntaxLayer = new GrSyntaxLayerWorker(
- resolve(this, highlightServiceToken),
- () => getAppContext().reportingService
- );
-
constructor() {
super();
subscribe(
this,
+ () => this.getUserModel().preferences$,
+ preferences => {
+ const layers: DiffLayer[] = [this.syntaxLayer];
+ if (!preferences?.disable_token_highlighting) {
+ layers.push(this.tokenHighlightLayer);
+ }
+ this.layers = layers;
+ }
+ );
+ subscribe(
+ this,
() => this.getChangeModel().changeNum$,
changeNum => (this.changeNum = changeNum)
);
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
index da0220c..4519063 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
@@ -29,9 +29,11 @@
import {
createChangeViewState,
createDiffViewState,
+ createPreferences,
createRange,
} from '../../../test/test-data-generators';
import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken} from '../../../models/user/user-model';
suite('gr-suggestion-diff-preview tests', () => {
let element: GrSuggestionDiffPreview;
@@ -135,6 +137,27 @@
);
});
+ test('syntax and token highlight layers', async () => {
+ assert.isTrue(element.layers.includes(element.syntaxLayer));
+
+ const userModel = testResolver(userModelToken);
+ userModel.setPreferences({
+ ...createPreferences(),
+ disable_token_highlighting: true,
+ });
+ await element.updateComplete;
+ assert.equal(element.layers.length, 1);
+ assert.isTrue(element.layers.includes(element.syntaxLayer));
+
+ userModel.setPreferences({
+ ...createPreferences(),
+ disable_token_highlighting: false,
+ });
+ await element.updateComplete;
+ assert.equal(element.layers.length, 2);
+ assert.isTrue(element.layers.includes(element.syntaxLayer));
+ });
+
suite('applyFix navigation', () => {
let setUrlStub: sinon.SinonStub;
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts
index b80b0a9..ea4fb22 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts
@@ -20,12 +20,38 @@
import {NumericChangeId, RevisionPatchSetNum} from '../../../api/rest-api';
import {getAppContext} from '../../../services/app-context';
import {stubFlags, visualDiffDarkTheme} from '../../../test/test-utils';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {testResolver} from '../../../test/common-test-setup';
+import * as sinon from 'sinon';
+import {highlightedStringToRanges} from '../../../utils/syntax-util';
+import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
suite('gr-user-suggestion-fix screenshot tests', () => {
let element: GrUserSuggestionsFix;
setup(async () => {
stubFlags('isEnabled').returns(true);
+ const highlightService = testResolver(highlightServiceToken);
+ const leftRanges: SyntaxLayerLine[] = highlightedStringToRanges(
+ '<span class="keyword">export</span> <span class="keyword">class</span> <span class="title">Test</span> {\n' +
+ ' <span class="keyword">private</span> <span class="title function_">oldMethod</span>() {\n' +
+ ' <span class="variable">console</span>.<span class="title function_">log</span>(<span class="string">"old"</span>);\n' +
+ ' }\n' +
+ '}'
+ );
+ const rightRanges: SyntaxLayerLine[] = highlightedStringToRanges(
+ '<span class="keyword">export</span> <span class="keyword">class</span> <span class="title">Test</span> {\n' +
+ ' <span class="keyword">private</span> <span class="title function_">newMethod</span>() {\n' +
+ ' <span class="variable">console</span>.<span class="title function_">log</span>(<span class="string">"new"</span>);\n' +
+ ' }\n' +
+ '}'
+ );
+ sinon.stub(highlightService, 'highlight').callsFake(async (_lang, code) => {
+ if (code?.includes('oldMethod')) return leftRanges;
+ if (code?.includes('newMethod')) return rightRanges;
+ return [];
+ });
+
const commentModel = new CommentModel(getAppContext().restApiService);
commentModel.updateState({
comment: createComment(),
@@ -88,6 +114,13 @@
],
},
};
+ element.requestUpdate();
+ await element.updateComplete;
+ await element.suggestionDiffPreview!.updateComplete;
+ // Allow syntax worker promise and notify to apply annotations
+ await new Promise(r => setTimeout(r, 100));
+ await document.fonts?.ready;
+
await visualDiff(element, 'gr-user-suggestion-fix');
await visualDiffDarkTheme(element, 'gr-user-suggestion-fix');
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index 0380d0c..a449d38 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -5,6 +5,7 @@
*/
import {html, LitElement, nothing, PropertyValues} from 'lit';
import {property, state} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
import {ifDefined} from 'lit/directives/if-defined.js';
import {createRef, Ref, ref} from 'lit/directives/ref.js';
import {
@@ -38,7 +39,9 @@
import {isDefined} from '../../../types/types';
import {BehaviorSubject, combineLatest} from 'rxjs';
import '../../../elements/shared/gr-hovercard/gr-hovercard';
+import '../../../elements/shared/gr-icon/gr-icon';
import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
import {distinctUntilChanged, map} from 'rxjs/operators';
import {deepEqual} from '../../../utils/deep-util';
import {subscribe} from '../../../elements/lit/subscription-controller';
@@ -88,6 +91,12 @@
@property({type: Object})
layers: DiffLayer[] = [];
+ @property({type: Object})
+ group?: GrDiffGroup;
+
+ @property({type: Boolean})
+ showRevertButton = false;
+
/**
* Semantic DOM diff testing does not work with just table fragments, so when
* running such tests the render() method has to wrap the DOM in a proper
@@ -209,6 +218,12 @@
// We have to wait for the <gr-diff-text> child component to finish
// rendering before we can apply layers, which will re-write the HTML.
await contentEl?.updateComplete;
+ if (
+ this.contentRef(side).value !== contentEl ||
+ this.lineNumberRef(side).value !== lineNumberEl
+ ) {
+ return;
+ }
for (const layer of this.layers) {
if (typeof layer.annotate === 'function') {
layer.annotate(contentEl, lineNumberEl, line, side);
@@ -446,7 +461,7 @@
if (lineNumber)
fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
}}
- >${this.renderText(side)}${this.renderLostMessage(side)}${this.renderThreadGroup(side)}</td>
+ >${this.renderText(side)}${this.renderLostMessage(side)}${this.renderThreadGroup(side)}${this.renderRevertButton(side)}</td>
`;
}
@@ -597,6 +612,46 @@
? html`<slot name="post-${side}-line-${lineNumber}"></slot>`
: nothing;
}
+
+ @state()
+ private isReverting = false;
+
+ private renderRevertButton(side: Side) {
+ if (!this.showRevertButton) return nothing;
+ if (!this.unifiedDiff && side !== Side.LEFT) return nothing;
+ return html`
+ <div class="revert-container">
+ <button
+ class=${classMap({
+ 'revert-btn': true,
+ loading: this.isReverting,
+ })}
+ type="button"
+ ?disabled=${this.isReverting}
+ title=${this.isReverting ? 'Reverting...' : 'Revert this change'}
+ aria-label=${this.isReverting ? 'Reverting...' : 'Revert this change'}
+ @click=${this.handleRevertClick}
+ >
+ ${this.isReverting
+ ? html`<span class="loadingSpin"></span>`
+ : html`<gr-icon icon="arrow_forward"></gr-icon>`}
+ </button>
+ </div>
+ `;
+ }
+
+ private handleRevertClick(e: MouseEvent) {
+ e.stopPropagation();
+ e.preventDefault();
+ if (!this.group || this.isReverting) return;
+ this.isReverting = true;
+ fire(this, 'revert-delta', {
+ group: this.group,
+ onComplete: () => {
+ this.isReverting = false;
+ },
+ });
+ }
}
customElements.define('gr-diff-row', GrDiffRow);
@@ -605,4 +660,10 @@
interface HTMLElementTagNameMap {
'gr-diff-row': GrDiffRow;
}
+ interface HTMLElementEventMap {
+ 'revert-delta': CustomEvent<{
+ group: GrDiffGroup;
+ onComplete?: () => void;
+ }>;
+ }
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
index 7e87d32..23bd6ef 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -4,14 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
+import {LitElement} from 'lit';
import './gr-diff-row';
import {GrDiffRow} from './gr-diff-row';
import {assert, fixture, html} from '@open-wc/testing';
import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {DiffViewMode, GrDiffLineType} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffViewMode, GrDiffLineType, Side} from '../../../api/diff';
import {diffModelToken} from '../gr-diff-model/gr-diff-model';
import {testResolver} from '../../../test/common-test-setup';
+interface GrDiffRowPrivate {
+ layersApplied: boolean;
+ updateLayers(side: Side): Promise<void>;
+}
+
suite('gr-diff-row test', () => {
let element: GrDiffRow;
@@ -239,4 +246,106 @@
`
);
});
+
+ test('renders revert button when showRevertButton is true', async () => {
+ const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+ line.text = 'lorem ipsum';
+ element.left = line;
+ element.right = new GrDiffLine(GrDiffLineType.BLANK);
+ element.showRevertButton = true;
+ await element.updateComplete;
+
+ const revertBtn = element.querySelector('.revert-btn');
+ assert.isNotNull(revertBtn);
+ });
+
+ test('does not render revert button when showRevertButton is false', async () => {
+ const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+ line.text = 'lorem ipsum';
+ element.left = line;
+ element.right = new GrDiffLine(GrDiffLineType.BLANK);
+ element.showRevertButton = false;
+ await element.updateComplete;
+
+ const revertBtn = element.querySelector('.revert-btn');
+ assert.isNull(revertBtn);
+ });
+
+ test('fires revert-delta event on button click', async () => {
+ const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+ line.text = 'lorem ipsum';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [line],
+ });
+ element.left = line;
+ element.right = new GrDiffLine(GrDiffLineType.BLANK);
+ element.group = group;
+ element.showRevertButton = true;
+ await element.updateComplete;
+
+ let eventDetail: {group: GrDiffGroup; onComplete?: () => void} | undefined;
+ element.addEventListener('revert-delta', (e: CustomEvent) => {
+ eventDetail = e.detail;
+ });
+
+ const revertBtn = element.querySelector<HTMLButtonElement>('.revert-btn')!;
+ assert.isNotNull(revertBtn);
+ revertBtn.click();
+ await element.updateComplete;
+
+ assert.isDefined(eventDetail);
+ assert.equal(eventDetail?.group, group);
+ assert.isTrue(revertBtn.classList.contains('loading'));
+ assert.isNotNull(revertBtn.querySelector('.loadingSpin'));
+ assert.isNull(revertBtn.querySelector('gr-icon'));
+
+ eventDetail?.onComplete?.();
+ await element.updateComplete;
+ assert.isFalse(revertBtn.classList.contains('loading'));
+ assert.isNull(revertBtn.querySelector('.loadingSpin'));
+ assert.isNotNull(revertBtn.querySelector('gr-icon'));
+ });
+
+ test('updateLayers aborts when DOM element references change during await', async () => {
+ const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+ line.text = 'lorem ipsum';
+ element.left = line;
+ element.right = line;
+ let annotateCalled = false;
+ element.layers = [
+ {
+ annotate() {
+ annotateCalled = true;
+ },
+ },
+ ];
+ await element.updateComplete;
+ await new Promise(resolve => setTimeout(resolve, 0));
+ annotateCalled = false;
+
+ // Create a mock content element with a controllable updateComplete promise
+ let resolveUpdate: () => void;
+ const updatePromise = new Promise<boolean>(r => {
+ resolveUpdate = () => r(true);
+ });
+ const oldContentEl = {
+ updateComplete: updatePromise,
+ } as unknown as LitElement;
+ element.contentLeftRef = {value: oldContentEl};
+
+ const privElement = element as unknown as GrDiffRowPrivate;
+ privElement.layersApplied = false;
+ const updateLayersPromise = privElement.updateLayers(Side.LEFT);
+
+ // Swap the ref while updateLayers is awaiting updateComplete
+ element.contentLeftRef = {
+ value: document.createElement('div') as unknown as LitElement,
+ };
+ resolveUpdate!();
+ await updateLayersPromise;
+
+ assert.isFalse(annotateCalled);
+ assert.isFalse(privElement.layersApplied);
+ });
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index b9a239a..2d7fbed 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -139,10 +139,13 @@
const hideFileCommentButton =
this.diffPrefs?.show_file_comment_button === false ||
this.renderPrefs?.show_file_comment_button === false;
+ const isDelta =
+ this.group.type === GrDiffGroupType.DELTA && !this.group.dueToRebase;
+ const isEditMode = !!this.renderPrefs?.is_edit_mode;
const body = html`
<tbody class=${extras.join(' ')}>
${this.renderContextControls()} ${this.renderMoveControls()}
- ${pairs.map(pair => {
+ ${pairs.map((pair, index) => {
const leftClass = `left-${pair.left.lineNumber(Side.LEFT)}`;
const rightClass = `right-${pair.right.lineNumber(Side.RIGHT)}`;
return html`
@@ -150,6 +153,8 @@
class="${leftClass} ${rightClass}"
.left=${pair.left}
.right=${pair.right}
+ .group=${this.group}
+ .showRevertButton=${isDelta && isEditMode && index === 0}
.layers=${this.layers}
.lineLength=${this.diffPrefs?.line_length ?? 80}
.tabSize=${this.diffPrefs?.tab_size ?? 2}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
index 99b92ca..1134a9e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -259,4 +259,45 @@
`
);
});
+
+ suite('revert button in edit mode', () => {
+ test('passes showRevertButton to first row of delta group in edit mode', async () => {
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+ removeLine.text = 'old line';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+ addLine.text = 'new line';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+
+ element.group = group;
+ element.renderPrefs = {is_edit_mode: true};
+ await element.updateComplete;
+
+ const rows = element.querySelectorAll('gr-diff-row');
+ assert.equal(rows.length, 1);
+ assert.isTrue(rows[0].showRevertButton);
+ assert.equal(rows[0].group, group);
+ });
+
+ test('does not pass showRevertButton when not in edit mode', async () => {
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+ removeLine.text = 'old line';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+ addLine.text = 'new line';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+
+ element.group = group;
+ element.renderPrefs = {is_edit_mode: false};
+ await element.updateComplete;
+
+ const rows = element.querySelectorAll('gr-diff-row');
+ assert.equal(rows.length, 1);
+ assert.isFalse(rows[0].showRevertButton);
+ });
+ });
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 843e053..058d490 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -161,6 +161,9 @@
font-size: var(--font-size-normal);
--image-border-width: 2px;
}
+ :host(.fit) {
+ max-height: var(--image-viewer-max-height, 75vh);
+ }
.imageArea {
grid-row-start: 1;
grid-column-start: 1;
@@ -679,6 +682,7 @@
// We don't want property changes in updateSizes() to trigger infinite update
// loops, so we perform this in update() instead of updated().
override update(changedProperties: PropertyValues) {
+ this.classList.toggle('fit', this.scaledSelected);
if (!this.baseUrl) this.baseSelected = false;
if (!this.revisionUrl) this.baseSelected = true;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
index 67a6cd7..67ecd18 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -466,6 +466,86 @@
min-width: var(--content-width, 80ch);
width: var(--content-width, 80ch);
}
+ gr-diff-row td:has(.revert-container) {
+ position: relative;
+ }
+ gr-diff-row td.left .revert-container {
+ position: absolute;
+ right: -9px;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ z-index: 10;
+ pointer-events: none;
+ }
+ gr-diff-row tr.unified td.content .revert-container {
+ position: absolute;
+ right: 4px;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ z-index: 10;
+ pointer-events: none;
+ }
+ gr-diff-row .revert-btn {
+ pointer-events: auto;
+ width: 18px;
+ height: 18px;
+ border-radius: 3px;
+ border: 1px solid var(--border-color, #dadce0);
+ background-color: var(--background-color-primary, #ffffff);
+ color: var(--deemphasized-text-color, #5f6368);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 0;
+ margin: 0;
+ box-shadow: var(--elevation-level-1, 0 1px 2px rgba(60, 64, 67, 0.3));
+ transition: background-color 150ms ease, border-color 150ms ease,
+ box-shadow 150ms ease, color 150ms ease;
+ }
+ gr-diff-row .revert-btn gr-icon {
+ font-size: 14px;
+ line-height: 14px;
+ width: 14px;
+ height: 14px;
+ color: inherit;
+ }
+ gr-diff-row .revert-btn:hover {
+ background-color: var(--hover-background-color, #f1f3f4);
+ border-color: var(--primary-button-background-color, #1a73e8);
+ color: var(--primary-button-background-color, #1a73e8);
+ box-shadow: var(--elevation-level-2, 0 1px 3px 1px rgba(60, 64, 67, 0.15));
+ }
+ gr-diff-row .revert-btn:active {
+ background-color: var(--chip-selected-background-color, #e8f0fe);
+ }
+ gr-diff-row .revert-btn.loading {
+ cursor: wait;
+ pointer-events: none;
+ background-color: var(--chip-selected-background-color, #e8f0fe);
+ border-color: var(--primary-button-background-color, #1a73e8);
+ }
+ gr-diff-row .revert-btn .loadingSpin {
+ width: 10px;
+ height: 10px;
+ border: 2px solid var(--disabled-button-background-color, #dadce0);
+ border-top: 2px solid var(--primary-button-background-color, #1a73e8);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ box-sizing: border-box;
+ }
+ @keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
/* If there are no intraline info, consider everything changed */
gr-diff-row td.content.add div.contentText .intraline,
gr-diff-row td.content.add.no-intraline-info div.contentText,
@@ -843,9 +923,11 @@
width: 100%;
height: 100%;
max-width: var(--image-viewer-max-width, 95vw);
- max-height: var(--image-viewer-max-height, 90vh);
--primary-background-color: var(--background-color-secondary);
}
+ gr-image-viewer.fit {
+ max-height: var(--image-viewer-max-height, 75vh);
+ }
tbody.image-diff .gr-diff {
text-align: center;
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index 73c792a..2a25d7d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -3,7 +3,11 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {BlameInfo, CommentRange} from '../../../types/common';
+import {
+ BlameInfo,
+ CommentRange,
+ FixSuggestionInfo,
+} from '../../../types/common';
import {Side, SpecialFilePath} from '../../../constants/constants';
import {
DiffContextExpandedExternalDetail,
@@ -15,7 +19,9 @@
LOST,
RenderPreferences,
} from '../../../api/diff';
-import {GrDiffGroup} from './gr-diff-group';
+import {GrDiffGroup, GrDiffGroupType} from './gr-diff-group';
+import {GrDiffLine} from './gr-diff-line';
+import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
/**
* In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -332,3 +338,249 @@
info.ranges.find(range => range.start <= line && line <= range.end)
);
}
+
+function findPrevLineOnRight(
+ allGroups: GrDiffGroup[],
+ group: GrDiffGroup
+): GrDiffLine | undefined {
+ const groupIdx = allGroups.indexOf(group);
+ if (groupIdx === -1) return undefined;
+ for (let i = groupIdx - 1; i >= 0; i--) {
+ const lines = allGroups[i].lines;
+ for (let j = lines.length - 1; j >= 0; j--) {
+ const line = lines[j];
+ if (typeof line.afterNumber === 'number' && line.afterNumber > 0) {
+ return line;
+ }
+ }
+ }
+ return undefined;
+}
+
+function findNextLineOnRight(
+ allGroups: GrDiffGroup[],
+ group: GrDiffGroup
+): GrDiffLine | undefined {
+ const groupIdx = allGroups.indexOf(group);
+ if (groupIdx === -1) return undefined;
+ for (let i = groupIdx + 1; i < allGroups.length; i++) {
+ const lines = allGroups[i].lines;
+ for (const line of lines) {
+ if (typeof line.afterNumber === 'number' && line.afterNumber > 0) {
+ return line;
+ }
+ }
+ }
+ return undefined;
+}
+
+export function createRevertFixSuggestion(
+ path: string,
+ group: GrDiffGroup,
+ allGroups: GrDiffGroup[] = []
+): FixSuggestionInfo | undefined {
+ if (group.type !== GrDiffGroupType.DELTA) return undefined;
+
+ const groupIdx = allGroups.indexOf(group);
+ if (allGroups.length > 0 && groupIdx === -1) {
+ return undefined;
+ }
+
+ const removes = group.removes ?? [];
+ const adds = group.adds ?? [];
+
+ // Case 1: Modification (lines removed in base AND lines added in edit)
+ if (removes.length > 0 && adds.length > 0) {
+ const startLine = adds[0].afterNumber;
+ const endLine = adds[adds.length - 1].afterNumber;
+ if (typeof startLine !== 'number' || typeof endLine !== 'number') {
+ return undefined;
+ }
+ const lastLineText = adds[adds.length - 1].text;
+ const replacement = removes.map(l => l.text).join('\n');
+ return {
+ fix_id: PROVIDED_FIX_ID,
+ description: 'Revert change',
+ replacements: [
+ {
+ path,
+ range: {
+ start_line: startLine,
+ start_character: 0,
+ end_line: endLine,
+ end_character: lastLineText.length,
+ },
+ replacement,
+ },
+ ],
+ };
+ }
+
+ // Case 2: Pure Addition in Edit (removes is empty, adds has lines)
+ if (removes.length === 0 && adds.length > 0) {
+ const startLine = adds[0].afterNumber;
+ const endLine = adds[adds.length - 1].afterNumber;
+ if (typeof startLine !== 'number' || typeof endLine !== 'number') {
+ return undefined;
+ }
+ const prevLine = findPrevLineOnRight(allGroups, group);
+ const nextLine = findNextLineOnRight(allGroups, group);
+
+ if (startLine > 1 && prevLine && typeof prevLine.afterNumber === 'number') {
+ // Include the line above: replace from start of line above
+ if (nextLine && typeof nextLine.afterNumber === 'number') {
+ return {
+ fix_id: PROVIDED_FIX_ID,
+ description: 'Revert change',
+ replacements: [
+ {
+ path,
+ range: {
+ start_line: prevLine.afterNumber,
+ start_character: 0,
+ end_line: nextLine.afterNumber,
+ end_character: 0,
+ },
+ replacement: prevLine.text + '\n',
+ },
+ ],
+ };
+ } else {
+ // Addition at EOF: replace from start of line above to end of last added line
+ const lastLineText = adds[adds.length - 1].text;
+ return {
+ fix_id: PROVIDED_FIX_ID,
+ description: 'Revert change',
+ replacements: [
+ {
+ path,
+ range: {
+ start_line: prevLine.afterNumber,
+ start_character: 0,
+ end_line: endLine,
+ end_character: lastLineText.length,
+ },
+ replacement: prevLine.text + '\n',
+ },
+ ],
+ };
+ }
+ } else if (
+ startLine === 1 &&
+ nextLine &&
+ typeof nextLine.afterNumber === 'number'
+ ) {
+ // Addition at the top of the file (line 1, no line above):
+ // Include line below: replace from (1, 0) to end of next line
+ return {
+ fix_id: PROVIDED_FIX_ID,
+ description: 'Revert change',
+ replacements: [
+ {
+ path,
+ range: {
+ start_line: 1,
+ start_character: 0,
+ end_line: nextLine.afterNumber,
+ end_character: nextLine.text.length,
+ },
+ replacement: nextLine.text,
+ },
+ ],
+ };
+ } else if (
+ startLine === 1 &&
+ !prevLine &&
+ !nextLine &&
+ (allGroups.length === 0 || allGroups.length === 1)
+ ) {
+ // Entire file was added (no line above and no line below)
+ const lastLineText = adds[adds.length - 1].text;
+ return {
+ fix_id: PROVIDED_FIX_ID,
+ description: 'Revert change',
+ replacements: [
+ {
+ path,
+ range: {
+ start_line: 1,
+ start_character: 0,
+ end_line: endLine,
+ end_character: lastLineText.length,
+ },
+ replacement: '',
+ },
+ ],
+ };
+ } else {
+ return undefined;
+ }
+ }
+
+ // Case 3: Pure Deletion in Edit (removes has lines, adds is empty)
+ if (removes.length > 0 && adds.length === 0) {
+ const nextLine = findNextLineOnRight(allGroups, group);
+ if (nextLine && typeof nextLine.afterNumber === 'number') {
+ return {
+ fix_id: PROVIDED_FIX_ID,
+ description: 'Revert change',
+ replacements: [
+ {
+ path,
+ range: {
+ start_line: nextLine.afterNumber,
+ start_character: 0,
+ end_line: nextLine.afterNumber,
+ end_character: 0,
+ },
+ replacement: removes.map(l => l.text).join('\n') + '\n',
+ },
+ ],
+ };
+ } else {
+ // Deletion at the end of the file
+ const prevLine = findPrevLineOnRight(allGroups, group);
+ if (prevLine && typeof prevLine.afterNumber === 'number') {
+ const prevLineNumber = prevLine.afterNumber;
+ return {
+ fix_id: PROVIDED_FIX_ID,
+ description: 'Revert change',
+ replacements: [
+ {
+ path,
+ range: {
+ start_line: prevLineNumber,
+ start_character: prevLine.text.length,
+ end_line: prevLineNumber,
+ end_character: prevLine.text.length,
+ },
+ replacement: '\n' + removes.map(l => l.text).join('\n'),
+ },
+ ],
+ };
+ } else if (allGroups.length === 0 || allGroups.length === 1) {
+ // File in Edit was completely empty
+ return {
+ fix_id: PROVIDED_FIX_ID,
+ description: 'Revert change',
+ replacements: [
+ {
+ path,
+ range: {
+ start_line: 1,
+ start_character: 0,
+ end_line: 1,
+ end_character: 0,
+ },
+ replacement: removes.map(l => l.text).join('\n') + '\n',
+ },
+ ],
+ };
+ } else {
+ return undefined;
+ }
+ }
+ }
+
+ return undefined;
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 2f5b077..c0400a5 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -10,6 +10,7 @@
computeContext,
computeKeyLocations,
computeLineLength,
+ createRevertFixSuggestion,
FULL_CONTEXT,
FullContext,
getDataFromCommentThreadEl,
@@ -17,8 +18,11 @@
GrDiffCommentThread,
GrDiffThreadElement,
} from './gr-diff-utils';
-import {FILE, LOST, Side} from '../../../api/diff';
+import {FILE, GrDiffLineType, LOST, Side} from '../../../api/diff';
import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffGroup, GrDiffGroupType} from './gr-diff-group';
+import {GrDiffLine} from './gr-diff-line';
+import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
suite('gr-diff-utils tests', () => {
test('getRange returns undefined with start_line = 0', () => {
@@ -238,4 +242,337 @@
);
});
});
+
+ suite('createRevertFixSuggestion', () => {
+ test('returns undefined for non-delta group', () => {
+ const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+ line.text = 'common line';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [line],
+ });
+ assert.isUndefined(createRevertFixSuggestion('foo.ts', group));
+ });
+
+ test('creates fix for modification', () => {
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+ removeLine.text = 'const a = 1;';
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+ addLine.text = 'const a = 2;';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine, addLine],
+ });
+ const fix = createRevertFixSuggestion('foo.ts', group);
+ assert.isDefined(fix);
+ assert.equal(fix.fix_id, PROVIDED_FIX_ID);
+ assert.equal(fix.description, 'Revert change');
+ assert.deepEqual(fix.replacements, [
+ {
+ path: 'foo.ts',
+ range: {
+ start_line: 10,
+ start_character: 0,
+ end_line: 10,
+ end_character: 12,
+ },
+ replacement: 'const a = 1;',
+ },
+ ]);
+ });
+
+ test('creates fix for pure addition in middle of file', () => {
+ const prevLine = new GrDiffLine(GrDiffLineType.BOTH, 4, 4);
+ prevLine.text = 'common line 4';
+ const prevGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [prevLine],
+ });
+
+ const addLine1 = new GrDiffLine(GrDiffLineType.ADD, 0, 5);
+ addLine1.text = 'new line 5';
+ const addLine2 = new GrDiffLine(GrDiffLineType.ADD, 0, 6);
+ addLine2.text = 'new line 6';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [addLine1, addLine2],
+ });
+
+ const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 5, 7);
+ nextLine.text = 'common line 7';
+ const nextGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [nextLine],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [
+ prevGroup,
+ group,
+ nextGroup,
+ ]);
+ assert.isDefined(fix);
+ assert.deepEqual(fix.replacements, [
+ {
+ path: 'foo.ts',
+ range: {
+ start_line: 4,
+ start_character: 0,
+ end_line: 7,
+ end_character: 0,
+ },
+ replacement: 'common line 4\n',
+ },
+ ]);
+ });
+
+ test('creates fix for pure addition of empty line in middle of file', () => {
+ const prevLine = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+ prevLine.text = 'first line';
+ const prevGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [prevLine],
+ });
+
+ const emptyLine = new GrDiffLine(GrDiffLineType.ADD, 0, 2);
+ emptyLine.text = '';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [emptyLine],
+ });
+
+ const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 2, 3);
+ nextLine.text = 'second line';
+ const nextGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [nextLine],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [
+ prevGroup,
+ group,
+ nextGroup,
+ ]);
+ assert.isDefined(fix);
+ assert.deepEqual(fix.replacements, [
+ {
+ path: 'foo.ts',
+ range: {
+ start_line: 1,
+ start_character: 0,
+ end_line: 3,
+ end_character: 0,
+ },
+ replacement: 'first line\n',
+ },
+ ]);
+ });
+
+ test('creates fix for pure addition at beginning of file', () => {
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+ addLine.text = '';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [addLine],
+ });
+
+ const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 1, 2);
+ nextLine.text = 'existing line';
+ const nextGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [nextLine],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [
+ group,
+ nextGroup,
+ ]);
+ assert.isDefined(fix);
+ assert.deepEqual(fix.replacements, [
+ {
+ path: 'foo.ts',
+ range: {
+ start_line: 1,
+ start_character: 0,
+ end_line: 2,
+ end_character: 13,
+ },
+ replacement: 'existing line',
+ },
+ ]);
+ });
+
+ test('creates fix for pure addition at end of file', () => {
+ const prevLine = new GrDiffLine(GrDiffLineType.BOTH, 10, 10);
+ prevLine.text = 'prev line 10';
+ const prevGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [prevLine],
+ });
+
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 11);
+ addLine.text = 'end addition';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [addLine],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [
+ prevGroup,
+ group,
+ ]);
+ assert.isDefined(fix);
+ assert.deepEqual(fix.replacements, [
+ {
+ path: 'foo.ts',
+ range: {
+ start_line: 10,
+ start_character: 0,
+ end_line: 11,
+ end_character: 12,
+ },
+ replacement: 'prev line 10\n',
+ },
+ ]);
+ });
+
+ test('creates fix for pure addition of whole file', () => {
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+ addLine.text = 'whole file content';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [addLine],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [group]);
+ assert.isDefined(fix);
+ assert.deepEqual(fix.replacements, [
+ {
+ path: 'foo.ts',
+ range: {
+ start_line: 1,
+ start_character: 0,
+ end_line: 1,
+ end_character: 18,
+ },
+ replacement: '',
+ },
+ ]);
+ });
+
+ test('creates fix for pure deletion in middle of file', () => {
+ const removeLine1 = new GrDiffLine(GrDiffLineType.REMOVE, 5, 0);
+ removeLine1.text = 'deleted line 5';
+ const removeLine2 = new GrDiffLine(GrDiffLineType.REMOVE, 6, 0);
+ removeLine2.text = 'deleted line 6';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine1, removeLine2],
+ });
+
+ const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 7, 5);
+ nextLine.text = 'common line';
+ const nextGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [nextLine],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [
+ group,
+ nextGroup,
+ ]);
+ assert.isDefined(fix);
+ assert.deepEqual(fix.replacements, [
+ {
+ path: 'foo.ts',
+ range: {
+ start_line: 5,
+ start_character: 0,
+ end_line: 5,
+ end_character: 0,
+ },
+ replacement: 'deleted line 5\ndeleted line 6\n',
+ },
+ ]);
+ });
+
+ test('creates fix for pure deletion at end of file', () => {
+ const prevLine = new GrDiffLine(GrDiffLineType.BOTH, 4, 4);
+ prevLine.text = 'prev line 4';
+ const prevGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [prevLine],
+ });
+
+ const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 5, 0);
+ removeLine.text = 'deleted last line';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [removeLine],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [
+ prevGroup,
+ group,
+ ]);
+ assert.isDefined(fix);
+ assert.deepEqual(fix.replacements, [
+ {
+ path: 'foo.ts',
+ range: {
+ start_line: 4,
+ start_character: 11,
+ end_line: 4,
+ end_character: 11,
+ },
+ replacement: '\ndeleted last line',
+ },
+ ]);
+ });
+
+ test('returns undefined when group is not found in non-empty allGroups', () => {
+ const otherGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [new GrDiffLine(GrDiffLineType.BOTH, 1, 1)],
+ });
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [new GrDiffLine(GrDiffLineType.ADD, 0, 5)],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [otherGroup]);
+ assert.isUndefined(fix);
+ });
+
+ test('returns undefined when pure addition has startLine > 1 without surrounding context', () => {
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [new GrDiffLine(GrDiffLineType.ADD, 0, 10)],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [group]);
+ assert.isUndefined(fix);
+ });
+
+ test('returns undefined when pure addition has startLine > 1 without prevLine even if nextLine exists', () => {
+ const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 5);
+ addLine.text = 'added line';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [addLine],
+ });
+
+ const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 6, 6);
+ nextLine.text = 'next line';
+ const nextGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [nextLine],
+ });
+
+ const fix = createRevertFixSuggestion('foo.ts', group, [
+ group,
+ nextGroup,
+ ]);
+ assert.isUndefined(fix);
+ });
+ });
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 502d239..3277633 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -65,6 +65,13 @@
element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
});
+ test('embedder isolation: scroll-padding-top is not set on root', () => {
+ assert.equal(
+ document.documentElement.style.getPropertyValue('scroll-padding-top'),
+ ''
+ );
+ });
+
suite('selectionchange event handling', () => {
let handleSelectionChangeStub: sinon.SinonSpy;
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index 52bb4d8..f241926 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -148,6 +148,30 @@
});
}
+ restoreChanges(
+ reason?: string,
+ // errorFn is needed to avoid showing an error dialog
+ errFn?: (changeNum: NumericChangeId) => void
+ ): Promise<Response>[] {
+ const current = this.getState();
+ return current.selectedChangeNums.map(changeNum => {
+ if (!current.allChanges.get(changeNum))
+ throw new Error('invalid change id');
+ const change = current.allChanges.get(changeNum)!;
+ if (change.status !== ChangeStatus.ABANDONED) {
+ return Promise.resolve(new Response());
+ }
+ return this.restApiService.executeChangeAction(
+ getChangeNumber(change),
+ change.actions!.restore!.method,
+ '/restore',
+ undefined,
+ {message: reason ?? ''},
+ () => errFn && errFn(getChangeNumber(change))
+ );
+ });
+ }
+
voteChanges(reviewInput: ReviewInput) {
const current = this.getState();
return current.selectedChangeNums.map(changeNum => {
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index 5169305..aef87e7 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -213,7 +213,11 @@
detailedActionsStub.returns(
Promise.resolve([
{...c1, actions: {abandon: {method: HttpMethod.POST}}},
- {...c2, status: ChangeStatus.ABANDONED},
+ {
+ ...c2,
+ actions: {restore: {method: HttpMethod.POST}},
+ status: ChangeStatus.ABANDONED,
+ },
])
);
@@ -235,6 +239,19 @@
{message: ''},
]);
});
+
+ test('restore only calls executeChangeAction for abandoned changes', () => {
+ const actionStub = stubRestApi('executeChangeAction').resolves();
+ bulkActionsModel.restoreChanges();
+ assert.equal(actionStub.callCount, 1);
+ assert.deepEqual(actionStub.lastCall.args.slice(0, 5), [
+ 2 as NumericChangeId,
+ HttpMethod.POST,
+ '/restore',
+ undefined,
+ {message: ''},
+ ]);
+ });
});
suite('add reviewers', () => {
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index c6a0d02..bc45fcb 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -335,6 +335,27 @@
return isMergeCommit ? FIRST_PARENT : PARENT;
}
+/**
+ * The base to put into the URL of a change other than the current one, e.g.
+ * for the links of the relation chain. There is no user choice of base to
+ * preserve there, so this is only about spelling out what the
+ * `default_base_for_merges` preference picks anyway: an explicit base makes
+ * the link resolve to the same diff for everyone, regardless of what the
+ * recipient of the link has configured.
+ *
+ * Only merge commits have a choice of base, so `undefined` is returned for
+ * everything else, which leaves the base out of the URL.
+ */
+export function urlBaseForCommit(
+ isMergeCommit: boolean,
+ preferences: PreferencesInfo
+): BasePatchSetNum | undefined {
+ if (!isMergeCommit) return undefined;
+ return preferences.default_base_for_merges === DefaultBase.FIRST_PARENT
+ ? FIRST_PARENT
+ : AUTO_MERGE;
+}
+
// TODO: Figure out how to best enforce immutability of all states. Use Immer?
// Use DeepReadOnly?
const initialState: ChangeState = {
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index 8d50a38..aa79b80 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -53,6 +53,7 @@
RevisionFileUpdateStatus,
updateChangeWithEdit,
updateRevisionsWithCommitShas,
+ urlBaseForCommit,
} from './change-model';
import {ChangeModel} from './change-model';
import {assert} from '@open-wc/testing';
@@ -852,6 +853,27 @@
});
});
+ suite('urlBaseForCommit', () => {
+ const firstParent = {
+ ...createDefaultPreferences(),
+ default_base_for_merges: DefaultBase.FIRST_PARENT,
+ };
+ const autoMerge = {
+ ...createDefaultPreferences(),
+ default_base_for_merges: DefaultBase.AUTO_MERGE,
+ };
+
+ test('spells out the base of a merge commit', () => {
+ assert.equal(urlBaseForCommit(true, firstParent), FIRST_PARENT);
+ assert.equal(urlBaseForCommit(true, autoMerge), AUTO_MERGE);
+ });
+
+ test('leaves the base out for a single parent commit', () => {
+ assert.isUndefined(urlBaseForCommit(false, firstParent));
+ assert.isUndefined(urlBaseForCommit(false, autoMerge));
+ });
+ });
+
test('revision$ selector latest', async () => {
changeViewModel.updateState({patchNum: undefined});
changeModel.updateState({change: knownChange});
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index 4e0af0a..e20f353 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -5,6 +5,7 @@
*/
import {
BasePatchSetNum,
+ EDIT,
FileInfo,
FileNameToFileInfoMap,
PARENT,
@@ -12,12 +13,13 @@
PatchSetNumber,
RevisionPatchSetNum,
} from '../../types/common';
-import {combineLatest, from, Observable, of} from 'rxjs';
+import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
import {select} from '../../utils/observable-util';
import {FileInfoStatus, SpecialFilePath} from '../../constants/constants';
import {specialFilePathCompare} from '../../utils/path-list-util';
+import {RevisionInfo as RevisionInfoClass} from '../../elements/shared/revision-info/revision-info';
import {Model} from '../base/model';
import {define} from '../dependency';
import {ChangeModel} from './change-model';
@@ -133,12 +135,24 @@
* Empty if the left chosen patchset is PARENT.
*/
filesRightBase: NormalizedFileInfo[];
+
+ /**
+ * For merge commits vs Auto Merge, paths of files that merged cleanly.
+ */
+ cleanlyMergedPaths: string[];
+
+ /**
+ * Old paths of cleanly merged files (for renamed files).
+ */
+ cleanlyMergedOldPaths: string[];
}
const initialState: FilesState = {
files: [],
filesLeftBase: [],
filesRightBase: [],
+ cleanlyMergedPaths: [],
+ cleanlyMergedOldPaths: [],
};
export const filesModelToken = define<FilesModel>('files-model');
@@ -162,6 +176,16 @@
public readonly filesRightBase$;
+ public readonly cleanlyMergedPaths$ = select(
+ this.state$,
+ state => state.cleanlyMergedPaths
+ );
+
+ public readonly cleanlyMergedOldPaths$ = select(
+ this.state$,
+ state => state.cleanlyMergedOldPaths
+ );
+
constructor(
readonly changeModel: ChangeModel,
readonly commentsModel: CommentsModel,
@@ -214,6 +238,7 @@
return {filesRightBase: [...files]};
}
),
+ this.subscribeToCleanlyMergedPaths(),
];
}
@@ -265,4 +290,60 @@
this.updateState(state);
});
}
+
+ private subscribeToCleanlyMergedPaths() {
+ return combineLatest([
+ this.changeModel.change$,
+ this.changeModel.changeNum$,
+ this.changeModel.basePatchNum$,
+ this.changeModel.patchNum$,
+ ])
+ .pipe(
+ switchMap(([change, changeNum, basePatchNum, patchNum]) => {
+ if (
+ !change ||
+ !changeNum ||
+ !patchNum ||
+ !new RevisionInfoClass(change).isMergeCommit(patchNum) ||
+ basePatchNum !== PARENT ||
+ patchNum === EDIT
+ ) {
+ return of({cleanlyMergedPaths: [], cleanlyMergedOldPaths: []});
+ }
+ return forkJoin([
+ from(
+ this.restApiService.getChangeOrEditFiles(changeNum, {
+ basePatchNum: -1 as BasePatchSetNum,
+ patchNum,
+ })
+ ),
+ from(
+ this.restApiService.getChangeOrEditFiles(changeNum, {
+ basePatchNum: PARENT,
+ patchNum,
+ })
+ ),
+ ]).pipe(
+ map(([allFilesByPath, conflictingFilesByPath]) => {
+ if (!allFilesByPath) {
+ return {cleanlyMergedPaths: [], cleanlyMergedOldPaths: []};
+ }
+ const conflictingPaths = Object.keys(
+ conflictingFilesByPath ?? {}
+ );
+ const cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
+ path => !conflictingPaths.includes(path)
+ );
+ const cleanlyMergedOldPaths = cleanlyMergedPaths
+ .map(path => allFilesByPath[path].old_path)
+ .filter((oldPath): oldPath is string => !!oldPath);
+ return {cleanlyMergedPaths, cleanlyMergedOldPaths};
+ })
+ );
+ })
+ )
+ .subscribe(state => {
+ this.updateState(state);
+ });
+ }
}
diff --git a/polygerrit-ui/app/models/change/files-model_test.ts b/polygerrit-ui/app/models/change/files-model_test.ts
new file mode 100644
index 0000000..054c5f5
--- /dev/null
+++ b/polygerrit-ui/app/models/change/files-model_test.ts
@@ -0,0 +1,174 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import * as sinon from 'sinon';
+import '../../test/common-test-setup';
+import {assert} from '@open-wc/testing';
+import {FilesModel} from './files-model';
+import {ChangeModel} from './change-model';
+import {createDefaultPreferences} from '../../constants/constants';
+import {
+ createChangeViewState,
+ createParsedChange,
+ createRevision,
+ TEST_NUMERIC_CHANGE_ID,
+} from '../../test/test-data-generators';
+import {stubRestApi, waitUntilObserved} from '../../test/test-utils';
+import {
+ CommitId,
+ FileNameToFileInfoMap,
+ PARENT,
+ RevisionPatchSetNum,
+} from '../../types/common';
+import {getAppContext} from '../../services/app-context';
+import {testResolver} from '../../test/common-test-setup';
+import {ChangeViewModel, changeViewModelToken} from '../views/change';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
+import {userModelToken} from '../user/user-model';
+import {commentsModelToken} from '../comments/comments-model';
+import {checksModelToken} from '../checks/checks-model';
+import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+
+suite('files-model tests', () => {
+ let changeModel: ChangeModel;
+ let changeViewModel: ChangeViewModel;
+ let filesModel: FilesModel;
+
+ setup(() => {
+ stubRestApi('getAllRevisionFiles').resolves({});
+ stubRestApi('getChangeDetail').callsFake(() => new Promise(() => {}));
+ stubRestApi('getChangeEdit').resolves(undefined);
+ testResolver(userModelToken).setPreferences(createDefaultPreferences());
+ changeViewModel = testResolver(changeViewModelToken);
+ changeModel = new ChangeModel(
+ testResolver(navigationToken),
+ changeViewModel,
+ getAppContext().restApiService,
+ testResolver(userModelToken),
+ testResolver(pluginLoaderToken),
+ getAppContext().reportingService
+ );
+ filesModel = new FilesModel(
+ changeModel,
+ testResolver(commentsModelToken),
+ testResolver(checksModelToken),
+ getAppContext().restApiService,
+ getAppContext().reportingService
+ );
+ });
+
+ teardown(() => {
+ filesModel.finalize();
+ changeModel.finalize();
+ });
+
+ test('cleanly merged paths for merge commit', async () => {
+ stubRestApi('getChangeOrEditFiles').callsFake((_changeNum, range) => {
+ if (range?.basePatchNum === -1) {
+ return Promise.resolve({
+ 'conflict.txt': {},
+ 'cleanlyMerged.txt': {old_path: 'cleanlyMergedOld.txt'},
+ } as FileNameToFileInfoMap);
+ }
+ return Promise.resolve({
+ 'conflict.txt': {},
+ } as FileNameToFileInfoMap);
+ });
+
+ const revision = createRevision(1);
+ const mergeCommit = {
+ ...revision.commit!,
+ parents: [
+ {commit: 'p1' as CommitId, subject: 'parent 1'},
+ {commit: 'p2' as CommitId, subject: 'parent 2'},
+ ],
+ };
+
+ const change = {
+ ...createParsedChange(),
+ _number: TEST_NUMERIC_CHANGE_ID,
+ revisions: {
+ sha1: {
+ ...revision,
+ commit: mergeCommit,
+ },
+ },
+ current_revision: 'sha1' as CommitId,
+ };
+
+ changeViewModel.setState({
+ ...createChangeViewState(),
+ changeNum: TEST_NUMERIC_CHANGE_ID,
+ patchNum: 1 as RevisionPatchSetNum,
+ basePatchNum: PARENT,
+ });
+ changeModel.updateStateChange(change);
+
+ const cleanlyMergedPaths = await waitUntilObserved(
+ filesModel.cleanlyMergedPaths$,
+ paths => paths.length > 0
+ );
+ assert.deepEqual(cleanlyMergedPaths, ['cleanlyMerged.txt']);
+
+ const cleanlyMergedOldPaths = await waitUntilObserved(
+ filesModel.cleanlyMergedOldPaths$,
+ paths => paths.length > 0
+ );
+ assert.deepEqual(cleanlyMergedOldPaths, ['cleanlyMergedOld.txt']);
+ });
+
+ test('non-merge commit does not query -1 base', async () => {
+ const getChangeOrEditFilesStub = stubRestApi(
+ 'getChangeOrEditFiles'
+ ).resolves({
+ 'file1.txt': {},
+ } as FileNameToFileInfoMap);
+
+ const revision = createRevision(1);
+ const singleParentCommit = {
+ ...revision.commit!,
+ parents: [{commit: 'p1' as CommitId, subject: 'parent 1'}],
+ };
+
+ const change = {
+ ...createParsedChange(),
+ _number: TEST_NUMERIC_CHANGE_ID,
+ revisions: {
+ sha1: {
+ ...revision,
+ commit: singleParentCommit,
+ },
+ },
+ current_revision: 'sha1' as CommitId,
+ };
+
+ changeViewModel.setState({
+ ...createChangeViewState(),
+ changeNum: TEST_NUMERIC_CHANGE_ID,
+ patchNum: 1 as RevisionPatchSetNum,
+ basePatchNum: PARENT,
+ });
+ changeModel.updateStateChange(change);
+
+ const files = await waitUntilObserved(filesModel.files$, f => f.length > 0);
+ assert.equal(files.length, 1);
+
+ // Verify getChangeOrEditFiles was not called with basePatchNum: -1
+ assert.isFalse(
+ getChangeOrEditFilesStub.calledWith(
+ TEST_NUMERIC_CHANGE_ID,
+ sinon.match({
+ basePatchNum: -1,
+ })
+ )
+ );
+
+ const cleanlyMergedPaths = await waitUntilObserved(
+ filesModel.cleanlyMergedPaths$,
+ paths => paths.length === 0
+ );
+ assert.deepEqual(cleanlyMergedPaths, []);
+ });
+});
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index c8ec819..cc157ea 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -23,6 +23,7 @@
createDefaultDiffPrefs,
createDefaultEditPrefs,
createDefaultPreferences,
+ DEFAULT_VISIBLE_COLUMNS,
} from '../../constants/constants';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
import {DiffPreferencesInfo} from '../../types/diff';
@@ -34,7 +35,9 @@
export function changeTablePrefs(prefs: Partial<PreferencesInfo>) {
const cols = prefs.change_table ?? [];
- if (cols.length === 0) return Object.values(ColumnNames);
+ // An empty pref means "the defaults", which intentionally excludes opt-in
+ // columns such as Hashtags.
+ if (cols.length === 0) return [...DEFAULT_VISIBLE_COLUMNS];
return cols
.map(column => (column === 'Project' ? ColumnNames.REPO : column))
.map(column => (column === ' Status ' ? ColumnNames.STATUS : column));
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index cd928a4..797f8c1 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -2650,11 +2650,15 @@
);
const body: {
fix_replacement_infos: FixReplacementInfo[];
- original_patchset_for_fix?: PatchSetNum;
+ original_patchset_for_fix?: number;
} = {
fix_replacement_infos: fixReplacementInfos,
};
- if (targetPatchNum !== undefined && targetPatchNum !== fixPatchNum) {
+ if (
+ targetPatchNum !== undefined &&
+ targetPatchNum !== fixPatchNum &&
+ typeof fixPatchNum === 'number'
+ ) {
body.original_patchset_for_fix = fixPatchNum;
}
return this._restApiHelper.fetch({
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
index 48e7fab..5f98513 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -2078,6 +2078,25 @@
assert.deepEqual(body.fix_replacement_infos[0], fixReplacementInfo);
assert.deepEqual(body.original_patchset_for_fix, 1);
});
+
+ test('applyFixSuggestion with non-numeric fixPatchNum (EDIT) does not set original_patchset_for_fix', async () => {
+ const fixReplacementInfo = createFixReplacementInfo();
+ await element.applyFixSuggestion(
+ 123 as NumericChangeId,
+ 'edit' as PatchSetNum,
+ [fixReplacementInfo],
+ 2 as PatchSetNum
+ );
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(
+ fetchStub.lastCall.args[0].url,
+ '/changes/test-project~123/revisions/2/fix:apply'
+ );
+ const body = JSON.parse(fetchStub.lastCall.args[0].fetchOptions.body);
+ assert.isTrue(Object.keys(body).length === 1);
+ assert.deepEqual(body.fix_replacement_infos[0], fixReplacementInfo);
+ assert.isUndefined(body.original_patchset_for_fix);
+ });
});
suite('getFixPreview', () => {
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index 446d2d9..331fe59 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -411,9 +411,13 @@
'Hide/show left diff',
{key: 'A'}
);
- describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', {
- key: 'c',
- });
+ describe(
+ Shortcut.NEW_COMMENT,
+ ShortcutSection.DIFFS,
+ 'Draft new comment',
+ {key: 'c'},
+ {key: 'C'}
+ );
describe(
Shortcut.SAVE_COMMENT,
ShortcutSection.DIFFS,
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index a73afc4..cfc00ce 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -126,8 +126,12 @@
.truncatedRepo {
display: none;
}
+ .hashtags {
+ white-space: nowrap;
+ }
@media only screen and (max-width: 150em) {
- .branch {
+ .branch,
+ .hashtags {
overflow: hidden;
max-width: 18rem;
text-overflow: ellipsis;
@@ -140,7 +144,8 @@
}
}
@media only screen and (max-width: 100em) {
- .branch {
+ .branch,
+ .hashtags {
max-width: 10rem;
}
}
@@ -182,6 +187,7 @@
.status,
.repo,
.branch,
+ .hashtags,
.updated,
.submitted,
.waiting,
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index d92b45a..d4a703d 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -439,13 +439,6 @@
--change-header-height: 0px;
--diff-header-height: 0px;
- /* Defines top optimal viewing region for PageDown/PageUp keyboard paging & anchor jumps */
- scroll-padding-top: calc(
- var(--main-header-height) +
- var(--change-header-height) +
- var(--diff-header-height)
- );
-
/* diff colors */
--dark-add-highlight-color: #aaf2aa;
--light-add-highlight-color: #d8fed8;
diff --git a/polygerrit-ui/app/utils/commit-message-formatter-util.ts b/polygerrit-ui/app/utils/commit-message-formatter-util.ts
index 6d80a39..f97611a 100644
--- a/polygerrit-ui/app/utils/commit-message-formatter-util.ts
+++ b/polygerrit-ui/app/utils/commit-message-formatter-util.ts
@@ -32,7 +32,7 @@
const MAX_LINE_LENGTH = 72;
const INDENTATION_THRESHOLD = 4;
const BULLET_POINT_REGEX = /^\s*[-+*#]\s/;
-const FOOTER_REGEX = /^([\w-]+):[ \t]+(.+)$/;
+const FOOTER_REGEX = /^([\w-]+)(?::[ \t]+|=)(.*)$/;
/*
* Check if last line of "Body" follows the "footer" format and if yes, then transfer it to the "footer section"
diff --git a/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts b/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts
index b4ba84b..bb8ab84 100644
--- a/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts
+++ b/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts
@@ -100,6 +100,42 @@
);
});
+ test('footers with equals sign (TAG=agy, CONV=...) are not split or merged', () => {
+ const message =
+ 'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\nChange-Id: abcdefg\n';
+ assert.equal(
+ formatCommitMessageString(message),
+ 'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\nChange-Id: abcdefg\n'
+ );
+ });
+
+ test('footers with equals sign separated by a blank line', () => {
+ const message =
+ 'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\n\nChange-Id: abcdefg\n';
+ assert.equal(
+ formatCommitMessageString(message),
+ 'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\nChange-Id: abcdefg\n'
+ );
+ });
+
+ test('footers with equals sign only are recognized as footers', () => {
+ const message =
+ 'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\n';
+ assert.equal(
+ formatCommitMessageString(message),
+ 'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\n'
+ );
+ });
+
+ test('chromium-style footers with equals sign are preserved', () => {
+ const message =
+ 'Add new feature\n\nThis is a long description of the feature that should be wrapped across multiple lines if needed.\n\nBUG=chromium:12345\nTEST=browser_tests\nR=reviewer@chromium.org\n';
+ assert.equal(
+ formatCommitMessageString(message),
+ 'Add new feature\n\nThis is a long description of the feature that should be wrapped across\nmultiple lines if needed.\n\nBUG=chromium:12345\nTEST=browser_tests\nR=reviewer@chromium.org\n'
+ );
+ });
+
test('indented lines are untouched', () => {
const message =
'Fix the thing\n\n This is an indented line.\n This is another indented line.\n\nChange-Id: abcdefg\n';
@@ -505,5 +541,18 @@
'footer with at least one proper format line should be kept as footer'
);
});
+
+ test('footer with equals sign format line is kept as footer', () => {
+ assertParseResult(
+ 'Subject\n\nBody line\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048',
+ {
+ subject: 'Subject',
+ body: ['Body line'],
+ footer: ['TAG=agy', 'CONV=fe186e29-8ffb-4bb8-a778-d48e3c804048'],
+ hasTrailingBlankLine: false,
+ },
+ 'footer with equals sign format line should be kept as footer'
+ );
+ });
});
});
diff --git a/polygerrit-ui/app/utils/diff-util.ts b/polygerrit-ui/app/utils/diff-util.ts
index b6b51a0..56313b8 100644
--- a/polygerrit-ui/app/utils/diff-util.ts
+++ b/polygerrit-ui/app/utils/diff-util.ts
@@ -117,3 +117,19 @@
!!diff?.meta_b?.content_type.startsWith('image/')
);
}
+
+const MARKDOWN_FILE_EXTENSIONS = /\.(md|markdown|mdown|mkdn|mkd)$/i;
+
+export function isMarkdownDiff(path?: string, diff?: DiffInfo): boolean {
+ if (path && MARKDOWN_FILE_EXTENSIONS.test(path)) {
+ return true;
+ }
+ const contentTypeA = diff?.meta_a?.content_type;
+ const contentTypeB = diff?.meta_b?.content_type;
+ return (
+ !!contentTypeA?.startsWith('text/x-markdown') ||
+ !!contentTypeB?.startsWith('text/x-markdown') ||
+ !!contentTypeA?.startsWith('text/markdown') ||
+ !!contentTypeB?.startsWith('text/markdown')
+ );
+}
diff --git a/polygerrit-ui/app/utils/diff-util_test.ts b/polygerrit-ui/app/utils/diff-util_test.ts
index 838fab3..becae05 100644
--- a/polygerrit-ui/app/utils/diff-util_test.ts
+++ b/polygerrit-ui/app/utils/diff-util_test.ts
@@ -11,6 +11,7 @@
getContentFromDiff,
isFileUnchanged,
isLineUnchanged,
+ isMarkdownDiff,
} from './diff-util';
suite('diff-util tests', () => {
@@ -196,4 +197,69 @@
assert.equal(getContentFromDiff(diff, 18, 1, 18, 3, Side.RIGHT), 'xc');
});
});
+
+ suite('isMarkdownDiff()', () => {
+ test('detects markdown extensions', () => {
+ assert.isTrue(isMarkdownDiff('foo/bar/README.md'));
+ assert.isTrue(isMarkdownDiff('SKILL.md'));
+ assert.isTrue(isMarkdownDiff('doc.markdown'));
+ assert.isTrue(isMarkdownDiff('notes.mdown'));
+ assert.isTrue(isMarkdownDiff('file.mkd'));
+ assert.isFalse(isMarkdownDiff('code.ts'));
+ assert.isFalse(isMarkdownDiff('image.png'));
+ assert.isFalse(isMarkdownDiff(undefined));
+ });
+
+ test('detects markdown content types', () => {
+ const diff: DiffInfo = {
+ ...createDiff(),
+ meta_a: {
+ name: 'doc',
+ content_type: 'text/x-markdown',
+ lines: 10,
+ },
+ };
+ assert.isTrue(isMarkdownDiff('doc', diff));
+
+ const diffB: DiffInfo = {
+ ...createDiff(),
+ meta_b: {
+ name: 'doc',
+ content_type: 'text/markdown',
+ lines: 10,
+ },
+ };
+ assert.isTrue(isMarkdownDiff('doc', diffB));
+
+ const diffCharset: DiffInfo = {
+ ...createDiff(),
+ meta_b: {
+ name: 'doc',
+ content_type: 'text/markdown; charset=utf-8',
+ lines: 10,
+ },
+ };
+ assert.isTrue(isMarkdownDiff('doc', diffCharset));
+
+ const diffXCharset: DiffInfo = {
+ ...createDiff(),
+ meta_a: {
+ name: 'doc',
+ content_type: 'text/x-markdown; charset=utf-8',
+ lines: 10,
+ },
+ };
+ assert.isTrue(isMarkdownDiff('doc', diffXCharset));
+
+ const diffOther: DiffInfo = {
+ ...createDiff(),
+ meta_a: {
+ name: 'code',
+ content_type: 'text/plain',
+ lines: 10,
+ },
+ };
+ assert.isFalse(isMarkdownDiff('code', diffOther));
+ });
+ });
});
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png
index 10dbc5a..5bf9965 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png
index ff4d272..b7b7977 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px-dark.png
index d1deb28..bc9fb48 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png
index 842ab66..d5b01a9 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-dark.png
new file mode 100644
index 0000000..f34e01e
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified-dark.png
new file mode 100644
index 0000000..c5f7ae3
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified.png
new file mode 100644
index 0000000..54826a2
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert.png
new file mode 100644
index 0000000..444b459
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions-dark.png
new file mode 100644
index 0000000..7f557d0
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions.png
new file mode 100644
index 0000000..90727b4e
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix-dark.png
index 0e0b230..bdd2cd9 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix.png
index 3ae5bc2..85eb563 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix.png
Binary files differ
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index ca1fc8c..121f301 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -51,7 +51,7 @@
usage() {
me=`basename "$0"`
- echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site] [--debug [--debug-port|--debug-address ...] [--suspend]] [--count=n]"
+ echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads|histogram} [-d site] [--debug [--debug-port|--debug-address ...] [--suspend]] [--count=n]"
exit 1
}
@@ -76,6 +76,13 @@
return 0;
}
+histogram_dump() {
+ test -f $1 || return 1
+ PID=`cat $1`
+ $JCMD $PID GC.class_histogram || return 1
+ return 0;
+}
+
get_config() {
if test -f "$GERRIT_CONFIG" ; then
if test "x$1" = x--int ; then
@@ -258,6 +265,7 @@
GERRIT_PID="$GERRIT_LOGS/gerrit.pid"
GERRIT_RUN="$GERRIT_LOGS/gerrit.run"
GERRIT_THREADS="$GERRIT_LOGS/threads"
+GERRIT_HISTOGRAM="$GERRIT_LOGS/histogram"
GERRIT_TMP="$GERRIT_SITE/tmp"
export GERRIT_TMP
@@ -333,6 +341,10 @@
JSTACK="$JAVA_HOME/bin/jstack"
fi
+if test -z "$JCMD"; then
+ JCMD="$JAVA_HOME/bin/jcmd"
+fi
+
#####################################################
# Add Gerrit properties to Java VM options.
#####################################################
@@ -680,6 +692,19 @@
exit 3
;;
+ histogram)
+ if running "$GERRIT_PID" ; then
+ mkdir -p -- "$GERRIT_HISTOGRAM"
+ HISTOGRAM="$GERRIT_HISTOGRAM/histogram-`ztime`"
+ histogram_dump "$GERRIT_PID" > "$HISTOGRAM" || exit 1
+ echo "$HISTOGRAM"
+ exit 0
+ else
+ echo "Gerrit not running?"
+ fi
+ exit 3
+ ;;
+
*)
usage
;;
diff --git a/tools/deps.toml b/tools/deps.toml
index e500cd6..0500703 100644
--- a/tools/deps.toml
+++ b/tools/deps.toml
@@ -1,14 +1,13 @@
[versions]
antlr = "3.5.2"
autoValueGson = "1.3.1"
-bouncyCastle = "1.84"
-byteBuddy = "1.18.11"
+bouncyCastle = "1.85"
+byteBuddy = "1.18.12"
caffeine = "2.9.2"
commonmark = "0.24.0"
-gitiles = "1.6.0"
greenmail = "1.5.5"
httpcomp = "4.5.14"
-jetty = "12.1.11"
+jetty = "12.1.12"
mail = "1.6.0"
mime4j = "0.8.1"
ow2 = "9.9.1"
@@ -38,9 +37,8 @@
autotransient = { module = "io.sweers.autotransient:autotransient", version = "1.0.0" }
bcpg-jdk18on = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncyCastle" }
bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncyCastle" }
-bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" }
+bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.85.2" }
bcutil-jdk18on = { module = "org.bouncycastle:bcutil-jdk18on", version.ref = "bouncyCastle" }
-blame-cache = { module = "com.google.gitiles:blame-cache", version.ref = "gitiles" }
byte-buddy = { module = "net.bytebuddy:byte-buddy", version.ref = "byteBuddy" }
byte-buddy-agent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "byteBuddy" }
caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" }
@@ -48,10 +46,11 @@
commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
+commonmark-ext-yaml-front-matter = { module = "org.commonmark:commonmark-ext-yaml-front-matter", version.ref = "commonmark" }
commons-codec = { module = "commons-codec:commons-codec", version = "1.18.0" }
commons-compress = { module = "org.apache.commons:commons-compress", version = "1.28.0" }
commons-dbcp = { module = "commons-dbcp:commons-dbcp", version = "1.4" }
-commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.18.0" }
+commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.20.0" }
commons-net = { module = "commons-net:commons-net", version = "3.6" }
commons-pool = { module = "commons-pool:commons-pool", version = "1.5.5" }
commons-text = { module = "org.apache.commons:commons-text", version = "1.15.0" }
@@ -60,7 +59,6 @@
failureaccess = { module = "com.google.guava:failureaccess", version = "1.0.3" }
flexmark-all = { module = "com.vladsch.flexmark:flexmark-all", version = "0.64.0:lib" }
fluent-hc = { module = "org.apache.httpcomponents:fluent-hc", version.ref = "httpcomp" }
-gitiles-servlet = { module = "com.google.gitiles:gitiles-servlet", version.ref = "gitiles" }
greenmail = { module = "com.icegreen:greenmail", version.ref = "greenmail" }
guava = { module = "com.github.ben-manes.caffeine:guava", version.ref = "caffeine" }
guava-retrying = { module = "com.github.rholder:guava-retrying", version = "2.0.0" }
diff --git a/tools/nongoogle.toml b/tools/nongoogle.toml
index 5af54f27..d0514d0 100644
--- a/tools/nongoogle.toml
+++ b/tools/nongoogle.toml
@@ -52,7 +52,7 @@
mina-core = { module = "org.apache.mina:mina-core", version = "2.2.9" }
nekohtml = { module = "net.sourceforge.nekohtml:nekohtml", version = "1.9.10" }
openid-consumer = { module = "org.openid4java:openid4java", version = "1.0.0" }
-protobuf-java = { module = "com.google.protobuf:protobuf-java", version = "4.35.1" }
+protobuf-java = { module = "com.google.protobuf:protobuf-java", version = "4.36.1" }
soy = { module = "com.google.template:soy", version = "2024-01-30" }
sshd-mina = { module = "org.apache.sshd:sshd-mina", version.ref = "sshd" }
sshd-osgi = { module = "org.apache.sshd:sshd-osgi", version.ref = "sshd" }
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index 8c2386e..1bfa7e20 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -31,12 +31,9 @@
# Set several flags related to specifying the platform, toolchain and java
# properties.
-build:remote_shared --crosstool_top=@rbe_autoconfig//cc:toolchain
-build:remote_shared --extra_toolchains=@rbe_autoconfig//config:cc-toolchain
-build:remote_shared --extra_execution_platforms=@rbe_autoconfig//config:platform
-build:remote_shared --host_platform=@rbe_autoconfig//config:platform
-build:remote_shared --platforms=@rbe_autoconfig//config:platform
-build:remote_shared --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
+build:remote_shared --extra_execution_platforms=//tools/remote:platform
+build:remote_shared --host_platform=//tools/remote:platform
+build:remote_shared --platforms=//tools/remote:platform
# Set various strategies so that all actions execute remotely. Mixing remote
# and local execution will lead to errors unless the toolchain and remote
diff --git a/tools/remote/BUILD b/tools/remote/BUILD
new file mode 100644
index 0000000..57dd4a0
--- /dev/null
+++ b/tools/remote/BUILD
@@ -0,0 +1,12 @@
+platform(
+ name = "platform",
+ constraint_values = [
+ "@platforms//cpu:x86_64",
+ "@platforms//os:linux",
+ ],
+ exec_properties = {
+ "OSFamily": "Linux",
+ "container-image": "docker://gcr.io/bazel-public/ubuntu2404@sha256:57bbaa84bec679736c53dcd1d326e8f835b3b9ce3e36c12a3c12a07eb59177d3",
+ },
+ visibility = ["//visibility:public"],
+)
diff --git a/tools/repos.MODULE.bazel b/tools/repos.MODULE.bazel
index c2ac5af..bc38f21 100644
--- a/tools/repos.MODULE.bazel
+++ b/tools/repos.MODULE.bazel
@@ -13,3 +13,9 @@
name = "java-prettify",
path = "modules/java-prettify",
)
+
+# Gitiles source repository consumed from git submodule.
+local_repository(
+ name = "gitiles",
+ path = "modules/gitiles",
+)