restructure for ease of use from command line

This commit is contained in:
2024-01-24 14:14:57 -06:00
parent b07bb15d6d
commit a3580ffc16
13 changed files with 462 additions and 269 deletions

View File

@@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.8.34309.116
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "repo-backup-util", "repo-backup-util\repo-backup-util.csproj", "{352652F3-958B-416B-B5F5-CDC36B0AA0C4}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "git-anchor", "repo-backup-util\git-anchor.csproj", "{352652F3-958B-416B-B5F5-CDC36B0AA0C4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@@ -0,0 +1,10 @@
using System.Diagnostics;
namespace GitAnchor.Actions;
public abstract class BaseAction {
public virtual async Task<ActivityStatusCode> Run()
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,107 @@
using GitAnchor.Lib;
using Octokit;
using System.Diagnostics;
namespace GitAnchor.Actions;
public class Create : BaseAction
{
public new static async Task<ActivityStatusCode> Run()
{
string[] args = Environment.GetCommandLineArgs();
string? backupName = args.FirstOrDefault(arg => arg.StartsWith("--name"));
string? token = args.FirstOrDefault(arg => arg.StartsWith("--token"));
bool verbose = args.Any(arg => arg.StartsWith("--verbose")
|| arg.StartsWith("-v"));
// find or configure the default backup directory
DirectoryInfo backupDir = FileSystemTools.SetUpMainBackupFile() ?? throw new Exception("Encountered a problem creating main backup directory");
// create backup file
DirectoryInfo? anchorDir = SetUpAnchor(backupDir, backupName);
if (anchorDir == null)
{
Console.WriteLine("Could not create anchor directory.");
return ActivityStatusCode.Error;
}
// authenticate with github
GitHubClient github = GitHub.CreateClient();
token = GitHub.Authenticate(github, token);
// load accessible repositories
IEnumerable<Repository> repos = await GitHub.GetRepos(github) ?? throw new Exception("No repos found");
// organize and ensure all repositories are unique
HashSet<Repository> uniqueRepos = GitHub.GetUniqueRepos(repos);
if (verbose)
{
// derive repository count and clone urls
var privateRepos = uniqueRepos.Where(r => r.Private);
var publicRepos = uniqueRepos.Where(r => !r.Private);
var cloneUrls = uniqueRepos.Select(r => r.CloneUrl);
Console.WriteLine($"Found {privateRepos.Count()} private repos and {publicRepos.Count()} public repos.");
}
// set up, update directories for hosting projects
HashSet<Repository> newProjects = Volumes.PopulateBackupDirectories(uniqueRepos, anchorDir);
// identify all work to be done and prepare as tasks
List<Task> taskPool = [];
foreach (Repository newProject in newProjects)
{
GitHub.CloneProjectOptions options = new()
{
BackupDir = anchorDir,
Repo = newProject,
Token = token,
User = github.User.Current().Result,
};
taskPool.Add(new Task(() => GitHub.CloneProject(options)));
}
Console.WriteLine($"Preparing to clone {taskPool.Count} repositories. Starting...");
// start a timer to track progress
Stopwatch stopwatch = new();
stopwatch.Start();
// execute all tasks
Parallel.ForEach(taskPool, task => task.Start());
Task.WaitAll([.. taskPool]);
stopwatch.Stop();
long elapsed = stopwatch.ElapsedMilliseconds < 1000
? stopwatch.ElapsedMilliseconds
: stopwatch.ElapsedMilliseconds / 1000;
string unit = stopwatch.ElapsedMilliseconds < 1000 ? "ms" : "s";
Console.WriteLine($"All tasks completed in {elapsed}{unit}. Exiting...");
return ActivityStatusCode.Ok;
}
private static DirectoryInfo? SetUpAnchor(DirectoryInfo backupDir, string? initialInput)
{
string defaultName = FileSystemTools.DefaultAnchorPath;
string? backupName = initialInput;
if (backupName == null)
{
Console.WriteLine($"Enter a name for this backup: (default {defaultName})");
backupName = Console.ReadLine();
if (backupName == "") backupName = defaultName;
}
if (backupName == null || backupName == "") throw new Exception("No backup name provided");
// create backup file
return Volumes.CreateAnchor(backupDir, backupName);
}
}

View File

@@ -0,0 +1,33 @@
using System.Diagnostics;
namespace GitAnchor.Actions;
public class Help : BaseAction
{
public static async new Task<ActivityStatusCode> Run()
{
Console.WriteLine("\nGitAnchor - a tool for performing scoped backups of GitHub repositories based on credentials and configuration.");
Console.WriteLine("Usage:\n");
Console.WriteLine("git-anchor create [--verbose, -v] [--name <name>] [--token <token>]");
Console.WriteLine(" Creates a new backup directory and clones all repositories accessible by the provided token.");
Console.WriteLine(" --name <name> The name of the backup directory to create. Defaults to the current date.");
Console.WriteLine(" --token <token> The GitHub token to use for authentication.");
Console.WriteLine(" --verbose Prints verbose output.\n");
Console.WriteLine("Example: git-anchor create -v --name=cool-new-backup --token=(your token)");
Console.WriteLine("git-anchor pull [--name <name>] [--verbose, -v]");
Console.WriteLine(" Pulls all repositories in an existing backup directory.");
Console.WriteLine(" --name <name> The name of the backup directory to pull from. Defaults to the current date.");
Console.WriteLine(" --verbose Prints verbose output.\n");
Console.WriteLine("Example: git-anchor pull -v --name=cool-new-backup");
Console.WriteLine("git-anchor find [--regex, -e] [--name, -n <name>]");
Console.WriteLine(" Finds an existing backup matching the provided pattern.");
Console.WriteLine(" --regex, -e Find your backup directory based on a Regex search.");
Console.WriteLine(" --name, -n The name of the backup directory to pull from. Defaults to the current date.\n\n");
return ActivityStatusCode.Ok;
}
}

View File

@@ -0,0 +1,12 @@
using System.Diagnostics;
namespace GitAnchor.Actions
{
internal class Pull : BaseAction
{
public static async new Task<ActivityStatusCode> Run()
{
throw new NotImplementedException();
}
}
}

View File

@@ -1,82 +1 @@
// See https://aka.ms/new-console-template for more information
using Octokit;
using RepoBackupUtil.Lib;
using System.Diagnostics;
static async Task Main()
{
// initialize our github client
ProductHeaderValue productHeaderValue = new("repo-backup-util");
GitHubClient github = new(productHeaderValue);
// check for user with received input
string token = GitHub.GetCredentialsToken(github);
string username = github.User.Current().Result.Login;
User user = await GitHub.GetUser(username, github);
// get all repos for user
IEnumerable<Repository> repos = await GitHub.GetRepos(github) ?? throw new Exception("No repos found");
// organize and ensure all repositories are unique
HashSet<Repository> uniqueRepos = GitHub.GetUniqueRepos(repos);
// derive repository count and clone urls
var privateRepos = uniqueRepos.Where(r => r.Private);
var publicRepos = uniqueRepos.Where(r => !r.Private);
var cloneUrls = uniqueRepos.Select(r => r.CloneUrl);
Console.WriteLine($"Found {privateRepos.Count()} private repos and {publicRepos.Count()} public repos.");
// create backup directory
DirectoryInfo backupDir = Volumes.SetUpBackupFile() ?? throw new Exception("Error setting up backup directory");
// set up, update directories for hosting projects
HashSet<Repository> newProjects = Volumes.PopulateBackupDirectories(uniqueRepos, backupDir);
IEnumerable<Repository> projectsToUpdate = GitHub.GetProjectsToUpdate(uniqueRepos, backupDir);
Console.WriteLine(projectsToUpdate.Count() + " projects to update.");
Console.WriteLine(newProjects.Count + " new projects to clone.");
// identify all work to be done and prepare as tasks
List<Task> taskPool = [];
foreach (Repository project in projectsToUpdate)
{
taskPool.Add(new Task(() => GitHub.UpdateExistingRepo(project, backupDir)));
}
foreach (Repository newProject in newProjects)
{
ICloneProject options = new GitHub.CloneProjectOptions
{
BackupDir = backupDir,
Repo = newProject,
Token = token,
User = user
};
taskPool.Add(new Task(() => GitHub.CloneProject(options)));
}
Console.WriteLine($"Prepared {taskPool.Count} tasks. Starting...");
// start a timer to track progress
Stopwatch stopwatch = new();
stopwatch.Start();
// execute all tasks
Parallel.ForEach(taskPool, task => task.Start());
Task.WaitAll(taskPool.ToArray());
stopwatch.Stop();
double elapsed = stopwatch.ElapsedMilliseconds < 1000
? stopwatch.ElapsedMilliseconds
: stopwatch.ElapsedMilliseconds / 1000;
string unit = stopwatch.ElapsedMilliseconds < 1000 ? "ms" : "s";
Console.WriteLine($"All tasks completed in {elapsed}{unit}. Exiting...");
}
await Main();
await GitAnchor.Lib.EntryPoint.Run();

View File

@@ -3,9 +3,9 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>RepoBackupUtil</RootNamespace>
<RootNamespace>GitAnchor</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,39 @@
using Octokit;
namespace GitAnchor.Lib;
public static class CommandLineTools {
public static int ReadAsInt()
{
string? input = Console.ReadLine();
while (input == null)
{
input = Console.ReadLine();
}
try
{
return int.Parse(input);
}
catch
{
Console.WriteLine("Invaid input. Please enter a number: ");
return ReadAsInt();
}
}
public static string ReadAccessToken(GitHubClient github)
{
Console.WriteLine("Please enter your Github Personal Access Token: ");
string? token = Console.ReadLine();
if (token == null)
{
Console.WriteLine("Received invalid input. Try again:");
return ReadAccessToken(github);
}
return token;
}
}

View File

@@ -0,0 +1,38 @@
using System.Collections;
using System.Diagnostics;
using GitAnchor.Actions;
namespace GitAnchor.Lib;
public class EntryPoint
{
public static async Task<ActivityStatusCode> Run()
{
string[] args = Environment.GetCommandLineArgs();
SortedDictionary<string, Task<ActivityStatusCode>> options = new()
{
{ "create", Create.Run() },
{ "pull", Pull.Run() },
{ "help", Help.Run() }
};
try
{
foreach (string arg in args)
{
var result = options.TryGetValue(arg, out Task<ActivityStatusCode>? action);
if (!result) continue;
if (action != null) return await action;
}
return await Help.Run();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return ActivityStatusCode.Error;
}
}
}

View File

@@ -0,0 +1,91 @@
namespace GitAnchor.Lib;
public static class FileSystemTools {
public static string DefaultAnchorPath
{
get => DateTime.Now.ToString("Mddy");
}
public static bool MainBackupExists
{
get => MainBackupFile != null;
}
public static bool AnchorExists(string anchorName)
{
return MainBackupExists && Directory.Exists($"{MainBackupFile}/anchorName");
}
public static string? MainBackupFile
{
get => Volumes.FindMainBackupFile();
}
public static DirectoryInfo? SetUpMainBackupFile()
{
IEnumerable<DriveInfo> volumes = Volumes.GetVolumes();
DriveInfo? selection = Volumes.SelectFromList(volumes);
if (selection == null)
{
Console.WriteLine("No selection found");
return null;
}
if (MainBackupFile != null) return new DirectoryInfo(MainBackupFile);
bool isWindows = OperatingSystem.IsWindows();
bool isMacOS = OperatingSystem.IsMacOS();
bool isLinux = OperatingSystem.IsLinux();
if (isWindows)
return CreateMainWindowsDirectory(selection);
else if (isLinux || isMacOS)
return CreateMainUnixDirectory(selection);
else
throw new Exception("Unsupported operating system");
}
private static DirectoryInfo? CreateMainWindowsDirectory(DriveInfo volume, string? location = ".git-anchor")
{
bool isSystemDrive = volume.Name == "C:\\";
try
{
if (isSystemDrive)
{
Console.WriteLine("Using system drive. Directing you to user folder...");
string? userPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
DirectoryInfo userDir = new(userPath);
Console.WriteLine($"Checking for folder `.ghbackups` at `{userDir.FullName}`...");
DirectoryInfo? backupDir = userDir.CreateSubdirectory(".ghbackups");
return backupDir;
}
else
{
Console.WriteLine($"Using {volume.Name}. Directing you to root folder...");
string rootPath = volume.RootDirectory.FullName;
DirectoryInfo rootDir = new(rootPath);
Console.WriteLine($"Checking for folder `.ghbackups` at `{rootDir.FullName}`...");
DirectoryInfo backupDir = Directory.CreateDirectory($"{rootDir.FullName}.ghbackups");
return backupDir;
}
}
catch (Exception e)
{
Console.WriteLine(e.Message);
return null;
}
}
private static DirectoryInfo? CreateMainUnixDirectory(DriveInfo volume)
{
throw new NotImplementedException();
}
}

View File

@@ -1,30 +1,27 @@
using Octokit;
using System.Diagnostics;
namespace RepoBackupUtil.Lib
namespace GitAnchor.Lib
{
public class GitHub
{
public static string GetCredentialsToken(GitHubClient github)
public static GitHubClient CreateClient()
{
Console.WriteLine("Please enter your Github Personal Access Token: ");
string? token = Console.ReadLine();
if (token == null)
{
Console.WriteLine("Received invalid input. Try again:");
return GetCredentialsToken(github);
}
else
{
AuthenticationType authType = AuthenticationType.Bearer;
Credentials auth = new(token, authType);
github.Credentials = auth;
Console.WriteLine("Successfully authenticated with GitHub.");
return token;
}
ProductHeaderValue productHeaderValue = new("git-anchor");
return new(productHeaderValue);
}
public static string Authenticate(GitHubClient github, string? initialToken = null)
{
string token = initialToken ?? CommandLineTools.ReadAccessToken(github);
AuthenticationType authType = AuthenticationType.Bearer;
Credentials auth = new(token, authType);
github.Credentials = auth;
Console.WriteLine("Successfully authenticated with GitHub.");
return token;
}
/** @deprecated */
public static string SetOAuthCredentials(GitHubClient github)
{
Console.WriteLine("Provide OAuth Client Secret: ");
@@ -83,15 +80,17 @@ namespace RepoBackupUtil.Lib
return projectsToUpdate;
}
public record CloneProjectOptions : ICloneProject
public record CloneProjectOptions
{
public User User { get; init; } = default!;
public Repository Repo { get; init; } = default!;
public DirectoryInfo BackupDir { get; init; } = default!;
public string Token { get; init; } = default!;
public string? AnchorPath { get; init; }
public bool Verbose { get; init; } = false;
}
public static void CloneProject(ICloneProject options)
public static void CloneProject(CloneProjectOptions options)
{
string cloneString = "";
cloneString += $"clone https://{options.Token}@github.com/";
@@ -111,14 +110,15 @@ namespace RepoBackupUtil.Lib
};
process.Start();
Console.WriteLine(process.StandardOutput.ReadToEnd());
if (options.Verbose) Console.WriteLine(process.StandardOutput.ReadToEnd());
process.WaitForExit();
Console.WriteLine($"Clone complete for {options.Repo.Name}");
};
}
public static void UpdateExistingRepo(Repository repo, DirectoryInfo backupDir)
public static void PullExistingRepo(Repository repo, DirectoryInfo backupDir, bool verbose = false)
{
Console.WriteLine($"Preparing update task for {repo.Name}...");
Console.WriteLine($"Pulling {repo.Name}...");
using (Process process = new())
{
@@ -132,8 +132,9 @@ namespace RepoBackupUtil.Lib
};
process.Start();
Console.WriteLine(process.StandardOutput.ReadToEnd());
if (verbose) Console.WriteLine(process.StandardOutput.ReadToEnd());
process.WaitForExit();
Console.WriteLine($"Pull complete for {repo.Name}");
};
}
}

View File

@@ -1,10 +0,0 @@
using Octokit;
namespace RepoBackupUtil.Lib;
public interface ICloneProject
{
Repository Repo { get; }
DirectoryInfo BackupDir { get; }
User User { get; }
string Token { get; }
}

View File

@@ -1,163 +1,116 @@
using Octokit;
using System.Collections;
namespace RepoBackupUtil.Lib
namespace GitAnchor.Lib;
public static class Volumes
{
public static class Volumes
public static DriveInfo[] GetVolumes()
{
public static DirectoryInfo? SetUpBackupFile()
try
{
IEnumerable<DriveInfo> volumes = GetVolumes();
DriveInfo? selection = SelectFromList(volumes);
if (selection == null)
{
Console.WriteLine("No selection found");
return null;
}
var backupDir = CreateBackupDirectory(selection);
return backupDir;
return DriveInfo.GetDrives();
}
public static IEnumerable<DriveInfo> GetVolumes()
catch (UnauthorizedAccessException e)
{
try
{
return DriveInfo.GetDrives();
}
catch (Exception e)
{
Console.WriteLine(e.Message);
return [];
}
Console.WriteLine("Unauthorized access to volumes. Please run as administrator.");
Console.WriteLine(e.Message);
return [];
}
private static int ReadInput()
catch (Exception e)
{
string? input = Console.ReadLine();
while (input == null)
{
Console.WriteLine("Please enter a number: ");
input = Console.ReadLine();
}
try
{
return int.Parse(input);
}
catch
{
Console.WriteLine("Invaid input. Please enter a number: ");
return ReadInput();
}
}
private static DriveInfo? GetSelection(Hashtable options)
{
int inputAsInt = ReadInput();
if (!options.ContainsKey(inputAsInt))
{
return null;
}
foreach (DictionaryEntry entry in options)
{
if (entry.Key.Equals(inputAsInt))
{
Console.WriteLine($"value: {entry.Value}");
Console.WriteLine($"type: {entry.Value?.GetType()}");
return entry.Value != null ? (DriveInfo)entry.Value : null;
}
}
return null;
}
public static DriveInfo? SelectFromList(IEnumerable<DriveInfo> volumes)
{
int i = 0;
Hashtable options = [];
// prepare table and present options to user
Console.WriteLine("Select the drive you want to backup to (enter a number): \n");
foreach (DriveInfo volume in volumes)
{
++i;
string option = $"{i}: {volume.Name} ({GetSizeInGB(volume)}GB available)";
Console.WriteLine(option);
options.Add(i, volume);
}
try
{
// parse user input and return appropiate drive info
return GetSelection(options);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
return null;
}
}
public static double GetSizeInGB(DriveInfo volume) => volume.TotalSize / 1024 / 1024 / 1024;
public static DirectoryInfo? CreateBackupDirectory(DriveInfo volume)
{
bool isSystemDrive = volume.DriveType == DriveType.Fixed;
try
{
if (isSystemDrive)
{
Console.WriteLine("Using system drive. Directing you to user folder...");
string? userPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
DirectoryInfo userDir = new(userPath);
Console.WriteLine($"Checking for folder `.ghbackups` at `{userDir.FullName}`...");
DirectoryInfo? backupDir = userDir.CreateSubdirectory(".ghbackups");
return backupDir;
}
else
{
Console.WriteLine($"Using {volume.Name}. Directing you to root folder...");
DirectoryInfo? rootDir = volume.RootDirectory;
Console.WriteLine($"Checking for folder `.ghbackups` at `{rootDir.FullName}`...");
DirectoryInfo? backupDir = rootDir.CreateSubdirectory(".ghbackups");
return backupDir;
}
}
catch (Exception e)
{
Console.WriteLine(e.Message);
return null;
}
}
public static HashSet<Repository> PopulateBackupDirectories(HashSet<Repository> repos, DirectoryInfo backupDir)
{
HashSet<Repository> newProjects = [];
foreach (Repository repo in repos)
{
bool exists = Directory.Exists($"{backupDir.FullName}/{repo.Name}");
bool hasContents = exists && Directory.EnumerateFileSystemEntries(
$"{backupDir.FullName}/{repo.Name}").Any();
if (exists && hasContents) continue;
DirectoryInfo? repoDir = backupDir.CreateSubdirectory(repo.Name);
Console.WriteLine($"Created directory for project {repoDir.FullName}");
newProjects.Add(repo);
}
return newProjects;
Console.WriteLine(e.Message);
return [];
}
}
private static DriveInfo? GetSelectedDrive(Hashtable options)
{
int inputAsInt = CommandLineTools.ReadAsInt();
if (!options.ContainsKey(inputAsInt)) return null;
foreach (DictionaryEntry entry in options)
{
if (entry.Key.Equals(inputAsInt))
{
return entry.Value != null ? (DriveInfo)entry.Value : null;
}
}
return null;
}
public static DriveInfo? SelectFromList(IEnumerable<DriveInfo> volumes)
{
int i = 0;
Hashtable options = [];
// prepare table and present options to user
Console.WriteLine("Select the drive you want to backup to (enter a number): \n");
foreach (DriveInfo volume in volumes)
{
++i;
string option = $"{i}: {volume.Name} ({GetSizeInGB(volume)}GB available)";
Console.WriteLine(option);
options.Add(i, volume);
}
try
{
// parse user input and return appropiate drive info
return GetSelectedDrive(options);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
return null;
}
}
public static DirectoryInfo? CreateAnchor(DirectoryInfo backupDir, string anchorName)
{
return backupDir.CreateSubdirectory(anchorName);
}
public static string? FindMainBackupFile()
{
// find a folder entitled `.ghbackups`
// check the system drive first, then scan for additional drives and check the root of each
var userFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
bool backupExistsInUserFolder = Directory.Exists($"{userFolder}/.ghbackups");
if (backupExistsInUserFolder) return $"{userFolder}/.ghbackups";
DriveInfo[] volumes = GetVolumes();
foreach (DriveInfo volume in volumes)
{
bool backupExistsInRoot = Directory.Exists($"{volume.RootDirectory.FullName}/.ghbackups");
if (backupExistsInRoot) return $"{volume.RootDirectory.FullName}/.ghbackups";
}
return null;
}
public static double GetSizeInGB(DriveInfo volume) => volume.TotalSize / 1024 / 1024 / 1024;
public static HashSet<Repository> PopulateBackupDirectories(HashSet<Repository> repos, DirectoryInfo backupDir)
{
HashSet<Repository> newProjects = [];
foreach (Repository repo in repos)
{
bool exists = Directory.Exists($"{backupDir.FullName}/{repo.Name}");
bool hasContents = exists && Directory.EnumerateFileSystemEntries(
$"{backupDir.FullName}/{repo.Name}").Any();
if (exists && hasContents) continue;
DirectoryInfo? repoDir = backupDir.CreateSubdirectory(repo.Name);
Console.WriteLine($"Created directory for project {repoDir.FullName}");
newProjects.Add(repo);
}
return newProjects;
}
}