Skip to content
Open
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
25 changes: 25 additions & 0 deletions NuGetCleaner.sln
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions NuGetCleaner/NuGetCleaner.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.1</TargetFramework>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should multitarget 3.0


<PackAsTool>true</PackAsTool>
<ToolCommandName>NuGetCleaner</ToolCommandName>
<PackageOutputPath>./nupkg</PackageOutputPath>
<Authors>Christopher Gill</Authors>
<Company>Microsoft</Company>
<Description>Cleans up Global Package Folder by deleting directories unaccessed for a time greater than a specified threshold.</Description>
<PackageProjectUrl>https://github.com/chgill-MSFT/NuGetCleaner</PackageProjectUrl>
<RepositoryUrl>https://github.com/chgill-MSFT/NuGetCleaner</RepositoryUrl>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .nupkg and .snupkg files should probably not be checked in to the repo.

<Version>1.0.8</Version>

<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<SignAssembly>false</SignAssembly>

</PropertyGroup>

<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.6.0" />
<PackageReference Include="NuGet.Configuration" Version="5.2.0" />
</ItemGroup>

</Project>
17 changes: 17 additions & 0 deletions NuGetCleaner/Options.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
166 changes: 166 additions & 0 deletions NuGetCleaner/Program.cs
Original file line number Diff line number Diff line change
@@ -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"))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider:

private static IsWindows(){
            if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
            {
                return true;
            }

            return false;
}

{
Console.WriteLine("This tool is currently only available for Windows, sorry for the inconvenience!\n");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicit line endings are a bit strange. I would recommend either Environment.NewLine or another Console.WriteLine.

return;
}

Parser.Default.ParseArguments<Options>(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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


int Days = options.Days;

if (options.DryRun)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SearchAndPrint and SearchAndDestory are mostly the same, just the body of the inner most loop is different. I'd put that difference in individual methods and have a single Search method that accepts and calls an Action<string>. That reduces duplicate code.

{
SearchAndPrint(gpfPath, Days);
}
else {
SearchAndDestroy(gpfPath, Days);
}
}

public static int CheckDisableLastAccess()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method will only work on Windows, making the entire tool Windows only. If that's the intent, ok, but nothing in the package description makes that obvious, only if they look at the readme they might guess. So I can imagine people on Mac or Linux trying to install and use the tool.

You could work cross platform if you did something similar to the following:

  1. create an empty file (possibly need to contain at least 1 byte)
  2. use File.SetLastAccessFile to some time in the past (possibly need to read the value to make sure it's correct)
  3. read the file
  4. check the last access file, to make sure it's newer than the value you set in 2
  5. delete the file

{
System.Diagnostics.Process process = new System.Diagnostics.Process();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dispose 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice method name 😎

{
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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
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;
}
}
}
Binary file added NuGetCleaner/nupkg/NuGetCleaner.1.0.0.nupkg
Binary file not shown.
Binary file added NuGetCleaner/nupkg/NuGetCleaner.1.0.2.nupkg
Binary file not shown.
Binary file added NuGetCleaner/nupkg/NuGetCleaner.1.0.3.nupkg
Binary file not shown.
Binary file added NuGetCleaner/nupkg/NuGetCleaner.1.0.4.nupkg
Binary file not shown.
Binary file added NuGetCleaner/nupkg/NuGetCleaner.1.0.5.nupkg
Binary file not shown.
Binary file added NuGetCleaner/nupkg/NuGetCleaner.1.0.6.nupkg
Binary file not shown.
Binary file added NuGetCleaner/nupkg/NuGetCleaner.1.0.7.nupkg
Binary file not shown.
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

```
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
```
26 changes: 26 additions & 0 deletions spec.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repo is not in the NuGet org, so I don't think it's appropriate to direct customers to file issues in NuGet/Home.

* 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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who is Karan? :) maybe mention his alias

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or better GitHub username


## 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