diff --git a/.editorconfig b/.editorconfig index 4873197385..396e79e721 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,7 +18,7 @@ ij_wrap_on_typing = false [*.properties] ij_properties_align_group_field_declarations = false -ij_properties_keep_blank_lines = true +ij_properties_keep_blank_lines = false ij_properties_key_value_delimiter = equals ij_properties_spaces_around_key_value_delimiter = false @@ -354,4 +354,4 @@ ij_yaml_keep_line_breaks = true ij_yaml_sequence_on_new_line = false ij_yaml_space_before_colon = false ij_yaml_spaces_within_braces = true -ij_yaml_spaces_within_brackets = true \ No newline at end of file +ij_yaml_spaces_within_brackets = true diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 7062f51fbd..64ffcbdd49 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -13,6 +13,8 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1174[#1174]: Add UrlUpdater for Java Azul edition * https://github.com/devonfw/IDEasy/issues/451[#451]: Automatically remove macOS quarantine attribute after tool extraction * https://github.com/devonfw/IDEasy/issues/1823[#1823]: Fix IDEasy creates duplicate entries in .gitconfig +* https://github.com/devonfw/IDEasy/issues/1788[#1788]: Add Commandlet to create links + The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/44?closed=1[milestone 2026.05.001]. 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 9d4217fb8b..6e655d2c45 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 @@ -103,6 +103,7 @@ public CommandletManagerImpl(IdeContext context) { add(new StatusCommandlet(context)); add(new RepositoryCommandlet(context)); add(new UninstallCommandlet(context)); + add(new LnCommandlet(context)); add(new UpdateCommandlet(context)); add(new UpgradeSettingsCommandlet(context)); add(new CreateCommandlet(context)); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java new file mode 100644 index 0000000000..0a60842bfe --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java @@ -0,0 +1,186 @@ +package com.devonfw.tools.ide.commandlet; + +import java.nio.file.AccessDeniedException; +import java.nio.file.FileStore; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.property.KeywordProperty; +import com.devonfw.tools.ide.property.StringProperty; + +/** + * // * Link creation {@link Commandlet} similar to {@code ln -s}. + *

+ * It tries to create a true symbolic link first. On Windows, symlink creation may be restricted due to missing privileges. In that case, IDEasy will create a + * hard link as an alternative (file-only, same volume) to avoid the Git-Bash behavior of silently copying files. + */ +public final class LnCommandlet extends Commandlet { + + private static final Logger LOG = LoggerFactory.getLogger(LnCommandlet.class); + + /** Grammar token {@code -s} (required). */ + public final KeywordProperty symbolic; + + /** The source path to link to. */ + public final StringProperty source; + + /** The target path (the created link). */ + public final StringProperty target; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public LnCommandlet(IdeContext context) { + + super(context); + addKeyword(getName()); + + // Treat -s as grammar token + this.symbolic = add(new KeywordProperty("-s", true, null)); + + this.source = add(new StringProperty("", true, "source")); + this.target = add(new StringProperty("", true, "target")); + } + + @Override + public String getName() { + + return "ln"; + } + + @Override + public boolean isIdeRootRequired() { + + return false; + } + + @Override + public boolean isIdeHomeRequired() { + + return false; + } + + @Override + public boolean isWriteLogFile() { + + return false; + } + + @Override + protected void doRun() { + + Path cwd = this.context.getCwd(); + if (cwd == null) { + throw new CliException("Missing current working directory!"); + } + + Path sourcePath = cwd.resolve(this.source.getValue()).normalize().toAbsolutePath(); + Path targetPath = cwd.resolve(this.target.getValue()).normalize().toAbsolutePath(); + + if (!Files.exists(sourcePath)) { + throw new CliException("Source does not exist: " + sourcePath); + } + if (Files.exists(targetPath)) { + throw new CliException("Target already exists: " + targetPath); + } + + // 1) Try true symlink first (desired behavior). + try { + Files.createSymbolicLink(targetPath, sourcePath); + LOG.info("Created symbolic link {} -> {}", targetPath, sourcePath); + } catch (Exception symlinkError) { + + // 2) If Windows blocks symlink creation due to missing privileges: + // create hard link as an alternative (never copy). + if (this.context.getSystemInfo().isWindows() && isWindowsSymlinkPrivilegeProblem(symlinkError)) { + createHardLink(sourcePath, targetPath, symlinkError); + return; + } + + // Otherwise: real failure + throw new CliException("Failed to create symbolic link " + targetPath + " -> " + sourcePath, symlinkError); + } + } + + /** + * Detects common Windows privilege failures for symlink creation. + */ + private boolean isWindowsSymlinkPrivilegeProblem(Exception e) { + + if (e instanceof AccessDeniedException) { + return true; + } + if (e instanceof FileSystemException fse) { + String msg = fse.getMessage(); + if (msg != null) { + String m = msg.toLowerCase(); + return m.contains("required privilege") + || m.contains("privilege is not held") + || m.contains("access is denied"); + } + } + Throwable c = e.getCause(); + while (c != null) { + if (c instanceof AccessDeniedException) { + return true; + } + c = c.getCause(); + } + return false; + } + + /** + * Creates a hard link as an alternative when symbolic link creation is blocked on Windows. + *

+ * Hard links work only for files and only within the same volume. + */ + private void createHardLink(Path sourcePath, Path targetPath, Exception originalSymlinkError) { + + if (Files.isDirectory(sourcePath)) { + throw new CliException( + "Windows blocked symbolic link creation (missing privileges).\n" + + "Hard link alternative is not possible because the source is a directory.", + originalSymlinkError); + } + + ensureSameVolume(sourcePath, targetPath); + + try { + Files.createLink(targetPath, sourcePath); + LOG.info("Created hard link {} => {}", targetPath, sourcePath); + LOG.warn("NOTE: Created hard link as an alternative because Windows blocked symbolic link creation."); + } catch (Exception e) { + throw new CliException( + "Hard link creation failed. Source and target must be on the same volume and filesystem must support hard links.\n" + + "Source: " + sourcePath + "\nTarget: " + targetPath, + e); + } + } + + private void ensureSameVolume(Path sourcePath, Path targetPath) { + + try { + FileStore src = Files.getFileStore(sourcePath); + Path parent = (targetPath.getParent() != null) ? targetPath.getParent() : targetPath; + FileStore tgt = Files.getFileStore(parent); + if (!src.equals(tgt)) { + throw new CliException( + "Hard link alternative not possible: source and target are on different volumes.\n" + + "Source: " + sourcePath + "\nTarget: " + targetPath); + } + } catch (CliException e) { + throw e; + } catch (Exception e) { + throw new CliException("Failed to check volume for hard link alternative.", e); + } + } + +} diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties index 48c7c55c0d..5c4654a80f 100644 --- a/cli/src/main/resources/nls/Help.properties +++ b/cli/src/main/resources/nls/Help.properties @@ -75,6 +75,8 @@ cmd.list-editions=List the available editions of the selected tool. cmd.list-editions.detail=To list all available editions of e.g. 'intellij' simply type: 'ide list-editions intellij'. cmd.list-versions=List the available versions of the selected tool. cmd.list-versions.detail=To list all available versions of e.g. 'intellij' simply type: 'ide list-versions intellij'. +cmd.ln=Create a link (ln -s). +cmd.ln.detail=Creates a link similar to "ln -s". If symbolic links are restricted on Windows, IDEasy creates a hard link as an alternative (file-only, same volume). No copying is performed. cmd.mvn=Tool commandlet for Maven (Build-Tool). cmd.mvn.detail=Apache Maven is a build automation and dependency management tool for Java projects. Detailed documentation can be found at https://maven.apache.org/guides/index.html cmd.ng=Tool commandlet for Angular CLI. @@ -169,6 +171,8 @@ val.commandlet=The selected commandlet (use 'ide help' to list all commandlets). val.edition=The tool edition. val.plugin=The plugin to select val.settingsRepository=The settings git repository with the IDEasy configuration for the project. +val.source=Source +val.target=Target val.tool=The tool commandlet to select. val.version=The tool version. values=Values: diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties index f0a7057798..16eb045685 100644 --- a/cli/src/main/resources/nls/Help_de.properties +++ b/cli/src/main/resources/nls/Help_de.properties @@ -75,6 +75,8 @@ cmd.list-editions=Listet die verfügbaren Editionen des selektierten Werkzeugs a cmd.list-editions.detail=Um alle verfügbaren Editionen von z.B. 'intellij' aufzulisten, geben Sie einfach 'ide list-editions intellij' in die Konsole ein. cmd.list-versions=Listet die verfügbaren Versionen des selektierten Werkzeugs auf. cmd.list-versions.detail=Um alle verfügbaren Versionen von z.B. 'intellij' aufzulisten, geben Sie einfach 'ide list-versions intellij' in die Konsole ein. +cmd.ln=Link erstellen (wie ln -s). +cmd.ln.detail=Erstellt einen Link wie "ln -s". Wenn symbolische Links unter Windows eingeschraenkt sind, erstellt IDEasy alternativ einen Hardlink (nur Dateien, gleiches Laufwerk). Es wird nichts kopiert. cmd.mvn=Werkzeug Kommando für Maven (Build-Werkzeug). cmd.mvn.detail=Apache Maven ist ein Build-Automatisierungs- und Abhängigkeitsverwaltungstool für Java-Projekte. Detaillierte Dokumentation ist zu finden unter https://maven.apache.org/guides/index.html cmd.ng=Werkzeug Kommando für Angular CLI. @@ -169,6 +171,8 @@ val.commandlet=Das ausgewählte Commandlet ("ide help" verwenden, um alle Comman val.edition=Die Werkzeug Edition. val.plugin=Die zu selektierende Erweiterung. val.settingsRepository=Das settings git Repository mit den IDEasy Einstellungen für das Projekt. +val.source=Quelle +val.target=Ziel val.tool=Das zu selektierende Werkzeug Kommando. val.version=Die Werkzeug Version. values=Werte: diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/LnCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/LnCommandletTest.java new file mode 100644 index 0000000000..f79e0a4ed9 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/LnCommandletTest.java @@ -0,0 +1,76 @@ +package com.devonfw.tools.ide.commandlet; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; + +/** + * Test of {@link LnCommandlet}. + */ +class LnCommandletTest extends AbstractIdeContextTest { + + /** + * Tests a link creation on Windows. + */ + @Test + @EnabledOnOs(OS.WINDOWS) + void testLnCreatesRealLinkAndReflectsChanges_Windows() throws Exception { + IdeTestContext context = newContext(PROJECT_BASIC); + + Path testDir = context.getWorkspacePath().resolve("ln-test"); + context.getFileAccess().mkdirs(testDir); + context.setCwd(testDir, context.getWorkspaceName(), context.getIdeHome()); + + Path source = testDir.resolve("source.txt"); + Path link = testDir.resolve("link.txt"); + Files.writeString(source, "A", StandardCharsets.UTF_8); + + LnCommandlet cmd = new LnCommandlet(context); + cmd.symbolic.setValueAsString("-s", context); + cmd.source.setValueAsString("source.txt", context); + cmd.target.setValueAsString("link.txt", context); + + cmd.run(); + + assertThat(link).exists(); + + Files.writeString(source, "B", StandardCharsets.UTF_8); + assertThat(Files.readString(link, StandardCharsets.UTF_8)).isEqualTo("B"); + } + + /** + * Tests a link creation on Unix. + */ + @Test + @EnabledOnOs({ OS.LINUX, OS.MAC }) + void testLnCreatesRealLinkAndReflectsChanges_Unix() throws Exception { + IdeTestContext context = newContext(PROJECT_BASIC); + + Path testDir = context.getWorkspacePath().resolve("ln-test"); + context.getFileAccess().mkdirs(testDir); + context.setCwd(testDir, context.getWorkspaceName(), context.getIdeHome()); + + Path source = testDir.resolve("source.txt"); + Path link = testDir.resolve("link.txt"); + Files.writeString(source, "A", StandardCharsets.UTF_8); + + LnCommandlet cmd = new LnCommandlet(context); + cmd.symbolic.setValueAsString("-s", context); + cmd.source.setValueAsString("source.txt", context); + cmd.target.setValueAsString("link.txt", context); + + cmd.run(); + + assertThat(link).exists(); + + Files.writeString(source, "B", StandardCharsets.UTF_8); + assertThat(Files.readString(link, StandardCharsets.UTF_8)).isEqualTo("B"); + } +}