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 @@ +