Merge "config.md: Correct formatting of config values" into stable-2.16
diff --git a/.bazelrc b/.bazelrc
index f5c0ae3..615adcd 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,4 +1,4 @@
-build --workspace_status_command=./tools/workspace-status.sh
+build --workspace_status_command="python ./tools/workspace_status.py"
 
 # Standalone build compatbility with npm requires PATH on action_env (Issue 10372).
 build --action_env=PATH
diff --git a/.bazelversion b/.bazelversion
index 3eefcb9..227cea2 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-1.0.0
+2.0.0
diff --git a/WORKSPACE b/WORKSPACE
index a5e1670..839a68b 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -3,7 +3,7 @@
 load("//:bazlets.bzl", "load_bazlets")
 
 load_bazlets(
-    commit = "ec989bb514e39447764057c60d3f9959bff8e153",
+    commit = "577450c73780560972f8a1a92c91410192ff7224",
     #local_path = "/home/<user>/projects/bazlets",
 )
 
@@ -16,9 +16,11 @@
 gerrit_polymer()
 
 # Load closure compiler with transitive dependencies
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_repositories")
+load("@io_bazel_rules_closure//closure:repositories.bzl", "rules_closure_dependencies", "rules_closure_toolchains")
 
-closure_repositories()
+rules_closure_dependencies()
+
+rules_closure_toolchains()
 
 # Load Gerrit npm_binary toolchain
 load("@com_googlesource_gerrit_bazlets//tools:js.bzl", "GERRIT", "npm_binary")
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java
index 040f7e4..459d819 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -37,8 +36,7 @@
       DeleteLog deleteLog,
       DeletePreconditions preConditions,
       Configuration cfg,
-      HideProject hideProject,
-      NotesMigration migration) {
+      HideProject hideProject) {
     super(
         dbHandler,
         fsHandler,
@@ -47,8 +45,7 @@
         deleteLog,
         preConditions,
         cfg,
-        hideProject,
-        migration);
+        hideProject);
     this.protectedProjects = protectedProjects;
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java
index e3fa6b9..4b6e75f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -50,7 +49,6 @@
   private final DeleteLog deleteLog;
   private final Configuration cfg;
   private final HideProject hideProject;
-  private NotesMigration migration;
 
   @Inject
   DeleteProject(
@@ -61,8 +59,7 @@
       DeleteLog deleteLog,
       DeletePreconditions preConditions,
       Configuration cfg,
-      HideProject hideProject,
-      NotesMigration migration) {
+      HideProject hideProject) {
     this.dbHandler = dbHandler;
     this.fsHandler = fsHandler;
     this.cacheHandler = cacheHandler;
@@ -71,7 +68,6 @@
     this.preConditions = preConditions;
     this.cfg = cfg;
     this.hideProject = hideProject;
-    this.migration = migration;
   }
 
   @Override
@@ -91,9 +87,7 @@
     Exception ex = null;
     try {
       if (!preserve || !cfg.projectOnPreserveHidden()) {
-        if (!migration.disableChangeReviewDb()) {
-          dbHandler.delete(project);
-        }
+        dbHandler.delete(project);
         try {
           fsHandler.delete(project, preserve);
         } catch (RepositoryNotFoundException e) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/database/DatabaseDeleteHandler.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/database/DatabaseDeleteHandler.java
index 5dff8c5..cca8386 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/database/DatabaseDeleteHandler.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/database/DatabaseDeleteHandler.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.deleteproject.database;
 
 import static java.util.Collections.singleton;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -30,7 +31,11 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.jdbc.JdbcSchema;
@@ -56,6 +61,9 @@
   private final ChangeIndexer indexer;
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final ChangeNotes.Factory schemaFactoryNoteDb;
+  private final GitRepositoryManager repoManager;
+  private final NotesMigration migration;
 
   @Inject
   public DatabaseDeleteHandler(
@@ -63,6 +71,9 @@
       StarredChangesUtil starredChangesUtil,
       DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
       ChangeIndexer indexer,
+      ChangeNotes.Factory schemaFactoryNoteDb,
+      NotesMigration migration,
+      GitRepositoryManager repoManager,
       Provider<InternalAccountQuery> accountQueryProvider,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
     this.dbProvider = dbProvider;
@@ -71,29 +82,40 @@
     this.indexer = indexer;
     this.accountQueryProvider = accountQueryProvider;
     this.accountsUpdateProvider = accountsUpdateProvider;
+    this.schemaFactoryNoteDb = schemaFactoryNoteDb;
+    this.repoManager = repoManager;
+    this.migration = migration;
   }
 
-  public void delete(Project project) throws OrmException {
+  public void delete(Project project) throws OrmException, IOException {
     ReviewDb db = ReviewDbUtil.unwrapDb(dbProvider.get());
-    Connection conn = ((JdbcSchema) db).getConnection();
-    try {
-      conn.setAutoCommit(false);
+    if (isReviewDb()) {
+      Connection conn = ((JdbcSchema) db).getConnection();
       try {
-        atomicDelete(db, project, getChangesList(project, conn));
-        conn.commit();
-      } finally {
-        conn.setAutoCommit(true);
+        conn.setAutoCommit(false);
+        try {
+          atomicDelete(db, project, getChangesList(project, conn));
+          conn.commit();
+        } finally {
+          conn.setAutoCommit(true);
+        }
+      } catch (SQLException e) {
+        try {
+          conn.rollback();
+        } catch (SQLException ex) {
+          throw new OrmException(ex);
+        }
+        throw new OrmException(e);
       }
-    } catch (SQLException e) {
-      try {
-        conn.rollback();
-      } catch (SQLException ex) {
-        throw new OrmException(ex);
-      }
-      throw new OrmException(e);
+    } else {
+      atomicDelete(db, project, getChangesListFromNoteDb(project));
     }
   }
 
+  private boolean isReviewDb() {
+    return !migration.disableChangeReviewDb();
+  }
+
   private List<Change.Id> getChangesList(Project project, Connection conn) throws SQLException {
     try (PreparedStatement changesForProject =
         conn.prepareStatement("SELECT change_id FROM changes WHERE dest_project_name = ?")) {
@@ -110,6 +132,20 @@
     }
   }
 
+  private List<Change.Id> getChangesListFromNoteDb(Project project) throws IOException {
+    Project.NameKey projectKey = project.getNameKey();
+    List<Change.Id> changeIds =
+        schemaFactoryNoteDb
+            .scan(repoManager.openRepository(projectKey), dbProvider.get(), projectKey)
+            .map(ChangeNotesResult::id)
+            .collect(toList());
+    log.atFine().log(
+        "Number of changes in noteDb related to project %s are %d",
+        projectKey.get(),
+        changeIds.size());
+    return changeIds;
+  }
+
   private void deleteChanges(ReviewDb db, Project.NameKey project, List<Change.Id> changeIds)
       throws OrmException {
 
@@ -119,18 +155,19 @@
       } catch (NoSuchChangeException e) {
         // we can ignore the exception during delete
       }
-      ResultSet<PatchSet> patchSets = db.patchSets().byChange(id);
-      if (patchSets != null) {
-        deleteFromPatchSets(db, patchSets);
+      if (isReviewDb()) {
+        ResultSet<PatchSet> patchSets = db.patchSets().byChange(id);
+        if (patchSets != null) {
+          deleteFromPatchSets(db, patchSets);
+        }
+
+        // In the future, use schemaVersion to decide what to delete.
+        db.patchComments().delete(db.patchComments().byChange(id));
+        db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
+
+        db.changeMessages().delete(db.changeMessages().byChange(id));
+        db.changes().deleteKeys(Collections.singleton(id));
       }
-
-      // In the future, use schemaVersion to decide what to delete.
-      db.patchComments().delete(db.patchComments().byChange(id));
-      db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
-
-      db.changeMessages().delete(db.changeMessages().byChange(id));
-      db.changes().deleteKeys(Collections.singleton(id));
-
       // Delete from the secondary index
       try {
         indexer.delete(id);
diff --git a/src/main/resources/Documentation/build.md b/src/main/resources/Documentation/build.md
index b47afaa..a61ad8c 100644
--- a/src/main/resources/Documentation/build.md
+++ b/src/main/resources/Documentation/build.md
@@ -68,7 +68,7 @@
 To execute the tests run:
 
 ```
-  bazel test plugins/@PLUGIN@:delete_project_tests
+  bazel test plugins/@PLUGIN@:delete-project_tests
 ```
 
 or filtering using the comma separated tags:
diff --git a/src/main/resources/static/delete-project-with-preserve-disabled.js b/src/main/resources/static/delete-project-with-preserve-disabled.js
index 550ea6c..2065616 100644
--- a/src/main/resources/static/delete-project-with-preserve-disabled.js
+++ b/src/main/resources/static/delete-project-with-preserve-disabled.js
@@ -12,31 +12,44 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-if (!window.Polymer) {
+function _getCookie(name) {
+  var key = name + '=';
+  var result = '';
+  document.cookie.split(';').some(c => {
+    c = c.trim();
+    if (c.indexOf(key, 0) === 0) {
+      result = c.substring(key.length);
+      return true;
+    }
+  });
+  return result;
+}
+
+if (_getCookie('GERRIT_UI') === 'GWT') {
   Gerrit.install(function(self) {
-      function onDeleteProject(c) {
-        var f = c.checkbox();
-        var b = c.button('Delete',
-          {onclick: function(){
-            c.call(
-              {force: f.checked, preserve: false},
-              function(r) {
-                c.hide();
-                window.alert('The project: "'
-                  + c.project
-                  + '" was deleted.'),
-                Gerrit.go('/admin/projects/');
-              });
-          }});
-        c.popup(c.div(
-          c.msg('Are you really sure you want to delete the project: "'
-            + c.project
-            + '"?'),
-          c.br(),
-          c.label(f, 'Delete project even if open changes exist?'),
-          c.br(),
-          b));
-      }
-      self.onAction('project', 'delete', onDeleteProject);
-    });
-}
\ No newline at end of file
+    function onDeleteProject(c) {
+      var f = c.checkbox();
+      var b = c.button('Delete',
+        {onclick: function(){
+          c.call(
+            {force: f.checked, preserve: false},
+            function(r) {
+              c.hide();
+              window.alert('The project: "'
+                + c.project
+                + '" was deleted.'),
+              Gerrit.go('/admin/projects/');
+            });
+        }});
+      c.popup(c.div(
+        c.msg('Are you really sure you want to delete the project: "'
+          + c.project
+          + '"?'),
+        c.br(),
+        c.label(f, 'Delete project even if open changes exist?'),
+        c.br(),
+        b));
+    }
+    self.onAction('project', 'delete', onDeleteProject);
+  });
+}
diff --git a/src/main/resources/static/delete-project.js b/src/main/resources/static/delete-project.js
index 2291279..a72ad60 100644
--- a/src/main/resources/static/delete-project.js
+++ b/src/main/resources/static/delete-project.js
@@ -12,34 +12,47 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-if (!window.Polymer) {
+function _getCookie(name) {
+  var key = name + '=';
+  var result = '';
+  document.cookie.split(';').some(c => {
+    c = c.trim();
+    if (c.indexOf(key, 0) === 0) {
+      result = c.substring(key.length);
+      return true;
+    }
+  });
+  return result;
+}
+
+if (_getCookie('GERRIT_UI') === 'GWT') {
   Gerrit.install(function(self) {
-      function onDeleteProject(c) {
-        var f = c.checkbox();
-        var p = c.checkbox();
-        var b = c.button('Delete',
-          {onclick: function(){
-            c.call(
-              {force: f.checked, preserve: p.checked},
-              function(r) {
-                c.hide();
-                window.alert('The project: "'
-                  + c.project
-                  + '" was deleted.'),
-                Gerrit.go('/admin/projects/');
-              });
-          }});
-        c.popup(c.div(
-          c.msg('Are you really sure you want to delete the project: "'
-            + c.project
-            + '"?'),
-          c.br(),
-          c.label(f, 'Delete project even if open changes exist?'),
-          c.br(),
-          c.label(p, 'Preserve GIT Repository?'),
-          c.br(),
-          b));
-      }
-      self.onAction('project', 'delete', onDeleteProject);
-    });
+    function onDeleteProject(c) {
+      var f = c.checkbox();
+      var p = c.checkbox();
+      var b = c.button('Delete',
+        {onclick: function(){
+          c.call(
+            {force: f.checked, preserve: p.checked},
+            function(r) {
+              c.hide();
+              window.alert('The project: "'
+                + c.project
+                + '" was deleted.'),
+              Gerrit.go('/admin/projects/');
+            });
+        }});
+      c.popup(c.div(
+        c.msg('Are you really sure you want to delete the project: "'
+          + c.project
+          + '"?'),
+        c.br(),
+        c.label(f, 'Delete project even if open changes exist?'),
+        c.br(),
+        c.label(p, 'Preserve GIT Repository?'),
+        c.br(),
+        b));
+    }
+    self.onAction('project', 'delete', onDeleteProject);
+  });
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditionsTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditionsTest.java
index 6658478..1074e65 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditionsTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditionsTest.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.deleteproject;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.googlesource.gerrit.plugins.deleteproject.DeleteOwnProjectCapability.DELETE_OWN_PROJECT;
 import static com.googlesource.gerrit.plugins.deleteproject.DeleteProjectCapability.DELETE_PROJECT;
 import static org.mockito.Mockito.doNothing;
@@ -43,9 +44,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
@@ -68,8 +67,6 @@
   @Mock private PermissionBackend permissionBackend;
   @Mock private PermissionBackend.WithUser userPermission;
 
-  @Rule public ExpectedException expectedException = ExpectedException.none();
-
   private ProjectResource rsrc;
   private DeletePreconditions preConditions;
 
@@ -121,16 +118,17 @@
   @Test
   public void testUserCannotDelete() throws Exception {
     when(permissionBackend.user(currentUser)).thenReturn(userPermission);
-    expectedException.expect(AuthException.class);
-    expectedException.expectMessage("not allowed to delete project");
-    preConditions.assertDeletePermission(rsrc);
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> preConditions.assertDeletePermission(rsrc));
+    assertThat(thrown).hasMessageThat().contains("not allowed to delete project");
   }
 
   @Test
   public void testIsProtectedSoCannotBeDeleted() throws Exception {
     doThrow(CannotDeleteProjectException.class).when(protectedProjects).assertIsNotProtected(rsrc);
-    expectedException.expect(ResourceConflictException.class);
-    preConditions.assertCanBeDeleted(rsrc, new DeleteProject.Input());
+    assertThrows(
+        ResourceConflictException.class,
+        () -> preConditions.assertCanBeDeleted(rsrc, new DeleteProject.Input()));
   }
 
   @Test
@@ -140,9 +138,13 @@
     when(listChildProjectsProvider.get()).thenReturn(childProjects);
     when(childProjects.withLimit(1)).thenReturn(childProjects);
     when(childProjects.apply(rsrc)).thenReturn(ImmutableList.of(new ProjectInfo()));
-    expectedException.expect(ResourceConflictException.class);
-    expectedException.expectMessage("Cannot delete project because it has at least one child:");
-    preConditions.assertCanBeDeleted(rsrc, new DeleteProject.Input());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> preConditions.assertCanBeDeleted(rsrc, new DeleteProject.Input()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot delete project because it has at least one child:");
   }
 
   @Test
@@ -152,9 +154,11 @@
     when(queryChange.byProjectOpen(PROJECT_NAMEKEY)).thenReturn(ImmutableList.of(cd));
     when(queryProvider.get()).thenReturn(queryChange);
     String expectedMessage = String.format("Project '%s' has open changes.", PROJECT_NAMEKEY.get());
-    expectedException.expectMessage(expectedMessage);
-    expectedException.expect(CannotDeleteProjectException.class);
-    preConditions.assertHasOpenChanges(PROJECT_NAMEKEY, false);
+    CannotDeleteProjectException thrown =
+        assertThrows(
+            CannotDeleteProjectException.class,
+            () -> preConditions.assertHasOpenChanges(PROJECT_NAMEKEY, false));
+    assertThat(thrown).hasMessageThat().contains(expectedMessage);
   }
 
   @Test
@@ -164,8 +168,10 @@
     when(queryProvider.get()).thenReturn(queryChange);
     String expectedMessage =
         String.format("Unable to verify if '%s' has open changes.", PROJECT_NAMEKEY.get());
-    expectedException.expectMessage(expectedMessage);
-    expectedException.expect(CannotDeleteProjectException.class);
-    preConditions.assertHasOpenChanges(PROJECT_NAMEKEY, false);
+    CannotDeleteProjectException thrown =
+        assertThrows(
+            CannotDeleteProjectException.class,
+            () -> preConditions.assertHasOpenChanges(PROJECT_NAMEKEY, false));
+    assertThat(thrown).hasMessageThat().contains(expectedMessage);
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java
index 206f231..36d1ab3 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java
@@ -28,9 +28,11 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gwtorm.server.OrmException;
 import com.googlesource.gerrit.plugins.deleteproject.DeleteProject.Input;
 import java.io.File;
 import java.io.IOException;
@@ -79,6 +81,7 @@
     RestResponse r = httpDeleteProjectHelper(true);
     r.assertNoContent();
     assertThat(projectDir.exists()).isFalse();
+    assertAllChangesDeletedInIndex();
   }
 
   @Test
@@ -97,6 +100,8 @@
     RestResponse r = httpDeleteProjectHelper(true);
     r.assertNoContent();
     assertThat(projectDir.exists()).isFalse();
+    assertAllChangesDeletedInIndex();
+    assertWatchRemoved();
   }
 
   @Test
@@ -139,6 +144,19 @@
     adminSshSession.exec(cmd);
     assertThat(adminSshSession.getError()).isNull();
     assertThat(projectDir.exists()).isFalse();
+    assertAllChangesDeletedInIndex();
+  }
+
+  @Test
+  @UseLocalDisk
+  public void testSshDeleteProjectWithWatches() throws Exception {
+    watch(project.get());
+    String cmd = createDeleteCommand(project.get());
+    adminSshSession.exec(cmd);
+    assertThat(adminSshSession.getError()).isNull();
+    assertThat(projectDir.exists()).isFalse();
+    assertAllChangesDeletedInIndex();
+    assertWatchRemoved();
   }
 
   @Test
@@ -237,6 +255,7 @@
     assertThat(isEmpty(archiveFolder.toPath())).isFalse();
     assertThat(containsDeletedProject(archiveFolder.toPath(), project.get())).isTrue();
     assertThat(projectDir.exists()).isFalse();
+    assertAllChangesDeletedInIndex();
   }
 
   @Test
@@ -268,7 +287,7 @@
     assertThat(containsDeletedProject(archiveFolder.toPath().resolve(PARENT_FOLDER), name))
         .isTrue();
     assertThat(projectDir.exists()).isFalse();
-
+    assertAllChangesDeletedInIndex();
     assertThat(parentFolder.toFile().exists()).isFalse();
   }
 
@@ -321,4 +340,12 @@
       return dirStream.anyMatch(d -> d.toString().contains(projectName));
     }
   }
+
+  private void assertAllChangesDeletedInIndex() throws OrmException {
+    assertThat(queryProvider.get().byProject(project)).isEmpty();
+  }
+
+  private void assertWatchRemoved() throws RestApiException {
+    assertThat(gApi.accounts().self().getWatchedProjects()).isEmpty();
+  }
 }
diff --git a/tools/workspace-status.sh b/tools/workspace-status.sh
deleted file mode 100755
index 3185cae..0000000
--- a/tools/workspace-status.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/bash
-
-# This script will be run by bazel when the build process starts to
-# generate key-value information that represents the status of the
-# workspace. The output should be like
-#
-# KEY1 VALUE1
-# KEY2 VALUE2
-#
-# If the script exits with non-zero code, it's considered as a failure
-# and the output will be discarded.
-
-function rev() {
-  cd $1; git describe --always --match "v[0-9].*" --dirty
-}
-
-echo STABLE_BUILD_DELETE-PROJECT_LABEL $(rev .)
diff --git a/tools/workspace_status.py b/tools/workspace_status.py
new file mode 100644
index 0000000..e65db86
--- /dev/null
+++ b/tools/workspace_status.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+
+# This script will be run by bazel when the build process starts to
+# generate key-value information that represents the status of the
+# workspace. The output should be like
+#
+# KEY1 VALUE1
+# KEY2 VALUE2
+#
+# If the script exits with non-zero code, it's considered as a failure
+# and the output will be discarded.
+
+from __future__ import print_function
+import subprocess
+import sys
+
+CMD = ['git', 'describe', '--always', '--match', 'v[0-9].*', '--dirty']
+
+
+def revision():
+    try:
+        return subprocess.check_output(CMD).strip().decode("utf-8")
+    except OSError as err:
+        print('could not invoke git: %s' % err, file=sys.stderr)
+        sys.exit(1)
+    except subprocess.CalledProcessError as err:
+        print('error using git: %s' % err, file=sys.stderr)
+        sys.exit(1)
+
+
+print("STABLE_BUILD_DELETE-PROJECT_LABEL %s" % revision())