Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
ij_yaml_spaces_within_brackets = true
2 changes: 2 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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].

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
186 changes: 186 additions & 0 deletions cli/src/main/java/com/devonfw/tools/ide/commandlet/LnCommandlet.java
Original file line number Diff line number Diff line change
@@ -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}.
* <p>
* 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it would be better to conform to the syntax and options of the actual ln -s command:
ln -s <target> <link> instead of ln -s <source> <target>


/** 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")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These contains are not locale safe. E.g. when i run the code on my german machine the error does not get recognized because the message is german.

But as suggested above, this also gets handled by FileAccess of IDEasy.

|| 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.
* <p>
* 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For file access and operations look into this.context.getFilesAccess().symlink... (FileAccess & FileAccessImpl) because most of the handling of symlinks, junctions and hardlinks is already done there.

With that most of your code becomes obsolete and everything can be shortened.

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);
}
}

}
4 changes: 4 additions & 0 deletions cli/src/main/resources/nls/Help.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions cli/src/main/resources/nls/Help_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading