Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ForceOps.Lib/src/FileAndDirectoryDeleter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ internal void DeleteFile(FileInfo file)
{
try
{
return GetLockingProcessInfos(new[] { file.FullName });
return GetLockingProcessInfos([file.FullName]);
}
catch (Win32Exception e)
{
Expand Down
2 changes: 1 addition & 1 deletion ForceOps.Lib/src/ForceOpsContext/RelaunchHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public static void RunWithRelaunchAsElevated(Action action, Func<List<string>> b
{
var args = buildArgsForRelaunch();
var childOutputFile = GetChildOutputFile();
args.AddRange(new[] { "2>&1", ">", childOutputFile });
args.AddRange(["2>&1", ">", childOutputFile]);
logger.Information($"Unable to perform operation as an unelevated process. Retrying as elevated and logging to \"{childOutputFile}\".");
var childProcessExitCode = forceOpsContext.relaunchAsElevated.RelaunchAsElevated(args, childOutputFile);
if (childProcessExitCode != 0)
Expand Down
51 changes: 34 additions & 17 deletions ForceOps.Test/src/ProgramTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ public void ExceptionThrownIfChildFails()
var testContext = new TestContext();
testContext.forceOpsContext.processKiller = new Mock<IProcessKiller>().Object;

var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath }, testContext.forceOpsContext);
Assert.Equal(1, forceOps.Run());
var forceOps = new ForceOps(["delete", tempDirectoryPath], testContext.forceOpsContext);
Assert.Equal(1, new ForceOpsRunner(forceOps).Run());

Assert.IsType<AggregateException>(forceOps.caughtException);
testContext.relaunchAsElevatedMock.Verify(t => t.RelaunchAsElevated(It.IsAny<List<string>>(), It.IsAny<string>()), Times.Once());
Expand All @@ -39,8 +39,8 @@ public void SuccessfulChildDoesntThrowException()
testContext.relaunchAsElevatedMock.Setup(t => t.RelaunchAsElevated(It.IsAny<List<string>>(), It.IsAny<string>())).Returns(0);
testContext.forceOpsContext.processKiller = new Mock<IProcessKiller>().Object;

var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath }, testContext.forceOpsContext);
Assert.Equal(0, forceOps.Run());
var forceOps = new ForceOps(["delete", tempDirectoryPath], testContext.forceOpsContext);
Assert.Equal(0, new ForceOpsRunner(forceOps).Run());

testContext.relaunchAsElevatedMock.Verify(t => t.RelaunchAsElevated(It.IsAny<List<string>>(), It.IsAny<string>()), Times.Once());
}
Expand All @@ -53,13 +53,30 @@ public void RetryDelayAndMaxRetriesWork()
testContext.relaunchAsElevatedMock.Setup(t => t.RelaunchAsElevated(It.IsAny<List<string>>(), It.IsAny<string>())).Returns(0);
testContext.forceOpsContext.processKiller = new Mock<IProcessKiller>().Object;

var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath, "--retry-delay", "33", "--max-retries", "8" }, testContext.forceOpsContext);
Assert.Equal(0, forceOps.Run());
var forceOps = new ForceOps(["delete", tempDirectoryPath, "--retry-delay", "33", "--max-retries", "8"], testContext.forceOpsContext);
Assert.Equal(0, new ForceOpsRunner(forceOps).Run());

testContext.relaunchAsElevatedMock.Verify(t => t.RelaunchAsElevated(It.IsAny<List<string>>(), It.IsAny<string>()), Times.Once());
Assert.Contains("Beginning retry 1/8 in 33ms.", testContext.fakeLoggerFactory.GetAllLogsString());
}

[Fact]
public void DeleteMultipleFiles()
{
Directory.CreateDirectory(tempDirectoryPath);
var file1 = Path.Join(tempDirectoryPath, "file1");
var file2 = Path.Join(tempDirectoryPath, "file2");
File.Create(file1).Close();
File.Create(file2).Close();
Assert.True(File.Exists(file1), "file1 should exist");
Assert.True(File.Exists(file2), "file2 should exist");

var forceOps = new ForceOps(["delete", file1, file2]);
Assert.Equal(0, new ForceOpsRunner(forceOps).Run());
Assert.False(File.Exists(file1), "file1 should be deleted");
Assert.False(File.Exists(file2), "file2 should be deleted");
}

[Fact]
public void ExceptionThrownIfAlreadyElevated()
{
Expand All @@ -68,8 +85,8 @@ public void ExceptionThrownIfAlreadyElevated()
testContext.forceOpsContext.processKiller = new Mock<IProcessKiller>().Object;
testContext.elevateUtilsMock.Setup(t => t.IsProcessElevated()).Returns(true);

var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath }, testContext.forceOpsContext);
Assert.Equal(1, forceOps.Run());
var forceOps = new ForceOps(["delete", tempDirectoryPath], testContext.forceOpsContext);
Assert.Equal(1, new ForceOpsRunner(forceOps).Run());

Assert.IsType<IOException>(forceOps.caughtException);
testContext.relaunchAsElevatedMock.Verify(t => t.RelaunchAsElevated(It.IsAny<List<string>>(), It.IsAny<string>()), Times.Never());
Expand All @@ -84,9 +101,9 @@ public void RelaunchedProgramWorks()

string exeNameOverride = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "forceops.exe");
testContext.forceOpsContext.relaunchAsElevated = new RelaunchAsElevated() { verb = "", exeNameOverride = exeNameOverride };
var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath }, testContext.forceOpsContext);
var forceOps = new ForceOps(["delete", tempDirectoryPath], testContext.forceOpsContext);
var stdoutString = GetStdoutString(stdoutStringBuilder);
Assert.True(0 == forceOps.Run(), BuildFailMessage(testContext, forceOps, stdoutString));
Assert.True(0 == new ForceOpsRunner(forceOps).Run(), BuildFailMessage(testContext, forceOps, stdoutString));
Assert.True(!Directory.Exists(tempDirectoryPath), "Deleted by relaunch");
Assert.Contains("Unable to perform operation as an unelevated process. Retrying as elevated and logging to", testContext.fakeLoggerFactory.GetAllLogsString());
}
Expand Down Expand Up @@ -148,10 +165,10 @@ public void RelaunchedProgramUsesForceDelete()

string exeNameOverride = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "forceops.exe");
testContext.forceOpsContext.relaunchAsElevated = new RelaunchAsElevated() { verb = "", exeNameOverride = exeNameOverride };
var forceOps = new ForceOps(new[] { "delete", pathThatCanBeDeleted, tempDirectoryPath }, testContext.forceOpsContext);
var forceOps = new ForceOps(["delete", pathThatCanBeDeleted, tempDirectoryPath], testContext.forceOpsContext);
forceOps.extraRelaunchArgs = new List<string>() { "--disable-elevate" };
var stdoutString = GetStdoutString(stdoutStringBuilder);
Assert.True(0 == forceOps.Run(), BuildFailMessage(testContext, forceOps, stdoutString));
Assert.True(0 == new ForceOpsRunner(forceOps).Run(), BuildFailMessage(testContext, forceOps, stdoutString));
Assert.True(!Directory.Exists(tempDirectoryPath), "Deleted by relaunch");
Assert.Contains("Unable to perform operation as an unelevated process. Retrying as elevated and logging to", testContext.fakeLoggerFactory.GetAllLogsString());
}
Expand All @@ -160,8 +177,8 @@ public void RelaunchedProgramUsesForceDelete()
public void DeleteNonExistingFileThrowsMessage()
{
var testContext = new TestContext();
var forceOps = new ForceOps(new[] { "delete", @"C:\C:\C:\" }, testContext.forceOpsContext);
forceOps.Run();
var forceOps = new ForceOps(["delete", @"C:\C:\C:\"], testContext.forceOpsContext);
new ForceOpsRunner(forceOps).Run();
Assert.Equal(ExitCode.FileNotFound, testContext.friendlyExitCode);
Assert.Equal(@"Cannot remove 'C:\C:\C:\'. No such file or directory", testContext.friendlyExitMessage);
}
Expand All @@ -170,8 +187,8 @@ public void DeleteNonExistingFileThrowsMessage()
public void ListNonExistingFileThrowsMessage()
{
var testContext = new TestContext();
var forceOps = new ForceOps(new[] { "list", @"C:\C:\C:\" }, testContext.forceOpsContext);
forceOps.Run();
var forceOps = new ForceOps(["list", @"C:\C:\C:\"], testContext.forceOpsContext);
new ForceOpsRunner(forceOps).Run();
Assert.Equal(ExitCode.FileNotFound, testContext.friendlyExitCode);
Assert.Equal(@"Cannot list locks of 'C:\C:\C:\'. No such file or directory", testContext.friendlyExitMessage);
}
Expand Down Expand Up @@ -206,7 +223,7 @@ public void RmGetListThrowsError5()
fileInfo.SetAccessControl(fileSecurity);

var forceOps = new ForceOps(["delete", temporaryFile], testContext.forceOpsContext);
forceOps.Run();
new ForceOpsRunner(forceOps).Run();

Assert.True(File.Exists(temporaryFile), "Deleting the file should fail");
Assert.Contains("Unable to perform operation as an unelevated process. Retrying as elevated and logging to", testContext.fakeLoggerFactory.GetAllLogsString());
Expand Down
86 changes: 6 additions & 80 deletions ForceOps/src/ForceOps.cs
Original file line number Diff line number Diff line change
@@ -1,87 +1,24 @@
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
using ForceOps.Lib;
using ForceOps.Lib;
using Serilog;

namespace ForceOps;

internal class ForceOps
{
readonly internal ForceOpsContext forceOpsContext = new();
internal readonly string[] args;
internal readonly ForceOpsContext forceOpsContext = new();
readonly ILogger logger;
readonly string[] args;
internal Exception? caughtException = null;
internal List<string>? extraRelaunchArgs = null;

public ForceOps(string[] args, ForceOpsContext? forceOpsContext = null, ILogger? logger = null)
{
this.args = args;
this.forceOpsContext = forceOpsContext ?? new ForceOpsContext();
this.logger = logger ?? this.forceOpsContext.loggerFactory.CreateLogger<ForceOps>();
this.args = args;
}

public int Run()
{
var rootCommand = new RootCommand("By hook or by crook, perform operations on files and directories. If they are in use by a process, kill the process.")
{
Name = "forceops"
};
rootCommand.AddCommand(CreateDeleteCommand());
rootCommand.AddCommand(CreateListCommand());

var parser = new CommandLineBuilder(rootCommand)
.UseDefaults()
.AddMiddleware(async (context, next) =>
{
try
{
await next(context);
}
catch (Exception ex)
{
caughtException = ex;

if (ex is FileNotFoundException fileNotFoundEx)
{
forceOpsContext.environmentExit.Exit((int)ExitCode.FileNotFound, ex.Message);
}
throw;
}
})
.Build();

return parser.Invoke(args);
}

Command CreateDeleteCommand()
{
var filesToDeleteArgument = new Argument<string[]>("files", "Files or directories to delete.")
{
Arity = ArgumentArity.OneOrMore
};

var forceOption = new Option<bool>(new[] { "-f", "--force" }, "Ignore nonexistent files and arguments.");
var disableElevate = new Option<bool>(new[] { "-e", "--disable-elevate" }, "Do not attempt to elevate if the file can't be deleted.");
var retryDelay = new Option<int>(new[] { "-d", "--retry-delay" }, () => 50, "Delay when retrying to delete a file, after deleting processes holding a lock.");
var maxRetries = new Option<int>(new[] { "-n", "--max-retries" }, () => 10, "Number of retries when deleting a locked file.");

var deleteCommand = new Command("delete", "Delete files or a directories recursively.")
{
filesToDeleteArgument,
forceOption,
disableElevate,
retryDelay,
maxRetries
};

deleteCommand.AddAlias("rm");
deleteCommand.AddAlias("remove");
deleteCommand.SetHandler(DeleteCommand, filesToDeleteArgument, forceOption, disableElevate, retryDelay, maxRetries);
return deleteCommand;
}

void DeleteCommand(string[] filesOrDirectoriesToDelete, bool force, bool disableElevate, int retryDelay, int maxRetries)
public void DeleteCommand(string[] filesOrDirectoriesToDelete, bool force, bool disableElevate, int retryDelay, int maxRetries)
{
RelaunchHelpers.RunWithRelaunchAsElevated(() =>
{
Expand All @@ -96,18 +33,7 @@ void DeleteCommand(string[] filesOrDirectoriesToDelete, bool force, bool disable
}, BuildArgsForRelaunch, forceOpsContext, logger, disableElevate);
}

Command CreateListCommand()
{
var fileOrDirectoryArgument = new Argument<string>("fileOrDirectory", "File or directory to get the locks of.");
var listCommand = new Command("list", "Uses LockCheck to output processes using a file or directory.")
{
fileOrDirectoryArgument
};
listCommand.SetHandler(ListCommand, fileOrDirectoryArgument);
return listCommand;
}

void ListCommand(string fileOrDirectory)
public void ListCommand(string fileOrDirectory)
{
new ListFileOrDirectoryLocks(forceOpsContext).PrintLocks(fileOrDirectory);
}
Expand Down
88 changes: 84 additions & 4 deletions ForceOps/src/Program.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,92 @@
using System.Runtime.Versioning;
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
using System.Runtime.Versioning;

namespace ForceOps;

[SupportedOSPlatform("windows")]
public class Program
{
public static int Main(string[] args)
public static int Main(string[] args) => new ForceOpsRunner(new ForceOps(args)).Run();
}

internal class ForceOpsRunner
{
readonly ForceOps _forceOps;
public ForceOpsRunner(ForceOps forceOps)
{
_forceOps = forceOps;
}

internal int Run()
{
var rootCommand = new RootCommand("By hook or by crook, perform operations on files and directories. If they are in use by a process, kill the process.")
{
Name = "forceops"
};
rootCommand.AddCommand(CreateDeleteCommand());
rootCommand.AddCommand(CreateListCommand());

var parser = new CommandLineBuilder(rootCommand)
.UseDefaults()
.AddMiddleware(async (context, next) =>
{
try
{
await next(context);
}
catch (Exception ex)
{
_forceOps.caughtException = ex;

if (ex is FileNotFoundException fileNotFoundEx)
{
_forceOps.forceOpsContext.environmentExit.Exit((int)ExitCode.FileNotFound, ex.Message);
}
throw;
}
})
.Build();

return parser.Invoke(_forceOps.args);
}

Command CreateDeleteCommand()
{
var filesToDeleteArgument = new Argument<string[]>("files", "Files or directories to delete.")
{
Arity = ArgumentArity.OneOrMore
};

var forceOption = new Option<bool>(["-f", "--force"], "Ignore nonexistent files and arguments.");
var disableElevate = new Option<bool>(["-e", "--disable-elevate"], "Do not attempt to elevate if the file can't be deleted.");
var retryDelay = new Option<int>(["-d", "--retry-delay"], () => 50, "Delay when retrying to delete a file, after deleting processes holding a lock.");
var maxRetries = new Option<int>(["-n", "--max-retries"], () => 10, "Number of retries when deleting a locked file.");

var deleteCommand = new Command("delete", "Delete files or a directories recursively.")
{
filesToDeleteArgument,
forceOption,
disableElevate,
retryDelay,
maxRetries
};

deleteCommand.AddAlias("rm");
deleteCommand.AddAlias("remove");
deleteCommand.SetHandler(_forceOps.DeleteCommand, filesToDeleteArgument, forceOption, disableElevate, retryDelay, maxRetries);
return deleteCommand;
}

Command CreateListCommand()
{
return new ForceOps(args).Run();
var fileOrDirectoryArgument = new Argument<string>("fileOrDirectory", "File or directory to get the locks of.");
var listCommand = new Command("list", "Uses LockCheck to output processes using a file or directory.")
{
fileOrDirectoryArgument
};
listCommand.SetHandler(_forceOps.ListCommand, fileOrDirectoryArgument);
return listCommand;
}
}
}
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,31 @@ Uses [LockChecker](https://github.com/domsleee/LockCheck) to find processes lock

## Installation

### Install with [`scoop`](http://scoop.sh/) (recommended)
It has a faster startup because it uses [Native AOT](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/):

```shell
dotnet tool install -g forceops
scoop install https://gist.github.com/domsleee/f765105f512ec607ee0a6e3ee5debd6d/raw/forceops.json
```

To update:
```shell
scoop update forceops --force
```
dotnet tool update -g forceops

### Alternative - install with [`dotnet`](https://dotnet.microsoft.com/en-us/download)

```shell
dotnet tool install -g forceops
```

Alternatively, the executable is available for download in [the latest release]([releases](https://github.com/domsleee/ForceOps/releases/atest)).
To update:
```shell
dotnet tool update -g forceops
```

### Alternative - install from releases
Download the latest exe from [releases](https://github.com/domsleee/ForceOps/releases).

## Usage: As a CLI
### Deleting when a process owned by the current user is using it
Expand Down
2 changes: 1 addition & 1 deletion installLocal.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ pwsh -NoProfile -Command {
{
dotnet tool uninstall -g ForceOps
}
dotnet tool install -g ForceOps --prerelease --configfile "../scripts/LocalNuGet.config" --no-cache
dotnet tool install -g ForceOps --configfile "../scripts/LocalNuGet.config" --no-cache
}
Loading