SubmoduleDeinitCommand.java

/*
 * Copyright (C) 2017, Two Sigma Open Source and others
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Distribution License v. 1.0 which is available at
 * https://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 */
package org.eclipse.jgit.api;

import static org.eclipse.jgit.util.FileUtils.RECURSIVE;

import java.io.File;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;

import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.InvalidConfigurationException;
import org.eclipse.jgit.api.errors.JGitInternalException;
import org.eclipse.jgit.api.errors.NoHeadException;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.ConfigConstants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.submodule.SubmoduleWalk;
import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
import org.eclipse.jgit.treewalk.filter.TreeFilter;
import org.eclipse.jgit.util.FileUtils;

/**
 * A class used to execute a submodule deinit command.
 * <p>
 * This will remove the module(s) from the working tree, but won't affect
 * .git/modules.
 *
 * @since 4.10
 * @see <a href=
 *      "http://www.kernel.org/pub/software/scm/git/docs/git-submodule.html"
 *      >Git documentation about submodules</a>
 */
public class SubmoduleDeinitCommand
		extends GitCommand<Collection<SubmoduleDeinitResult>> {

	private final Collection<String> paths;

	private boolean force;

	/**
	 * Constructor of SubmoduleDeinitCommand
	 *
	 * @param repo
	 */
	public SubmoduleDeinitCommand(Repository repo) {
		super(repo);
		paths = new ArrayList<>();
	}

	/**
	 * {@inheritDoc}
	 * <p>
	 *
	 * @return the set of repositories successfully deinitialized.
	 * @throws NoSuchSubmoduleException
	 *             if any of the submodules which we might want to deinitialize
	 *             don't exist
	 */
	@Override
	public Collection<SubmoduleDeinitResult> call() throws GitAPIException {
		checkCallable();
		try {
			if (paths.isEmpty()) {
				return Collections.emptyList();
			}
			for (String path : paths) {
				if (!submoduleExists(path)) {
					throw new NoSuchSubmoduleException(path);
				}
			}
			List<SubmoduleDeinitResult> results = new ArrayList<>(paths.size());
			try (RevWalk revWalk = new RevWalk(repo);
					SubmoduleWalk generator = SubmoduleWalk.forIndex(repo)) {
				generator.setFilter(PathFilterGroup.createFromStrings(paths));
				StoredConfig config = repo.getConfig();
				while (generator.next()) {
					String path = generator.getPath();
					String name = generator.getModuleName();
					SubmoduleDeinitStatus status = checkDirty(revWalk, path);
					switch (status) {
					case SUCCESS:
						deinit(path);
						break;
					case ALREADY_DEINITIALIZED:
						break;
					case DIRTY:
						if (force) {
							deinit(path);
							status = SubmoduleDeinitStatus.FORCED;
						}
						break;
					default:
						throw new JGitInternalException(MessageFormat.format(
								JGitText.get().unexpectedSubmoduleStatus,
								status));
					}

					config.unsetSection(
							ConfigConstants.CONFIG_SUBMODULE_SECTION, name);
					results.add(new SubmoduleDeinitResult(path, status));
				}
			}
			return results;
		} catch (ConfigInvalidException e) {
			throw new InvalidConfigurationException(e.getMessage(), e);
		} catch (IOException e) {
			throw new JGitInternalException(e.getMessage(), e);
		}
	}

	/**
	 * Recursively delete the *contents* of path, but leave path as an empty
	 * directory
	 *
	 * @param path
	 *            the path to clean
	 * @throws IOException
	 */
	private void deinit(String path) throws IOException {
		File dir = new File(repo.getWorkTree(), path);
		if (!dir.isDirectory()) {
			throw new JGitInternalException(MessageFormat.format(
					JGitText.get().expectedDirectoryNotSubmodule, path));
		}
		final File[] ls = dir.listFiles();
		if (ls != null) {
			for (File f : ls) {
				FileUtils.delete(f, RECURSIVE);
			}
		}
	}

	/**
	 * Check if a submodule is dirty. A submodule is dirty if there are local
	 * changes to the submodule relative to its HEAD, including untracked files.
	 * It is also dirty if the HEAD of the submodule does not match the value in
	 * the parent repo's index or HEAD.
	 *
	 * @param revWalk
	 * @param path
	 * @return status of the command
	 * @throws GitAPIException
	 * @throws IOException
	 */
	private SubmoduleDeinitStatus checkDirty(RevWalk revWalk, String path)
			throws GitAPIException, IOException {
		Ref head = repo.exactRef("HEAD"); //$NON-NLS-1$
		if (head == null) {
			throw new NoHeadException(
					JGitText.get().invalidRepositoryStateNoHead);
		}
		RevCommit headCommit = revWalk.parseCommit(head.getObjectId());
		RevTree tree = headCommit.getTree();

		ObjectId submoduleHead;
		try (SubmoduleWalk w = SubmoduleWalk.forPath(repo, tree, path)) {
			submoduleHead = w.getHead();
			if (submoduleHead == null) {
				// The submodule is not checked out.
				return SubmoduleDeinitStatus.ALREADY_DEINITIALIZED;
			}
			if (!submoduleHead.equals(w.getObjectId())) {
				// The submodule's current HEAD doesn't match the value in the
				// outer repo's HEAD.
				return SubmoduleDeinitStatus.DIRTY;
			}
		}

		try (SubmoduleWalk w = SubmoduleWalk.forIndex(repo)) {
			if (!w.next()) {
				// The submodule does not exist in the index (shouldn't happen
				// since we check this earlier)
				return SubmoduleDeinitStatus.DIRTY;
			}
			if (!submoduleHead.equals(w.getObjectId())) {
				// The submodule's current HEAD doesn't match the value in the
				// outer repo's index.
				return SubmoduleDeinitStatus.DIRTY;
			}

			try (Repository submoduleRepo = w.getRepository()) {
				Status status = Git.wrap(submoduleRepo).status().call();
				return status.isClean() ? SubmoduleDeinitStatus.SUCCESS
						: SubmoduleDeinitStatus.DIRTY;
			}
		}
	}

	/**
	 * Check if this path is a submodule by checking the index, which is what
	 * git submodule deinit checks.
	 *
	 * @param path
	 *            path of the submodule
	 *
	 * @return {@code true} if path exists and is a submodule in index,
	 *         {@code false} otherwise
	 * @throws IOException
	 */
	private boolean submoduleExists(String path) throws IOException {
		TreeFilter filter = PathFilter.create(path);
		try (SubmoduleWalk w = SubmoduleWalk.forIndex(repo)) {
			return w.setFilter(filter).next();
		}
	}

	/**
	 * Add repository-relative submodule path to deinitialize
	 *
	 * @param path
	 *            (with <code>/</code> as separator)
	 * @return this command
	 */
	public SubmoduleDeinitCommand addPath(String path) {
		paths.add(path);
		return this;
	}

	/**
	 * If {@code true}, call() will deinitialize modules with local changes;
	 * else it will refuse to do so.
	 *
	 * @param force
	 * @return {@code this}
	 */
	public SubmoduleDeinitCommand setForce(boolean force) {
		this.force = force;
		return this;
	}

	/**
	 * The user tried to deinitialize a submodule that doesn't exist in the
	 * index.
	 */
	public static class NoSuchSubmoduleException extends GitAPIException {
		private static final long serialVersionUID = 1L;

		/**
		 * Constructor of NoSuchSubmoduleException
		 *
		 * @param path
		 *            path of non-existing submodule
		 */
		public NoSuchSubmoduleException(String path) {
			super(MessageFormat.format(JGitText.get().noSuchSubmodule, path));
		}
	}

	/**
	 * The effect of a submodule deinit command for a given path
	 */
	public enum SubmoduleDeinitStatus {
		/**
		 * The submodule was not initialized in the first place
		 */
		ALREADY_DEINITIALIZED,
		/**
		 * The submodule was deinitialized
		 */
		SUCCESS,
		/**
		 * The submodule had local changes, but was deinitialized successfully
		 */
		FORCED,
		/**
		 * The submodule had local changes and force was false
		 */
		DIRTY,
	}
}