This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 2dfd972c4e harden project delete/rename, fixes #4275 (#8583)
2dfd972c4e is described below

commit 2dfd972c4eed92329aef6772d272b89b79f88160
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Sat Sep 26 14:29:45 2026 +0200

    harden project delete/rename, fixes #4275 (#8583)
    
    * harden project delete/rename, fixes #4275
    
    * fix remarks and code hardening
---
 .../apache/hop/projects/config/ProjectsConfig.java |  56 ++-
 .../gui/CloneFromVersionControlDialog.java         |   2 +-
 .../hop/projects/gui/ProjectConfigDialog.java      |  29 +-
 .../apache/hop/projects/gui/ProjectsGuiPlugin.java |  27 +-
 .../hop/projects/gui/SelectProjectsDialog.java     |   8 +-
 .../project/ManageProjectsOptionPlugin.java        |  10 +-
 .../org/apache/hop/projects/project/Project.java   |   4 +-
 .../apache/hop/projects/project/ProjectDialog.java |  78 +++--
 .../util/ProjectRenameBlockedException.java        |  43 +++
 .../org/apache/hop/projects/util/ProjectsUtil.java | 307 ++++++++++++++---
 .../hop/projects/xp/HopGuiStartProjectLoad.java    |   5 +-
 .../project/messages/messages_en_US.properties     |   9 +
 .../projects/project/ProjectReferencesTest.java    | 375 +++++++++++++++++++++
 13 files changed, 846 insertions(+), 107 deletions(-)

diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
index 1d2d53899f..2ec1ed084f 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
@@ -100,7 +100,7 @@ public class ProjectsConfig {
     List<LifecycleEnvironment> list = new ArrayList<>();
     lifecycleEnvironments.forEach(
         e -> {
-          if (e.getProjectName().equals(projectName)) {
+          if (projectName != null && 
projectName.equalsIgnoreCase(e.getProjectName())) {
             list.add(e);
           }
         });
@@ -128,8 +128,13 @@ public class ProjectsConfig {
    * @param projectConfig updated registration (may have a new projectName)
    */
   public void updateProjectConfig(String originalName, ProjectConfig 
projectConfig) {
-    if (StringUtils.isEmpty(originalName)
-        || originalName.equalsIgnoreCase(projectConfig.getProjectName())) {
+    if (StringUtils.isEmpty(originalName)) {
+      addProjectConfig(projectConfig);
+      return;
+    }
+    renameProjectReferences(originalName, projectConfig.getProjectName());
+    if (originalName.equalsIgnoreCase(projectConfig.getProjectName())) {
+      // Same registration, possibly with a different case
       addProjectConfig(projectConfig);
       return;
     }
@@ -168,15 +173,60 @@ public class ProjectsConfig {
         new ProjectConfig(projectName, null, null)); // Only considers the name
   }
 
+  /**
+   * Remove a project registration. The default project and standard parent 
project settings are
+   * cleared when they point to the removed project, so they never name a 
project that doesn't
+   * exist.
+   *
+   * @param projectName the name of the project to remove
+   * @return the removed project registration or null if it wasn't found
+   */
   public ProjectConfig removeProjectConfig(String projectName) {
     int index = indexOfProjectConfig(projectName);
     if (index >= 0) {
+      renameProjectReferences(projectName, null);
       return projectConfigurations.remove(index);
     } else {
       return null;
     }
   }
 
+  /**
+   * Point the default project, standard parent project and lifecycle 
environments to a renamed
+   * project. When the project is removed (newName is null) the default and 
standard parent project
+   * settings are cleared but the environments are left alone: an environment 
without a project is
+   * available for every project.
+   *
+   * @param oldName the previous name of the project
+   * @param newName the new name of the project, null when the project is 
removed
+   */
+  public void renameProjectReferences(String oldName, String newName) {
+    if (StringUtils.isEmpty(oldName)) {
+      return;
+    }
+    if (oldName.equalsIgnoreCase(defaultProject)) {
+      defaultProject = newName;
+    }
+    if (oldName.equalsIgnoreCase(standardParentProject)) {
+      standardParentProject = newName;
+    }
+    if (StringUtils.isNotEmpty(newName)) {
+      for (LifecycleEnvironment environment : lifecycleEnvironments) {
+        if (oldName.equalsIgnoreCase(environment.getProjectName())) {
+          environment.setProjectName(newName);
+        }
+      }
+    }
+  }
+
+  /**
+   * @return the standard parent project for new projects or null if no 
project with that name is
+   *     registered
+   */
+  public String findRegisteredStandardParentProject() {
+    return findProjectConfig(standardParentProject) == null ? null : 
standardParentProject;
+  }
+
   public List<String> listProjectConfigNames() {
     List<String> names = new ArrayList<>();
     projectConfigurations.forEach(config -> 
names.add(config.getProjectName()));
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
index a92ec921a9..0cb71ee43f 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
@@ -319,7 +319,7 @@ public class CloneFromVersionControlDialog extends Dialog {
       String defaultConfigFile = 
variables.resolve(config.getDefaultProjectConfigFile());
       ProjectConfig projectConfig = new ProjectConfig(projectName, clonePath, 
defaultConfigFile);
       Project project = new Project();
-      project.setParentProjectName(config.getStandardParentProject());
+      
project.setParentProjectName(config.findRegisteredStandardParentProject());
 
       String configFilename = 
projectConfig.getActualProjectConfigFilename(variables);
       FileObject configFile = HopVfs.getFileObject(configFilename);
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
index 848c4c30d2..6b3b9f69e1 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
@@ -20,11 +20,15 @@ package org.apache.hop.projects.gui;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.projects.config.ProjectsConfig;
 import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.project.Project;
 import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.projects.util.ProjectRenameBlockedException;
+import org.apache.hop.projects.util.ProjectsUtil;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.BaseDialog;
@@ -43,6 +47,7 @@ import org.eclipse.swt.widgets.Button;
 import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Dialog;
 import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.MessageBox;
 import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.Text;
 
@@ -264,11 +269,15 @@ public class ProjectConfigDialog extends Dialog {
 
       ProjectsConfig config = ProjectsConfigSingleton.getConfig();
       if (!name.equals(originalName)) {
+        // Project names are unique regardless of case, a case-only rename is 
fine
         ProjectConfig clash = config.findProjectConfig(name);
-        if (clash != null) {
+        if (clash != null && 
!clash.getProjectName().equalsIgnoreCase(originalName)) {
           throw new IllegalArgumentException(
               BaseMessages.getString(PKG, 
"ProjectConfigDialog.Error.NameExists", name));
         }
+        if (StringUtils.isNotEmpty(originalName) && !checkRename(name)) {
+          return;
+        }
       }
 
       projectConfig.setProjectName(name);
@@ -289,6 +298,24 @@ public class ProjectConfigDialog extends Dialog {
     }
   }
 
+  /**
+   * Verify that the projects using this one as their parent can follow the 
rename.
+   *
+   * @return false when the rename is blocked, the reason was shown to the user
+   */
+  private boolean checkRename(String newName) {
+    try {
+      ProjectsUtil.checkProjectRename(originalName, newName, variables, 
LogChannel.UI);
+      return true;
+    } catch (ProjectRenameBlockedException e) {
+      MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+      box.setText(BaseMessages.getString(Project.class, 
"ProjectRename.Blocked.Header"));
+      box.setMessage(e.getUserMessage());
+      box.open();
+      return false;
+    }
+  }
+
   private void cancel() {
     ok = false;
     dispose();
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 01a0278824..350a41848c 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -720,15 +720,16 @@ public class ProjectsGuiPlugin {
           new ProjectDialog(
               hopGui.getActiveShell(), project, projectConfig, 
hopGui.getVariables(), true);
       if (projectDialog.open() != null) {
-        config.addProjectConfig(projectConfig);
-
-        if (!projectName.equals(projectConfig.getProjectName())) {
-          // Project got renamed
-          projectName = projectConfig.getProjectName();
-        }
-
-        // Persist project registration (name, home, config path, read-only) 
in hop-config.json
-        HopConfig.getInstance().saveToFile();
+        // Persist project registration (name, home, config path, read-only) 
in hop-config.json.
+        // A rename also updates the projects using this one as their parent, 
all or nothing.
+        //
+        ProjectsUtil.saveProjectConfig(
+            projectName,
+            projectConfig,
+            hopGui.getVariables(),
+            hopGui.getLog(),
+            ProjectsConfigSingleton::saveConfig);
+        projectName = projectConfig.getProjectName();
 
         // Do not write project-config.json for read-only projects (archives, 
HTTP, ...).
         //
@@ -887,7 +888,7 @@ public class ProjectsGuiPlugin {
       MenuItem item = new MenuItem(menu, SWT.NONE);
       item.setText(name);
       item.addListener(SWT.Selection, e -> selectProject(name));
-      if (currentProjectName.equals(name)) {
+      if (currentProjectName.equalsIgnoreCase(name)) {
         item.setImage(GuiResource.getInstance().getImageCheck());
       }
       if (++count == LAST_USED_PROJECTS_MAX_ENTRIES) break;
@@ -943,7 +944,7 @@ public class ProjectsGuiPlugin {
       LifecycleEnvironment environment = config.findEnvironment(name);
       if (environment != null
           && (Utils.isEmpty(environment.getProjectName())
-              || currentProjectName.equals(environment.getProjectName()))) {
+              || 
currentProjectName.equalsIgnoreCase(environment.getProjectName()))) {
         // Create a final copy of the name variable for the lambda closure
         // This is critical for RAP/web compatibility - each menu item needs 
its own copy
         final String environmentName = name;
@@ -1023,7 +1024,7 @@ public class ProjectsGuiPlugin {
           if (environment != null) {
             // See that the project belongs to the environment
             //
-            if (projectName.equals(environment.getProjectName())) {
+            if (projectName.equalsIgnoreCase(environment.getProjectName())) {
               // We found what we've been looking for
               break;
             } else {
@@ -1168,7 +1169,7 @@ public class ProjectsGuiPlugin {
           new ProjectConfig("", standardProjectsFolder, 
defaultProjectConfigFilename);
 
       Project project = new Project();
-      project.setParentProjectName(config.getStandardParentProject());
+      
project.setParentProjectName(config.findRegisteredStandardParentProject());
 
       ProjectDialog projectDialog =
           new ProjectDialog(hopGui.getActiveShell(), project, projectConfig, 
variables, false);
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
index e37fcbe1b0..ba8059819c 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
@@ -350,8 +350,12 @@ public class SelectProjectsDialog extends Dialog {
     ProjectConfigDialog editDialog = new ProjectConfigDialog(shell, 
projectConfig, variables);
     if (editDialog.open()) {
       try {
-        config.updateProjectConfig(editDialog.getOriginalName(), 
projectConfig);
-        ProjectsConfigSingleton.saveConfig();
+        ProjectsUtil.saveProjectConfig(
+            editDialog.getOriginalName(),
+            projectConfig,
+            variables,
+            LogChannel.UI,
+            ProjectsConfigSingleton::saveConfig);
         refreshTable();
         selectProjectByName(projectConfig.getProjectName());
         updateButtons();
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
index c3ce448dd8..161a61bb0b 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
@@ -252,6 +252,14 @@ public class ManageProjectsOptionPlugin implements 
IConfigOptions {
     if (projectConfig == null) {
       throw new HopException(CONST_PROJECT + projectName + "' doesn't exist, 
it can't be deleted");
     }
+    List<String> references = 
ProjectsUtil.getParentProjectReferences(projectName, variables, log);
+    if (!references.isEmpty()) {
+      throw new HopException(
+          CONST_PROJECT
+              + projectName
+              + "' can't be deleted, it is the parent project of: "
+              + String.join(", ", references));
+    }
     config.removeProjectConfig(projectName);
     ProjectsConfigSingleton.saveConfig();
 
@@ -388,7 +396,7 @@ public class ManageProjectsOptionPlugin implements 
IConfigOptions {
     // --project-parent still wins via modifyProjectSettings below.
     //
     if (StringUtils.isEmpty(project.getParentProjectName())) {
-      project.setParentProjectName(config.getStandardParentProject());
+      
project.setParentProjectName(config.findRegisteredStandardParentProject());
     }
     modifyProjectSettings(project);
 
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
index 00f1010dc8..c04ab290cf 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
@@ -344,7 +344,7 @@ public class Project extends ConfigFile implements 
IConfigFile {
       return;
     }
 
-    if (parentProjectName.equals(projectName)) {
+    if (parentProjectName.equalsIgnoreCase(projectName)) {
       throw new HopException(
           "Parent project '" + parentProjectName + "' can not be the same as 
the project itself");
     }
@@ -381,7 +381,7 @@ public class Project extends ConfigFile implements 
IConfigFile {
             realParentProjectName = 
variables.resolve(parentProject.parentProjectName);
             // See if we've had this one before...
             if (StringUtils.isNotEmpty(realParentProjectName)
-                && projectsList.contains(realParentProjectName)) {
+                && 
projectsList.stream().anyMatch(realParentProjectName::equalsIgnoreCase)) {
               throw new HopException(
                   "There is a loop in the parent projects hierarchy: project "
                       + realParentProjectName
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
index 17abc6b8ea..c8d0795079 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
@@ -37,6 +37,7 @@ import org.apache.hop.projects.config.ProjectsConfig;
 import org.apache.hop.projects.config.ProjectsConfigSingleton;
 import org.apache.hop.projects.gui.ProjectsGuiPlugin;
 import org.apache.hop.projects.util.Defaults;
+import org.apache.hop.projects.util.ProjectRenameBlockedException;
 import org.apache.hop.projects.util.ProjectsUtil;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
@@ -842,38 +843,53 @@ public class ProjectDialog extends Dialog {
 
       if (wParentProject.getText() != null
           && !wParentProject.getText().isEmpty()
-          && projectName.equals(wParentProject.getText())) {
+          && projectName.equalsIgnoreCase(wParentProject.getText())) {
         throw new HopException(
             CONST_PROJECT + projectName + "' cannot be set as a parent project 
of itself");
       }
 
+      // Project names are unique regardless of case, a case-only rename is 
fine
+      //
       ProjectsConfig prjsCfg = ProjectsConfigSingleton.getConfig();
-      List<String> prjs = prjsCfg.listProjectConfigNames();
-
-      if (StringUtils.isEmpty(oriProjectName)
-          || (StringUtils.isNotEmpty(oriProjectName) && 
!projectName.equals(oriProjectName))) {
-        for (String prj : prjs) {
-          if (projectName.equals(prj)) {
-            throw new HopException(
-                CONST_PROJECT + projectName + "' already exists. Project name 
must be unique!");
-          }
-        }
+      ProjectConfig sameName = prjsCfg.findProjectConfig(projectName);
+      if (sameName != null
+          && (StringUtils.isEmpty(oriProjectName)
+              || !sameName.getProjectName().equalsIgnoreCase(oriProjectName))) 
{
+        throw new HopException(
+            CONST_PROJECT
+                + projectName
+                + "' already exists as '"
+                + sameName.getProjectName()
+                + "'. Project names must be unique, regardless of case!");
       }
 
       HopGui hopGui = HopGui.getInstance();
-      if (!Utils.isEmpty(wParentProject.getText())) {
-
-        boolean parentPrjExists = 
ProjectsUtil.projectExists(wParentProject.getText());
-        if (!parentPrjExists)
-          throw new HopException(
-              CONST_PROJECT
-                  + wParentProject.getText()
-                  + "' cannot be set as parent project because it does not 
exists!");
+      if (!Utils.isEmpty(wParentProject.getText())
+          && !ProjectsUtil.projectExists(wParentProject.getText())) {
+        // The parent project was deleted or renamed: offer to drop the 
reference
+        //
+        MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO | 
SWT.ICON_WARNING);
+        box.setText(
+            BaseMessages.getString(PKG, 
"ProjectDialog.MissingParentProject.Dialog.Header"));
+        box.setMessage(
+            BaseMessages.getString(
+                PKG,
+                "ProjectDialog.MissingParentProject.Dialog.Message",
+                wParentProject.getText()));
+        if (box.open() != SWT.YES) {
+          wParentProject.setFocus();
+          return;
+        }
+        wParentProject.setText("");
+      }
 
+      if (!Utils.isEmpty(wParentProject.getText())) {
         ProjectConfig parentPrjCfg = 
prjsCfg.findProjectConfig(wParentProject.getText());
         Project parentPrj = parentPrjCfg.loadProject(hopGui.getVariables());
-        if (parentPrj.getParentProjectName() != null
-            && parentPrj.getParentProjectName().equals(projectName))
+        String grandParentName = parentPrj.getParentProjectName();
+        if (grandParentName != null
+            && (grandParentName.equalsIgnoreCase(projectName)
+                || grandParentName.equalsIgnoreCase(oriProjectName)))
           throw new HopException(
               CONST_PROJECT
                   + projectName
@@ -894,14 +910,24 @@ public class ProjectDialog extends Dialog {
         int anwser = box.open();
         if ((anwser & SWT.NO) != 0) {
           wName.setText(oriProjectName);
+          projectName = oriProjectName;
         }
       }
 
-      if (!oriProjectName.equals(projectName)) {
-        List<String> refs = 
ProjectsUtil.getParentProjectReferences(oriProjectName);
-
-        if (!refs.isEmpty()) {
-          ProjectsUtil.changeParentProjectReferences(oriProjectName, 
projectName);
+      // Verify that the projects using this one as their parent can follow 
the rename. The
+      // rename itself is saved by the caller, all or nothing, once the dialog 
is closed.
+      //
+      if (this.editMode
+          && StringUtils.isNotEmpty(oriProjectName)
+          && !oriProjectName.equals(projectName)) {
+        try {
+          ProjectsUtil.checkProjectRename(oriProjectName, projectName, 
variables, hopGui.getLog());
+        } catch (ProjectRenameBlockedException e) {
+          MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+          box.setText(BaseMessages.getString(PKG, 
"ProjectRename.Blocked.Header"));
+          box.setMessage(e.getUserMessage());
+          box.open();
+          return;
         }
       }
 
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectRenameBlockedException.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectRenameBlockedException.java
new file mode 100644
index 0000000000..91f21019de
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectRenameBlockedException.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.projects.util;
+
+import java.util.List;
+import lombok.Getter;
+import org.apache.hop.core.exception.HopException;
+
+/**
+ * A project can't be renamed because the parent project reference in one or 
more other projects
+ * can't be updated. Nothing was changed when this is thrown. The message 
names every blocking
+ * project and the reason, so it can be shown to the user as is.
+ */
+@Getter
+public class ProjectRenameBlockedException extends HopException {
+
+  /** The names of the projects which block the rename */
+  private final List<String> blockingProjects;
+
+  /** The user facing explanation, without the decoration {@link HopException} 
adds */
+  private final String userMessage;
+
+  public ProjectRenameBlockedException(String userMessage, List<String> 
blockingProjects) {
+    super(userMessage);
+    this.userMessage = userMessage;
+    this.blockingProjects = List.copyOf(blockingProjects);
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
index 8132cfe2f0..2d68d60181 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
@@ -18,9 +18,9 @@
 package org.apache.hop.projects.util;
 
 import java.util.ArrayList;
-import java.util.Iterator;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.commons.vfs2.FileSystemException;
 import org.apache.hop.core.AttributesContext;
@@ -34,6 +34,7 @@ import org.apache.hop.core.logging.ILogChannel;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.history.AuditManager;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHasHopMetadataProvider;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 import org.apache.hop.metadata.util.HopMetadataInstance;
@@ -49,6 +50,8 @@ import org.apache.hop.ui.hopgui.HopGui;
 
 public class ProjectsUtil {
 
+  private static final Class<?> PKG = Project.class;
+
   public static final String VARIABLE_PROJECT_HOME = "PROJECT_HOME";
   public static final String VARIABLE_PARENT_PROJECT_HOME = 
"PARENT_PROJECT_HOME";
   public static final String VARIABLE_PARENT_PROJECT_NAME = 
"PARENT_PROJECT_NAME";
@@ -338,79 +341,271 @@ public class ProjectsUtil {
    * @return
    */
   public static boolean projectExists(String projectName) {
+    return ProjectsConfigSingleton.getConfig().findProjectConfig(projectName) 
!= null;
+  }
 
-    boolean prjFound = false;
+  /**
+   * Find the registered projects which have the given project as their parent 
project.
+   *
+   * @param projectName the name of the parent project
+   * @return the names of the child projects
+   */
+  public static List<String> getParentProjectReferences(String projectName) 
throws HopException {
+    HopGui hopGui = HopGui.getInstance();
+    return getParentProjectReferences(projectName, hopGui.getVariables(), 
hopGui.getLog());
+  }
 
+  /**
+   * Find the registered projects which have the given project as their parent 
project. Projects
+   * which can't be loaded are logged and skipped.
+   *
+   * @param projectName the name of the parent project
+   * @param variables the variables to resolve the project locations with
+   * @param log the log channel to report projects which can't be loaded
+   * @return the names of the child projects
+   */
+  public static List<String> getParentProjectReferences(
+      String projectName, IVariables variables, ILogChannel log) {
+    List<String> references = new ArrayList<>();
+    if (StringUtils.isEmpty(projectName)) {
+      return references;
+    }
     ProjectsConfig config = ProjectsConfigSingleton.getConfig();
-    List<String> prjs = config.listProjectConfigNames();
-    Iterator<String> iPrj = prjs.iterator();
-
-    while (!prjFound && iPrj.hasNext()) {
-      String p = iPrj.next();
-      prjFound = p.equals(projectName);
+    for (String name : config.listProjectConfigNames()) {
+      if (name.equalsIgnoreCase(projectName)) {
+        continue;
+      }
+      Project project = loadProject(config.findProjectConfig(name), variables, 
log);
+      if (project != null && 
projectName.equalsIgnoreCase(project.getParentProjectName())) {
+        references.add(name);
+      }
     }
+    return references;
+  }
 
-    return prjFound;
+  /** Saves the projects configuration in hop-config.json after a project 
registration changed. */
+  @FunctionalInterface
+  public interface IProjectsConfigSaver {
+    void save() throws HopException;
   }
 
-  public static List<String> getParentProjectReferences(String projectName) 
throws HopException {
+  /** A project which uses the renamed project as its parent project. */
+  private record ChildProject(String name, Project project) {}
+
+  /**
+   * Verify that a project can be renamed. Every project which uses it as its 
parent project must be
+   * writable, and every registered project must be readable to know whether 
it's one of them.
+   * Nothing is changed. Projects with a home folder which doesn't exist are 
skipped: they have no
+   * configuration to update.
+   *
+   * @param currentName the current name of the project
+   * @param newName the new name of the project
+   * @param variables the variables to resolve the project locations with
+   * @param log the log channel
+   * @return the names of the projects which will get the new parent project 
name
+   * @throws ProjectRenameBlockedException naming every project which blocks 
the rename and why
+   */
+  public static List<String> checkProjectRename(
+      String currentName, String newName, IVariables variables, ILogChannel 
log)
+      throws ProjectRenameBlockedException {
+    return findChildProjects(currentName, newName, variables, log).stream()
+        .map(ChildProject::name)
+        .toList();
+  }
 
+  /**
+   * Save a project registration, renaming the project when its name changed. 
A rename is all or
+   * nothing: the default project, standard parent project and lifecycle 
environments follow the new
+   * name, the registration is saved and then the projects which use it as 
their parent project are
+   * updated and saved. The registration is saved first so that no project 
ever points to an
+   * unregistered parent project. If a project can't be saved, everything is 
restored to the
+   * previous name.
+   *
+   * @param currentName the name the project is registered with
+   * @param projectConfig the updated registration, possibly with a new name. 
This can be the
+   *     registered instance itself.
+   * @param variables the variables to resolve the project locations with
+   * @param log the log channel
+   * @param saver saves the projects configuration
+   * @return the names of the projects which now use the new name as their 
parent project
+   * @throws ProjectRenameBlockedException when the rename can't be done, 
nothing was changed
+   * @throws HopException when saving failed, the rename was undone
+   */
+  public static List<String> saveProjectConfig(
+      String currentName,
+      ProjectConfig projectConfig,
+      IVariables variables,
+      ILogChannel log,
+      IProjectsConfigSaver saver)
+      throws HopException {
     ProjectsConfig config = ProjectsConfigSingleton.getConfig();
-    List<String> prjs = config.listProjectConfigNames();
+    String newName = projectConfig.getProjectName();
+    if (StringUtils.isEmpty(currentName) || currentName.equals(newName)) {
+      config.updateProjectConfig(currentName, projectConfig);
+      saver.save();
+      return List.of();
+    }
 
-    HopGui hopGui = HopGui.getInstance();
-    List<String> parentProjectReferences = new ArrayList<>();
-    ProjectConfig currentProjectConfig = config.findProjectConfig(projectName);
-
-    if (currentProjectConfig == null) {
-      parentProjectReferences = List.of();
-    } else {
-      for (String prj : prjs) {
-        if (!prj.equals(projectName)) {
-          ProjectConfig prjCfg = config.findProjectConfig(prj);
-          Project thePrj = prjCfg.loadProject(hopGui.getVariables());
-          if (thePrj != null) {
-            if (thePrj.getParentProjectName() != null
-                && thePrj.getParentProjectName().equals(projectName)) {
-              parentProjectReferences.add(prj);
-            }
-          } else {
-            hopGui.getLog().logError("Unable to load project '" + prj + "' 
from its configuration");
-          }
+    List<ChildProject> children;
+    try {
+      children = findChildProjects(currentName, newName, variables, log);
+    } catch (ProjectRenameBlockedException e) {
+      projectConfig.setProjectName(currentName);
+      throw e;
+    }
+
+    config.updateProjectConfig(currentName, projectConfig);
+    try {
+      saver.save();
+    } catch (HopException e) {
+      revertRename(config, projectConfig, currentName, newName);
+      throw e;
+    }
+
+    List<ChildProject> updated = new ArrayList<>();
+    for (ChildProject child : children) {
+      try {
+        child.project().setParentProjectName(newName);
+        child.project().saveToFile();
+        updated.add(child);
+      } catch (Exception e) {
+        // The failed save may have left a partial file behind: write the old 
name back if we can
+        restoreParentProjectName(List.of(child), currentName, log);
+        List<String> notRestored = restoreParentProjectName(updated, 
currentName, log);
+        revertRename(config, projectConfig, currentName, newName);
+        try {
+          saver.save();
+        } catch (HopException saveException) {
+          log.logError(
+              "Unable to save the restored registration of project '" + 
currentName + "'",
+              saveException);
         }
+        String message =
+            notRestored.isEmpty()
+                ? BaseMessages.getString(
+                    PKG, "ProjectRename.SaveFailed.Message", currentName, 
newName, child.name())
+                : BaseMessages.getString(
+                    PKG,
+                    "ProjectRename.SaveFailed.NotRestored",
+                    currentName,
+                    newName,
+                    child.name(),
+                    String.join(", ", notRestored));
+        throw new HopException(message, e);
       }
     }
-    return parentProjectReferences;
+    return updated.stream().map(ChildProject::name).toList();
   }
 
-  public static List<String> changeParentProjectReferences(String currentName, 
String newName)
-      throws HopException {
-
+  private static List<ChildProject> findChildProjects(
+      String currentName, String newName, IVariables variables, ILogChannel 
log)
+      throws ProjectRenameBlockedException {
     ProjectsConfig config = ProjectsConfigSingleton.getConfig();
-    List<String> prjs = config.listProjectConfigNames();
-
-    HopGui hopGui = HopGui.getInstance();
-    List<String> parentProjectReferences = new ArrayList<>();
-    ProjectConfig currentProjectConfig = config.findProjectConfig(currentName);
-
-    if (currentProjectConfig == null) {
-      parentProjectReferences = List.of();
-    } else {
-      for (String prj : prjs) {
-        if (!prj.equals(currentName)) {
-          ProjectConfig prjCfg = config.findProjectConfig(prj);
-          Project thePrj = prjCfg.loadProject(hopGui.getVariables());
-          if (thePrj != null) {
-            if (thePrj.getParentProjectName() != null
-                && thePrj.getParentProjectName().equals(currentName)) {
-              thePrj.setParentProjectName(newName);
-            }
-          } else {
-            hopGui.getLog().logError("Unable to load project '" + prj + "' 
from its configuration");
-          }
+    List<ChildProject> children = new ArrayList<>();
+    List<String> blocking = new ArrayList<>();
+    List<String> reasons = new ArrayList<>();
+    for (String name : config.listProjectConfigNames()) {
+      if (name.equalsIgnoreCase(currentName)) {
+        continue;
+      }
+      ProjectConfig projectConfig = config.findProjectConfig(name);
+      String home = variables.resolve(projectConfig.getProjectHome());
+      Project project;
+      try {
+        if (StringUtils.isEmpty(home) || !HopVfs.getFileObject(home).exists()) 
{
+          log.logDetailed(
+              "Project '" + name + "' is skipped, its home folder '" + home + 
"' doesn't exist");
+          continue;
         }
+        project = projectConfig.loadProject(variables);
+      } catch (Exception e) {
+        blocking.add(name);
+        reasons.add(
+            BaseMessages.getString(
+                PKG, "ProjectRename.Blocked.Unreadable", name, currentName, 
describeCause(e)));
+        continue;
+      }
+      if (!currentName.equalsIgnoreCase(project.getParentProjectName())) {
+        continue;
       }
+      if (projectConfig.isReadOnly()) {
+        blocking.add(name);
+        reasons.add(
+            BaseMessages.getString(PKG, "ProjectRename.Blocked.ReadOnly", 
name, currentName));
+      } else if (ProjectConfig.isArchiveUri(home)) {
+        blocking.add(name);
+        reasons.add(
+            BaseMessages.getString(PKG, "ProjectRename.Blocked.Archive", name, 
currentName, home));
+      } else {
+        children.add(new ChildProject(name, project));
+      }
+    }
+    if (!blocking.isEmpty()) {
+      throw new ProjectRenameBlockedException(
+          BaseMessages.getString(
+              PKG,
+              "ProjectRename.Blocked.Message",
+              currentName,
+              newName,
+              String.join(Const.CR, reasons)),
+          blocking);
+    }
+    return children;
+  }
+
+  /**
+   * @return the names of the projects which couldn't be restored
+   */
+  private static List<String> restoreParentProjectName(
+      List<ChildProject> children, String parentProjectName, ILogChannel log) {
+    List<String> notRestored = new ArrayList<>();
+    for (ChildProject child : children) {
+      try {
+        child.project().setParentProjectName(parentProjectName);
+        child.project().saveToFile();
+      } catch (Exception e) {
+        log.logError(
+            "Unable to restore parent project '"
+                + parentProjectName
+                + "' of project '"
+                + child.name()
+                + "'",
+            e);
+        notRestored.add(child.name());
+      }
+    }
+    return notRestored;
+  }
+
+  private static void revertRename(
+      ProjectsConfig config, ProjectConfig projectConfig, String currentName, 
String newName) {
+    ProjectConfig registered = config.findProjectConfig(newName);
+    if (registered != null) {
+      registered.setProjectName(currentName);
+    }
+    projectConfig.setProjectName(currentName);
+    config.renameProjectReferences(newName, currentName);
+  }
+
+  /** The first line of the root cause message: enough to tell the user what's 
wrong. */
+  private static String describeCause(Exception e) {
+    Throwable root = ExceptionUtils.getRootCause(e);
+    String message = root == null ? null : root.getMessage();
+    if (StringUtils.isBlank(message)) {
+      return (root == null ? e : root).getClass().getSimpleName();
+    }
+    return message.strip().lines().findFirst().orElse(message).strip();
+  }
+
+  private static Project loadProject(
+      ProjectConfig projectConfig, IVariables variables, ILogChannel log) {
+    try {
+      return projectConfig.loadProject(variables);
+    } catch (Exception e) {
+      log.logError(
+          "Unable to load project '" + projectConfig.getProjectName() + "' 
from its configuration",
+          e);
+      return null;
     }
-    return parentProjectReferences;
   }
 }
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
index aaa0b610ac..5216c1e228 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
@@ -136,7 +136,7 @@ public class HopGuiStartProjectLoad implements 
IExtensionPoint {
 
             LifecycleEnvironment lastEnvironment = null;
             if (cliLifecycleEnvironment != null
-                && 
lastProjectName.equals(cliLifecycleEnvironment.getProjectName())) {
+                && 
lastProjectName.equalsIgnoreCase(cliLifecycleEnvironment.getProjectName())) {
               lastEnvironment = cliLifecycleEnvironment;
             } else {
               List<AuditEvent> envEvents =
@@ -149,7 +149,8 @@ public class HopGuiStartProjectLoad implements 
IExtensionPoint {
 
               for (AuditEvent envEvent : envEvents) {
                 LifecycleEnvironment environment = 
config.findEnvironment(envEvent.getName());
-                if (environment != null && 
lastProjectName.equals(environment.getProjectName())) {
+                if (environment != null
+                    && 
lastProjectName.equalsIgnoreCase(environment.getProjectName())) {
                   lastEnvironment = environment;
                   break;
                 }
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
index 90f4f1e8b0..5addd1abf1 100644
--- 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
@@ -63,6 +63,8 @@ ProjectDialog.Label.ProjectName=Name
 ProjectDialog.Label.ReadOnly=This project is read only
 ProjectDialog.Label.UnitTestBaseFolder=Unit tests base path 
(HOP_UNIT_TESTS_FOLDER)
 ProjectDialog.Label.Version=Version
+ProjectDialog.MissingParentProject.Dialog.Header=Parent project not found
+ProjectDialog.MissingParentProject.Dialog.Message=Parent project ''{0}'' 
doesn''t exist. It may have been deleted or renamed.\n\nDo you want to remove 
the parent project and continue?\nChoose No to select another parent project.
 ProjectDialog.ProjectConfigError.Error.Dialog.Header=Error
 ProjectDialog.ProjectConfigError.Error.Dialog.Message=There is a configuration 
error in the project
 ProjectDialog.ProjectDefinitionError.Error.Dialog.Header=Error
@@ -74,3 +76,10 @@ ProjectDialog.ReadOnly.HomeMissing.Error=This project is 
read only, so the home
 ProjectDialog.Shell.Name=Project Properties
 ProjectGuiPlugin.WrongConfigPath.Dialog.Header=Wrong project''s root
 ProjectGuiPlugin.WrongConfigPath.Dialog.Message=The path to selected 
project-config.json is outside the boundaries of this project and that is not 
permitted!
+ProjectRename.Blocked.Archive=- ''{0}'' uses ''{1}'' as its parent project, 
but it is stored in an archive which can''t be written to: {2}
+ProjectRename.Blocked.Header=Project can''t be renamed
+ProjectRename.Blocked.Message=Project ''{0}'' can''t be renamed to ''{1}'' 
because the following projects can''t be checked or updated:\n\n{2}\n\nFix 
these projects or change their parent project, then try again. Nothing was 
changed.
+ProjectRename.Blocked.ReadOnly=- ''{0}'' uses ''{1}'' as its parent project, 
but it is read-only.
+ProjectRename.Blocked.Unreadable=- ''{0}'': its configuration can''t be read, 
so it can''t be checked whether it uses ''{1}'' as its parent project. Reason: 
{2}
+ProjectRename.SaveFailed.Message=Project ''{0}'' wasn''t renamed to ''{1}'': 
the parent project of ''{2}'' couldn''t be saved. The rename was undone.
+ProjectRename.SaveFailed.NotRestored=Project ''{0}'' wasn''t renamed to 
''{1}'': the parent project of ''{2}'' couldn''t be saved. The rename was 
undone, but these projects couldn''t be restored and still use ''{1}'' as their 
parent project: {3}
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectReferencesTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectReferencesTest.java
new file mode 100644
index 0000000000..86f615f69f
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectReferencesTest.java
@@ -0,0 +1,375 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.projects.project;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.environment.LifecycleEnvironment;
+import org.apache.hop.projects.util.ProjectRenameBlockedException;
+import org.apache.hop.projects.util.ProjectsUtil;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Parent project, default project and standard parent project references 
(#4275). */
+class ProjectReferencesTest {
+
+  @TempDir Path tempRoot;
+
+  private final List<String> registeredProjectNames = new ArrayList<>();
+  private final List<String> registeredEnvironmentNames = new ArrayList<>();
+  private int saveCount;
+  private String originalDefaultProject;
+  private String originalStandardParentProject;
+
+  @BeforeAll
+  static void beforeAll() {
+    HopLogStore.init();
+  }
+
+  @BeforeEach
+  void setUp() {
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    originalDefaultProject = config.getDefaultProject();
+    originalStandardParentProject = config.getStandardParentProject();
+  }
+
+  @AfterEach
+  void tearDown() {
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    registeredProjectNames.forEach(config::removeProjectConfig);
+    registeredEnvironmentNames.forEach(config::removeEnvironment);
+    config.setDefaultProject(originalDefaultProject);
+    config.setStandardParentProject(originalStandardParentProject);
+  }
+
+  @Test
+  void removingProjectClearsDefaultAndStandardParentProject() throws Exception 
{
+    registerProject("ref-parent", null);
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    config.setDefaultProject("ref-parent");
+    config.setStandardParentProject("ref-parent");
+    assertEquals("ref-parent", config.findRegisteredStandardParentProject());
+
+    config.removeProjectConfig("ref-parent");
+
+    assertNull(config.getDefaultProject());
+    assertNull(config.getStandardParentProject());
+    assertNull(config.findRegisteredStandardParentProject());
+  }
+
+  @Test
+  void standardParentProjectMustBeRegistered() {
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    config.setStandardParentProject("ref-does-not-exist");
+
+    assertNull(config.findRegisteredStandardParentProject());
+  }
+
+  @Test
+  void renamingRegistrationInPlaceRenamesDefaultAndStandardParentProject() 
throws Exception {
+    ProjectConfig projectConfig = registerProject("ref-old", null);
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    config.setDefaultProject("ref-old");
+    config.setStandardParentProject("ref-old");
+
+    // The projects browser dialog renames the registered instance before 
updating it
+    projectConfig.setProjectName("ref-new");
+    registeredProjectNames.add("ref-new");
+    config.updateProjectConfig("ref-old", projectConfig);
+
+    assertEquals("ref-new", config.getDefaultProject());
+    assertEquals("ref-new", config.getStandardParentProject());
+  }
+
+  @Test
+  void parentProjectReferencesSkipProjectsWhichCantBeLoaded() throws Exception 
{
+    registerProject("ref-parent", null);
+    registerProject("ref-child", "ref-parent");
+    registerProject("ref-broken", null);
+    Files.writeString(
+        
tempRoot.resolve("ref-broken").resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{ not json",
+        StandardCharsets.UTF_8);
+
+    List<String> references =
+        ProjectsUtil.getParentProjectReferences("ref-parent", new Variables(), 
LogChannel.GENERAL);
+
+    assertEquals(List.of("ref-child"), references);
+  }
+
+  @Test
+  void renamingProjectUpdatesChildProjectsEnvironmentsAndDefaults() throws 
Exception {
+    ProjectConfig parentConfig = registerProject("ref-parent", null);
+    ProjectConfig childConfig = registerProject("ref-child", "ref-parent");
+    registerProject("ref-other", null);
+    LifecycleEnvironment environment = registerEnvironment("ref-env", 
"ref-parent");
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    config.setDefaultProject("ref-parent");
+
+    List<String> changed = rename(parentConfig, "ref-parent", "ref-renamed");
+
+    assertEquals(List.of("ref-child"), changed);
+    assertEquals(1, saveCount);
+    assertEquals("ref-renamed", childConfig.loadProject(new 
Variables()).getParentProjectName());
+    assertEquals("ref-renamed", environment.getProjectName());
+    assertEquals("ref-renamed", config.getDefaultProject());
+    assertNotNull(config.findProjectConfig("ref-renamed"));
+    assertNull(config.findProjectConfig("ref-parent"));
+  }
+
+  @Test
+  void caseOnlyRenameUpdatesReferences() throws Exception {
+    ProjectConfig parentConfig = registerProject("ref-parent", null);
+    ProjectConfig childConfig = registerProject("ref-child", "ref-parent");
+    LifecycleEnvironment environment = registerEnvironment("ref-env", 
"ref-parent");
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    config.setStandardParentProject("ref-parent");
+
+    List<String> changed = rename(parentConfig, "ref-parent", "REF-Parent");
+
+    assertEquals(List.of("ref-child"), changed);
+    assertEquals("REF-Parent", childConfig.loadProject(new 
Variables()).getParentProjectName());
+    assertEquals("REF-Parent", environment.getProjectName());
+    assertEquals("REF-Parent", config.getStandardParentProject());
+    assertEquals("REF-Parent", 
config.findProjectConfig("ref-parent").getProjectName());
+  }
+
+  @Test
+  void environmentsOfProjectAreFoundRegardlessOfCase() {
+    registerEnvironment("ref-env", "Ref-Parent");
+
+    List<LifecycleEnvironment> environments =
+        
ProjectsConfigSingleton.getConfig().findEnvironmentsOfProject("ref-parent");
+
+    assertEquals(1, environments.size());
+    assertEquals("ref-env", environments.get(0).getName());
+  }
+
+  @Test
+  void removingProjectLeavesEnvironmentsAlone() throws Exception {
+    registerProject("ref-parent", null);
+    LifecycleEnvironment environment = registerEnvironment("ref-env", 
"ref-parent");
+
+    ProjectsConfigSingleton.getConfig().removeProjectConfig("ref-parent");
+
+    // A null project would make the environment show up for every project
+    assertEquals("ref-parent", environment.getProjectName());
+  }
+
+  @Test
+  void readOnlyChildProjectBlocksRenameAndNothingChanges() throws Exception {
+    ProjectConfig parentConfig = registerProject("ref-parent", null);
+    ProjectConfig writableChild = registerProject("ref-child-a", "ref-parent");
+    registerProject("ref-child-b", "ref-parent").setReadOnly(true);
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    config.setDefaultProject("ref-parent");
+
+    ProjectRenameBlockedException exception =
+        assertThrows(
+            ProjectRenameBlockedException.class,
+            () -> rename(parentConfig, "ref-parent", "ref-renamed"));
+
+    assertEquals(List.of("ref-child-b"), exception.getBlockingProjects());
+    assertTrue(
+        exception
+            .getUserMessage()
+            .contains("Project 'ref-parent' can't be renamed to 
'ref-renamed'"),
+        exception.getUserMessage());
+    assertTrue(
+        exception
+            .getUserMessage()
+            .contains("'ref-child-b' uses 'ref-parent' as its parent project, 
but it is read-only"),
+        exception.getUserMessage());
+    assertEquals(0, saveCount);
+    assertEquals("ref-parent", parentConfig.getProjectName());
+    assertEquals("ref-parent", config.getDefaultProject());
+    assertEquals("ref-parent", writableChild.loadProject(new 
Variables()).getParentProjectName());
+  }
+
+  @Test
+  void unreadableProjectBlocksRename() throws Exception {
+    ProjectConfig parentConfig = registerProject("ref-parent", null);
+    registerProject("ref-broken", null);
+    Files.writeString(
+        
tempRoot.resolve("ref-broken").resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{ not json",
+        StandardCharsets.UTF_8);
+
+    ProjectRenameBlockedException exception =
+        assertThrows(
+            ProjectRenameBlockedException.class,
+            () ->
+                ProjectsUtil.checkProjectRename(
+                    "ref-parent", "ref-renamed", new Variables(), 
LogChannel.GENERAL));
+
+    assertEquals(List.of("ref-broken"), exception.getBlockingProjects());
+    assertTrue(
+        exception.getUserMessage().contains("'ref-broken': its configuration 
can't be read"),
+        exception.getUserMessage());
+    assertEquals("ref-parent", parentConfig.getProjectName());
+  }
+
+  @Test
+  void projectWithoutHomeFolderDoesNotBlockRename() throws Exception {
+    registerProject("ref-parent", null);
+    ProjectConfig gone =
+        new ProjectConfig(
+            "ref-gone",
+            tempRoot.resolve("does-not-exist").toString(),
+            ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+    ProjectsConfigSingleton.getConfig().addProjectConfig(gone);
+    registeredProjectNames.add("ref-gone");
+
+    assertEquals(
+        List.of(),
+        ProjectsUtil.checkProjectRename(
+            "ref-parent", "ref-renamed", new Variables(), LogChannel.GENERAL));
+  }
+
+  @Test
+  void failingRegistrationSaveUndoesRename() throws Exception {
+    ProjectConfig parentConfig = registerProject("ref-parent", null);
+    ProjectConfig childConfig = registerProject("ref-child", "ref-parent");
+    LifecycleEnvironment environment = registerEnvironment("ref-env", 
"ref-parent");
+
+    parentConfig.setProjectName("ref-renamed");
+    registeredProjectNames.add("ref-renamed");
+    assertThrows(
+        HopException.class,
+        () ->
+            ProjectsUtil.saveProjectConfig(
+                "ref-parent",
+                parentConfig,
+                new Variables(),
+                LogChannel.GENERAL,
+                () -> {
+                  throw new HopException("disk full");
+                }));
+
+    assertEquals("ref-parent", parentConfig.getProjectName());
+    assertEquals("ref-parent", environment.getProjectName());
+    assertEquals("ref-parent", childConfig.loadProject(new 
Variables()).getParentProjectName());
+  }
+
+  @Test
+  void failingChildSaveRestoresEveryProject() throws Exception {
+    ProjectConfig parentConfig = registerProject("ref-parent", null);
+    ProjectConfig firstChild = registerProject("ref-child-a", "ref-parent");
+    registerProject("ref-child-b", "ref-parent");
+    // A home folder which can't be written makes saving the second child fail
+    File secondHome = tempRoot.resolve("ref-child-b").toFile();
+    File secondConfig = new File(secondHome, 
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+    assumeTrue(secondConfig.setWritable(false) && !secondConfig.canWrite());
+    assumeTrue(secondHome.setWritable(false) && !secondHome.canWrite());
+
+    try {
+      HopException exception =
+          assertThrows(HopException.class, () -> rename(parentConfig, 
"ref-parent", "ref-renamed"));
+
+      assertTrue(
+          exception
+              .getMessage()
+              .contains(
+                  "Project 'ref-parent' wasn't renamed to 'ref-renamed': the 
parent project of"
+                      + " 'ref-child-b' couldn't be saved. The rename was 
undone."),
+          exception.getMessage());
+      assertEquals("ref-parent", parentConfig.getProjectName());
+      assertEquals("ref-parent", firstChild.loadProject(new 
Variables()).getParentProjectName());
+      // Registration saved with the new name, then saved again with the old 
one
+      assertEquals(2, saveCount);
+    } finally {
+      secondHome.setWritable(true);
+      secondConfig.setWritable(true);
+    }
+  }
+
+  @Test
+  void commandLineDeleteRefusesParentProject() throws Exception {
+    registerProject("ref-parent", null);
+    registerProject("ref-child", "ref-parent");
+
+    ManageProjectsOptionPlugin plugin = new ManageProjectsOptionPlugin();
+    plugin.setProjectName("ref-parent");
+    plugin.setDeleteProject(true);
+
+    HopException exception =
+        assertThrows(
+            HopException.class,
+            () -> plugin.handleOption(LogChannel.GENERAL, null, new 
Variables()));
+
+    
assertNotNull(ProjectsConfigSingleton.getConfig().findProjectConfig("ref-parent"));
+    assertTrue(
+        exception
+            .getCause()
+            .getMessage()
+            .contains("'ref-parent' can't be deleted, it is the parent project 
of: ref-child"));
+  }
+
+  /** Rename the registered instance in place, the way the project dialogs do. 
*/
+  private List<String> rename(ProjectConfig projectConfig, String currentName, 
String newName)
+      throws HopException {
+    projectConfig.setProjectName(newName);
+    registeredProjectNames.add(newName);
+    return ProjectsUtil.saveProjectConfig(
+        currentName, projectConfig, new Variables(), LogChannel.GENERAL, () -> 
saveCount++);
+  }
+
+  private LifecycleEnvironment registerEnvironment(String name, String 
projectName) {
+    LifecycleEnvironment environment =
+        new LifecycleEnvironment(name, "Testing", projectName, List.of());
+    ProjectsConfigSingleton.getConfig().addEnvironment(environment);
+    registeredEnvironmentNames.add(name);
+    return environment;
+  }
+
+  private ProjectConfig registerProject(String name, String parentProjectName) 
throws Exception {
+    Path home = tempRoot.resolve(name);
+    Files.createDirectories(home);
+    String parentJson = parentProjectName == null ? "null" : "\"" + 
parentProjectName + "\"";
+    Files.writeString(
+        home.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{ \"parentProjectName\" : " + parentJson + " }",
+        StandardCharsets.UTF_8);
+
+    ProjectConfig projectConfig =
+        new ProjectConfig(name, home.toString(), 
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+    ProjectsConfigSingleton.getConfig().addProjectConfig(projectConfig);
+    registeredProjectNames.add(name);
+    return projectConfig;
+  }
+}

Reply via email to