diff --git a/NuGetCleaner.sln b/NuGetCleaner.sln new file mode 100644 index 0000000..adc4e1f --- /dev/null +++ b/NuGetCleaner.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29230.47 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NuGetCleaner", "NuGetCleaner\NuGetCleaner.csproj", "{7A5AC6CF-FC9B-4904-811F-20DA4CF2661E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7A5AC6CF-FC9B-4904-811F-20DA4CF2661E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A5AC6CF-FC9B-4904-811F-20DA4CF2661E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A5AC6CF-FC9B-4904-811F-20DA4CF2661E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A5AC6CF-FC9B-4904-811F-20DA4CF2661E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2E908518-7DF3-4925-8998-1332DA0C6B40} + EndGlobalSection +EndGlobal diff --git a/NuGetCleaner/NuGetCleaner.csproj b/NuGetCleaner/NuGetCleaner.csproj new file mode 100644 index 0000000..391b57c --- /dev/null +++ b/NuGetCleaner/NuGetCleaner.csproj @@ -0,0 +1,29 @@ + + + + Exe + netcoreapp2.1 + + true + NuGetCleaner + ./nupkg + Christopher Gill + Microsoft + Cleans up Global Package Folder by deleting directories unaccessed for a time greater than a specified threshold. + https://github.com/chgill-MSFT/NuGetCleaner + https://github.com/chgill-MSFT/NuGetCleaner + true + 1.0.8 + + true + snupkg + false + + + + + + + + + diff --git a/NuGetCleaner/Options.cs b/NuGetCleaner/Options.cs new file mode 100644 index 0000000..fb743ac --- /dev/null +++ b/NuGetCleaner/Options.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using CommandLine; + +namespace NuGetCleaner +{ + // ReSharper disable once ClassNeverInstantiated.Global + public class Options + { + [Option("dry-run", HelpText = "Show packages that would be deleted without actually deleting them.")] + public bool DryRun { get; set; } + + [Option("days", Required = true, HelpText = "Specify cut off for number of days since last package access. \n" + + "Packages last used in the number of days specified or greater will be deleted.")] + public int Days { get; set; } + } +} diff --git a/NuGetCleaner/Program.cs b/NuGetCleaner/Program.cs new file mode 100644 index 0000000..2c42e7d --- /dev/null +++ b/NuGetCleaner/Program.cs @@ -0,0 +1,166 @@ +using System; +using System.Reflection; +using System.IO; +using CommandLine; +using System.ComponentModel; +using NuGet.Configuration; + +namespace NuGetCleaner +{ + class Program + { + + public static void Main(string[] args) + { + var osNameAndVersion = System.Runtime.InteropServices.RuntimeInformation.OSDescription; + if (!osNameAndVersion.Contains("Windows")) + { + Console.WriteLine("This tool is currently only available for Windows, sorry for the inconvenience!\n"); + return; + } + + Parser.Default.ParseArguments(args) + .WithParsed(options => Execute(options)); + } + + public static void Execute(Options options) + { + if (CheckDisableLastAccess() != 2) + { + Console.Write("\nYour Last Access updates are not currently enabled so this tool will not work. \n" + + "To enable Last Access updates, run powershell as administrator and input:\n\n" + + "\tfsutil behavior set disablelastaccess 2\n\n" + + "You may be asked to reboot for the settings change to take effect\n\n" + + "Note: Last usage time of packages will only be tracked once the setting is enabled.\n" + + "As such, last package usage will only be determined from the enable date onward.\n" + + "For further information view documentation at https://github.com/chgill-MSFT/NuGetCleaner \n"); + return; + } + + var settings = Settings.LoadDefaultSettings("."); + var gpfPath = SettingsUtility.GetGlobalPackagesFolder(settings); + + int Days = options.Days; + + if (options.DryRun) + { + SearchAndPrint(gpfPath, Days); + } + else { + SearchAndDestroy(gpfPath, Days); + } + } + + public static int CheckDisableLastAccess() + { + System.Diagnostics.Process process = new System.Diagnostics.Process(); + System.Diagnostics.ProcessStartInfo startInfo = new System.Diagnostics.ProcessStartInfo(); + startInfo.UseShellExecute = false; + startInfo.RedirectStandardOutput = true; + startInfo.FileName = "CMD.exe"; + startInfo.Arguments = "/c fsutil behavior query disablelastaccess"; + process.StartInfo = startInfo; + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + string[] outputArray = output.Split(" "); + int setting = Convert.ToInt32(outputArray[2]); + + return setting; + } + + public static void SearchAndDestroy(string Path, int Days) + { + try + { + Console.WriteLine("Deleted Packages: "); + + foreach (string pkg in Directory.GetDirectories(Path)) + { + foreach (string pkgVersion in Directory.GetDirectories(pkg)) + { + var dirAge = DateTime.Now - RecursiveFindLAT(pkgVersion, DateTime.MinValue); + + if (dirAge.TotalDays >= Days) + { + Console.WriteLine(pkgVersion); + RecursiveDelete(pkgVersion); + + if (Directory.GetDirectories(pkg).Length == 0) + { + Directory.Delete(pkg); + } + } + } + } + } + catch (System.Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + public static void SearchAndPrint(string Path, int Days) + { + try + { + Console.WriteLine("DRY RUN (NOTHING IS DELETED)"); + + Console.WriteLine("Would-Be Deleted Packages: \n"); + + foreach (string pkg in Directory.GetDirectories(Path)) + { + foreach (string pkgVersion in Directory.GetDirectories(pkg)) + { + + var dirAge = DateTime.Now - RecursiveFindLAT(pkgVersion, DateTime.MinValue); + + if (dirAge.TotalDays >= Days) + { + Console.WriteLine(pkgVersion.Substring(Path.Length + 1) + " --- Last Access: " + RecursiveFindLAT(pkgVersion, DateTime.MinValue)); + } + } + } + } + catch (System.Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + public static void RecursiveDelete(string dir) + { + foreach (string subdir in Directory.GetDirectories(dir)) + { + RecursiveDelete(subdir); + } + foreach (string subdir in Directory.GetDirectories(dir)) + { + Directory.Delete(subdir); + } + foreach (string f in Directory.GetFiles(dir)) + { + File.Delete(f); + } + Directory.Delete(dir); + } + + public static DateTime RecursiveFindLAT(string dir, DateTime dt) + { + foreach (string subdir in Directory.GetDirectories(dir)) + { + dt = RecursiveFindLAT(subdir, dt); + } + foreach (string f in Directory.GetFiles(dir)) + { + if (dt < File.GetLastAccessTime(f)) + { + dt = File.GetLastAccessTime(f); + } + } + + return dt; + } + } +} \ No newline at end of file diff --git a/NuGetCleaner/nupkg/NuGetCleaner.1.0.0.nupkg b/NuGetCleaner/nupkg/NuGetCleaner.1.0.0.nupkg new file mode 100644 index 0000000..3ee94b1 Binary files /dev/null and b/NuGetCleaner/nupkg/NuGetCleaner.1.0.0.nupkg differ diff --git a/NuGetCleaner/nupkg/NuGetCleaner.1.0.2.nupkg b/NuGetCleaner/nupkg/NuGetCleaner.1.0.2.nupkg new file mode 100644 index 0000000..278a80c Binary files /dev/null and b/NuGetCleaner/nupkg/NuGetCleaner.1.0.2.nupkg differ diff --git a/NuGetCleaner/nupkg/NuGetCleaner.1.0.3.nupkg b/NuGetCleaner/nupkg/NuGetCleaner.1.0.3.nupkg new file mode 100644 index 0000000..71e2104 Binary files /dev/null and b/NuGetCleaner/nupkg/NuGetCleaner.1.0.3.nupkg differ diff --git a/NuGetCleaner/nupkg/NuGetCleaner.1.0.4.nupkg b/NuGetCleaner/nupkg/NuGetCleaner.1.0.4.nupkg new file mode 100644 index 0000000..6915b50 Binary files /dev/null and b/NuGetCleaner/nupkg/NuGetCleaner.1.0.4.nupkg differ diff --git a/NuGetCleaner/nupkg/NuGetCleaner.1.0.5.nupkg b/NuGetCleaner/nupkg/NuGetCleaner.1.0.5.nupkg new file mode 100644 index 0000000..140e301 Binary files /dev/null and b/NuGetCleaner/nupkg/NuGetCleaner.1.0.5.nupkg differ diff --git a/NuGetCleaner/nupkg/NuGetCleaner.1.0.6.nupkg b/NuGetCleaner/nupkg/NuGetCleaner.1.0.6.nupkg new file mode 100644 index 0000000..dab8f6c Binary files /dev/null and b/NuGetCleaner/nupkg/NuGetCleaner.1.0.6.nupkg differ diff --git a/NuGetCleaner/nupkg/NuGetCleaner.1.0.7.nupkg b/NuGetCleaner/nupkg/NuGetCleaner.1.0.7.nupkg new file mode 100644 index 0000000..61d9e52 Binary files /dev/null and b/NuGetCleaner/nupkg/NuGetCleaner.1.0.7.nupkg differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf2272a --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# NuGetCleaner + +## Description + +The Global Packages Folder is where all packages installed through the NuGet package manager tool on Visual Studios are stored. When packages are no longer in use becuase a user has updated their packages for a project or has moved on to other projects, those unused packages remain in the GPF and continue to take up storage. It is not uncommon for the folder to have several Gigabytes of unnecessary package data after long-term use. + +`nugetcleaner` is the solution! It is a global .NET CLI tool that deletes packages that haven't been used to build a project in a number of days specified by the user. + +## Installation + +`nugetcleaner` is available as a .NET Core Global Tool: + +```bash +dotnet tool install --global NuGetCleaner --version 1.0.7 +``` +The latest version can also be downloaded directly from NuGet.org at: +https://www.nuget.org/packages/NuGetCleaner/ + +## Prerequisites + +`nugetcleaner` makes use of last access timestamp updates which are disabled by default in Windows 7, 8, and 10. In order to use the tool, last access updates must be enabled. + +In order to enable last access updates, run Windows PowerShell or Command Prompt in administrator mode and enter: +```bash +fsutil behavior set disablelastaccess 2 +``` + +You may be prompted to reboot afterwards for the settings change to take effect. + +It should be noted that last access timestamps will only begin to update after the settings change. Therefore, the package last access date will only be tracked from the enable date onward. + +Last access updates were disabled by default starting in Windows Vista in order to increase performance. However, there seems to be little evidence pointing to noticeable performance decreases in more recent Windows OS version when enabling last access updates. However, if you would like to disable last access updates (revert to default) following successfull use of `nugetcleaner`, then run Windows PowerShell or Command Prompt in administrator mode and enter: +```bash +fsutil behavior set disablelastaccess 3 +``` + +Again, you may be prompted to reboot afterwards for the settings change to take effect. + +## How It Works + +`nugetcleaner` works by checking the last access date of all the packages in the global package folder and deleting ones with last access timestamps that exceed the age in days specified by the user. To be clear, it will check each version of a package and delete only the versions that exceed the age. For example, if there are multiple versions in the "newtonsoft.json" folder and only the most recent version has been used within the time constraint, then only the older version will be deleted. The "newtonsoft.json" parent directory will remain with the lastest version. However, if none of the versions were used within the time constraint, then all versions along with the parent folder will be deleted. At the end of the cleaning process, no empty folders will remain. + +#### Use Warning: +If the cleaning process is activated immediately after last access updates enabled, before ongoing projects have been updated or built (thus accessing the relevant packages), then deletion will be determined solely by package installation date as last access timestamps wouldn't have had time to update. If this is an undesirable outcome, then it is highly recommended that the user waits until ongoing/relevant projects have been updated or built with last access timestamps enabled before using `nugetcleaner` to clean out the global package folder. If you're interested in seeing the potential outcome of the cleaning process before going through with it, enable `--dry-run` to see a preview of the packages that would be deleted. + +## Usage + +```bash +nugetcleaner [--dry-run] --days <# of days> +``` + +`--dry-run` - (Optional) List packages that would be deleted given the specified number of days, but don't actually delete them. + +`--days <# of days>` - (Required) Packages last accessed (used in a project build) on the number of days in the past specified or greater are selected for deletion. + +#### Example 1: Delete packages that have not been accessed in 60 or more days. + +```bash +nugetcleaner --days 60 +``` + +#### Example 2: List packages that have not been accessed in 90 or more days, but don't delete them. + +```bash +nugetcleaner --dry-run --days 90 +``` diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..4a9ab35 --- /dev/null +++ b/spec.md @@ -0,0 +1,26 @@ +# Your spec's journey +* Create a new wiki page using the spec template below, to jot your thoughts down. +* Create an [issue](https://github.com/NuGet/Home/issues) to track the feature or link an existing issue to the new wiki page. Engage the community on the feature. Feel free to tweet it from the @NuGet handle or ask the PM to tweet it out. +* Send a mail with a link to the wiki page to the core team alias. +* Campaign offline or in a meeting for the feature :. +* Once it is reviewed and signed off, a Manager or PM will move it to the Reviewed section. + +## Issue +TBD + +## Problem +There is currently no automated process to delete .npkg files from the Global Package Folder that are no longer needed/relevant. This presents a storage ineffiency that can be potentially significant for customers with very large or very many packages that are no longer in use. Customers who experience this problem currently must find the GPS folder themselves and delete the unwanted files manually. +## Who is the customer? +Any .NET developer that installs packages (i.e. every .NET developer). + +## Evidence +At the moment, my evidence is that Karan says he has seen complaints from customers about this issue. Exact evidence TBD. + +## Solution + +The soltuion will come in a form on a downloadable .NET CLI tool. The first version of this tool will be very simple and follow the steps oulined below: + +1. User invokes "clean" command +2. GPS file metadata is checked for most recent read +3. Files with read date > 30 days will be deleted +4. List of deleted files will be printed to console