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: + *
    + *
  1. {@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.
  2. + *
  3. {@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.
  4. + *
+ */ +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: