From 41566993c589eb792f28e035da9d354794e05273 Mon Sep 17 00:00:00 2001 From: Lars Vogel Date: Wed, 2 Sep 2026 13:31:20 +0200 Subject: [PATCH] Close a project when its .project file is deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the project description file disappeared, the workspace kept the project open with the in-memory description, logged a refresh error and silently wrote the description back to disk at the next snapshot or on close. Switching to a git branch that no longer contains a project thus left a dirty working tree with a recreated .project file. A refresh that finds the description file deleted, and deleting the file through the workspace, now close the project instead. Closing a project and saving the workspace no longer recreate a missing description file. Opening the project again works once the file is back, as for a project with a missing description on startup. A project move whose content could only be moved partially now refreshes the destination after the tree has been moved, instead of the source before. The tree always ends up under the destination name, so the source refresh described the destination with the leftovers of the source: files already deleted there were dropped from the tree with their markers even though they exist at the destination, and with this change the source refresh would also have closed the project because its description file is gone. The destination refresh keeps the moved resources and their markers and trims what was never copied. Fixes https://github.com/eclipse-platform/eclipse.platform/issues/1074 Assisted-by: multiple AI agents and layers of automated tooling 🤖 --- .../localstore/FileSystemResourceManager.java | 42 +---- .../localstore/RefreshLocalVisitor.java | 28 ++- .../eclipse/core/internal/resources/File.java | 16 +- .../core/internal/resources/Project.java | 33 ++-- .../core/internal/resources/Resource.java | 6 + .../core/internal/resources/ResourceTree.java | 19 +- .../core/internal/resources/SaveManager.java | 50 +---- .../internal/localstore/LocalSyncTest.java | 12 +- .../resources/IProjectDescriptionTest.java | 173 ++++++++++++++++++ .../core/tests/resources/IProjectTest.java | 18 +- .../tests/resources/ISynchronizerTest.java | 6 +- .../core/tests/resources/IWorkspaceTest.java | 8 +- .../tests/resources/session/TestBug12575.java | 18 +- .../resources/usecase/Snapshot2Test.java | 7 +- 14 files changed, 297 insertions(+), 139 deletions(-) diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/FileSystemResourceManager.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/FileSystemResourceManager.java index e1d3fff3a8e..16dbdeccafb 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/FileSystemResourceManager.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/FileSystemResourceManager.java @@ -1115,6 +1115,12 @@ protected boolean refreshResource(IResource target, int depth, boolean updateAli SubMonitor refreshMonitor = subMonitor.newChild(98); RefreshLocalVisitor visitor = updateAliases ? new RefreshLocalAliasVisitor(refreshMonitor) : new RefreshLocalVisitor(refreshMonitor); tree.accept(visitor, depth); + // a project without description file is closed rather than recreating the file + for (Project project : visitor.getProjectsWithoutDescription()) { + if (project.isOpen()) { + project.basicClose(null); + } + } IStatus result = visitor.getErrorStatus(); if (!result.isOK()) { throw new ResourceException(result); @@ -1487,42 +1493,6 @@ public void write(IFolder target, boolean force, IProgressMonitor monitor) throw updateLocalSync(info, store.fetchInfo().getLastModified()); } - /** - * Write the .project file without modifying the resource tree. This is called - * during save when it is discovered that the .project file is missing. The tree - * cannot be modified during save. - */ - public void writeSilently(IProject target) throws CoreException { - IPath location = locationFor(target, false); - //if the project location cannot be resolved, we don't know if a description file exists or not - if (location == null) { - return; - } - IFileStore projectStore = getStore(target); - projectStore.mkdir(EFS.NONE, null); - //can't do anything if there's no description - IProjectDescription desc = ((Project) target).internalGetDescription(); - if (desc == null) { - return; - } - //write the project's private description to the meta-data area - getWorkspace().getMetaArea().writePrivateDescription(target); - - //write the file that represents the project description - IFileStore fileStore = projectStore.getChild(IProjectDescription.DESCRIPTION_FILE_NAME); - try ( - OutputStream out = fileStore.openOutputStream(EFS.NONE, null) - ) { - IFile file = target.getFile(IProjectDescription.DESCRIPTION_FILE_NAME); - new ModelObjectWriter().write(desc, out, file.getLineSeparator(true)); - } catch (IOException e) { - String msg = NLS.bind(Messages.resources_writeMeta, target.getFullPath()); - throw new ResourceException(IResourceStatus.FAILED_WRITE_METADATA, target.getFullPath(), msg, e); - } - //for backwards compatibility, ensure the old .prj file is deleted - getWorkspace().getMetaArea().clearOldDescription(target); - } - public boolean storeHistory(IResource file) { WorkspaceDescription description = workspace.internalGetDescription(); return (description.isKeepDerivedState() || !file.isDerived()) && !disableHistory(file); diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/RefreshLocalVisitor.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/RefreshLocalVisitor.java index ee136683f1c..78d721a7899 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/RefreshLocalVisitor.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/localstore/RefreshLocalVisitor.java @@ -14,10 +14,13 @@ *******************************************************************************/ package org.eclipse.core.internal.localstore; +import java.util.LinkedHashSet; +import java.util.Set; import org.eclipse.core.internal.resources.Container; import org.eclipse.core.internal.resources.File; import org.eclipse.core.internal.resources.Folder; import org.eclipse.core.internal.resources.ICoreConstants; +import org.eclipse.core.internal.resources.Project; import org.eclipse.core.internal.resources.Resource; import org.eclipse.core.internal.resources.ResourceInfo; import org.eclipse.core.internal.resources.ResourceStatus; @@ -56,6 +59,7 @@ public class RefreshLocalVisitor implements IUnifiedTreeVisitor, ILocalStoreCons protected SubMonitor monitor; protected boolean resourceChanged; protected Workspace workspace; + private final Set projectsWithoutDescription = new LinkedHashSet<>(); public RefreshLocalVisitor(IProgressMonitor monitor) { this.monitor = SubMonitor.convert(monitor); @@ -146,6 +150,13 @@ protected void folderToFile(UnifiedTreeNode node, Resource target) throws CoreEx target.getLocalManager().updateLocalSync(info, node.getLastModified()); } + /** + * Returns the open projects whose description file was found deleted. + */ + public Set getProjectsWithoutDescription() { + return projectsWithoutDescription; + } + /** * Returns the status of the nodes visited so far. This will be a multi-status * that describes all problems that have occurred, or an OK status if everything @@ -314,13 +325,22 @@ public boolean visit(UnifiedTreeNode node) throws CoreException { errors.merge(new ResourceStatus(IResourceStatus.INVALID_RESOURCE_NAME, message)); return false; } + boolean existedInWorkspace = node.existsInWorkspace(); int state = synchronizeExistence(node, target); if (state == RL_IN_SYNC || state == RL_NOT_IN_SYNC) { if (targetType == IResource.FILE) { - try { - ((File) target).updateMetadataFiles(); - } catch (CoreException e) { - errors.merge(e.getStatus()); + File file = (File) target; + Project project = (Project) file.getProject(); + // a filtered description file is still read from disk + if (existedInWorkspace && !file.exists() && file.isProjectDescriptionFile() + && !file.getLocalManager().hasSavedDescription(project)) { + projectsWithoutDescription.add(project); + } else { + try { + file.updateMetadataFiles(); + } catch (CoreException e) { + errors.merge(e.getStatus()); + } } } return true; diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/File.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/File.java index eff57d98b85..f1227ef42a9 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/File.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/File.java @@ -557,7 +557,7 @@ public void setContents(byte[] content, int updateFlags, IProgressMonitor monito public long setLocalTimeStamp(long value) throws CoreException { //override to handle changing timestamp on project description file long result = super.setLocalTimeStamp(value); - if (path.segmentCount() == 2 && path.segment(1).equals(IProjectDescription.DESCRIPTION_FILE_NAME)) { + if (isProjectDescriptionFile()) { //handle concurrent project deletion ResourceInfo projectInfo = ((Project) getProject()).getResourceInfo(false, false); if (projectInfo != null) { @@ -576,10 +576,7 @@ public long setLocalTimeStamp(long value) throws CoreException { * been modified (added, removed, or changed). */ public void updateMetadataFiles() throws CoreException { - int count = path.segmentCount(); - String name = path.segment(1); - // is this a project description file? - if (count == 2 && name.equals(IProjectDescription.DESCRIPTION_FILE_NAME)) { + if (isProjectDescriptionFile()) { Project project = (Project) getProject(); project.updateDescription(); // Discard stale project natures on ProjectInfo @@ -588,12 +585,19 @@ public void updateMetadataFiles() throws CoreException { return; } // check to see if we are in the .settings directory - if (count == 3 && EclipsePreferences.DEFAULT_PREFERENCES_DIRNAME.equals(name)) { + if (path.segmentCount() == 3 && EclipsePreferences.DEFAULT_PREFERENCES_DIRNAME.equals(path.segment(1))) { ProjectPreferences.updatePreferences(this); return; } } + /** + * Returns whether this file is the description file (.project) of its project. + */ + public boolean isProjectDescriptionFile() { + return path.segmentCount() == 2 && path.segment(1).equals(IProjectDescription.DESCRIPTION_FILE_NAME); + } + @Deprecated @Override public void setCharset(String newCharset) throws CoreException { diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Project.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Project.java index 47413ea6c2c..9529a25e887 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Project.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Project.java @@ -235,19 +235,8 @@ public void close(IProgressMonitor monitor) throws CoreException { if (!isOpen(flags)) { return; } - // Signal that this resource is about to be closed. Do this at the very - // beginning so that infrastructure pieces have a chance to do clean up - // while the resources still exist. workspace.beginOperation(true); - workspace.broadcastEvent(LifecycleEvent.newEvent(LifecycleEvent.PRE_PROJECT_CLOSE, this)); - // flush the build order early in case there is a problem - workspace.flushBuildOrder(); - IProgressMonitor sub = subMonitor.newChild(49, SubMonitor.SUPPRESS_SUBTASK); - IStatus saveStatus = workspace.getSaveManager().save(ISaveContext.PROJECT_SAVE, this, sub); - internalClose(subMonitor.newChild(49)); - if (saveStatus != null && !saveStatus.isOK()) { - throw new ResourceException(saveStatus); - } + basicClose(subMonitor.newChild(98)); } catch (OperationCanceledException e) { workspace.getWorkManager().operationCanceled(); throw e; @@ -670,6 +659,26 @@ private boolean shouldBuild() { workspace.run(buildRunnable, null, IWorkspace.AVOID_UPDATE, monitor); } + /** + * Closes this open project. Must be called from within a workspace operation + * whose scheduling rule covers this project. + */ + public void basicClose(IProgressMonitor monitor) throws CoreException { + SubMonitor subMonitor = SubMonitor.convert(monitor, 2); + // Signal that this resource is about to be closed. Do this at the very + // beginning so that infrastructure pieces have a chance to do clean up + // while the resources still exist. + workspace.broadcastEvent(LifecycleEvent.newEvent(LifecycleEvent.PRE_PROJECT_CLOSE, this)); + // flush the build order early in case there is a problem + workspace.flushBuildOrder(); + IProgressMonitor sub = subMonitor.newChild(1, SubMonitor.SUPPRESS_SUBTASK); + IStatus saveStatus = workspace.getSaveManager().save(ISaveContext.PROJECT_SAVE, this, sub); + internalClose(subMonitor.newChild(1)); + if (saveStatus != null && !saveStatus.isOK()) { + throw new ResourceException(saveStatus); + } + } + /** * Closes the project. This is called during restore when there is a failure * to read the project description. Since it is called during workspace restore, diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Resource.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Resource.java index 94bfe274d0c..3773950da34 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Resource.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Resource.java @@ -848,6 +848,12 @@ public void delete(int updateFlags, IProgressMonitor monitor) throws CoreExcepti ((Rules) workspace.getRuleFactory()).setRuleFactory((IProject) this, null); // Make sure project deletion is remembered. workspace.getSaveManager().requestSnapshot(); + } else if (getType() == FILE && ((File) this).isProjectDescriptionFile()) { + // a project without description file is closed rather than recreating the file + Project project = (Project) getProject(); + if (project.isOpen() && !getLocalManager().hasSavedDescription(project)) { + project.basicClose(progress.split(1)); + } } } catch (OperationCanceledException e) { workspace.getWorkManager().operationCanceled(); diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceTree.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceTree.java index cac927a0644..b9a213772ce 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceTree.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceTree.java @@ -1114,18 +1114,14 @@ public void standardMoveProject(IProject source, IProjectDescription description } // Move the project content in the local file system. + boolean contentMoved = true; try { moveProjectContent(source, destinationStore, flags, Policy.subMonitorFor(monitor, Policy.totalWork * 3 / 4)); } catch (CoreException e) { message = NLS.bind(Messages.localstore_couldNotMove, source.getFullPath()); IStatus status = new ResourceStatus(IStatus.ERROR, source.getFullPath(), message, e); failed(status); - //refresh the project because it might have been partially moved - try { - source.refreshLocal(IResource.DEPTH_INFINITE, null); - } catch (CoreException e2) { - //ignore secondary failures - } + contentMoved = false; } // If we got this far the project content has been moved on disk (if necessary) @@ -1133,8 +1129,17 @@ public void standardMoveProject(IProject source, IProjectDescription description movedProjectSubtree(source, description); monitor.worked(Policy.totalWork * 1 / 8); + IProject destination = source.getWorkspace().getRoot().getProject(description.getName()); + if (!contentMoved) { + // the content might have been partially moved, so align the tree with the destination + try { + destination.refreshLocal(IResource.DEPTH_INFINITE, null); + } catch (CoreException e) { + //ignore secondary failures + } + } boolean isDeep = (flags & IResource.SHALLOW) == 0; - updateTimestamps(source.getWorkspace().getRoot().getProject(description.getName()), isDeep); + updateTimestamps(destination, isDeep); monitor.worked(Policy.totalWork * 1 / 8); } finally { lock.release(); diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/SaveManager.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/SaveManager.java index 96d15a2702a..cad8b3fddf0 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/SaveManager.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/SaveManager.java @@ -1311,8 +1311,9 @@ public IStatus save(int kind, boolean keepConsistencyWhenCanceled, Project proje workspace.getFileSystemManager().getHistoryStore().clean(Policy.subMonitorFor(monitor, 1)); monitor.ignoreCancelState(keepConsistencyWhenCanceled); - // write out all metainfo (e.g., workspace/project descriptions) - saveMetaInfo(warnings, Policy.subMonitorFor(monitor, 1)); + // write out the workspace metainfo (e.g., the workspace description) + saveMetaInfo(); + monitor.worked(1); break; case ISaveContext.SNAPSHOT : snapTree(workspace.getElementTree(), Policy.subMonitorFor(monitor, 1)); @@ -1327,8 +1328,9 @@ public IStatus save(int kind, boolean keepConsistencyWhenCanceled, Project proje } collapseTrees(contexts); clearSavedDelta(); - // write out all metainfo (e.g., workspace/project descriptions) - saveMetaInfo(warnings, Policy.subMonitorFor(monitor, 1)); + // write out the workspace metainfo (e.g., the workspace description) + saveMetaInfo(); + monitor.worked(1); break; case ISaveContext.PROJECT_SAVE : writeTree(project, IResource.DEPTH_INFINITE); @@ -1338,10 +1340,6 @@ public IStatus save(int kind, boolean keepConsistencyWhenCanceled, Project proje monitor.worked(1); // reset the snapshot file resetSnapshots(project); - IStatus result = saveMetaInfo(project, null); - if (!result.isOK()) { - warnings.merge(result); - } monitor.worked(1); break; } @@ -1404,52 +1402,20 @@ protected void saveMasterTable(int kind, IPath location) throws CoreException { } /** - * Writes the metainfo (e.g. descriptions) of the given workspace and - * all projects to the local disk. + * Writes the metainfo (e.g. description) of the workspace to the local disk. */ - protected void saveMetaInfo(MultiStatus problems, IProgressMonitor monitor) throws CoreException { + protected void saveMetaInfo() { if (Policy.DEBUG_SAVE_METAINFO) { Policy.debug("Save workspace metainfo: starting..."); //$NON-NLS-1$ } long start = System.currentTimeMillis(); // save preferences (workspace description, path variables, etc) ResourcesPlugin.getPlugin().savePluginPreferences(); - // save projects' meta info - IProject[] roots = workspace.getRoot().getProjects(IContainer.INCLUDE_HIDDEN); - for (IProject root : roots) { - if (root.isAccessible()) { - IStatus result = saveMetaInfo((Project) root, null); - if (!result.isOK()) { - problems.merge(result); - } - } - } if (Policy.DEBUG_SAVE_METAINFO) { Policy.debug("Save workspace metainfo: " + (System.currentTimeMillis() - start) + "ms"); //$NON-NLS-1$ //$NON-NLS-2$ } } - /** - * Ensures that the project meta-info is saved. The project meta-info - * is usually saved as soon as it changes, so this is just a sanity check - * to make sure there is something on disk before we shutdown. - * - * @return Status object containing non-critical warnings, or an OK status. - */ - protected IStatus saveMetaInfo(Project project, IProgressMonitor monitor) throws CoreException { - long start = System.currentTimeMillis(); - //if there is nothing on disk, write the description - if (!workspace.getFileSystemManager().hasSavedDescription(project)) { - workspace.getFileSystemManager().writeSilently(project); - String msg = NLS.bind(Messages.resources_missingProjectMetaRepaired, project.getName()); - return new ResourceStatus(IResourceStatus.MISSING_DESCRIPTION_REPAIRED, project.getFullPath(), msg); - } - if (Policy.DEBUG_SAVE_METAINFO) { - Policy.debug("Save metainfo for " + project.getFullPath() + ": " + (System.currentTimeMillis() - start) + "ms"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ - } - return Status.OK_STATUS; - } - /** * Writes a snapshot of project refresh information to the specified * location. diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/localstore/LocalSyncTest.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/localstore/LocalSyncTest.java index 8aca927fd13..4c68b4f7347 100644 --- a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/localstore/LocalSyncTest.java +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/localstore/LocalSyncTest.java @@ -23,11 +23,9 @@ import static org.eclipse.core.tests.resources.ResourceTestUtil.createInWorkspace; import static org.eclipse.core.tests.resources.ResourceTestUtil.removeFromFileSystem; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.eclipse.core.internal.resources.ICoreConstants; -import org.eclipse.core.internal.resources.TestingSupport; import org.eclipse.core.internal.resources.Workspace; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IProject; @@ -61,9 +59,6 @@ private boolean existsInFileSystemWithNoContent(IResource resource) { @Test public void testProjectDeletion() throws CoreException { - //snapshot will recreate the deleted .project file - TestingSupport.waitForSnapshot(); - // create resources IResource[] resources = buildResources(project, "/File1", "/Folder1/", "/Folder1/File1", "/Folder1/Folder2/"); createInWorkspace(resources); @@ -72,11 +67,12 @@ public void testProjectDeletion() throws CoreException { Workspace.clear(project.getLocation().toFile()); // run synchronize - //The .project file has been deleted, so this will fail - assertThrows(CoreException.class, () -> project.refreshLocal(IResource.DEPTH_INFINITE, null)); + // The .project file has been deleted, so this closes the project + project.refreshLocal(IResource.DEPTH_INFINITE, null); - /* project should still exists */ + /* project should still exist but be closed */ assertTrue(project.exists()); + assertFalse(project.isOpen()); /* resources should not exist anymore */ for (int i = 1; i < resources.length; i++) { diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IProjectDescriptionTest.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IProjectDescriptionTest.java index e309038f3f8..6be54945c36 100644 --- a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IProjectDescriptionTest.java +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IProjectDescriptionTest.java @@ -14,27 +14,44 @@ *******************************************************************************/ package org.eclipse.core.tests.resources; +import static java.util.function.Predicate.not; import static org.assertj.core.api.Assertions.assertThat; import static org.eclipse.core.resources.ResourcesPlugin.getWorkspace; +import static org.eclipse.core.tests.harness.FileSystemHelper.getRandomLocation; +import static org.eclipse.core.tests.harness.FileSystemHelper.getTempDir; +import static org.eclipse.core.tests.resources.ResourceTestUtil.assertDoesNotExistInFileSystem; import static org.eclipse.core.tests.resources.ResourceTestUtil.createInWorkspace; import static org.eclipse.core.tests.resources.ResourceTestUtil.createTestMonitor; +import static org.eclipse.core.tests.resources.ResourceTestUtil.removeFromFileSystem; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileStore; +import org.eclipse.core.filesystem.URIUtil; import org.eclipse.core.internal.events.BuildCommand; import org.eclipse.core.internal.resources.Project; import org.eclipse.core.resources.ICommand; import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IProjectDescription; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.IncrementalProjectBuilder; import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.Status; import org.eclipse.core.tests.internal.builders.CustomTriggerBuilder; +import org.eclipse.core.tests.internal.filesystem.wrapper.WrapperFileStore; +import org.eclipse.core.tests.internal.filesystem.wrapper.WrapperFileSystem; import org.eclipse.core.tests.resources.util.WorkspaceResetExtension; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -51,6 +68,162 @@ public void testDescriptionConstant() { assertEquals(".project", IProjectDescription.DESCRIPTION_FILE_NAME); } + /** + * Deleting the description file through the workspace closes the project and + * does not recreate the file. + */ + @Test + public void testDeleteDescriptionFileClosesProject() throws CoreException { + IProject project = getWorkspace().getRoot().getProject("Project"); + IFile descriptionFile = project.getFile(IProjectDescription.DESCRIPTION_FILE_NAME); + createInWorkspace(project); + + descriptionFile.delete(IResource.NONE, createTestMonitor()); + + assertThat(project).matches(IProject::exists, "exists").matches(not(IProject::isOpen), "is closed"); + getWorkspace().save(true, createTestMonitor()); + assertDoesNotExistInFileSystem(descriptionFile); + } + + /** + * A refresh that finds the description file deleted closes the project and + * does not recreate the file. Restoring the file allows to reopen the project. + */ + @Test + public void testRefreshWithDeletedDescriptionFileClosesProject() throws Exception { + IProject project = getWorkspace().getRoot().getProject("Project"); + IFile descriptionFile = project.getFile(IProjectDescription.DESCRIPTION_FILE_NAME); + IFile file = project.getFile("file.txt"); + createInWorkspace(file); + Path backup = getTempDir().append("dotProjectBackup").toPath(); + Files.copy(descriptionFile.getLocation().toPath(), backup); + try { + removeFromFileSystem(descriptionFile); + + project.refreshLocal(IResource.DEPTH_INFINITE, createTestMonitor()); + + assertThat(project).matches(IProject::exists, "exists").matches(not(IProject::isOpen), "is closed"); + getWorkspace().save(true, createTestMonitor()); + assertDoesNotExistInFileSystem(descriptionFile); + + Files.copy(backup, descriptionFile.getLocation().toPath()); + project.open(createTestMonitor()); + assertThat(project).matches(IProject::isOpen, "is open"); + assertThat(file).matches(IResource::exists, "exists"); + } finally { + Files.deleteIfExists(backup); + } + } + + /** + * A refresh that finds the whole project directory deleted closes the project + * and does not recreate the directory. + */ + @Test + public void testRefreshWithDeletedProjectDirectoryClosesProject() throws CoreException { + IProject project = getWorkspace().getRoot().getProject("Project"); + createInWorkspace(project.getFile("file.txt")); + + removeFromFileSystem(project); + project.refreshLocal(IResource.DEPTH_INFINITE, createTestMonitor()); + + assertThat(project).matches(IProject::exists, "exists").matches(not(IProject::isOpen), "is closed"); + getWorkspace().save(true, createTestMonitor()); + assertDoesNotExistInFileSystem(project); + } + + /** + * Closing a project does not recreate a deleted description file. + */ + @Test + public void testCloseDoesNotRecreateDescriptionFile() throws CoreException { + IProject project = getWorkspace().getRoot().getProject("Project"); + IFile descriptionFile = project.getFile(IProjectDescription.DESCRIPTION_FILE_NAME); + createInWorkspace(project); + + removeFromFileSystem(descriptionFile); + project.close(createTestMonitor()); + + assertThat(project).matches(not(IProject::isOpen), "is closed"); + assertDoesNotExistInFileSystem(descriptionFile); + } + + /** + * Moves by copying and deleting, and refuses to delete a file named + * {@link #UNDELETABLE_FILE} after deleting everything else, like a locked + * file on Windows. + */ + public static class UndeletableFileStore extends WrapperFileStore { + static final String UNDELETABLE_FILE = "undeletable.txt"; + + public UndeletableFileStore(IFileStore store) { + super(store); + } + + @Override + public void move(IFileStore destination, int options, IProgressMonitor monitor) throws CoreException { + copy(destination, options, monitor); + delete(EFS.NONE, monitor); + } + + @Override + public void delete(int options, IProgressMonitor monitor) throws CoreException { + CoreException failure = null; + for (IFileStore child : childStores(EFS.NONE, null)) { + try { + child.delete(options, monitor); + } catch (CoreException e) { + failure = e; + } + } + if (failure != null) { + throw failure; + } + if (UNDELETABLE_FILE.equals(getName())) { + throw new CoreException(Status.error("cannot delete " + this)); + } + super.delete(options, monitor); + } + } + + /** + * A project move that copied the content and then failed to delete part of + * the source, including its description file, still ends up at the + * destination with its markers. + */ + @Test + public void testMoveWithUndeletableSourceContent() throws Exception { + IPath sourceLocation = getRandomLocation(); + IProject source = getWorkspace().getRoot().getProject("Source"); + IProjectDescription sourceDescription = getWorkspace().newProjectDescription(source.getName()); + sourceDescription.setLocationURI(WrapperFileSystem.getWrappedURI(URIUtil.toURI(sourceLocation))); + source.create(sourceDescription, createTestMonitor()); + source.open(createTestMonitor()); + IFile file = source.getFile("file.txt"); + IFile undeletableFile = source.getFile(UndeletableFileStore.UNDELETABLE_FILE); + createInWorkspace(new IResource[] { file, undeletableFile }); + IMarker marker = file.createMarker(IMarker.BOOKMARK); + IProject destination = getWorkspace().getRoot().getProject("Destination"); + IProjectDescription destinationDescription = getWorkspace().newProjectDescription(destination.getName()); + WrapperFileSystem.setCustomFileStore(UndeletableFileStore.class); + try { + assertThrows(CoreException.class, + () -> source.move(destinationDescription, IResource.FORCE, createTestMonitor())); + + assertFalse(sourceLocation.append(IProjectDescription.DESCRIPTION_FILE_NAME).toFile().exists()); + assertThat(source).matches(not(IProject::exists), "does not exist"); + assertThat(destination).matches(IProject::isOpen, "is open"); + IFile movedFile = destination.getFile(file.getProjectRelativePath()); + assertThat(movedFile).matches(IResource::exists, "exists"); + assertNotNull(movedFile.findMarker(marker.getId())); + assertThat(destination.getFile(undeletableFile.getProjectRelativePath())).matches(IResource::exists, + "exists"); + } finally { + WrapperFileSystem.setCustomFileStore(null); + removeFromFileSystem(sourceLocation.toFile()); + } + } + /** * Tests that setting the build spec preserves any instantiated builder. */ diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IProjectTest.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IProjectTest.java index 113fa4ee5c8..134e0a4dccb 100644 --- a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IProjectTest.java +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IProjectTest.java @@ -29,6 +29,7 @@ import static org.eclipse.core.tests.resources.ResourceTestUtil.createRandomString; import static org.eclipse.core.tests.resources.ResourceTestUtil.createUniqueString; import static org.eclipse.core.tests.resources.ResourceTestUtil.getLineSeparatorFromFile; +import static org.eclipse.core.tests.resources.ResourceTestUtil.removeFromFileSystem; import static org.eclipse.core.tests.resources.ResourceTestUtil.removeFromWorkspace; import static org.eclipse.core.tests.resources.ResourceTestUtil.waitForRefresh; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -582,18 +583,13 @@ public void testProjectCreationLineSeparator() throws BackingStoreException, Cor Preferences projectNode = rootNode.node(ProjectScope.SCOPE).node(project.getName()).node(Platform.PI_RUNTIME); projectNode.put(Platform.PREF_LINE_SEPARATOR, newProjectValue); projectNode.flush(); - // remove .project file but leave the project - monitor.prepare(); - file.delete(true, monitor); - monitor.assertUsedUp(); - assertFalse(file.exists()); - // workspace save should recreate .project file with project-specific line delimiter - monitor.prepare(); - getWorkspace().save(true, monitor); - monitor.assertUsedUp(); - // refresh project to update the resource tree + // remove .project file from disk but leave the project + removeFromFileSystem(file); + // writing the description should recreate .project file with project-specific line delimiter + description = project.getDescription(); + description.setComment("another comment"); monitor.prepare(); - project.refreshLocal(IResource.DEPTH_INFINITE, monitor); + project.setDescription(description, IResource.FORCE, monitor); monitor.assertUsedUp(); assertTrue(file.exists()); // new .project should have project-specific line separator diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/ISynchronizerTest.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/ISynchronizerTest.java index 19b9471d151..1b46a9b2ab2 100644 --- a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/ISynchronizerTest.java +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/ISynchronizerTest.java @@ -155,13 +155,15 @@ public void testDeleteResources() throws CoreException { }; getWorkspace().getRoot().accept(visitor); - // delete all resources under the projects. + // delete all resources under the projects, deleting .project would close them final IProject[] projects = getWorkspace().getRoot().getProjects(); IWorkspaceRunnable body = monitor -> { for (IProject project : projects) { IResource[] children = project.members(); for (IResource element : children) { - element.delete(false, createTestMonitor()); + if (!IProjectDescription.DESCRIPTION_FILE_NAME.equals(element.getName())) { + element.delete(false, createTestMonitor()); + } } } }; diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IWorkspaceTest.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IWorkspaceTest.java index 19ad9e3054c..27a00b1b0e7 100644 --- a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IWorkspaceTest.java +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IWorkspaceTest.java @@ -715,19 +715,21 @@ public void testMultiSetDescription() throws CoreException { } /** - * Test API method IWorkspace.setDescription. + * Test API method IWorkspace.save. */ @Test public void testSave() throws CoreException { - // ensure save returns a warning if a project's .project file is deleted. + // deleting a project's .project file closes the project, save must not recreate the file IProject project = getWorkspace().getRoot().getProject("Broken"); createInWorkspace(project); // wait for snapshot before modifying file TestingSupport.waitForSnapshot(); IFile descriptionFile = project.getFile(IProjectDescription.DESCRIPTION_FILE_NAME); descriptionFile.delete(IResource.NONE, null); + assertFalse(project.isOpen()); IStatus result = getWorkspace().save(true, createTestMonitor()); - assertEquals(IStatus.WARNING, result.getSeverity()); + assertTrue(result.isOK()); + assertDoesNotExistInFileSystem(descriptionFile); } /** diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/session/TestBug12575.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/session/TestBug12575.java index 9de75a504b2..77f689e6646 100644 --- a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/session/TestBug12575.java +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/session/TestBug12575.java @@ -13,8 +13,11 @@ *******************************************************************************/ package org.eclipse.core.tests.resources.session; +import static java.util.function.Predicate.not; +import static org.assertj.core.api.Assertions.assertThat; import static org.eclipse.core.resources.ResourcesPlugin.getWorkspace; import static org.eclipse.core.tests.resources.ResourceTestPluginConstants.PI_RESOURCES_TESTS; +import static org.eclipse.core.tests.resources.ResourceTestUtil.assertDoesNotExistInFileSystem; import static org.eclipse.core.tests.resources.ResourceTestUtil.createInWorkspace; import static org.eclipse.core.tests.resources.ResourceTestUtil.createTestMonitor; @@ -39,8 +42,8 @@ public class TestBug12575 { .withCustomization(SessionTestExtension.createCustomWorkspace()).create(); /** - * Setup. Create a simple project, delete the .project file, shutdown - * cleanly. + * Setup. Create a simple project, delete the .project file, which closes the + * project, shutdown cleanly. */ @Test @Order(1) @@ -50,20 +53,23 @@ public void test1() throws CoreException { project.open(createTestMonitor()); IFile dotProject = project.getFile(IProjectDescription.DESCRIPTION_FILE_NAME); dotProject.delete(IResource.NONE, createTestMonitor()); + assertThat(project).matches(not(IProject::isOpen), "is closed"); getWorkspace().save(true, createTestMonitor()); + assertDoesNotExistInFileSystem(dotProject); } /** - * Infection. Modify the .project, cause a snapshot, crash + * Infection. The project is still closed and without .project file. Delete + * it, cause a snapshot, crash */ @Test @Order(2) public void test2() throws CoreException { IProject project = getWorkspace().getRoot().getProject(projectName); IProject other = getWorkspace().getRoot().getProject("Other"); - IProjectDescription desc = project.getDescription(); - desc.setReferencedProjects(new IProject[] { other }); - project.setDescription(desc, IResource.FORCE, createTestMonitor()); + assertThat(project).matches(IProject::exists, "exists").matches(not(IProject::isOpen), "is closed"); + assertDoesNotExistInFileSystem(project.getFile(IProjectDescription.DESCRIPTION_FILE_NAME)); + project.delete(true, createTestMonitor()); //creating a project will cause a snapshot createInWorkspace(other); diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/usecase/Snapshot2Test.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/usecase/Snapshot2Test.java index 31a398aa267..03a3216bfca 100644 --- a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/usecase/Snapshot2Test.java +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/usecase/Snapshot2Test.java @@ -26,6 +26,7 @@ import java.util.Arrays; import java.util.List; import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IProjectDescription; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; @@ -70,8 +71,10 @@ public void testChangeProject2() throws CoreException { assertTrue(project.exists()); assertTrue(project.isOpen()); - // remove all resources - IResource[] children = project.members(); + // remove all resources but .project, deleting it would close the project + IResource[] children = Arrays.stream(project.members()) + .filter(child -> !IProjectDescription.DESCRIPTION_FILE_NAME.equals(child.getName())) + .toArray(IResource[]::new); getWorkspace().delete(children, true, null); // create some children