-
Notifications
You must be signed in to change notification settings - Fork 65
#1788: Created a commandlet to simulate the behaviour of ln -s #1847
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
|
||
| /** 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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These But as suggested above, this also gets handled by |
||
| || 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For file access and operations look into 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); | ||
| } | ||
| } | ||
|
|
||
| } | ||
| 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"); | ||
| } | ||
| } |
There was a problem hiding this comment.
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 -scommand:ln -s <target> <link>instead ofln -s <source> <target>