diff --git a/src/Tgstation.Server.Api/Models/Internal/RepositorySettings.cs b/src/Tgstation.Server.Api/Models/Internal/RepositorySettings.cs
index 11917d2f81..35a560f7da 100644
--- a/src/Tgstation.Server.Api/Models/Internal/RepositorySettings.cs
+++ b/src/Tgstation.Server.Api/Models/Internal/RepositorySettings.cs
@@ -41,6 +41,12 @@ namespace Tgstation.Server.Api.Models.Internal
[Permissions(WriteRight = RepositoryRights.ChangeTestMergeCommits)]
public bool PushTestMergeCommits { get; set; }
+ ///
+ /// If test merge commits are signed with the username of the person who merged it. Note this only affects future commits
+ ///
+ [Permissions(WriteRight = RepositoryRights.ChangeTestMergeCommits)]
+ public bool ShowTestMergeCommitters { get; set; }
+
///
/// How often the automatically updates in minutes
///
diff --git a/src/Tgstation.Server.Api/Models/Repository.cs b/src/Tgstation.Server.Api/Models/Repository.cs
index 96ca42c58c..db3ed5b303 100644
--- a/src/Tgstation.Server.Api/Models/Repository.cs
+++ b/src/Tgstation.Server.Api/Models/Repository.cs
@@ -26,6 +26,12 @@ namespace Tgstation.Server.Api.Models
[Permissions(DenyWrite = true)]
public RevisionInformation RevisionInformation { get; set; }
+ ///
+ /// If the repository was cloned from GitHub.com. If this enables test merge functionality
+ ///
+ [Permissions(DenyWrite = true)]
+ public bool IsGitHub { get; set; }
+
///
/// The branch or tag HEAD points to
///
diff --git a/src/Tgstation.Server.Api/Models/TestMergeParameters.cs b/src/Tgstation.Server.Api/Models/TestMergeParameters.cs
index 2c3ba33f02..ed27e6bb08 100644
--- a/src/Tgstation.Server.Api/Models/TestMergeParameters.cs
+++ b/src/Tgstation.Server.Api/Models/TestMergeParameters.cs
@@ -17,5 +17,10 @@ namespace Tgstation.Server.Api.Models
///
[Required]
public string PullRequestRevision { get; set; }
+
+ ///
+ /// Optional comment about the test
+ ///
+ public string Comment { get; set; }
}
}
\ No newline at end of file
diff --git a/src/Tgstation.Server.Host/Components/IRepository.cs b/src/Tgstation.Server.Host/Components/IRepository.cs
new file mode 100644
index 0000000000..e52261b3dc
--- /dev/null
+++ b/src/Tgstation.Server.Host/Components/IRepository.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Tgstation.Server.Host.Components
+{
+ ///
+ /// Represents an on-disk git repository
+ ///
+ interface IRepository : IDisposable
+ {
+ ///
+ /// If the was cloned from GitHub.com
+ ///
+ bool IsGitHubRepository { get; }
+
+ ///
+ /// The SHA of the HEAD
+ ///
+ string Head { get; }
+
+ ///
+ /// The current reference the HEAD is using. This can be a branch or tag
+ ///
+ string Reference { get; }
+
+ ///
+ /// The current origin remote the is using
+ ///
+ string Origin { get; }
+
+ ///
+ /// Checks out a given
+ ///
+ /// The sha or reference to checkout
+ /// The for the operation
+ /// A representing the running operation
+ Task CheckoutObject(string committish, CancellationToken cancellationToken);
+
+ ///
+ /// Attempt to merge a GitHub pull request into HEAD
+ ///
+ /// The pull request number on the remote repository
+ /// The commit in the pull request to merge
+ /// The name of the merge committer
+ /// The e-mail of the merge committer
+ /// The body of the commit message
+ /// The access string to fetch from the origin repository
+ /// The for the operation
+ /// A resulting in the SHA of the new HEAD on success, on merge conflict
+ Task AddTestMerge(int pullRequestNumber, string targetCommit, string committerName, string committerEmail, string commitBody, string accessString, CancellationToken cancellationToken);
+
+ ///
+ /// Fetch commits from the origin repository
+ ///
+ /// The access string to fetch from the origin repository
+ /// The for the operation
+ /// A representing the running operation
+ Task FetchOrigin(string accessString, CancellationToken cancellationToken);
+
+ ///
+ /// Requires the current HEAD to be a tracked reference. Hard resets the reference to what it tracks on the origin repository
+ ///
+ /// The for the operation
+ /// A resulting in the SHA of the new HEAD
+ Task ResetToOrigin(CancellationToken cancellationToken);
+
+ ///
+ /// Force push the current repository HEAD to ;
+ ///
+ /// The access string to fetch from the origin repository
+ /// The for the operation
+ /// A representing the running operation
+ Task PushHeadToTemporaryBranch(string accessString, CancellationToken cancellationToken);
+
+ ///
+ /// Copies the current working directory to a given
+ ///
+ /// The path to copy repository contents to
+ /// The for the operation
+ /// A representing the running operation
+ Task CopyTo(string path, CancellationToken cancellationToken);
+ }
+}
diff --git a/src/Tgstation.Server.Host/Components/IRepositoryManager.cs b/src/Tgstation.Server.Host/Components/IRepositoryManager.cs
new file mode 100644
index 0000000000..63800c9386
--- /dev/null
+++ b/src/Tgstation.Server.Host/Components/IRepositoryManager.cs
@@ -0,0 +1,27 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Tgstation.Server.Host.Components
+{
+ ///
+ /// Factory for creating and loading s
+ ///
+ interface IRepositoryManager
+ {
+ ///
+ /// Attempt to load the from the default location
+ ///
+ /// The for the operation
+ /// The loaded
+ Task LoadRepository(CancellationToken cancellationToken);
+
+ ///
+ /// Delete the current and replaces it with a clone of the repository at
+ ///
+ /// The location of the remote repository to clone
+ /// The access string to clone from
+ /// The for the operation
+ /// The newly cloned
+ Task CloneRepository(string url, string accessString, CancellationToken cancellationToken);
+ }
+}
diff --git a/src/Tgstation.Server.Host/Components/Repository.cs b/src/Tgstation.Server.Host/Components/Repository.cs
new file mode 100644
index 0000000000..994f71b249
--- /dev/null
+++ b/src/Tgstation.Server.Host/Components/Repository.cs
@@ -0,0 +1,223 @@
+using LibGit2Sharp;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Tgstation.Server.Host.Core;
+
+namespace Tgstation.Server.Host.Components
+{
+ ///
+ sealed class Repository : IRepository
+ {
+ ///
+ /// The branch name used for publishing testmerge commits
+ ///
+ public const string RemoteTemporaryBranchName = "___TGSTempBranch";
+
+ ///
+ public bool IsGitHubRepository { get; }
+
+ ///
+ public string Head => repository.Head.Tip.Sha;
+
+ ///
+ public string Reference => repository.Head.FriendlyName;
+
+ ///
+ public string Origin => repository.Network.Remotes.First().Url;
+
+ ///
+ /// The for the
+ ///
+ readonly LibGit2Sharp.IRepository repository;
+
+ ///
+ /// The for the
+ ///
+ readonly IIOManager ioMananger;
+
+ ///
+ /// Construct a
+ ///
+ /// The value of
+ /// The value of
+ public Repository(LibGit2Sharp.IRepository repository, IIOManager ioMananger)
+ {
+ this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ this.ioMananger = ioMananger ?? throw new ArgumentNullException(nameof(ioMananger));
+ IsGitHubRepository = Origin.ToUpperInvariant().Contains("GITHUB.COM");
+ }
+
+ ///
+ public void Dispose() => repository.Dispose();
+
+ ///
+ /// Convert to an "https://@{url} equivalent
+ ///
+ /// The URL to convert
+ /// The containing authentication info for the remote repository
+ /// An authenticated URL for accessing the remote repository
+ public static string GenerateAuthUrl(string url, string accessString)
+ {
+ if (url == null)
+ throw new ArgumentNullException(nameof(url));
+ if (String.IsNullOrWhiteSpace(accessString))
+ return url;
+ const string HttProtocolSecure = "HTTPS://";
+ if (!url.ToUpperInvariant().StartsWith(HttProtocolSecure, StringComparison.InvariantCulture))
+ throw new InvalidOperationException("Cannot use access string without HTTPS remote!");
+ //ONLY support https urls
+ return url.ToUpperInvariant().Replace(HttProtocolSecure, String.Concat(HttProtocolSecure, accessString, '@'));
+ }
+
+ ///
+ /// Runs a blocking force checkout to
+ ///
+ /// The committish to checkout
+ void RawCheckout(string committish)
+ {
+ Commands.Checkout(repository, committish, new CheckoutOptions
+ {
+ CheckoutModifiers = CheckoutModifiers.Force
+ });
+ repository.RemoveUntrackedFiles();
+ }
+
+ ///
+ public Task AddTestMerge(int pullRequestNumber, string targetCommit, string committerName, string committerEmail, string commitBody, string accessString, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
+ {
+ var Refspec = new List();
+ var prBranchName = String.Format(CultureInfo.InvariantCulture, "pr-{0}", pullRequestNumber);
+ var localBranchName = String.Format(CultureInfo.InvariantCulture, "pull/{0}/headrefs/heads/{1}", pullRequestNumber, prBranchName);
+ Refspec.Add(String.Format(CultureInfo.InvariantCulture, "pull/{0}/head:{1}", pullRequestNumber, prBranchName));
+ var logMessage = String.Format(CultureInfo.InvariantCulture, "Merge remote pull request #{0}", pullRequestNumber);
+
+ var remote = repository.Network.Remotes.Add("temp_pr_fetch", GenerateAuthUrl(Origin, accessString));
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Commands.Fetch((LibGit2Sharp.Repository)repository, remote.Name, Refspec, new FetchOptions
+ {
+ Prune = true,
+ OnProgress = (a) => !cancellationToken.IsCancellationRequested,
+ OnTransferProgress = (a) => !cancellationToken.IsCancellationRequested,
+ OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested
+ }, logMessage);
+ }
+ catch (UserCancelledException) { }
+ finally
+ {
+ repository.Network.Remotes.Remove(remote.Name);
+ //commit is there and we never gc so
+ repository.Branches.Remove(localBranchName);
+ repository.Branches.Remove(prBranchName);
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var originalCommit = repository.Head;
+
+ var result = repository.Merge(targetCommit, new Signature(new Identity(committerName, committerEmail), DateTimeOffset.Now), new MergeOptions
+ {
+ CommitOnSuccess = true,
+ FailOnConflict = true,
+ FastForwardStrategy = FastForwardStrategy.NoFastForward,
+ SkipReuc = true,
+ });
+
+ if (result.Status != MergeStatus.NonFastForward)
+ {
+ RawCheckout(originalCommit.FriendlyName ?? originalCommit.Tip.Sha);
+ return null;
+ }
+
+ return result.Commit.Sha;
+
+ }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ ///
+ public Task CheckoutObject(string committish, CancellationToken cancellationToken) => Task.Factory.StartNew(() => RawCheckout(committish), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ ///
+ public Task FetchOrigin(string accessString, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
+ {
+ var remote = repository.Network.Remotes.First();
+ try
+ {
+ Commands.Fetch((LibGit2Sharp.Repository)repository, remote.Name, remote.FetchRefSpecs.Select(x => x.Specification), new FetchOptions
+ {
+ Prune = true,
+ OnProgress = (a) => !cancellationToken.IsCancellationRequested,
+ OnTransferProgress = (a) => !cancellationToken.IsCancellationRequested,
+ OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested
+ }, "Fetch origin commits");
+ }
+ catch (UserCancelledException)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ ///
+ public Task PushHeadToTemporaryBranch(string accessString, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
+ {
+ var branch = repository.CreateBranch(RemoteTemporaryBranchName);
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var remote = repository.Network.Remotes.Add("temp_push", GenerateAuthUrl(Origin, accessString));
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ repository.Network.Push(remote, String.Format(CultureInfo.InvariantCulture, "+{0}:{0}", branch.CanonicalName), new PushOptions
+ {
+ OnPackBuilderProgress = (a, b, c) => !cancellationToken.IsCancellationRequested,
+ OnNegotiationCompletedBeforePush = (a) => !cancellationToken.IsCancellationRequested,
+ OnPushTransferProgress = (a, b, c) => !cancellationToken.IsCancellationRequested
+ });
+ }
+ catch (UserCancelledException)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ }
+ finally
+ {
+ repository.Network.Remotes.Remove(remote.Name);
+ }
+ }
+ finally
+ {
+ repository.Branches.Remove(branch);
+ }
+
+ }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ ///
+ public Task ResetToOrigin(CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
+ {
+ if (!repository.Head.IsTracking)
+ throw new InvalidOperationException("Cannot reset to origin while not on a tracked reference!");
+ var trackedBranch = repository.Head.TrackedBranch;
+ Commands.Checkout((LibGit2Sharp.Repository)repository, repository.Head.TrackedBranch, new CheckoutOptions
+ {
+ CheckoutModifiers = CheckoutModifiers.Force
+ });
+ repository.RemoveUntrackedFiles();
+ return trackedBranch.Tip.Sha;
+ }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ ///
+ public async Task CopyTo(string path, CancellationToken cancellationToken)
+ {
+ if (path == null)
+ throw new ArgumentNullException(nameof(path));
+ await ioMananger.CopyDirectory(".", path, new List { ".git" }, cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Components/RepositoryManager.cs b/src/Tgstation.Server.Host/Components/RepositoryManager.cs
new file mode 100644
index 0000000000..3080659d08
--- /dev/null
+++ b/src/Tgstation.Server.Host/Components/RepositoryManager.cs
@@ -0,0 +1,56 @@
+using LibGit2Sharp;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Tgstation.Server.Host.Core;
+
+namespace Tgstation.Server.Host.Components
+{
+ ///
+ sealed class RepositoryManager : IRepositoryManager
+ {
+ ///
+ /// The for the
+ ///
+ readonly IIOManager ioManager;
+
+ ///
+ /// Construct a
+ ///
+ /// The value of
+ public RepositoryManager(IIOManager ioManager) => this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
+
+ ///
+ public async Task CloneRepository(string url, string accessString, CancellationToken cancellationToken)
+ {
+ await ioManager.DeleteDirectory(".", cancellationToken).ConfigureAwait(false);
+
+ await Task.Factory.StartNew(() =>
+ {
+ string path = null;
+ try
+ {
+ path = LibGit2Sharp.Repository.Clone(Repository.GenerateAuthUrl(url, accessString), ioManager.ResolvePath("."), new CloneOptions
+ {
+ OnProgress = (a) => !cancellationToken.IsCancellationRequested,
+ OnTransferProgress = (a) => !cancellationToken.IsCancellationRequested,
+ RecurseSubmodules = true,
+ OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested,
+ RepositoryOperationStarting = (a) => !cancellationToken.IsCancellationRequested
+ });
+ }
+ catch (UserCancelledException) { }
+ cancellationToken.ThrowIfCancellationRequested();
+ }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
+
+ return await LoadRepository(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public Task LoadRepository(CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
+ {
+ var repo = new LibGit2Sharp.Repository(ioManager.ResolvePath("."));
+ return (IRepository)new Repository(repo, ioManager);
+ }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+ }
+}
diff --git a/src/Tgstation.Server.Host/Core/DefaultIOManager.cs b/src/Tgstation.Server.Host/Core/DefaultIOManager.cs
new file mode 100644
index 0000000000..f0644d6f33
--- /dev/null
+++ b/src/Tgstation.Server.Host/Core/DefaultIOManager.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Tgstation.Server.Host.Core
+{
+ ///
+ /// that resolves paths to
+ ///
+ class DefaultIOManager : IIOManager
+ {
+ ///
+ /// Default buffer size used by .NET
+ ///
+ public const int DefaultBufferSize = 4096;
+
+ ///
+ /// Recursively empty a directory
+ ///
+ /// of the directory to empty
+ /// The for the operation
+ /// A representing the running operation
+ static async Task NormalizeAndDelete(DirectoryInfo dir, CancellationToken cancellationToken)
+ {
+ var tasks = new List();
+
+ foreach (var subDir in dir.EnumerateDirectories())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ tasks.Add(NormalizeAndDelete(subDir, cancellationToken));
+ }
+ foreach (var file in dir.EnumerateFiles())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ file.Attributes = FileAttributes.Normal;
+ file.Delete();
+ }
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ dir.Delete(true);
+ }
+
+
+ ///
+ /// Copies a directory from to
+ ///
+ /// The source directory path
+ /// The destination directory path
+ /// Files and folders to ignore at the root level
+ /// The for the operation
+ /// A representing the running operation
+ async Task CopyDirectoryImpl(string src, string dest, IEnumerable ignore, CancellationToken cancellationToken)
+ {
+ await CreateDirectory(dest, cancellationToken).ConfigureAwait(false);
+
+ var dir = new DirectoryInfo(src);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var dirs = dir.EnumerateDirectories();
+ var files = dir.EnumerateFiles();
+
+ var fileCopyTasks = files.Select(x =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (ignore != null && ignore.Contains(x.Name))
+ return Task.CompletedTask;
+ return CopyFile(x.FullName, Path.Combine(dest, x.Name), cancellationToken);
+ });
+
+ var directoryCopyTasks = dirs.Select(x =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (ignore != null && ignore.Contains(x.Name))
+ return Task.CompletedTask;
+ return CopyDirectoryImpl(x.FullName, Path.Combine(dest, x.Name), null, cancellationToken);
+ });
+
+ await Task.WhenAll(fileCopyTasks.Concat(directoryCopyTasks)).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task CopyDirectory(string src, string dest, IEnumerable ignore, CancellationToken cancellationToken)
+ {
+ if (dest == null)
+ throw new ArgumentNullException(nameof(src));
+ if (dest == null)
+ throw new ArgumentNullException(nameof(src));
+
+ src = ResolvePath(src);
+ dest = ResolvePath(dest);
+ await CopyDirectoryImpl(src, dest, ignore, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task AppendAllText(string path, string additional_contents, CancellationToken cancellationToken)
+ {
+ if (additional_contents == null)
+ throw new ArgumentNullException(nameof(additional_contents));
+ using (var destStream = new FileStream(ResolvePath(path), FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, DefaultBufferSize, true))
+ {
+ var buf = Encoding.UTF8.GetBytes(additional_contents);
+ await destStream.WriteAsync(buf, 0, buf.Length, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ public string ConcatPath(params string[] paths)
+ {
+ if (paths == null)
+ throw new ArgumentNullException(nameof(paths));
+ return Path.Combine(paths);
+ }
+
+ ///
+ public async Task CopyFile(string src, string dest, CancellationToken cancellationToken)
+ {
+ if (src == null)
+ throw new ArgumentNullException(nameof(src));
+ if (dest == null)
+ throw new ArgumentNullException(nameof(dest));
+ using (var srcStream = new FileStream(ResolvePath(src), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, DefaultBufferSize, true))
+ using (var destStream = new FileStream(ResolvePath(dest), FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, DefaultBufferSize, true))
+ await srcStream.CopyToAsync(destStream, DefaultBufferSize, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public Task CreateDirectory(string path, CancellationToken cancellationToken) => Task.Factory.StartNew(() => Directory.CreateDirectory(ResolvePath(path)), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ ///
+ public async Task DeleteDirectory(string path, CancellationToken cancellationToken)
+ {
+ path = ResolvePath(path);
+ var di = new DirectoryInfo(path);
+ if (!di.Exists)
+ return;
+ await NormalizeAndDelete(di, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public Task DeleteFile(string path, CancellationToken cancellationToken) => Task.Factory.StartNew(() => File.Delete(ResolvePath(path)), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ public Task FileExists(string path, CancellationToken cancellationToken) => Task.Factory.StartNew(() => File.Exists(ResolvePath(path)), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ ///
+ public string GetDirectoryName(string path) => Path.GetDirectoryName(path ?? throw new ArgumentNullException(nameof(path)));
+
+ ///
+ public string GetFileName(string path) => Path.GetFileName(path ?? throw new ArgumentNullException(nameof(path)));
+
+ ///
+ public string GetFileNameWithoutExtension(string path) => Path.GetFileNameWithoutExtension(path ?? throw new ArgumentNullException(nameof(path)));
+
+ ///
+ public Task> GetFilesWithExtension(string path, string extension, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
+ {
+ path = ResolvePath(path);
+ if (extension == null)
+ throw new ArgumentNullException(extension);
+ var results = new List();
+ foreach (var I in Directory.EnumerateFiles(path, String.Format(CultureInfo.InvariantCulture, "*.{0}", extension), SearchOption.TopDirectoryOnly))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ results.Add(I);
+ }
+ return results;
+ }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ ///
+ public Task MoveFile(string source, string destination, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
+ {
+ if (destination == null)
+ throw new ArgumentNullException(nameof(destination));
+ source = ResolvePath(source ?? throw new ArgumentNullException(nameof(source)));
+ destination = ResolvePath(destination);
+ File.Move(source, destination);
+ }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+
+ ///
+ public async Task ReadAllBytes(string path, CancellationToken cancellationToken)
+ {
+ path = ResolvePath(path);
+ using (var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, DefaultBufferSize, true))
+ {
+ byte[] buf;
+ buf = new byte[file.Length];
+ await file.ReadAsync(buf, 0, (int)file.Length, cancellationToken).ConfigureAwait(false);
+ return buf;
+ }
+ }
+
+ ///
+ public virtual string ResolvePath(string path) => Path.GetFullPath(path ?? throw new ArgumentNullException(nameof(path)));
+
+ ///
+ public async Task WriteAllBytes(string path, byte[] contents, CancellationToken cancellationToken)
+ {
+ path = ResolvePath(path);
+ using (var file = File.Open(path, FileMode.Create, FileAccess.Write))
+ await file.WriteAsync(contents, 0, contents.Length, cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Core/IIOManager.cs b/src/Tgstation.Server.Host/Core/IIOManager.cs
new file mode 100644
index 0000000000..2083ad84aa
--- /dev/null
+++ b/src/Tgstation.Server.Host/Core/IIOManager.cs
@@ -0,0 +1,133 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Tgstation.Server.Host.Core
+{
+ ///
+ /// Interface for using filesystems
+ ///
+ interface IIOManager
+ {
+ ///
+ /// Retrieve the full path of some given a relative path. Must be used before passing relative paths to other APIs. All other operations in this call this internally on given paths
+ ///
+ /// Some path to retrieve the full path of
+ /// as a full canonical path
+ string ResolvePath(string path);
+
+ ///
+ /// Gets the file name portion of a
+ ///
+ /// The path to get the file name of
+ /// The file name portion of
+ string GetFileName(string path);
+
+ ///
+ /// Gets the file name portion of a with
+ ///
+ /// The path to get the file name of
+ /// The file name portion of
+ string GetFileNameWithoutExtension(string path);
+
+ ///
+ /// Copies a directory from to
+ ///
+ /// The source directory path
+ /// The destination directory path
+ /// Files and folders to ignore at the root level
+ /// The for the operation
+ /// A representing the running operation
+ Task CopyDirectory(string src, string dest, IEnumerable ignore, CancellationToken cancellationToken);
+
+ ///
+ /// Check that the file at exists
+ ///
+ /// The file to check for existence
+ /// The for the operation
+ /// A resulting in if the file at exists, otherwise
+ Task FileExists(string path, CancellationToken cancellationToken);
+
+ ///
+ /// Returns all the contents of a file at as a array
+ ///
+ /// The path of the file to read
+ /// A for the operation
+ /// A that results in the contents of a file at
+ Task ReadAllBytes(string path, CancellationToken cancellationToken);
+
+ ///
+ /// Writes some to a file at overwriting previous content
+ ///
+ /// The path of the file to write
+ /// The contents of the file
+ /// A for the operation
+ /// A representing the running operation
+ Task WriteAllBytes(string path, byte[] contents, CancellationToken cancellationToken);
+
+ ///
+ /// Copy a file from to
+ ///
+ /// The source file to copy
+ /// The destination path
+ /// A for the operation
+ /// A representing the running operation
+ Task CopyFile(string src, string dest, CancellationToken cancellationToken);
+
+ ///
+ /// Gets the directory portion of a given
+ ///
+ /// A path to check
+ /// The directory portion of the given
+ string GetDirectoryName(string path);
+
+ ///
+ /// Gets a list of files in with the given
+ ///
+ /// The directory which contains the files
+ /// The extension to look for without the preceeding "."
+ /// A for the operation
+ /// A resulting in a list of paths to files in with the given
+ Task> GetFilesWithExtension(string path, string extension, CancellationToken cancellationToken);
+
+ ///
+ /// Deletes a file at
+ ///
+ /// The path of the file to delete
+ /// A for the operation
+ /// A representing the running operation
+ Task DeleteFile(string path, CancellationToken cancellationToken);
+
+ ///
+ /// Create a directory at
+ ///
+ /// The path of the directory to create
+ /// A for the operation
+ /// A representing the running operation
+ Task CreateDirectory(string path, CancellationToken cancellationToken);
+
+ ///
+ /// Recursively delete a directory
+ ///
+ /// The path to the directory to delete
+ /// A for the operation
+ /// A representing the running operation
+ Task DeleteDirectory(string path, CancellationToken cancellationToken);
+
+ ///
+ /// Combines an array of strings into a path
+ ///
+ /// The paths to combine
+ /// The combined path
+ string ConcatPath(params string[] paths);
+
+ ///
+ /// Moves a file at to
+ ///
+ /// The source file path
+ /// The destination path
+ /// A for the operation
+ /// A representing the running operation
+ Task MoveFile(string source, string destination, CancellationToken cancellationToken);
+ }
+}
diff --git a/src/Tgstation.Server.Host/Core/ResolvingIOManager.cs b/src/Tgstation.Server.Host/Core/ResolvingIOManager.cs
new file mode 100644
index 0000000000..f7d4bf2045
--- /dev/null
+++ b/src/Tgstation.Server.Host/Core/ResolvingIOManager.cs
@@ -0,0 +1,39 @@
+using System;
+using System.IO;
+
+namespace Tgstation.Server.Host.Core
+{
+ ///
+ /// An that resolve relative paths from another to a subdirectory of that
+ ///
+ sealed class ResolvingIOManager : DefaultIOManager
+ {
+ ///
+ /// Path to the subdirectory attached to path resolutions
+ ///
+ readonly string subdirectory;
+
+ ///
+ /// Construct a
+ ///
+ /// The that resolves to the directory to work out of
+ /// The value of
+ public ResolvingIOManager(IIOManager parent, string _subdirectory)
+ {
+ if(parent == null)
+ throw new ArgumentNullException(nameof(parent));
+ if (_subdirectory == null)
+ throw new ArgumentNullException(nameof(_subdirectory));
+
+ subdirectory = ConcatPath(parent.ResolvePath("."), _subdirectory);
+ }
+
+ ///
+ public override string ResolvePath(string path)
+ {
+ if (!Path.IsPathRooted(path))
+ return base.ResolvePath(ConcatPath(subdirectory, path));
+ return path;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj
index 2370aabf52..2dfa73f107 100644
--- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj
+++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj
@@ -35,6 +35,7 @@
+