Merge tag 'v2.3.20' into testing
[gpl/argeo-jcr.git] / org.argeo.slc.repo / src / org / argeo / slc / repo / RepoUtils.java
diff --git a/org.argeo.slc.repo/src/org/argeo/slc/repo/RepoUtils.java b/org.argeo.slc.repo/src/org/argeo/slc/repo/RepoUtils.java
new file mode 100644 (file)
index 0000000..22fe6b0
--- /dev/null
@@ -0,0 +1,633 @@
+package org.argeo.slc.repo;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Enumeration;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.StringTokenizer;
+import java.util.TreeSet;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarInputStream;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import java.util.zip.ZipInputStream;
+
+import javax.jcr.Credentials;
+import javax.jcr.GuestCredentials;
+import javax.jcr.Node;
+import javax.jcr.NodeIterator;
+import javax.jcr.Property;
+import javax.jcr.PropertyIterator;
+import javax.jcr.Repository;
+import javax.jcr.RepositoryException;
+import javax.jcr.RepositoryFactory;
+import javax.jcr.Session;
+import javax.jcr.SimpleCredentials;
+import javax.jcr.nodetype.NodeType;
+
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.io.IOUtils;
+import org.argeo.api.cms.CmsLog;
+import org.argeo.api.cms.keyring.Keyring;
+import org.argeo.cms.ArgeoNames;
+import org.argeo.cms.ArgeoTypes;
+import org.argeo.cms.jcr.CmsJcrUtils;
+import org.argeo.jcr.JcrMonitor;
+import org.argeo.jcr.JcrUtils;
+import org.argeo.slc.DefaultNameVersion;
+import org.argeo.slc.NameVersion;
+import org.argeo.slc.SlcException;
+import org.argeo.slc.SlcNames;
+import org.argeo.slc.SlcTypes;
+import org.argeo.slc.repo.maven.ArtifactIdComparator;
+import org.argeo.slc.repo.maven.MavenConventionsUtils;
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.artifact.DefaultArtifact;
+import org.osgi.framework.Constants;
+
+/** Utilities around repo */
+public class RepoUtils implements ArgeoNames, SlcNames {
+       private final static CmsLog log = CmsLog.getLog(RepoUtils.class);
+
+       /** Packages a regular sources jar as PDE source. */
+       public static void packagesAsPdeSource(File sourceFile,
+                       NameVersion nameVersion, OutputStream out) throws IOException {
+               if (isAlreadyPdeSource(sourceFile)) {
+                       FileInputStream in = new FileInputStream(sourceFile);
+                       IOUtils.copy(in, out);
+                       IOUtils.closeQuietly(in);
+               } else {
+                       String sourceSymbolicName = nameVersion.getName() + ".source";
+
+                       Manifest sourceManifest = null;
+                       sourceManifest = new Manifest();
+                       sourceManifest.getMainAttributes().put(
+                                       Attributes.Name.MANIFEST_VERSION, "1.0");
+                       sourceManifest.getMainAttributes().putValue("Bundle-SymbolicName",
+                                       sourceSymbolicName);
+                       sourceManifest.getMainAttributes().putValue("Bundle-Version",
+                                       nameVersion.getVersion());
+                       sourceManifest.getMainAttributes().putValue(
+                                       "Eclipse-SourceBundle",
+                                       nameVersion.getName() + ";version="
+                                                       + nameVersion.getVersion());
+                       copyJar(sourceFile, out, sourceManifest);
+               }
+       }
+
+       public static byte[] packageAsPdeSource(InputStream sourceJar,
+                       NameVersion nameVersion) {
+               String sourceSymbolicName = nameVersion.getName() + ".source";
+
+               Manifest sourceManifest = null;
+               sourceManifest = new Manifest();
+               sourceManifest.getMainAttributes().put(
+                               Attributes.Name.MANIFEST_VERSION, "1.0");
+               sourceManifest.getMainAttributes().putValue("Bundle-SymbolicName",
+                               sourceSymbolicName);
+               sourceManifest.getMainAttributes().putValue("Bundle-Version",
+                               nameVersion.getVersion());
+               sourceManifest.getMainAttributes().putValue("Eclipse-SourceBundle",
+                               nameVersion.getName() + ";version=" + nameVersion.getVersion());
+
+               return modifyManifest(sourceJar, sourceManifest);
+       }
+
+       /**
+        * Check whether the file as already been packaged as PDE source, in order
+        * not to mess with Jar signing
+        */
+       private static boolean isAlreadyPdeSource(File sourceFile) {
+               JarInputStream jarInputStream = null;
+
+               try {
+                       jarInputStream = new JarInputStream(new FileInputStream(sourceFile));
+
+                       Manifest manifest = jarInputStream.getManifest();
+                       Iterator<?> it = manifest.getMainAttributes().keySet().iterator();
+                       boolean res = false;
+                       // containsKey() does not work, iterating...
+                       while (it.hasNext())
+                               if (it.next().toString().equals("Eclipse-SourceBundle")) {
+                                       res = true;
+                                       break;
+                               }
+                       // boolean res = manifest.getMainAttributes().get(
+                       // "Eclipse-SourceBundle") != null;
+                       if (res)
+                               log.info(sourceFile + " is already a PDE source");
+                       return res;
+               } catch (Exception e) {
+                       // probably not a jar, skipping
+                       if (log.isDebugEnabled())
+                               log.debug("Skipping " + sourceFile + " because of "
+                                               + e.getMessage());
+                       return false;
+               } finally {
+                       IOUtils.closeQuietly(jarInputStream);
+               }
+       }
+
+       /**
+        * Copy a jar, replacing its manifest with the provided one
+        * 
+        * @param manifest
+        *            can be null
+        */
+       private static void copyJar(File source, OutputStream out, Manifest manifest)
+                       throws IOException {
+               JarFile sourceJar = null;
+               JarOutputStream output = null;
+               try {
+                       output = manifest != null ? new JarOutputStream(out, manifest)
+                                       : new JarOutputStream(out);
+                       sourceJar = new JarFile(source);
+
+                       entries: for (Enumeration<?> entries = sourceJar.entries(); entries
+                                       .hasMoreElements();) {
+                               JarEntry entry = (JarEntry) entries.nextElement();
+                               if (manifest != null
+                                               && entry.getName().equals("META-INF/MANIFEST.MF"))
+                                       continue entries;
+
+                               InputStream entryStream = sourceJar.getInputStream(entry);
+                               JarEntry newEntry = new JarEntry(entry.getName());
+                               // newEntry.setMethod(JarEntry.DEFLATED);
+                               output.putNextEntry(newEntry);
+                               IOUtils.copy(entryStream, output);
+                       }
+               } finally {
+                       IOUtils.closeQuietly(output);
+                       try {
+                               if (sourceJar != null)
+                                       sourceJar.close();
+                       } catch (IOException e) {
+                               // silent
+                       }
+               }
+       }
+
+       /** Copy a jar changing onlythe manifest */
+       public static void copyJar(InputStream in, OutputStream out,
+                       Manifest manifest) {
+               JarInputStream jarIn = null;
+               JarOutputStream jarOut = null;
+               try {
+                       jarIn = new JarInputStream(in);
+                       jarOut = new JarOutputStream(out, manifest);
+                       JarEntry jarEntry = null;
+                       while ((jarEntry = jarIn.getNextJarEntry()) != null) {
+                               if (!jarEntry.getName().equals("META-INF/MANIFEST.MF")) {
+                                       JarEntry newJarEntry = new JarEntry(jarEntry.getName());
+                                       jarOut.putNextEntry(newJarEntry);
+                                       IOUtils.copy(jarIn, jarOut);
+                                       jarIn.closeEntry();
+                                       jarOut.closeEntry();
+                               }
+                       }
+               } catch (IOException e) {
+                       throw new SlcException("Could not copy jar with MANIFEST "
+                                       + manifest.getMainAttributes(), e);
+               } finally {
+                       if (!(in instanceof ZipInputStream))
+                               IOUtils.closeQuietly(jarIn);
+                       IOUtils.closeQuietly(jarOut);
+               }
+       }
+
+       /** Reads a jar file, modify its manifest */
+       public static byte[] modifyManifest(InputStream in, Manifest manifest) {
+               ByteArrayOutputStream out = new ByteArrayOutputStream(200 * 1024);
+               try {
+                       copyJar(in, out, manifest);
+                       return out.toByteArray();
+               } finally {
+                       IOUtils.closeQuietly(out);
+               }
+       }
+
+       /** Read the OSGi {@link NameVersion} */
+       public static NameVersion readNameVersion(Artifact artifact) {
+               File artifactFile = artifact.getFile();
+               if (artifact.getExtension().equals("pom")) {
+                       // hack to process jars which weirdly appear as POMs
+                       File jarFile = new File(artifactFile.getParentFile(),
+                                       FilenameUtils.getBaseName(artifactFile.getPath()) + ".jar");
+                       if (jarFile.exists()) {
+                               log.warn("Use " + jarFile + " instead of " + artifactFile
+                                               + " for " + artifact);
+                               artifactFile = jarFile;
+                       }
+               }
+               return readNameVersion(artifactFile);
+       }
+
+       /** Read the OSGi {@link NameVersion} */
+       public static NameVersion readNameVersion(File artifactFile) {
+               try {
+                       return readNameVersion(new FileInputStream(artifactFile));
+               } catch (Exception e) {
+                       // probably not a jar, skipping
+                       if (log.isDebugEnabled()) {
+                               log.debug("Skipping " + artifactFile + " because of " + e);
+                               // e.printStackTrace();
+                       }
+               }
+               return null;
+       }
+
+       /** Read the OSGi {@link NameVersion} */
+       public static NameVersion readNameVersion(InputStream in) {
+               JarInputStream jarInputStream = null;
+               try {
+                       jarInputStream = new JarInputStream(in);
+                       return readNameVersion(jarInputStream.getManifest());
+               } catch (Exception e) {
+                       // probably not a jar, skipping
+                       if (log.isDebugEnabled()) {
+                               log.debug("Skipping because of " + e);
+                       }
+               } finally {
+                       IOUtils.closeQuietly(jarInputStream);
+               }
+               return null;
+       }
+
+       /** Read the OSGi {@link NameVersion} */
+       public static NameVersion readNameVersion(Manifest manifest) {
+               DefaultNameVersion nameVersion = new DefaultNameVersion();
+               nameVersion.setName(manifest.getMainAttributes().getValue(
+                               Constants.BUNDLE_SYMBOLICNAME));
+
+               // Skip additional specs such as
+               // ; singleton:=true
+               if (nameVersion.getName().indexOf(';') > -1) {
+                       nameVersion
+                                       .setName(new StringTokenizer(nameVersion.getName(), " ;")
+                                                       .nextToken());
+               }
+
+               nameVersion.setVersion(manifest.getMainAttributes().getValue(
+                               Constants.BUNDLE_VERSION));
+
+               return nameVersion;
+       }
+
+       /*
+        * DATA MODEL
+        */
+       /** The artifact described by this node */
+       public static Artifact asArtifact(Node node) throws RepositoryException {
+               if (node.isNodeType(SlcTypes.SLC_ARTIFACT_VERSION_BASE)) {
+                       // FIXME update data model to store packaging at this level
+                       String extension = "jar";
+                       return new DefaultArtifact(node.getProperty(SLC_GROUP_ID)
+                                       .getString(),
+                                       node.getProperty(SLC_ARTIFACT_ID).getString(), extension,
+                                       node.getProperty(SLC_ARTIFACT_VERSION).getString());
+               } else if (node.isNodeType(SlcTypes.SLC_ARTIFACT)) {
+                       return new DefaultArtifact(node.getProperty(SLC_GROUP_ID)
+                                       .getString(),
+                                       node.getProperty(SLC_ARTIFACT_ID).getString(), node
+                                                       .getProperty(SLC_ARTIFACT_CLASSIFIER).getString(),
+                                       node.getProperty(SLC_ARTIFACT_EXTENSION).getString(), node
+                                                       .getProperty(SLC_ARTIFACT_VERSION).getString());
+               } else if (node.isNodeType(SlcTypes.SLC_MODULE_COORDINATES)) {
+                       return new DefaultArtifact(node.getProperty(SLC_CATEGORY)
+                                       .getString(), node.getProperty(SLC_NAME).getString(),
+                                       "jar", node.getProperty(SLC_VERSION).getString());
+               } else {
+                       throw new SlcException("Unsupported node type for " + node);
+               }
+       }
+
+       /**
+        * The path to the PDE source related to this artifact (or artifact version
+        * base). There may or there may not be a node at this location (the
+        * returned path will typically be used to test whether PDE sources are
+        * attached to this artifact).
+        */
+       public static String relatedPdeSourcePath(String artifactBasePath,
+                       Node artifactNode) throws RepositoryException {
+               Artifact artifact = asArtifact(artifactNode);
+               Artifact pdeSourceArtifact = new DefaultArtifact(artifact.getGroupId(),
+                               artifact.getArtifactId() + ".source", artifact.getExtension(),
+                               artifact.getVersion());
+               return MavenConventionsUtils.artifactPath(artifactBasePath,
+                               pdeSourceArtifact);
+       }
+
+       /**
+        * Copy this bytes array as an artifact, relative to the root of the
+        * repository (typically the workspace root node)
+        */
+       public static Node copyBytesAsArtifact(Node artifactsBase,
+                       Artifact artifact, byte[] bytes) throws RepositoryException {
+               String parentPath = MavenConventionsUtils.artifactParentPath(
+                               artifactsBase.getPath(), artifact);
+               Node folderNode = JcrUtils.mkfolders(artifactsBase.getSession(),
+                               parentPath);
+               return JcrUtils.copyBytesAsFile(folderNode,
+                               MavenConventionsUtils.artifactFileName(artifact), bytes);
+       }
+
+       private RepoUtils() {
+       }
+
+       /** If a source return the base bundle name, does not change otherwise */
+       public static String extractBundleNameFromSourceName(String sourceBundleName) {
+               if (sourceBundleName.endsWith(".source"))
+                       return sourceBundleName.substring(0, sourceBundleName.length()
+                                       - ".source".length());
+               else
+                       return sourceBundleName;
+       }
+
+       /*
+        * SOFTWARE REPOSITORIES
+        */
+
+       /** Retrieve repository based on information in the repo node */
+       public static Repository getRepository(RepositoryFactory repositoryFactory,
+                       Keyring keyring, Node repoNode) {
+               try {
+                       Repository repository;
+                       if (repoNode.isNodeType(ArgeoTypes.ARGEO_REMOTE_REPOSITORY)) {
+                               String uri = repoNode.getProperty(ARGEO_URI).getString();
+                               if (uri.startsWith("http")) {// http, https
+                                       repository = CmsJcrUtils.getRepositoryByUri(
+                                                       repositoryFactory, uri);
+                               } else if (uri.startsWith("vm:")) {// alias
+                                       repository = CmsJcrUtils.getRepositoryByUri(
+                                                       repositoryFactory, uri);
+                               } else {
+                                       throw new SlcException("Unsupported repository uri " + uri);
+                               }
+                               return repository;
+                       } else {
+                               throw new SlcException("Unsupported node type " + repoNode);
+                       }
+               } catch (RepositoryException e) {
+                       throw new SlcException("Cannot connect to repository " + repoNode,
+                                       e);
+               }
+       }
+
+       /**
+        * Reads credentials from node, using keyring if there is a password. Can
+        * return null if no credentials needed (local repo) at all, but returns
+        * {@link GuestCredentials} if user id is 'anonymous' .
+        */
+       public static Credentials getRepositoryCredentials(Keyring keyring,
+                       Node repoNode) {
+               try {
+                       if (repoNode.isNodeType(ArgeoTypes.ARGEO_REMOTE_REPOSITORY)) {
+                               if (!repoNode.hasProperty(ARGEO_USER_ID))
+                                       return null;
+
+                               String userId = repoNode.getProperty(ARGEO_USER_ID).getString();
+                               if (userId.equals("anonymous"))// FIXME hardcoded userId
+                                       return new GuestCredentials();
+                               char[] password = keyring.getAsChars(repoNode.getPath() + '/'
+                                               + ARGEO_PASSWORD);
+                               Credentials credentials = new SimpleCredentials(userId,
+                                               password);
+                               return credentials;
+                       } else {
+                               throw new SlcException("Unsupported node type " + repoNode);
+                       }
+               } catch (RepositoryException e) {
+                       throw new SlcException("Cannot connect to repository " + repoNode,
+                                       e);
+               }
+       }
+
+       /**
+        * Shortcut to retrieve a session given variable information: Handle the
+        * case where we only have an URI of the repository, that we want to connect
+        * as anonymous or the case of a identified connection to a local or remote
+        * repository.
+        * 
+        * Callers must close the session once it has been used
+        */
+       public static Session getRemoteSession(RepositoryFactory repositoryFactory,
+                       Keyring keyring, Node repoNode, String uri, String workspaceName) {
+               try {
+                       if (repoNode == null && uri == null)
+                               throw new SlcException(
+                                               "At least one of repoNode and uri must be defined");
+                       Repository currRepo = null;
+                       Credentials credentials = null;
+                       // Anonymous URI only workspace
+                       if (repoNode == null)
+                               // Anonymous
+                               currRepo = CmsJcrUtils.getRepositoryByUri(repositoryFactory, uri);
+                       else {
+                               currRepo = RepoUtils.getRepository(repositoryFactory, keyring,
+                                               repoNode);
+                               credentials = RepoUtils.getRepositoryCredentials(keyring,
+                                               repoNode);
+                       }
+                       return currRepo.login(credentials, workspaceName);
+               } catch (RepositoryException e) {
+                       throw new SlcException("Cannot connect to workspace "
+                                       + workspaceName + " of repository " + repoNode
+                                       + " with URI " + uri, e);
+               }
+       }
+
+       /**
+        * Shortcut to retrieve a session on a remote Jrc Repository from
+        * information stored in a local argeo node or from an URI: Handle the case
+        * where we only have an URI of the repository, that we want to connect as
+        * anonymous or the case of a identified connection to a local or remote
+        * repository.
+        * 
+        * Callers must close the session once it has been used
+        */
+       public static Session getRemoteSession(RepositoryFactory repositoryFactory,
+                       Keyring keyring, Repository localRepository, String repoNodePath,
+                       String uri, String workspaceName) {
+               Session localSession = null;
+               Node repoNode = null;
+               try {
+                       localSession = localRepository.login();
+                       if (repoNodePath != null && localSession.nodeExists(repoNodePath))
+                               repoNode = localSession.getNode(repoNodePath);
+
+                       return RepoUtils.getRemoteSession(repositoryFactory, keyring,
+                                       repoNode, uri, workspaceName);
+               } catch (RepositoryException e) {
+                       throw new SlcException("Cannot log to workspace " + workspaceName
+                                       + " for repo defined in " + repoNodePath, e);
+               } finally {
+                       JcrUtils.logoutQuietly(localSession);
+               }
+       }
+
+       /**
+        * Write group indexes: 'binaries' lists all bundles and their versions,
+        * 'sources' list their sources, and 'sdk' aggregates both.
+        */
+       public static void writeGroupIndexes(Session session,
+                       String artifactBasePath, String groupId, String version,
+                       Set<Artifact> binaries, Set<Artifact> sources) {
+               try {
+                       Set<Artifact> indexes = new TreeSet<Artifact>(
+                                       new ArtifactIdComparator());
+                       Artifact binariesArtifact = writeIndex(session, artifactBasePath,
+                                       groupId, RepoConstants.BINARIES_ARTIFACT_ID, version,
+                                       binaries);
+                       indexes.add(binariesArtifact);
+                       if (sources != null) {
+                               Artifact sourcesArtifact = writeIndex(session,
+                                               artifactBasePath, groupId,
+                                               RepoConstants.SOURCES_ARTIFACT_ID, version, sources);
+                               indexes.add(sourcesArtifact);
+                       }
+                       // sdk
+                       writeIndex(session, artifactBasePath, groupId,
+                                       RepoConstants.SDK_ARTIFACT_ID, version, indexes);
+                       session.save();
+               } catch (RepositoryException e) {
+                       throw new SlcException("Cannot write indexes for group " + groupId,
+                                       e);
+               }
+       }
+
+       /** Write a group index. */
+       private static Artifact writeIndex(Session session,
+                       String artifactBasePath, String groupId, String artifactId,
+                       String version, Set<Artifact> artifacts) throws RepositoryException {
+               Artifact artifact = new DefaultArtifact(groupId, artifactId, "pom",
+                               version);
+               String pom = MavenConventionsUtils.artifactsAsDependencyPom(artifact,
+                               artifacts, null);
+               Node node = RepoUtils.copyBytesAsArtifact(
+                               session.getNode(artifactBasePath), artifact, pom.getBytes());
+               addMavenChecksums(node);
+               return artifact;
+       }
+
+       /** Add files containing the SHA-1 and MD5 checksums. */
+       public static void addMavenChecksums(Node node) throws RepositoryException {
+               // TODO optimize
+               String sha = JcrUtils.checksumFile(node, "SHA-1");
+               JcrUtils.copyBytesAsFile(node.getParent(), node.getName() + ".sha1",
+                               sha.getBytes());
+               String md5 = JcrUtils.checksumFile(node, "MD5");
+               JcrUtils.copyBytesAsFile(node.getParent(), node.getName() + ".md5",
+                               md5.getBytes());
+       }
+
+       /**
+        * Custom copy since the one in commons does not fit the needs when copying
+        * a workspace completely.
+        */
+       public static void copy(Node fromNode, Node toNode) {
+               copy(fromNode, toNode, null);
+       }
+
+       public static void copy(Node fromNode, Node toNode, JcrMonitor monitor) {
+               try {
+                       String fromPath = fromNode.getPath();
+                       if (monitor != null)
+                               monitor.subTask("copying node :" + fromPath);
+                       if (log.isDebugEnabled())
+                               log.debug("copy node :" + fromPath);
+
+                       // FIXME : small hack to enable specific workspace copy
+                       if (fromNode.isNodeType("rep:ACL")
+                                       || fromNode.isNodeType("rep:system")) {
+                               if (log.isTraceEnabled())
+                                       log.trace("node " + fromNode + " skipped");
+                               return;
+                       }
+
+                       // add mixins
+                       for (NodeType mixinType : fromNode.getMixinNodeTypes()) {
+                               toNode.addMixin(mixinType.getName());
+                       }
+
+                       // Double check
+                       for (NodeType mixinType : toNode.getMixinNodeTypes()) {
+                               if (log.isDebugEnabled())
+                                       log.debug(mixinType.getName());
+                       }
+
+                       // process properties
+                       PropertyIterator pit = fromNode.getProperties();
+                       properties: while (pit.hasNext()) {
+                               Property fromProperty = pit.nextProperty();
+                               String propName = fromProperty.getName();
+                               try {
+                                       String propertyName = fromProperty.getName();
+                                       if (toNode.hasProperty(propertyName)
+                                                       && toNode.getProperty(propertyName).getDefinition()
+                                                                       .isProtected())
+                                               continue properties;
+
+                                       if (fromProperty.getDefinition().isProtected())
+                                               continue properties;
+
+                                       if (propertyName.equals("jcr:created")
+                                                       || propertyName.equals("jcr:createdBy")
+                                                       || propertyName.equals("jcr:lastModified")
+                                                       || propertyName.equals("jcr:lastModifiedBy"))
+                                               continue properties;
+
+                                       if (fromProperty.isMultiple()) {
+                                               toNode.setProperty(propertyName,
+                                                               fromProperty.getValues());
+                                       } else {
+                                               toNode.setProperty(propertyName,
+                                                               fromProperty.getValue());
+                                       }
+                               } catch (RepositoryException e) {
+                                       throw new SlcException("Cannot property " + propName, e);
+                               }
+                       }
+
+                       // recursively process children nodes
+                       NodeIterator nit = fromNode.getNodes();
+                       while (nit.hasNext()) {
+                               Node fromChild = nit.nextNode();
+                               Integer index = fromChild.getIndex();
+                               String nodeRelPath = fromChild.getName() + "[" + index + "]";
+                               Node toChild;
+                               if (toNode.hasNode(nodeRelPath))
+                                       toChild = toNode.getNode(nodeRelPath);
+                               else
+                                       toChild = toNode.addNode(fromChild.getName(), fromChild
+                                                       .getPrimaryNodeType().getName());
+                               copy(fromChild, toChild);
+                       }
+
+                       // update jcr:lastModified and jcr:lastModifiedBy in toNode in
+                       // case
+                       // they existed
+                       if (!toNode.getDefinition().isProtected()
+                                       && toNode.isNodeType(NodeType.MIX_LAST_MODIFIED))
+                               JcrUtils.updateLastModified(toNode);
+
+                       // Workaround to reduce session size: artifact is a saveable
+                       // unity
+                       if (toNode.isNodeType(SlcTypes.SLC_ARTIFACT))
+                               toNode.getSession().save();
+
+                       if (monitor != null)
+                               monitor.worked(1);
+
+               } catch (RepositoryException e) {
+                       throw new SlcException("Cannot copy " + fromNode + " to " + toNode, e);
+               }
+       }
+
+}