diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index c7377d8efd..cf79030669 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -5,7 +5,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE
== 2026.09.002
Release with new features and bugfixes:
-
+* https://github.com/devonfw/IDEasy/issues/1695[#1695]: Project creation logic extended with health checks and removed `--code` flag.
The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/50?closed=1[milestone 2026.09.002].
@@ -13,6 +13,7 @@ The full list of changes for this release can be found in https://github.com/dev
Release with new features and bugfixes:
+* https://github.com/devonfw/IDEasy/issues/1695[#1695]: Project creation logic extended with health checks and removed `--code` flag.
* https://github.com/devonfw/IDEasy/issues/1525[#1525]: Document known issue and workaround for lombok plugin in Eclipse
* https://github.com/devonfw/IDEasy/issues/1031[#1031]: Added OpenRewrite commandlet
* https://github.com/devonfw/IDEasy/issues/2361[#2361]: Improve dotnet installation by setting DOTNET_ROOT
diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java
index 16cb0598fe..3a39a319a4 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliException.java
@@ -64,4 +64,15 @@ public int getExitCode() {
return this.exitCode;
}
+ /**
+ * @return {@code true} if this exception has to be re-thrown from a {@link com.devonfw.tools.ide.step.Step Step} even if that {@code Step} was not asked to
+ * re-throw errors, {@code false} otherwise (default). A regular error only makes the according {@code Step} fail while the overall process continues with
+ * the next step. However, if a critical guardrail was violated (e.g. no valid settings could be established) continuing makes no sense and the entire
+ * process has to be aborted.
+ */
+ public boolean isForceRethrowInStep() {
+
+ return false;
+ }
+
}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java
new file mode 100644
index 0000000000..11a153aa76
--- /dev/null
+++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliFatalException.java
@@ -0,0 +1,36 @@
+package com.devonfw.tools.ide.cli;
+
+/**
+ * {@link CliException} that aborts the entire CLI process when a critical guardrail fails (e.g. the settings repository could not be cloned or is not a valid
+ * settings repository). Unlike a regular error that only makes the current {@link com.devonfw.tools.ide.step.Step Step} fail while the overall process
+ * continues, this exception {@link #isForceRethrowInStep() is always re-thrown} so no further step is executed in an invalid state.
+ */
+public final class CliFatalException extends CliException {
+
+ /**
+ * The constructor.
+ *
+ * @param message the {@link #getMessage() message}.
+ */
+ public CliFatalException(String message) {
+
+ super(message);
+ }
+
+ /**
+ * The constructor.
+ *
+ * @param message the {@link #getMessage() message}.
+ * @param cause the {@link #getCause() cause}.
+ */
+ public CliFatalException(String message, Throwable cause) {
+
+ super(message, cause);
+ }
+
+ @Override
+ public boolean isForceRethrowInStep() {
+
+ return true;
+ }
+}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java
index f98a63decd..d115fefd17 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java
@@ -14,6 +14,7 @@
import com.devonfw.tools.ide.cli.CliArgument;
import com.devonfw.tools.ide.cli.CliArguments;
import com.devonfw.tools.ide.commandlet.cleanup.CleanupCommandlet;
+import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet;
import com.devonfw.tools.ide.completion.CompletionCandidateCollector;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.git.repository.RepositoryCommandlet;
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java
index a68d6768c5..d9a10c3d1f 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CreateCommandlet.java
@@ -7,10 +7,10 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import com.devonfw.tools.ide.commandlet.update.AbstractUpdateCommandlet;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.io.FileAccess;
import com.devonfw.tools.ide.log.IdeLogLevel;
-import com.devonfw.tools.ide.property.FlagProperty;
import com.devonfw.tools.ide.property.StringProperty;
import com.devonfw.tools.ide.version.IdeVersion;
@@ -24,9 +24,6 @@ public class CreateCommandlet extends AbstractUpdateCommandlet {
/** {@link StringProperty} for the name of the new project */
public final StringProperty newProject;
- /** {@link FlagProperty} for creating a project with settings inside a code repository */
- public final FlagProperty codeRepositoryFlag;
-
/**
* The constructor.
*
@@ -36,7 +33,6 @@ public CreateCommandlet(IdeContext context) {
super(context);
this.newProject = add(new StringProperty("", true, "project"));
- this.codeRepositoryFlag = add(new FlagProperty("--code"));
add(this.settingsRepo);
}
@@ -55,42 +51,43 @@ public boolean isIdeHomeRequired() {
@Override
protected void doRun() {
- String newProjectName = this.newProject.getValue();
- Path newProjectPath = this.context.getIdeRoot().resolve(newProjectName);
-
+ Path newProjectPath = getNewProjectPath();
LOG.info("Creating new IDEasy project in {}", newProjectPath);
- if (!this.context.getFileAccess().isEmptyDir(newProjectPath)) {
+ FileAccess fileAccess = this.context.getFileAccess();
+ if (!fileAccess.isEmptyDir(newProjectPath)) {
this.context.askToContinue("Directory {} already exists. Do you want to continue?", newProjectPath);
- } else {
- this.context.getFileAccess().mkdirs(newProjectPath);
+ fileAccess.backup(newProjectPath);
}
- initializeProject(newProjectPath);
- this.context.setIdeHome(newProjectPath);
super.doRun();
this.context.getFileAccess().writeFileContent(IdeVersion.getVersionString(), newProjectPath.resolve(IdeContext.FILE_SOFTWARE_VERSION));
- IdeLogLevel.SUCCESS.log(LOG, "Successfully created new project '{}'.", newProjectName);
-
+ IdeLogLevel.SUCCESS.log(LOG, "Successfully created new project '{}'.", this.newProject.getValue());
logWelcomeMessage();
}
- private void initializeProject(Path newInstancePath) {
+ @Override
+ protected void onSettingHealthCheckFinished() {
+
+ // only called after the settings passed the health check
+ Path newProjectPath = getNewProjectPath();
FileAccess fileAccess = this.context.getFileAccess();
- fileAccess.mkdirs(newInstancePath.resolve(IdeContext.FOLDER_SOFTWARE));
- fileAccess.mkdirs(newInstancePath.resolve(IdeContext.FOLDER_PLUGINS));
- fileAccess.mkdirs(newInstancePath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN));
+ fileAccess.mkdirs(newProjectPath);
+ this.context.setIdeHome(newProjectPath);
+ fileAccess.mkdirs(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE));
+ fileAccess.mkdirs(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS));
+ fileAccess.mkdirs(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN));
}
- @Override
- protected boolean isCodeRepository() {
- return this.codeRepositoryFlag.isTrue();
+ private Path getNewProjectPath() {
+
+ return this.context.getIdeRoot().resolve(this.newProject.getValue());
}
@Override
protected String getStepMessage() {
- return "Create (clone) " + (isCodeRepository() ? "code" : "settings") + " repository";
+ return "Create (clone) repository";
}
private void logWelcomeMessage() {
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java
index 39a5131ff5..1cd9fe2fcd 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/StatusCommandlet.java
@@ -104,7 +104,7 @@ private void logSettingsGitStatus() {
} else {
GitContext gitContext = this.context.getGitContext();
if (gitContext.isRepositoryUpdateAvailable(settingsPath, this.context.getSettingsCommitIdPath())) {
- if (!this.context.isSettingsCodeRepository()) {
+ if (!this.context.isCombinedSettingsCodeRepository()) {
LOG.warn("Your settings are not up-to-date, please run 'ide update'.");
}
} else {
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java
similarity index 65%
rename from cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java
rename to cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java
index c034ddcf24..89091843a8 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/AbstractUpdateCommandlet.java
@@ -1,4 +1,4 @@
-package com.devonfw.tools.ide.commandlet;
+package com.devonfw.tools.ide.commandlet.update;
import java.io.IOException;
import java.nio.file.Files;
@@ -9,15 +9,21 @@
import java.util.Set;
import java.util.stream.Stream;
+import com.devonfw.tools.ide.cli.CliException;
+import com.devonfw.tools.ide.commandlet.update.settings.HealthCheckResultStatus;
+import com.devonfw.tools.ide.commandlet.update.settings.SettingsHealthCheckResult;
+import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdateResult;
+import com.devonfw.tools.ide.commandlet.update.settings.SettingsUpdater;
+
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import com.devonfw.tools.ide.cli.CliException;
+import com.devonfw.tools.ide.commandlet.Commandlet;
+import com.devonfw.tools.ide.commandlet.CommandletManager;
+import com.devonfw.tools.ide.commandlet.CreateCommandlet;
import com.devonfw.tools.ide.context.AbstractIdeContext;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.context.IdeStartContextImpl;
-import com.devonfw.tools.ide.git.GitContext;
-import com.devonfw.tools.ide.git.GitUrl;
import com.devonfw.tools.ide.git.repository.RepositoryCommandlet;
import com.devonfw.tools.ide.io.FileAccess;
import com.devonfw.tools.ide.property.FlagProperty;
@@ -43,17 +49,6 @@ public abstract class AbstractUpdateCommandlet extends Commandlet {
private static final Logger LOG = LoggerFactory.getLogger(AbstractUpdateCommandlet.class);
- private static final String MESSAGE_CODE_REPO_URL = """
- No code repository was given after '--code'.
- Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc
- Please enter the code repository below that includes your settings folder.""";
-
- private static final String MESSAGE_SETTINGS_REPO_URL = """
- No settings found at {} and no SETTINGS_URL is defined.
- Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc
- Please contact the technical lead of your project to get the SETTINGS_URL for your project to enter.
- In case you just want to test IDEasy you may simply hit return to install the default settings.""";
-
/** {@link StringProperty} for the settings repository URL. */
public final StringProperty settingsRepo;
@@ -107,6 +102,15 @@ protected void doRun() {
createStartScripts();
}
+ /**
+ * Hook that is called after the settings passed the health check but before they are moved to their final location. Does nothing by default and is overridden
+ * by {@link CreateCommandlet} to create the project structure so that no project is created at all if the health check failed.
+ */
+ protected void onSettingHealthCheckFinished() {
+
+ // nothing to do by default
+ }
+
private void reloadContext() {
((AbstractIdeContext) this.context).reload();
@@ -158,138 +162,78 @@ private void setupConf(Path template, Path conf) {
/**
* Updates the settings repository in IDE_HOME/settings by either cloning if no such repository exists or pulling if the repository exists then saves the
- * latest current commit ID in the file ".commit.id".
+ * latest current commit ID in the file ".commit.id". The settings are always cloned into a temporary directory first where a health check is performed. Only
+ * if that health check succeeded the settings are pulled or the verified clone is moved to its final location.
*/
protected void updateSettings() {
- boolean codeRepository = this.context.isSettingsCodeRepository();
- if (codeRepository && !(this.context.isForceMode() || forcePull.isTrue())) {
+ boolean codeRepository = this.context.isCombinedSettingsCodeRepository();
+ if (codeRepository && !(this.context.isForceMode() || this.forcePull.isTrue())) {
LOG.info("Skipping git pull in settings due to code repository. Use --force-pull to enforce pulling.");
return;
}
- this.context.newStep(getStepMessage()).run(() -> updateSettingsInStep(codeRepository));
+ Step step = this.context.newStep(getStepMessage());
+ step.run(this::updateSettingsInStep);
}
protected String getStepMessage() {
- return "update (pull) settings repository";
+ return "Update settings repository";
}
- private void updateSettingsInStep(boolean codeRepository) {
- Path settingsPath = this.context.getSettingsPath();
- if (!codeRepository) {
- boolean settingsRepository = this.context.getGitContext().isGitRepo(settingsPath);
- if (!settingsRepository) {
- if (Files.exists(settingsPath)) {
- if (!this.context.getFileAccess().isEmptyDir(settingsPath)) {
- this.context.askToContinue(
- "Your settings repository seems to be broken ('.git' folder not present). "
- + "We can fix this by moving your settings the backed up. "
- + "You will be asked for the settings git URL and your settings will be cloned from scratch. "
- + "Do you want to proceed?"
- );
- }
- this.context.getFileAccess().backup(settingsPath);
+ private void updateSettingsInStep() {
+
+ SettingsUpdater settingsUpdater = new SettingsUpdater(this.context, this.settingsRepo, (this.forcePull.isTrue() || this.context.isForceMode()));
+ try {
+ //Step 1: Perform health check
+ Step healthCheckStep = this.context.newStep("Performing settings health check");
+ SettingsHealthCheckResult healthCheckResult;
+ healthCheckResult = healthCheckStep.call(() -> {
+ SettingsHealthCheckResult _healthCheckResult = settingsUpdater.checkSettings(this.context.getSettingsPath());
+ HealthCheckResultStatus status = _healthCheckResult.status();
+
+ if (status == null) {
+ throw new CliException("Health check on settings failed due to unknown error - the settings have not been updated");
+ } else if (status == HealthCheckResultStatus.SETTINGS_INVALID) {
+ throw new CliException("The settings health check failed: " + _healthCheckResult.errorMessage());
}
- GitUrl gitUrl = getOrAskSettingsUrl();
- checkProjectNameConvention(gitUrl.getProjectName());
- initializeRepository(gitUrl);
- return;
+ return _healthCheckResult;
+ }, () -> null);
+
+ //If health check failed and force mode is disabled, skip application of settings and fail "Update settings" step.
+ if(!this.forcePull.isTrue() && (healthCheckResult == null || healthCheckResult.status() == null || healthCheckStep.isFailure())) {
+ throw new CliException("Settings update aborted due to error in health check");
}
- }
- GitContext gitContext = this.context.getGitContext();
- if (gitContext.hasUntrackedFiles(settingsPath)) {
- gitContext.pullSafelyWithStash(settingsPath);
- } else {
- gitContext.pull(settingsPath);
- }
- this.context.getGitContext().saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath());
- }
- private GitUrl getOrAskSettingsUrl() {
+ //Step 2: Let create/update commandlets prepare themselves for the settings update.
+ onSettingHealthCheckFinished();
- String repository = this.settingsRepo.getValue();
- repository = handleDefaultRepository(repository);
- String userPromt;
- String defaultUrl;
- if (isCodeRepository()) {
- userPromt = "Code repository URL:";
- defaultUrl = null;
- LOG.info(MESSAGE_CODE_REPO_URL);
- } else {
- userPromt = "Settings URL [" + IdeContext.DEFAULT_SETTINGS_REPO_URL + "]:";
- defaultUrl = IdeContext.DEFAULT_SETTINGS_REPO_URL;
- LOG.info(MESSAGE_SETTINGS_REPO_URL, this.context.getSettingsPath());
- }
- GitUrl gitUrl = null;
- if (repository != null) {
- gitUrl = GitUrl.of(repository);
- }
- while ((gitUrl == null) || !gitUrl.isValid()) {
- repository = this.context.askForInput(userPromt, defaultUrl);
- repository = handleDefaultRepository(repository);
- gitUrl = GitUrl.of(repository);
- if (!gitUrl.isValid()) {
- LOG.warn("The input URL is not valid, please try again.");
- }
- }
- return gitUrl;
- }
+ //Step 3: Apply (move/pull newest version) settings
+ Step applySettingsStep = this.context.newStep("Applying settings");
+ applySettingsStep.run(() -> {
- private String handleDefaultRepository(String repository) {
- if ("-".equals(repository)) {
- if (isCodeRepository()) {
- LOG.warn("'-' is found after '--code'. This is invalid.");
- repository = null;
- } else {
- LOG.info("'-' was found for settings repository, the default settings repository '{}' will be used.", IdeContext.DEFAULT_SETTINGS_REPO_URL);
- repository = IdeContext.DEFAULT_SETTINGS_REPO_URL;
- }
- }
- return repository;
- }
+ SettingsUpdateResult settingsUpdateResult = settingsUpdater.applySettings(healthCheckResult.status() == HealthCheckResultStatus.SETTINGS_VALID_EXISTING,
+ healthCheckResult.temporarySettingsDirectory());
+ if (settingsUpdateResult == null) {
- private void checkProjectNameConvention(String projectName) {
- boolean isSettingsRepo = projectName.contains(IdeContext.SETTINGS_REPOSITORY_KEYWORD);
- boolean codeRepository = isCodeRepository();
- if (isSettingsRepo == codeRepository) {
- String warningTemplate;
- if (codeRepository) {
- warningTemplate = """
- Your git URL is pointing to the project name {} that contains the keyword '{}'.
- Therefore we assume that you did a mistake by adding the '--code' option to the ide project creation.
- Do you really want to create the project?""";
- } else {
- warningTemplate = """
- Your git URL is pointing to the project name {} that does not contain the keyword ''{}''.
- Therefore we assume that you forgot to add the '--code' option to the ide project creation.
- Do you really want to create the project?""";
- }
- this.context.askToContinue(warningTemplate, projectName, IdeContext.SETTINGS_REPOSITORY_KEYWORD);
- }
- }
+ throw new CliException("Failed to apply the settings update due to unknown error.");
+ }
- private void initializeRepository(GitUrl gitUrl) {
+ switch (settingsUpdateResult.updateStatus()) {
+ case SETTINGS_UPDATED -> applySettingsStep.success("Settings update successfully applied");
+ case SETTINGS_CLONED -> applySettingsStep.success("Settings successfully applied (cloned)");
+ case SETTINGS_UPDATE_FAILED -> throw new CliException("The settings update could not be applied: " + settingsUpdateResult.errorMessage());
+ }
+ });
- GitContext gitContext = this.context.getGitContext();
- Path settingsPath = this.context.getSettingsPath();
- Path repoPath = settingsPath;
- boolean codeRepository = isCodeRepository();
- if (codeRepository) {
- // clone the given code repository into IDE_HOME/workspaces/main
- repoPath = context.getWorkspacePath().resolve(gitUrl.getProjectName());
- }
- gitContext.pullOrClone(gitUrl, repoPath);
- if (codeRepository) {
- // check for settings folder and create symlink to IDE_HOME/settings
- Path settingsFolder = repoPath.resolve(IdeContext.FOLDER_SETTINGS);
- if (Files.exists(settingsFolder)) {
- context.getFileAccess().symlink(settingsFolder, settingsPath);
- } else {
- throw new CliException("Invalid code repository " + gitUrl + ": missing a settings folder at " + settingsFolder);
+ //Make sure to always fail the parent step if the "Apply settings" step fails.
+ if(applySettingsStep.isFailure()) {
+ throw new CliException("Settings update failed due to error while applying the settings update");
}
+ } finally {
+ // the verified clone lives across both steps and the prepareProject hook so it is only here that its lifetime ends
+ settingsUpdater.cleanup();
}
- this.context.getGitContext().saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath());
}
private void updateSoftware() {
@@ -447,14 +391,4 @@ private void createStartScript(String ide, String workspace) {
fileAccess.writeFileContent(scriptContent, scriptPath);
fileAccess.makeExecutable(scriptPath);
}
-
- /**
- * Judge if the repository is a code repository.
- *
- * @return true when the repository is a code repository, otherwise false.
- */
- protected boolean isCodeRepository() {
- return false;
- }
-
}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpdateCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/UpdateCommandlet.java
similarity index 85%
rename from cli/src/main/java/com/devonfw/tools/ide/commandlet/UpdateCommandlet.java
rename to cli/src/main/java/com/devonfw/tools/ide/commandlet/update/UpdateCommandlet.java
index 944a4c0eeb..a0319dbb3b 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UpdateCommandlet.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/UpdateCommandlet.java
@@ -1,5 +1,6 @@
-package com.devonfw.tools.ide.commandlet;
+package com.devonfw.tools.ide.commandlet.update;
+import com.devonfw.tools.ide.commandlet.Commandlet;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.migration.IdeMigrator;
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/HealthCheckResultStatus.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/HealthCheckResultStatus.java
new file mode 100644
index 0000000000..97ae1588e9
--- /dev/null
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/HealthCheckResultStatus.java
@@ -0,0 +1,16 @@
+package com.devonfw.tools.ide.commandlet.update.settings;
+
+import java.nio.file.Path;
+
+/**
+ * Status of the settings {@link SettingsUpdater#checkSettings(Path)} health check} describing what {@link SettingsUpdater#applySettings(boolean, Path)} has
+ * to do.
+ */
+public enum HealthCheckResultStatus {
+ /** The settings repository was cloned to a temporary directory and is valid - it can be moved to its final location. */
+ SETTINGS_VALID,
+ /** The settings repository already existed and was cloned to a temporary directory and is valid - it can be moved to its final location. */
+ SETTINGS_VALID_EXISTING,
+ /** The settings repository is invalid */
+ SETTINGS_INVALID
+}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsHealthCheckResult.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsHealthCheckResult.java
new file mode 100644
index 0000000000..e644a03f56
--- /dev/null
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsHealthCheckResult.java
@@ -0,0 +1,38 @@
+package com.devonfw.tools.ide.commandlet.update.settings;
+
+import com.devonfw.tools.ide.git.repository.RepositoryType;
+
+import java.nio.file.Path;
+
+/**
+ * Result of the settings {@link SettingsUpdater#checkSettings(Path)} health check}.
+ *
+ * @param status the {@link HealthCheckResultStatus}.
+ * @param repositoryType the {@link RepositoryType} of the settings repository.
+ * @param errorMessage the reason why the settings could not be updated or {@code null} if the health check succeeded.
+ * @param temporarySettingsDirectory path to the temporary folder this health check was performed on.
+ */
+public record SettingsHealthCheckResult(HealthCheckResultStatus status, RepositoryType repositoryType, Path temporarySettingsDirectory, String errorMessage) {
+
+ /**
+ * @param status the {@link HealthCheckResultStatus}.
+ * @param repositoryType the {@link RepositoryType}.
+ * @param temporarySettingsDirectory path to the temporary folder this health check was performed on.
+ * @return a {@link SettingsHealthCheckResult} for a successful health check.
+ */
+ public static SettingsHealthCheckResult of(HealthCheckResultStatus status, RepositoryType repositoryType, Path temporarySettingsDirectory) {
+
+ return new SettingsHealthCheckResult(status, repositoryType, temporarySettingsDirectory, null);
+ }
+
+ /**
+ * @param repositoryType the {@link RepositoryType} of the settings that are already present.
+ * @param errorMessage the reason why the settings could not be updated.
+ * @param temporarySettingsDirectory path to the temporary folder this health check was performed on.
+ * @return a {@link SettingsHealthCheckResult} for a failed but recoverable health check.
+ */
+ public static SettingsHealthCheckResult failed(RepositoryType repositoryType, String errorMessage, Path temporarySettingsDirectory) {
+
+ return new SettingsHealthCheckResult(HealthCheckResultStatus.SETTINGS_INVALID, repositoryType, temporarySettingsDirectory, errorMessage);
+ }
+}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateResult.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateResult.java
new file mode 100644
index 0000000000..87d3fe1e02
--- /dev/null
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateResult.java
@@ -0,0 +1,10 @@
+package com.devonfw.tools.ide.commandlet.update.settings;
+
+import com.devonfw.tools.ide.git.repository.RepositoryType;
+
+/**
+ * @param updateStatus resulting status of the update operation
+ * @param repositoryType detected type of the repository
+ * @param errorMessage error message if updateStatus = {@link SettingsUpdateStatus}.SETTINGS_UPDATE_FAILED, otherwise {@code null}
+ */
+public record SettingsUpdateResult(SettingsUpdateStatus updateStatus, RepositoryType repositoryType, String errorMessage) {}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateStatus.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateStatus.java
new file mode 100644
index 0000000000..d13871f4eb
--- /dev/null
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdateStatus.java
@@ -0,0 +1,11 @@
+package com.devonfw.tools.ide.commandlet.update.settings;
+
+/// Status of the update action of a settings repo.
+public enum SettingsUpdateStatus {
+ /** Existing settings have been successfully updated **/
+ SETTINGS_UPDATED,
+ /** Freshly cloned settings have been successfully applied **/
+ SETTINGS_CLONED,
+ /** Error occurred **/
+ SETTINGS_UPDATE_FAILED
+}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdater.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdater.java
new file mode 100644
index 0000000000..0b7547db49
--- /dev/null
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/update/settings/SettingsUpdater.java
@@ -0,0 +1,347 @@
+package com.devonfw.tools.ide.commandlet.update.settings;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.devonfw.tools.ide.cli.CliAbortException;
+import com.devonfw.tools.ide.cli.CliException;
+import com.devonfw.tools.ide.cli.CliFatalException;
+import com.devonfw.tools.ide.context.IdeContext;
+import com.devonfw.tools.ide.git.GitContext;
+import com.devonfw.tools.ide.git.GitUrl;
+import com.devonfw.tools.ide.git.repository.RepositoryType;
+import com.devonfw.tools.ide.git.repository.RepositoryUtil;
+import com.devonfw.tools.ide.io.FileAccess;
+import com.devonfw.tools.ide.property.StringProperty;
+
+/**
+ * Handles the settings repository of the current project in two phases:
+ *
+ * - {@link #checkSettings(Path)} health check: the settings are always cloned into a temporary directory
+ * first where it is verified that the git URL is valid, that cloning succeeded,
+ * and that the repository actually is a settings or a combined code and settings repository.
+ * - {@link #applySettings(boolean, Path)} apply: only after the health check succeeded the settings are either pulled in place (if they were already
+ * present) or the verified clone is moved to its final location.
+ *
+ */
+public class SettingsUpdater {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SettingsUpdater.class);
+
+ private static final String MESSAGE_SETTINGS_REPO_URL = """
+ No settings found at {} and no SETTINGS_URL is defined.
+ Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc
+ Please contact the technical lead of your project to get the SETTINGS_URL for your project to enter.
+ In case you just want to test IDEasy you may simply hit return to install the default settings.""";
+
+ private static final String MESSAGE_INVALID_REPOSITORY = "Settings repository integrity check failed: "
+ + "The given git repository URL does not point to a valid settings or code-settings repository. Please verify and try again.";
+
+ private final IdeContext context;
+
+ private final FileAccess fileAccess;
+
+ private final StringProperty settingsRepoProperty;
+
+ /** The temporary directory holding the verified clone or {@code null} if there is nothing to move. */
+ private Path tempRepoDir;
+
+ /** The name of the git project - required to place a combined code and settings repository into the workspace. */
+ private String gitProjectName;
+
+ private final boolean isForceMode;
+
+ /**
+ * The constructor.
+ *
+ * @param context the {@link IdeContext}.
+ * @param settingsRepoProperty the {@link StringProperty} with the settings repository URL from the update commandlet.
+ * @param isForceMode if in force mode, the settings health check will always return either {@link HealthCheckResultStatus#SETTINGS_VALID} or
+ * {@link HealthCheckResultStatus#SETTINGS_VALID_EXISTING}
+ */
+ public SettingsUpdater(IdeContext context, StringProperty settingsRepoProperty, boolean isForceMode) {
+
+ super();
+ this.context = context;
+ this.settingsRepoProperty = settingsRepoProperty;
+ this.fileAccess = context.getFileAccess();
+ this.isForceMode = isForceMode;
+ }
+
+ /**
+ * Performs the health check on the settings repository. Nothing is changed in {@link IdeContext#getIdeHome() IDE_HOME} except that a broken settings folder
+ * is backed up. Whether the settings are pulled or cloned is decided solely by the state of {@link IdeContext#getSettingsPath() IDE_HOME/settings} so that
+ * {@code ide create} and {@code ide update} share the very same logic.
+ *
+ * @param settingsPath the path to the (code-)settings directory which the health check should be performed on.
+ * @return the {@link SettingsHealthCheckResult}.
+ */
+ public SettingsHealthCheckResult checkSettings(Path settingsPath) {
+
+ if (settingsPath != null && !fileAccess.isEmptyDir(settingsPath)) {
+ // for a combined code and settings repository IDE_HOME/settings is a symlink into the code repository whose '.git' folder is one level above,
+ // so isGitRepo would report it as broken settings
+ RepositoryType settingsRepoType = RepositoryUtil.getRepositoryType(settingsPath);
+ if (settingsRepoType.isSettingsOrCodeSettingsRepository()) {
+ return checkSettingsPresent(settingsPath, settingsRepoType);
+ }
+ }
+ return checkClonedSettings(settingsPath);
+ }
+
+ /**
+ * Applies the result of the {@link #checkSettings(Path)} health check by either pulling the settings in place or moving the verified clone to its final
+ * location.
+ *
+ * @param onlyPull if true, we simply perform a git pull on the actual (not the one in the temp directory) settings repository.
+ * @param sourcePath sourcePath of the settings to apply.
+ * @return a {@link SettingsUpdateResult} representing the state, whether moving/pulling the newest settings was successful.
+ */
+ public SettingsUpdateResult applySettings(boolean onlyPull, Path sourcePath) {
+
+ RepositoryType repositoryType = RepositoryUtil.getRepositoryType(sourcePath);
+ Path settingsPath = this.context.getSettingsPath();
+
+ // Case 1: We performed "ide update"; so settings already existed and we just need to perform a git pull in the existing repo.
+ if (onlyPull) {
+ repositoryType = RepositoryUtil.getRepositoryType(context.getSettingsPath());
+ if(repositoryType != RepositoryType.SETTINGS && !this.isForceMode) {
+ return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_UPDATE_FAILED, repositoryType, "Expected settings repository for update.");
+ }
+
+ pullSettingsAndSaveCommitId(settingsPath);
+ return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_UPDATED, repositoryType, null);
+ }
+
+ // Case 2: We freshly cloned the settings repo and need to move it to a target directory.
+ switch (repositoryType) {
+ case CODE, UNKNOWN -> {
+
+ return moveSettingsOnlyIfForceModeActive(sourcePath, repositoryType);
+ }
+ case SETTINGS -> {
+
+ //move to IDE_HOME/SETTINGS
+ moveProject(sourcePath, settingsPath);
+ this.context.getGitContext().saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath());
+ return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_CLONED, repositoryType, null);
+ }
+ case CODE_SETTINGS_COMBINED -> {
+
+ //this is a special case - here we need to symlink from IDE_HOME/settings to IDE_HOME/workspaces/main/repo_name/settings.
+ // (Formerly managed by the obsolete "--code" flag)
+ Path repoMoveTargetDirectory = this.context.getWorkspacePath().resolve(gitProjectName);
+ Path symlinkPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_SETTINGS);
+ Path repoSettingsDirectory = repoMoveTargetDirectory.resolve(IdeContext.FOLDER_SETTINGS);
+
+ moveProject(sourcePath, repoMoveTargetDirectory);
+
+ context.getFileAccess().symlink(repoSettingsDirectory, symlinkPath);
+
+ this.context.getGitContext().saveCurrentCommitId(repoSettingsDirectory, this.context.getSettingsCommitIdPath());
+ return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_CLONED, repositoryType, null);
+ }
+ }
+
+ return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_UPDATE_FAILED, repositoryType, "Unknown error during settings");
+ }
+
+ private SettingsUpdateResult moveSettingsOnlyIfForceModeActive(Path sourcePath, RepositoryType repositoryType) {
+ LOG.warn("Force mode is active: Moving potentially invalid settings repository to {}", this.context.getSettingsPath());
+ if(this.isForceMode) {
+ moveProject(sourcePath, this.context.getSettingsPath());
+ return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_CLONED, repositoryType, null);
+ } else {
+ //Technically should be caught during a health check, but we still handle this here.
+ return new SettingsUpdateResult(SettingsUpdateStatus.SETTINGS_UPDATE_FAILED, repositoryType, MESSAGE_INVALID_REPOSITORY);
+ }
+ }
+
+ /**
+ * Health check for settings that are already present. As the project keeps working with these settings, a failure is only fatal if the user explicitly
+ * aborted. Here, if a new version is available, we clone the new version into a temporary folder and perform health checks.
+ * If the cloned, new version is valid, we call git update in the existing settings folder.
+ */
+ private SettingsHealthCheckResult checkSettingsPresent(Path settingsPath, RepositoryType repositoryType) {
+
+ try {
+ //Get Git url of existing settings, clone newest version of them to temp dir
+ GitUrl gitUrl = GitUrl.of(this.context.getGitContext().retrieveGitUrl(settingsPath));
+ RepositoryType clonedType = RepositoryUtil.getRepositoryType(cloneRepoToTempDir(gitUrl));
+ cleanup();
+
+ //If cloned repo is not (code-)settings repo and no force override (e.g. force mode) is applied, return error.
+ if (!clonedType.isSettingsOrCodeSettingsRepository() && !requestUserConfirmInvalidRepository(clonedType, gitUrl, true)) {
+ return SettingsHealthCheckResult.failed(clonedType, MESSAGE_INVALID_REPOSITORY, settingsPath);
+ }
+
+ //Otherwise, (e.g. user overrides), return valid.
+ return SettingsHealthCheckResult.of(HealthCheckResultStatus.SETTINGS_VALID_EXISTING, repositoryType, settingsPath);
+ } catch (RuntimeException e) {
+ cleanup();
+ if (e instanceof CliAbortException) {
+ // the user answered "no" so we must not silently carry on
+ return SettingsHealthCheckResult.failed(repositoryType, "Settings update aborted by end-user", settingsPath);
+ }
+ return SettingsHealthCheckResult.failed(repositoryType, e.getMessage(), settingsPath);
+ }
+ }
+
+ /**
+ * Health check for missing or broken settings (e.g. {@code ide create}).
+ * Without valid settings there is nothing to continue with, so every failure is fatal here.
+ */
+ private SettingsHealthCheckResult checkClonedSettings(Path settingsPath) {
+
+ try {
+ backupBrokenSettings(settingsPath);
+ GitUrl gitUrl = getOrAskSettingsUrl();
+
+ Path tempCloneDir = cloneRepoToTempDir(gitUrl);
+ RepositoryType repositoryType = RepositoryUtil.getRepositoryType(tempCloneDir);
+
+ if (!repositoryType.isSettingsOrCodeSettingsRepository() && !requestUserConfirmInvalidRepository(repositoryType, gitUrl, false)) {
+ //see @javadoc why we throw fatally here.
+ throw new CliFatalException(MESSAGE_INVALID_REPOSITORY);
+ }
+ return SettingsHealthCheckResult.of(HealthCheckResultStatus.SETTINGS_VALID, repositoryType, tempCloneDir);
+ } catch (RuntimeException e) {
+ cleanup();
+ throw createGuaranteedFatalException(e);
+ }
+ }
+
+ /**
+ * @param error the {@link RuntimeException} that made the settings setup fail.
+ * @return a {@link CliFatalException} that aborts the entire process. An existing {@link CliException} keeps its message and
+ * {@link CliException#getExitCode() exit code} so that e.g. an abort by the user is still reported as such.
+ */
+ private static CliFatalException createGuaranteedFatalException(RuntimeException error) {
+
+ if (error instanceof CliFatalException rethrow) {
+ return rethrow;
+ } else if (error instanceof CliException) {
+ return new CliFatalException(error.getMessage(), error);
+ }
+ return new CliFatalException("Error occurred during settings update: " + error.getClass() + ": " + error.getMessage(), error);
+ }
+
+ private void pullSettingsAndSaveCommitId(Path settingsPath) {
+
+ GitContext gitContext = this.context.getGitContext();
+ if (gitContext.hasUntrackedFiles(settingsPath)) {
+ gitContext.pullSafelyWithStash(settingsPath);
+ } else {
+ gitContext.pull(settingsPath);
+ }
+ gitContext.saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath());
+ }
+
+ /**
+ * Clone a settings repository into a temporary directory.
+ * @param gitUrl {@link GitUrl} of the (code-)settings repository.
+ * @return {@link Path} of the temporary directory.
+ */
+ private Path cloneRepoToTempDir(GitUrl gitUrl) {
+
+ this.gitProjectName = gitUrl.getProjectName();
+
+ // createTempDir guarantees a unique and empty directory so no leftovers of a previous attempt can interfere and we can clone directly
+ this.tempRepoDir = this.context.getFileAccess().createTempDir("project-"+this.gitProjectName);
+ this.context.getGitContext().clone(gitUrl, this.tempRepoDir);
+ return this.tempRepoDir;
+ }
+
+ private void backupBrokenSettings(Path settingsPath) {
+
+ if ((settingsPath == null) || !Files.exists(settingsPath)) {
+ return;
+ }
+
+ if (!fileAccess.isEmptyDir(settingsPath)) {
+ this.context.askToContinue("""
+ Your settings repository seems to be broken ('.git' folder not present).
+ We can fix this by moving your settings to the backup.
+ You will be asked for the settings git URL and your settings will be cloned from scratch.
+ Do you want to proceed?""");
+ }
+ fileAccess.backup(settingsPath);
+ }
+
+ /**
+ * @return {@code true} if the user explicitly wants to continue with an invalid repository, {@code false} otherwise.
+ */
+ private boolean requestUserConfirmInvalidRepository(RepositoryType repositoryType, GitUrl gitUrl, boolean updatesExistingRepository) {
+ /* If we are in force mode, we give the user the option continue with a potentially invalid repo. If not in FM, we skip asking and act as if he declined.
+ For the case of updating existing settings repositories, we always want to ask the user regardless of --force-pull, as this could break the setup.
+ */
+ if(!this.isForceMode && !updatesExistingRepository) {
+ return false;
+ }
+
+ LOG.warn("{}\nURL: {}\nDetected settings repository type: {}", MESSAGE_INVALID_REPOSITORY, gitUrl, repositoryType);
+
+ this.context.askToContinue("The update to the settings repository you are trying to apply seems to be broken. Do you want to continue anyway?");
+ return true;
+ }
+
+ /**
+ * Removes the temporary clone. It is deleted and not backed up since it only contains a fresh clone without any user data and a backup would be created
+ * inside {@link IdeContext#getIdeHome() IDE_HOME} that may not even exist yet. Failures are only logged so that the actual error never gets masked.
+ */
+ public void cleanup() {
+
+ if (this.tempRepoDir == null) {
+ return;
+ }
+ try {
+ this.context.getFileAccess().delete(this.tempRepoDir);
+ } catch (RuntimeException e) {
+ LOG.warn("Failed to delete temporary directory {}", this.tempRepoDir, e);
+ }
+ this.tempRepoDir = null;
+ }
+
+ private GitUrl getOrAskSettingsUrl() {
+
+ String repository = handleDefaultRepository(this.settingsRepoProperty.getValue());
+ GitUrl gitUrl = null;
+ if (repository != null) {
+ gitUrl = GitUrl.of(repository);
+ }
+ if ((gitUrl == null) || !gitUrl.isValid()) {
+ LOG.info(MESSAGE_SETTINGS_REPO_URL, this.context.getSettingsPath());
+ }
+ String userPrompt = "Settings URL [" + IdeContext.DEFAULT_SETTINGS_REPO_URL + "]:";
+ while ((gitUrl == null) || !gitUrl.isValid()) {
+ repository = handleDefaultRepository(this.context.askForInput(userPrompt, IdeContext.DEFAULT_SETTINGS_REPO_URL));
+ gitUrl = GitUrl.of(repository);
+ if (!gitUrl.isValid()) {
+ LOG.warn("The input URL is not valid, please try again.");
+ }
+ }
+ return gitUrl;
+ }
+
+ private String handleDefaultRepository(String repository) {
+
+ if ("-".equals(repository)) {
+ LOG.info("'-' was found for the repository, the default settings repository '{}' will be used.", IdeContext.DEFAULT_SETTINGS_REPO_URL);
+ repository = IdeContext.DEFAULT_SETTINGS_REPO_URL;
+ }
+ return repository;
+ }
+
+ private void moveProject(Path from, Path to) {
+
+ try {
+ this.context.getFileAccess().move(from, to);
+ } catch (RuntimeException e) {
+ // FileAccess already reports source, target and the Windows file-lock hint so we only escalate to a fatal error here
+ throw new CliFatalException(e.getMessage(), e);
+ }
+ }
+}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java
index 8dfcabaa17..305f175dc0 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java
@@ -35,8 +35,8 @@
import com.devonfw.tools.ide.commandlet.CommandletManagerImpl;
import com.devonfw.tools.ide.commandlet.ContextCommandlet;
import com.devonfw.tools.ide.commandlet.EnvironmentCommandlet;
-import com.devonfw.tools.ide.commandlet.UpdateCommandlet;
import com.devonfw.tools.ide.commandlet.UpgradeCommandlet;
+import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet;
import com.devonfw.tools.ide.common.SystemPath;
import com.devonfw.tools.ide.completion.CompletionCandidate;
import com.devonfw.tools.ide.completion.CompletionCandidateCollector;
@@ -686,7 +686,7 @@ public Path getSettingsGitRepository() {
Path settingsPath = getSettingsPath();
// check whether the settings path has a .git folder only if its not a symbolic link or junction
- if ((settingsPath != null) && !Files.exists(settingsPath.resolve(".git")) && !isSettingsCodeRepository()) {
+ if ((settingsPath != null) && !Files.exists(settingsPath.resolve(".git")) && !isCombinedSettingsCodeRepository()) {
LOG.error("Settings repository exists but is not a git repository.");
return null;
}
@@ -694,7 +694,7 @@ public Path getSettingsGitRepository() {
}
@Override
- public boolean isSettingsCodeRepository() {
+ public boolean isCombinedSettingsCodeRepository() {
Path settingsPath = getSettingsPath();
if (settingsPath != null) {
@@ -1481,7 +1481,7 @@ settingsRepository, getSettingsCommitIdPath()))) {
*/
private String determineSettingsUpdateMessage(Commandlet cmd) {
boolean update = cmd instanceof UpdateCommandlet;
- if (isSettingsCodeRepository()) {
+ if (isCombinedSettingsCodeRepository()) {
if (update && (isForceMode() || isForcePull())) {
return null;
}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java
index 7516fae64d..e22cf3cd6b 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java
@@ -11,6 +11,7 @@
import com.devonfw.tools.ide.cli.CliException;
import com.devonfw.tools.ide.cli.CliOfflineException;
import com.devonfw.tools.ide.commandlet.CommandletManager;
+import com.devonfw.tools.ide.commandlet.update.AbstractUpdateCommandlet;
import com.devonfw.tools.ide.common.SystemPath;
import com.devonfw.tools.ide.environment.EnvironmentVariables;
import com.devonfw.tools.ide.environment.EnvironmentVariablesType;
@@ -69,7 +70,7 @@ public interface IdeContext extends IdeStartContext {
/**
* The default settings URL.
*
- * @see com.devonfw.tools.ide.commandlet.AbstractUpdateCommandlet
+ * @see AbstractUpdateCommandlet
*/
String DEFAULT_SETTINGS_REPO_URL = "https://github.com/devonfw/ide-settings.git";
@@ -596,7 +597,7 @@ default Path getRepositoriesPath() {
/**
* @return {@code true} if the settings repository is a symlink or a junction to a code-repository.
*/
- boolean isSettingsCodeRepository();
+ boolean isCombinedSettingsCodeRepository();
/**
* @return the {@link Path} to the file containing the last tracked commit Id of the settings repository.
diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java
new file mode 100644
index 0000000000..2a4a4e5a5e
--- /dev/null
+++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryType.java
@@ -0,0 +1,26 @@
+package com.devonfw.tools.ide.git.repository;
+
+/**
+ * Enum representation of a detected {@link RepositoryType}.
+ */
+public enum RepositoryType {
+
+ /** Git Repository is a code repository. */
+ CODE,
+
+ /** Git Repository is a settings repository. */
+ SETTINGS,
+
+ /** A combined code & settings repository contains both the settings-folder and the code within the workspace folder. */
+ CODE_SETTINGS_COMBINED,
+
+ /** The type of the repository could not be determined. */
+ UNKNOWN;
+
+ /**
+ * @return true if repository is either of type {@code SETTINGS} or {@code CODE_SETTINGS_COMBINED}
+ */
+ public boolean isSettingsOrCodeSettingsRepository() {
+ return this == SETTINGS || this == CODE_SETTINGS_COMBINED;
+ }
+}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryUtil.java b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryUtil.java
new file mode 100644
index 0000000000..cebdaab3e5
--- /dev/null
+++ b/cli/src/main/java/com/devonfw/tools/ide/git/repository/RepositoryUtil.java
@@ -0,0 +1,50 @@
+package com.devonfw.tools.ide.git.repository;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import com.devonfw.tools.ide.context.IdeContext;
+import com.devonfw.tools.ide.environment.EnvironmentVariables;
+import com.devonfw.tools.ide.git.GitContext;
+
+/**
+ * Utility class for IDEasy settings/code repositories.
+ */
+public class RepositoryUtil {
+
+ /**
+ * Checks whether the given git repository is a settings repository, a combined settings and code repository, or a typical code repository. A combined code
+ * and settings repository is detected by a top-level {@code settings} folder that itself is a valid settings folder.
+ *
+ * @param repositoryPath the {@link Path} to the repository to check.
+ * @return the {@link RepositoryType} of the repository.
+ */
+ public static RepositoryType getRepositoryType(Path repositoryPath) {
+
+ if (repositoryPath == null || !Files.isDirectory(repositoryPath)) {
+ return RepositoryType.UNKNOWN;
+ }
+ if (isSettingsFolder(repositoryPath) && Files.exists(repositoryPath.resolve(GitContext.GIT_FOLDER))) {
+ return RepositoryType.SETTINGS;
+ }
+ Path settingsFolder = repositoryPath.resolve(IdeContext.FOLDER_SETTINGS);
+ if (isSettingsFolder(settingsFolder)) {
+ return RepositoryType.CODE_SETTINGS_COMBINED;
+ }
+ if (!Files.exists(settingsFolder)) {
+ return RepositoryType.CODE;
+ }
+ // there is no valid settings folder to be found.
+ return RepositoryType.UNKNOWN;
+ }
+
+ /**
+ * @param folder the {@link Path} to check.
+ * @return {@code true} if the given {@code folder} is the root of a settings repository, {@code false} otherwise.
+ */
+ private static boolean isSettingsFolder(Path folder) {
+
+ return (Files.exists(folder.resolve(EnvironmentVariables.DEFAULT_PROPERTIES))
+ || Files.exists(folder.resolve(EnvironmentVariables.LEGACY_PROPERTIES)));
+ }
+}
diff --git a/cli/src/main/java/com/devonfw/tools/ide/step/Step.java b/cli/src/main/java/com/devonfw/tools/ide/step/Step.java
index 19760d9723..800caa95ab 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/step/Step.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/step/Step.java
@@ -3,6 +3,8 @@
import java.util.concurrent.Callable;
import java.util.function.Supplier;
+import com.devonfw.tools.ide.cli.CliException;
+
/**
* Interface for a {@link Step} of the process. Allows to split larger processes into smaller steps that are traced and measured. Also prevents that if one step
* fails, the overall process can still continue so a sub-step (e.g. "plugin installation" or "git update") does not automatically block the entire process. At
@@ -219,7 +221,8 @@ default boolean run(Runnable stepCode) {
/**
* @param stepCode the {@link Runnable} to {@link Runnable#run() execute} for this {@link Step}.
- * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}.
+ * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. Independent of this flag an error is always rethrown if it
+ * {@link CliException#isForceRethrowInStep() forces} it.
* @return {@code true} on success, {@code false} on error (if {@code rethrow} is {@code false}).
*/
default boolean run(Runnable stepCode, boolean rethrow) {
@@ -231,8 +234,10 @@ default boolean run(Runnable stepCode, boolean rethrow) {
}
return true;
} catch (RuntimeException | Error e) {
- error(e);
- if (rethrow) {
+ boolean forceRethrow = isForceRethrow(e);
+ // if the error is rethrown it gets logged by the caller so we suppress duplicated error messages here
+ error(e, forceRethrow);
+ if (rethrow || forceRethrow) {
throw e;
}
return false;
@@ -264,7 +269,8 @@ default R call(Callable stepCode, Supplier resultOnErrorSupplier) {
/**
* @param stepCode the {@link Callable} to {@link Callable#call() execute} for this {@link Step}.
- * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}.
+ * @param rethrow - {@code true} to rethrow a potential {@link Throwable error}. Independent of this flag an error is always rethrown if it
+ * {@link CliException#isForceRethrowInStep() forces} it.
* @param resultOnErrorSupplier the {@link Supplier} {@link Supplier#get() providing} the result to be returned in case of a {@link Throwable error}.
* @param type of the return value.
* @return the value returned from {@link Callable#call()}.
@@ -278,8 +284,10 @@ default R call(Callable stepCode, boolean rethrow, Supplier resultOnEr
}
return result;
} catch (Throwable e) {
- error(e);
- if (rethrow) {
+ boolean forceRethrow = isForceRethrow(e);
+ // if the error is rethrown it gets logged by the caller so we suppress duplicated error messages here
+ error(e, forceRethrow);
+ if (rethrow || forceRethrow) {
if (e instanceof RuntimeException re) {
throw re;
} else if (e instanceof Error error) {
@@ -294,4 +302,13 @@ default R call(Callable stepCode, boolean rethrow, Supplier resultOnEr
}
}
+ /**
+ * @param error the {@link Throwable} that occurred inside a {@link Step}.
+ * @return {@code true} if the given {@code error} has to be rethrown even if the {@link Step} was not asked to rethrow errors, {@code false} otherwise.
+ */
+ private static boolean isForceRethrow(Throwable error) {
+
+ return (error instanceof CliException cliException) && cliException.isForceRethrowInStep();
+ }
+
}
diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties
index e678805cc2..0f9d3dabbe 100644
--- a/cli/src/main/resources/nls/Help.properties
+++ b/cli/src/main/resources/nls/Help.properties
@@ -187,7 +187,6 @@ cmd.yarn.detail=Yarn is a package manager and build tool for JavaScript. Detaile
commandlets=Available commandlets:
icd-hint=Hint: Use 'icd' command to easily navigate between your IDE home, projects, and workspaces. Type 'icd --help' for more details.
opt.--batch=enable batch mode (non-interactive).
-opt.--code=clone given code repository containing a settings folder into workspaces so that settings can be committed alongside code changes.
opt.--debug=enable debug logging.
opt.--force=enable force mode.
opt.--force-plugin-reinstall=resets installed plugins to the project configuration
diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties
index 8276ff9bae..3413367b59 100644
--- a/cli/src/main/resources/nls/Help_de.properties
+++ b/cli/src/main/resources/nls/Help_de.properties
@@ -187,7 +187,6 @@ cmd.yarn.detail=Yarn ist ein Package Manager und Build-Werkzeug für JavaScript.
commandlets=Verfügbare Kommandos:
icd-hint=Hinweis: Verwenden Sie den Befehl 'icd' um einfach zwischen Ihrem IDE-Hauptverzeichnis, Projekten und Workspaces zu navigieren. Geben Sie 'icd --help' für weitere Details ein.
opt.--batch=Aktiviert den Batch-Modus (nicht-interaktive Stapelverarbeitung).
-opt.--code=Git-Repository sowohl als Code- als auch als Settings-Repository verwenden.
opt.--debug=Aktiviert Debug-Ausgaben (Fehleranalyse).
opt.--force=Aktiviert den Force-Modus (Erzwingen).
opt.--force-plugin-reinstall=Setzt installierte Plugins zurück auf die Projektkonfiguration.
diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java
index 98534e72a9..56e6f403cb 100644
--- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java
+++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CreateCommandletTest.java
@@ -7,18 +7,16 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
import com.devonfw.tools.ide.cli.CliArguments;
import com.devonfw.tools.ide.cli.CliException;
import com.devonfw.tools.ide.context.AbstractIdeContextTest;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.context.IdeTestContext;
-import com.devonfw.tools.ide.context.ProcessContextGitMock;
import com.devonfw.tools.ide.environment.EnvironmentVariables;
import com.devonfw.tools.ide.environment.EnvironmentVariablesType;
import com.devonfw.tools.ide.git.GitContextImplMock;
+import com.devonfw.tools.ide.io.WindowsSymlinkTestHelper;
import com.devonfw.tools.ide.version.IdeVersion;
/**
@@ -67,54 +65,9 @@ void testCreateCommandletRun() {
assertThat(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)).exists();
assertThat(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)).exists();
assertThat(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)).exists();
- }
-
- @ParameterizedTest
- @ValueSource(strings = { "https://some-code-repository", "ssh://some-settings-repository" })
- void testWarningWhenRepoDoesNotMeetNamingConvention(String invalidRepo, @TempDir Path tempDir) {
- // arrange
- ProcessContextGitMock gitMock = new ProcessContextGitMock(context, tempDir);
- context.setProcessContext(gitMock);
- CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class);
- cc.newProject.setValueAsString(NEW_PROJECT_NAME, context);
- cc.codeRepositoryFlag.setValue(!invalidRepo.contains("code")); // raise conflict
- cc.settingsRepo.setValue(invalidRepo);
- cc.skipTools.setValue(true);
- context.setAnswers("yes");
- // act
- cc.run();
- // assert
- assertThat(context).logAtInteraction().hasMessageContaining("Do you really want to create the project?");
- Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME);
- assertThat(newProjectPath).exists();
- assertThat(context.getIdeHome()).isEqualTo(newProjectPath);
- assertThat(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)).exists();
- assertThat(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)).exists();
- assertThat(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)).exists();
- }
-
- @Test
- void testWarningWhenCodeRepoUsingDefaultMark(@TempDir Path tempDir) {
- String invalidCodeRepo = "-";
- // arrange
- ProcessContextGitMock gitMock = new ProcessContextGitMock(context, tempDir);
- context.setProcessContext(gitMock);
- CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class);
- cc.newProject.setValueAsString(NEW_PROJECT_NAME, context);
- cc.settingsRepo.setValue(invalidCodeRepo);
- cc.codeRepositoryFlag.setValue(true);
- cc.skipTools.setValue(true);
- context.setAnswers("https://some-code-repository");
- // act
- cc.run();
- // assert
- assertThat(context).logAtWarning().hasMessageContaining("'-' is found after '--code'. This is invalid.");
- Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME);
- assertThat(newProjectPath).exists();
- assertThat(context.getIdeHome()).isEqualTo(newProjectPath);
- assertThat(newProjectPath.resolve(IdeContext.FOLDER_PLUGINS)).exists();
- assertThat(newProjectPath.resolve(IdeContext.FOLDER_SOFTWARE)).exists();
- assertThat(newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN)).exists();
+ // the settings have to be cloned into the new project and not into the project the create command was started from
+ assertThat(newProjectPath.resolve(IdeContext.FOLDER_SETTINGS).resolve("ide.properties")).exists();
+ assertThat(context.getIdeRoot().resolve("_ide/tmp/projects").resolve(NEW_PROJECT_NAME)).doesNotExist();
}
@Test
@@ -220,6 +173,79 @@ void testWelcomeMessageDisplayed() {
assertThat(context).logAtInfo().hasMessageContaining("Welcome to your new IDEasy project!");
}
+ @Test
+ void testProjectWithInvalidRepositoryNotCreated() {
+
+ // arrange - create a new project that is invalid (does not contain ide.properties file)
+ GitContextImplMock gitContextImplMock = new GitContextImplMock(context, TEST_RESOURCES.resolve("pypi"));
+
+ context.setGitContext(gitContextImplMock);
+ CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class);
+ cc.newProject.setValueAsString(NEW_PROJECT_NAME, context);
+ cc.settingsRepo.setValue(IdeContext.DEFAULT_SETTINGS_REPO_URL);
+ cc.skipTools.setValue(true);
+
+ // act - run the create command
+ assertThatThrownBy(cc::run)
+ .isInstanceOf(CliException.class)
+ .hasMessageContaining(
+ "Settings repository integrity check failed: "
+ + "The given git repository URL does not point to a valid settings or code-settings repository. Please verify and try again.");
+
+ // assert - if "ide create" fails then no project shall be created at all
+ assertThat(context.getIdeRoot().resolve(NEW_PROJECT_NAME)).doesNotExist();
+ assertThat(context.getTempPath().resolve(IdeContext.FOLDER_PROJECTS).resolve(NEW_PROJECT_NAME)).doesNotExist();
+ }
+
+ @Test
+ void testCreateWithCodeSettingsRepository() {
+
+ // arrange - a combined code and settings repository has the settings in a top-level "settings" folder
+ WindowsSymlinkTestHelper.assumeSymlinksSupported();
+ GitContextImplMock gitContextImplMock = new GitContextImplMock(context, TEST_RESOURCES.resolve("code-settings"));
+ context.setGitContext(gitContextImplMock);
+ CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class);
+ cc.newProject.setValueAsString(NEW_PROJECT_NAME, context);
+ cc.settingsRepo.setValue("https://github.com/devonfw/code-settings-repo.git");
+ cc.skipTools.setValue(true);
+
+ // act
+ cc.run();
+
+ // assert - the repository is placed into the workspace and IDE_HOME/settings is a symlink to its settings folder
+ Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME);
+ Path codePath = newProjectPath.resolve(IdeContext.FOLDER_WORKSPACES).resolve(IdeContext.WORKSPACE_MAIN).resolve("code-settings-repo");
+ assertThat(codePath.resolve("pom.xml")).exists();
+ assertThat(codePath.resolve(IdeContext.FOLDER_SETTINGS).resolve("ide.properties")).exists();
+ Path settingsLink = newProjectPath.resolve(IdeContext.FOLDER_SETTINGS);
+ assertThat(settingsLink).isSymbolicLink();
+ assertThat(settingsLink.resolve("ide.properties")).exists();
+ }
+
+ @Test
+ void testCreateWithInvalidRepositoryContinuesInForceMode() {
+
+ // arrange - force mode lets the user decide to continue even though the health check failed
+ GitContextImplMock gitContextImplMock = new GitContextImplMock(context, TEST_RESOURCES.resolve("pypi"));
+ context.setGitContext(gitContextImplMock);
+ context.getStartContext().setForceMode(true);
+ context.setAnswers("yes");
+ CreateCommandlet cc = context.getCommandletManager().getCommandlet(CreateCommandlet.class);
+ cc.newProject.setValueAsString(NEW_PROJECT_NAME, context);
+ cc.settingsRepo.setValue(IdeContext.DEFAULT_SETTINGS_REPO_URL);
+ cc.skipTools.setValue(true);
+ cc.skipRepositories.setValue(true);
+
+ // act
+ cc.run();
+
+ // assert
+ Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME);
+ assertThat(newProjectPath).exists();
+ assertThat(context).logAtWarning()
+ .hasMessageContaining("does not point to a valid settings or code-settings repository");
+ }
+
@Test
void testCreateWithDashPlaceholderAsCliArgument() {
// arrange - see https://github.com/devonfw/IDEasy/issues/2106
@@ -234,7 +260,7 @@ void testCreateWithDashPlaceholderAsCliArgument() {
assertThat(result).isEqualTo(0);
assertThat(context).logAtError().hasNoMessageContaining("not found for commandlet");
assertThat(context).logAtInfo()
- .hasMessageContaining("'-' was found for settings repository, the default settings repository");
+ .hasMessageContaining("'-' was found for the repository, the default settings repository");
Path newProjectPath = context.getIdeRoot().resolve(NEW_PROJECT_NAME);
assertThat(newProjectPath).exists();
}
diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java
index 626d0b0aa4..a1efb8035a 100644
--- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java
+++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/UpdateCommandletTest.java
@@ -7,11 +7,14 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet;
import com.devonfw.tools.ide.context.AbstractIdeContextTest;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.context.IdeTestContext;
import com.devonfw.tools.ide.environment.EnvironmentVariables;
import com.devonfw.tools.ide.environment.EnvironmentVariablesType;
+import com.devonfw.tools.ide.git.GitContext;
+import com.devonfw.tools.ide.git.GitContextMock;
import com.devonfw.tools.ide.tool.java.Java;
import com.devonfw.tools.ide.tool.mvn.Mvn;
import com.devonfw.tools.ide.variable.IdeVariables;
@@ -25,7 +28,7 @@
class UpdateCommandletTest extends AbstractIdeContextTest {
private static final String PROJECT_UPDATE = "update";
- private static final String SUCCESS_UPDATE_SETTINGS = "Successfully ended step 'update (pull) settings repository'.";
+ private static final String SUCCESS_UPDATE_SETTINGS = "Successfully ended step 'Update settings repository'.";
private static final String SUCCESS_INSTALL_OR_UPDATE_SOFTWARE = "Install or update software";
@Test
@@ -155,4 +158,57 @@ void testRunUpdateSoftwareDoesNotFailWhenSettingsPathIsDeleted(WireMockRuntimeIn
assertThat(context).logAtSuccess().hasMessage(SUCCESS_UPDATE_SETTINGS);
assertThat(context).logAtSuccess().hasMessageContaining(SUCCESS_INSTALL_OR_UPDATE_SOFTWARE);
}
+
+ /**
+ * Tests that a settings folder that exists but is not a git repository is backed up and cloned from scratch after the user confirmed.
+ */
+ @Test
+ void testRunUpdateWithBrokenSettingsFolder() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_UPDATE);
+ Path settingsPath = context.getSettingsPath();
+ // remove the '.git' folder so the settings are present but broken
+ context.getFileAccess().delete(settingsPath.resolve(GitContext.GIT_FOLDER));
+ UpdateCommandlet update = context.getCommandletManager().getCommandlet(UpdateCommandlet.class);
+ // first answer confirms the backup of the broken settings, second answer picks the default settings repository
+ context.setAnswers("yes", "-");
+
+ // act
+ update.run();
+
+ // assert
+ assertThat(context).logAtSuccess().hasMessage(SUCCESS_UPDATE_SETTINGS);
+ assertThat(context.getIdeHome().resolve(IdeContext.FOLDER_BACKUPS)).exists();
+ assertThat(settingsPath.resolve(GitContext.GIT_FOLDER)).exists();
+ assertThat(context).logAtSuccess().hasMessageContaining(SUCCESS_INSTALL_OR_UPDATE_SOFTWARE);
+ }
+
+ /**
+ * Tests that a failing "git pull" (e.g. due to an error of a custom git server) only fails the settings step while the software is still installed.
+ *
+ * See: #2335 for reference.
+ */
+ @Test
+ void testRunUpdateContinuesWhenPullFails() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_UPDATE);
+ context.setGitContext(new GitContextMock(context) {
+ @Override
+ public void pull(Path repository) {
+
+ throw new IllegalStateException("git pull failed due to an error of the custom git server");
+ }
+ });
+ UpdateCommandlet update = context.getCommandletManager().getCommandlet(UpdateCommandlet.class);
+
+ // act
+ update.run();
+
+ // assert
+ assertThat(context).logAtError().hasMessage("Step 'Applying settings' ended with failure.");
+ assertThat(context).log().hasNoMessage(SUCCESS_UPDATE_SETTINGS);
+ assertThat(context).logAtSuccess().hasMessageContaining(SUCCESS_INSTALL_OR_UPDATE_SOFTWARE);
+ }
}
diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java
index d794f0ad12..27723760ac 100644
--- a/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java
+++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitContextMock.java
@@ -20,7 +20,8 @@
*/
public class GitContextMock extends GitContextImpl {
- private static final String MOCKED_URL_VALUE = "mocked url value";
+ /** Fallback URL for repositories without a mocked {@code .git/config} - has to be a {@link GitUrl#isValid() valid} git URL. */
+ private static final String MOCKED_URL_VALUE = DEFAULT_SETTINGS_GIT_URL;
/** Filename used to persist mocked remotes inside the {@code .git} folder. */
private static final String REMOTES_FILE = "remotes.properties";
@@ -57,6 +58,9 @@ public void clone(GitUrl gitUrl, Path repository) {
FileAccess fileAccess = this.context.getFileAccess();
fileAccess.mkdirs(repository);
+ // Create ide.properties to simulate a valid repository
+ fileAccess.touch(repository.resolve("ide.properties"));
+
Path gitFolder = repository.resolve(GIT_FOLDER);
fileAccess.mkdirs(gitFolder);
String branch = gitUrl.branch();
diff --git a/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java b/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java
index 0a5b87d12c..c4ff3587b5 100644
--- a/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java
+++ b/cli/src/test/java/com/devonfw/tools/ide/step/StepTest.java
@@ -2,6 +2,7 @@
import org.junit.jupiter.api.Test;
+import com.devonfw.tools.ide.cli.CliFatalException;
import com.devonfw.tools.ide.context.AbstractIdeContextTest;
import com.devonfw.tools.ide.context.IdeTestContext;
import com.devonfw.tools.ide.log.IdeLogEntry;
@@ -130,4 +131,60 @@ void testInvalidUsageErrorSuccess() {
IdeLogEntry.ofDebug("Step 'Test-Step' ended successfully."));
}
+ @Test
+ void testRunSwallowsRegularError() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_BASIC, "project", false);
+ Step step = context.newStep("Test-Step");
+ // act
+ boolean success = step.run(() -> {
+ throw new IllegalStateException("regular error");
+ });
+ // assert
+ assertThat(success).isFalse();
+ assertThat(step.isFailure()).isTrue();
+ }
+
+ @Test
+ void testRunRethrowsForcedError() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_BASIC, "project", false);
+ Step step = context.newStep("Test-Step");
+ // act & assert
+ assertThatThrownBy(() -> step.run(() -> {
+ throw new CliFatalException("fatal error");
+ })).isInstanceOf(CliFatalException.class).hasMessage("fatal error");
+ assertThat(step.isFailure()).isTrue();
+ }
+
+ @Test
+ void testCallReturnsFallbackOnRegularError() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_BASIC, "project", false);
+ Step step = context.newStep("Test-Step");
+ // act
+ String result = step.call(() -> {
+ throw new IllegalStateException("regular error");
+ }, () -> "fallback");
+ // assert
+ assertThat(result).isEqualTo("fallback");
+ assertThat(step.isFailure()).isTrue();
+ }
+
+ @Test
+ void testCallRethrowsForcedError() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_BASIC, "project", false);
+ Step step = context.newStep("Test-Step");
+ // act & assert
+ assertThatThrownBy(() -> step.call(() -> {
+ throw new CliFatalException("fatal error");
+ }, () -> "fallback")).isInstanceOf(CliFatalException.class).hasMessage("fatal error");
+ assertThat(step.isFailure()).isTrue();
+ }
+
}
diff --git a/cli/src/test/resources/code-settings/pom.xml b/cli/src/test/resources/code-settings/pom.xml
new file mode 100644
index 0000000000..6d465deda5
--- /dev/null
+++ b/cli/src/test/resources/code-settings/pom.xml
@@ -0,0 +1 @@
+code
diff --git a/cli/src/test/resources/code-settings/settings/ide.properties b/cli/src/test/resources/code-settings/settings/ide.properties
new file mode 100644
index 0000000000..28913aee06
--- /dev/null
+++ b/cli/src/test/resources/code-settings/settings/ide.properties
@@ -0,0 +1 @@
+IDE_TOOLS=java,mvn
diff --git a/cli/src/test/resources/settings/ide.properties b/cli/src/test/resources/settings/ide.properties
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/documentation/settings.adoc b/documentation/settings.adoc
index e9de2335ca..3bf2f8e240 100644
--- a/documentation/settings.adoc
+++ b/documentation/settings.adoc
@@ -18,17 +18,18 @@ This gives you the freedom to control and manage the tools with their versions a
To setup and customize these settings simply follow the link:usage.adoc#admin[admin usage guide].
Then tell your team to create the project using your project sepcific settings git URL:
```
-ide create «project-name» --code «settings-url»
+ide create «project-name» «settings-url»
```
== Code-repository
It is even possible to include your settings into your code repository by having the `settings` folder directly on top-level of your code git repository.
This allows you to keep settings changes in sync with code changes and manage them in the same pull/merge requests.
-To use this approach simply copy the content of https://github.com/devonfw/ide-settings[ide-settings] to a top-level `settings` folder in your code repository root and tell your developers to create the project usining the `--code` option:
+To use this approach simply copy the content of https://github.com/devonfw/ide-settings[ide-settings] to a top-level `settings` folder in your code repository root.
+IDEasy will automatically recognize that you are using a code repository, therefore just use the same command as above:
```
-ide create «project-name» --code «code-repo-url»
+ide create «project-name» «code-repo-url»
```
IDEasy will clone your repository and create a symlink to the settings folder.
@@ -50,6 +51,21 @@ But we do not want to forget the following advantage:
Anyhow you can still create feature branches in standalone settings repositories to manage such scenarios and follow KISS and trunk-based development so you more or less avoid such problems.
However, if you are in a monolithic project with complex release branches you may consider using the "settings in code repository" approach.
+== Health check
+
+Whenever `IDEasy` clones or updates your settings it first clones the git repository into a temporary directory and performs a health check on it:
+
+* the given git URL has to be valid,
+* cloning the repository has to succeed,
+* and the repository has to be a settings repository or a combined code and settings repository (see link:#code-repository[above]).
+
+Only if this health check succeeded the settings are installed: an existing settings repository is updated via `git pull` while a new one is moved from the temporary directory to its final location.
+This way a broken or wrong git URL can never leave you with a damaged project.
+In particular `ide create` will not create the project at all if the health check fails, so you can simply fix the URL and try again.
+
+If you are sure that you know better, you can use the `--force` option.
+`IDEasy` will then still report the problem but ask you whether you want to continue anyway.
+
== Structure
The settings folder has to follow this file structure: