
com.telenav.cactus.maven.BranchCleanupMojo Maven / Gradle / Ivy
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// © 2011-2022 Telenav, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
package com.telenav.cactus.maven;
import com.telenav.cactus.cli.ProcessFailedException;
import com.telenav.cactus.git.Branches;
import com.telenav.cactus.git.Branches.Branch;
import com.telenav.cactus.git.GitCheckout;
import com.telenav.cactus.maven.log.BuildLog;
import com.telenav.cactus.maven.mojobase.BaseMojoGoal;
import com.telenav.cactus.maven.mojobase.ScopedCheckoutsMojo;
import com.telenav.cactus.tasks.TaskSet;
import com.telenav.cactus.maven.tree.ProjectTree;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.concurrent.CompletionException;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import static com.telenav.cactus.tasks.TaskSet.newTaskSet;
import static java.util.Arrays.asList;
import static java.util.Collections.emptySet;
import static java.util.Collections.sort;
import static java.util.Collections.unmodifiableSet;
import static org.apache.maven.plugins.annotations.InstantiationStrategy.SINGLETON;
import static org.apache.maven.plugins.annotations.LifecyclePhase.VALIDATE;
import static org.apache.maven.plugins.annotations.ResolutionScope.NONE;
/**
* Cleans up remote branches which have been merged with one of a list of "safe"
* remote branches, where the remote branch's name is not one of the safe
* branches, and not in a list of "protected" branches.
*
* This is useful for cleaning up already merged, defunct feature or bugfix
* branches or similar.
*
* The following branch names are hard coded to be "protected" and will
* never
* be deleted by this mojo:
*
*
* - master
* - develop
* - stable
* - release/current
* - Any branch whose name starts with
release/
*
*
* Branches are only deleted from the default remote, in the case that
* remotes are set up for more than one.
*
*
* Local branch clean up is also possible, deleting local branches that have no
* corresponding remote, and whose head commit exists on one or another safe
* branch on the remote.
*
*
* @author Tim Boudreau
*/
@org.apache.maven.plugins.annotations.Mojo(
defaultPhase = VALIDATE,
requiresDependencyResolution = NONE,
instantiationStrategy = SINGLETON,
name = "remote-branch-cleanup", threadSafe = true)
@BaseMojoGoal("remote-branch-cleanup")
public class BranchCleanupMojo extends ScopedCheckoutsMojo
{
private static final Set ALWAYS_PROTECTED
= unmodifiableSet(new HashSet<>(asList("master", "develop",
"stable", "release/current", "publish")));
/**
* Comma delimited list of branches which should not be deleted, no matter
* what.
*/
@Parameter(property = "cactus.protected-branches", required = false)
private String protectedBranches;
/**
* List of regular expressions.
*/
@Parameter(property = "cactus.protected-branch-patterns", required = false)
private List protectedPatterns;
/**
* Comma delimited list of branches which developers merge down to. If one
* of these contains the head commit of a remote branch, it is considered
* safe to delete it.
*/
@Parameter(property = "cactus.safe-branches",
defaultValue = "develop,release/current,publish")
private String safeBranches;
/**
* List of regular expressions.
*/
@Parameter(property = "cactus.safe-branch-patterns",
defaultValue = "develop,release/current,publish")
private List safePatterns;
/**
* Because this mojo could wreak quite a bit of havoc if used carelessly, a
* reminder property that must be explicitly set to true, or this mojo stays
* in "pretend" mode.
*/
@Parameter(property = "cactus.i-understand-the-risks")
private boolean acknowledged;
/**
* If true (the default), delete remote branches (regardless of whether
* there is a corresponding local branch).
*/
@Parameter(property = "cactus.cleanup-remote", defaultValue = "true")
private boolean cleanupRemote;
/**
* If true (the default), delete local branches that DO NOT have a
* corresponding remote branch, where those branches head commit exists in a
* safe branch. This is useful for cleaning up extraneous local temporary
* branches. Will never delete the branch the working tree is currently on,
* or any branch with the name of a safe or protected branch.
*/
@Parameter(property = "cactus.cleanup-local", defaultValue = "true")
private boolean cleanupLocal;
@Override
protected void onValidateParameters(BuildLog log, MavenProject project)
throws Exception
{
Set safe = safeBranches();
if (safe.isEmpty())
{
fail("Will not delete all remote branches");
}
safe.forEach(branch -> validateBranchName(branch, false));
if (!cleanupRemote && !cleanupLocal)
{
log.warn("Both cactus.cleanup-remote and cactus.cleanup-local are "
+ "false. Nothing will be done.");
}
protectedPatterns();
safePatterns();
}
@Override
protected void execute(BuildLog log, MavenProject project,
GitCheckout myCheckout, ProjectTree tree,
List checkouts) throws Exception
{
if (!acknowledged)
{
log.warn(
"cactus.i-understand-the-risks not set - running in "
+ "pretend-mode. No branches will actually be deleted");
}
TaskSet remoteTasks = newTaskSet(log);
Predicate protectedBranchFilter = protectedBranchFilter();
Predicate safeBranchFilter = safeBranchFilter();
log.debug(protectedBranchFilter::toString);
log.debug(safeBranchFilter::toString);
if (cleanupRemote)
{
collectRemoteBranchesForCleanup(checkouts, protectedBranchFilter,
safeBranchFilter, tree, log, remoteTasks);
}
boolean hadTasks = !remoteTasks.isEmpty();
remoteTasks.execute();
if (hadTasks && acknowledged && !isPretend())
{
for (GitCheckout checkout : checkouts)
{
log.info(
"Refresh remote branches after making changes for "
+ checkout.loggingName());
tree.invalidateBranches(checkout);
checkout.updateRemoteHeads();
checkout.fetchPruningDefunctLocalRecordsOfRemoteBranches();
}
}
// Deleting remote branches can obsolete some local branches that
// were not obsolete before, so only collect local branches after
// we have really deleted the remote branches that may correspond
TaskSet localTasks = newTaskSet(log);
if (cleanupLocal)
{
collectLocalBranchesForCleanup(checkouts, protectedBranchFilter,
safeBranchFilter, tree, log, localTasks);
}
hadTasks |= !localTasks.isEmpty();
localTasks.execute();
if (!hadTasks)
{
log.info("Nothing to do");
}
else
{
// The tree is shared, so clear its branch cache
tree.invalidateCache();
}
}
public void collectRemoteBranchesForCleanup(List checkouts,
Predicate protectedBranchFilter,
Predicate safeBranchNames,
ProjectTree tree,
BuildLog log1, TaskSet tasks)
{
collectRemoteBranches(checkouts, protectedBranchFilter, safeBranchNames,
tree,
(candidates) ->
{
if (candidates.isEmpty())
{
log1.info("No branches needing cleanup.");
return;
}
Set operateOn = filterToBranchesAlreadyMergedToSafeBranches(
candidates,
safeBranchNames,
tree, log1);
if (operateOn.isEmpty())
{
log1.info(
"All candidates contain commits not on a safe branch.");
return;
}
// So we log and work in a repeatable way
List sorted = new ArrayList<>(operateOn);
sort(sorted);
sorted.forEach(candidate ->
{
tasks.add("Delete " + candidate, () ->
{
if (acknowledged)
{
ifNotPretending(() -> candidate.deleteBranch(
tree,
log1));
}
});
});
});
}
public void collectLocalBranchesForCleanup(List checkouts,
Predicate protectedBranchFilter,
Predicate safeBranchNames,
ProjectTree tree,
BuildLog log, TaskSet tasks)
{
collectLocalBranches(checkouts, protectedBranchFilter, safeBranchNames,
tree, localBranches ->
{
localBranches.forEach((branch, candidates) ->
{
candidates.forEach(checkoutAndHead ->
{
Branches branches = tree.branches(checkoutAndHead.checkout);
Optional opt = branches.currentBranch();
boolean canDelete;
if (!opt.isPresent())
{
canDelete = true;
}
else
{
canDelete = !opt.get().name().equals(
checkoutAndHead.branch.name());
if (!canDelete)
{
log.info(
"Will not delete local branch " + checkoutAndHead
+ " because it is the current branch in the working tree.");
}
}
if (canDelete)
{
log.info(
"Will delete local branch " + checkoutAndHead.branch
.name() + " in "
+ checkoutAndHead.checkout.loggingName());
tasks.add(
"Delete local branch "
+ checkoutAndHead.branch.name() + " in "
+ checkoutAndHead.checkout.loggingName(), () ->
{
try
{
ifNotPretending(() ->
{
checkoutAndHead.checkout.deleteBranch(
checkoutAndHead.branch.name(), null,
false);
});
}
catch (ProcessFailedException | CompletionException ex)
{
log.error(
"Failed to delete " + checkoutAndHead + ": " + ex
.getMessage());
}
});
}
});
});
});
}
private Set filterToBranchesAlreadyMergedToSafeBranches(
Map> candidates,
Predicate safeBranchNames,
ProjectTree tree, BuildLog log)
{
Set result = new HashSet<>();
Set unclean = new HashSet<>();
candidates.forEach((branchName, targets) ->
{
if (!unclean.contains(branchName))
{
targets.forEach(checkoutAndBranch ->
{
Branches containingCommit = checkoutAndBranch
.branchesContainingHead();
boolean added = false;
for (Branch remoteBranch : containingCommit.remoteBranches())
{
if (remoteBranch.isLocal())
{
continue;
}
if (remoteBranch.isSameName(checkoutAndBranch.branch))
{
continue;
}
if (safeBranchNames.test(remoteBranch.name()))
{
log.debug(
() -> "Head " + checkoutAndBranch.head + " of " + checkoutAndBranch
+ " is included in the safe branch " + remoteBranch + " so it is safe to delete.");
result.add(checkoutAndBranch);
added = true;
break;
}
}
if (!added)
{
log.info(
"Will not delete branch '"
+ checkoutAndBranch.branch.trackingName()
+ "' in "
+ checkoutAndBranch.checkout.loggingName()
+ " because no safe branch contains its head commit.");
unclean.add(branchName);
}
});
}
else
{
log.info("Will not delete branch '" + branchName
+ "' because another checkout in the tree of a branch "
+ "with the same name has unpushed commits");
}
});
for (Iterator it = result.iterator(); it.hasNext();)
{
CheckoutAndHead ch = it.next();
if (unclean.contains(ch.branch.name()))
{
log.debug(() -> "Prune " + ch
+ " from deletions because some checkout has unmerged chanegs on it");
it.remove();
}
if (!ch.isFromDefaultRemote())
{
log.info(
"Skipping " + ch + " - it is not from the default remote");
it.remove();
}
}
return result;
}
void collectRemoteBranches(
Collection extends GitCheckout> checkouts,
Predicate protectedBranchFilter,
Predicate safeBranchNames,
ProjectTree tree,
Consumer
© 2015 - 2025 Weber Informatics LLC | Privacy Policy