diff --git a/README.md b/README.md
index d84dd0d..4b77a94 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,17 @@ Or on a sub-command
kickflip deploy --help
+## Testing
+
+Kickflip has an automated test suite covering both unit and integration levels:
+
+- **Unit tests** exercise the individual services (`GitService`, `FileSystemService`, `IgnoreService`, `OutputService`, `PullRequestCommentComposer`, `Utilities`) directly. Git based tests build real temporary git repositories so the find modes are proven end-to-end.
+- **Integration tests** drive the compiled CLI as an external process, verifying command wiring, argument validation, the different deployment modes (`Tags`, `GitHubMergePR`, `Folder`) and that a dry run reports the planned changes without ever connecting to the remote server.
+
+Run the whole suite with:
+
+ dotnet test
+
## Development
Kickflip uses [dotnet/Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) to handle semantic versioning and branching to for releases.
diff --git a/src/kickflip.Tests/CliIntegrationTests.cs b/src/kickflip.Tests/CliIntegrationTests.cs
new file mode 100644
index 0000000..a93e29c
--- /dev/null
+++ b/src/kickflip.Tests/CliIntegrationTests.cs
@@ -0,0 +1,145 @@
+using kickflip.Tests.TestHelpers;
+
+namespace kickflip.Tests;
+
+///
+/// End-to-end tests that drive the compiled CLI as an external process,
+/// verifying command wiring, argument validation, the different deployment
+/// modes and that a dry run makes no remote changes.
+///
+[Collection("CLI")]
+public class CliIntegrationTests
+{
+ [Fact]
+ public void Help_ListsTopLevelCommands()
+ {
+ var result = CliRunner.Run("--help");
+
+ Assert.Equal(0, result.ExitCode);
+ Assert.Contains("deploy", result.Output);
+ Assert.Contains("github", result.Output);
+ }
+
+ [Fact]
+ public void Deploy_Help_DescribesModeAndDryOptions()
+ {
+ var result = CliRunner.Run("deploy", "--help");
+
+ Assert.Equal(0, result.ExitCode);
+ Assert.Contains("--mode", result.Output);
+ Assert.Contains("--dry", result.Output);
+ Assert.Contains("--hostname", result.Output);
+ }
+
+ [Fact]
+ public void Deploy_MissingRequiredOptions_FailsWithError()
+ {
+ var result = CliRunner.Run("deploy");
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("--hostname", result.Output);
+ }
+
+ [Fact]
+ public void Deploy_TagsMode_DryRun_ReportsChangesAndDoesNotConnect()
+ {
+ using var repo = new GitRepositoryBuilder();
+ repo.WriteFile("base.txt").Commit("initial");
+ repo.Tag("v1.0");
+ repo.WriteFile("added.txt").Commit("add file");
+
+ var result = CliRunner.Run(
+ "deploy", repo.Path,
+ "--mode", "Tags",
+ "--hostname", "nonexistent.invalid",
+ "--username", "user",
+ "--password", "password",
+ "--dry");
+
+ Assert.Equal(0, result.ExitCode);
+ Assert.Contains("Dry run", result.Output);
+ Assert.Contains("added.txt", result.Output);
+ Assert.Contains("Deployment successful!", result.Output);
+ // A dry run must never connect to the remote server.
+ Assert.DoesNotContain("Connecting to remote server", result.Output);
+ }
+
+ [Fact]
+ public void Deploy_FolderMode_DryRun_UploadsFolderContents()
+ {
+ using var directory = new TempDirectory();
+ directory.WriteFile("index.html");
+ directory.WriteFile("assets/style.css");
+
+ var result = CliRunner.Run(
+ "deploy", directory.Path,
+ "--mode", "Folder",
+ "--deployment-path", "/public_html",
+ "--hostname", "nonexistent.invalid",
+ "--username", "user",
+ "--password", "password",
+ "--dry");
+
+ Assert.Equal(0, result.ExitCode);
+ Assert.Contains("index.html", result.Output);
+ Assert.Contains("style.css", result.Output);
+ Assert.Contains("Deployment successful!", result.Output);
+ Assert.DoesNotContain("Connecting to remote server", result.Output);
+ }
+
+ [Fact]
+ public void Deploy_GitHubMergePrMode_DryRun_ReportsPostMergeChanges()
+ {
+ using var repo = new GitRepositoryBuilder();
+ repo.WriteFile("base.txt").Commit("initial");
+ repo.WriteFile("merged.txt").Commit("Merge pull request #1 from feature/a");
+ repo.WriteFile("after.txt").Commit("work after merge");
+
+ var result = CliRunner.Run(
+ "deploy", repo.Path,
+ "--mode", "GitHubMergePR",
+ "--hostname", "nonexistent.invalid",
+ "--username", "user",
+ "--password", "password",
+ "--dry");
+
+ Assert.Equal(0, result.ExitCode);
+ Assert.Contains("after.txt", result.Output);
+ Assert.Contains("Deployment successful!", result.Output);
+ }
+
+ [Fact]
+ public void Github_Help_DescribesPullRequestCommand()
+ {
+ var result = CliRunner.Run("github", "--help");
+
+ Assert.Equal(0, result.ExitCode);
+ Assert.Contains("pull-request", result.Output);
+ }
+
+ [Fact]
+ public void Github_PullRequest_InvalidRepoFormat_FailsWithGuidance()
+ {
+ var result = CliRunner.Run(
+ "github", "pull-request",
+ "--repo", "invalidformat",
+ "--ref", "refs/pull/1/merge",
+ "--token", "token");
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("owner", result.Output.ToLowerInvariant());
+ }
+
+ [Fact]
+ public void Github_PullRequest_InvalidRefFormat_FailsWithGuidance()
+ {
+ var result = CliRunner.Run(
+ "github", "pull-request",
+ "--repo", "owner/repo",
+ "--ref", "refs/heads/main",
+ "--token", "token");
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("refs/pull/", result.Output);
+ }
+}
diff --git a/src/kickflip.Tests/FileSystemServiceTests.cs b/src/kickflip.Tests/FileSystemServiceTests.cs
new file mode 100644
index 0000000..57db143
--- /dev/null
+++ b/src/kickflip.Tests/FileSystemServiceTests.cs
@@ -0,0 +1,52 @@
+using kickflip.Enums;
+using kickflip.Models;
+using kickflip.Services;
+using kickflip.Tests.TestHelpers;
+
+namespace kickflip.Tests;
+
+public class FileSystemServiceTests
+{
+ [Fact]
+ public void GetChanges_ReturnsAddOrModifyForEveryFile()
+ {
+ using var directory = new TempDirectory();
+ directory.WriteFile("index.html");
+ directory.WriteFile("assets/style.css");
+
+ var service = new FileSystemService(new IgnoreService(directory.Path));
+ var changes = service.GetChanges(directory.Path, "/public_html");
+
+ Assert.All(changes.Where(c => c.Action != DeploymentAction.Ignore),
+ change => Assert.Equal(DeploymentAction.AddOrModify, change.Action));
+ Assert.All(changes, change => Assert.Equal(Source.Folder, change.Source));
+
+ var index = changes.Single(c => c.Path == "index.html");
+ Assert.Equal("/public_html/index.html", index.DeploymentPath);
+ }
+
+ [Fact]
+ public void GetChanges_MarksIgnoredFilesAsIgnored()
+ {
+ using var directory = new TempDirectory();
+ directory.WriteFile(".kickflipignore", "*.log");
+ directory.WriteFile("index.html");
+ directory.WriteFile("app.log");
+
+ var service = new FileSystemService(new IgnoreService(directory.Path));
+ var changes = service.GetChanges(directory.Path, "/");
+
+ var log = changes.Single(c => c.Path == "app.log");
+ Assert.Equal(DeploymentAction.Ignore, log.Action);
+ Assert.Equal(string.Empty, log.DeploymentPath);
+ }
+
+ [Fact]
+ public void GetChanges_ThrowsWhenDirectoryMissing()
+ {
+ var service = new FileSystemService(new IgnoreService(Path.GetTempPath()));
+
+ Assert.Throws(() =>
+ service.GetChanges(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), "/"));
+ }
+}
diff --git a/src/kickflip.Tests/GitServiceTests.cs b/src/kickflip.Tests/GitServiceTests.cs
new file mode 100644
index 0000000..14e8b2e
--- /dev/null
+++ b/src/kickflip.Tests/GitServiceTests.cs
@@ -0,0 +1,97 @@
+using kickflip.Enums;
+using kickflip.Models;
+using kickflip.Services;
+using kickflip.Tests.TestHelpers;
+
+namespace kickflip.Tests;
+
+public class GitServiceTests
+{
+ private static GitService CreateService(string path) => new(new IgnoreService(path));
+
+ [Fact]
+ public void GetChanges_Tags_ComparesFromLastTagToHead()
+ {
+ using var repo = new GitRepositoryBuilder();
+ repo.WriteFile("kept.txt").Commit("initial");
+ repo.Tag("v1.0");
+
+ repo.WriteFile("added.txt").Commit("add new file");
+
+ var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.Tags);
+
+ var added = changes.Single(c => c.Path == "added.txt");
+ Assert.Equal(DeploymentAction.Add, added.Action);
+ Assert.Equal(Source.Git, added.Source);
+ Assert.DoesNotContain(changes, c => c.Path == "kept.txt");
+ }
+
+ [Fact]
+ public void GetChanges_Tags_DetectsModifiedAndDeletedFiles()
+ {
+ using var repo = new GitRepositoryBuilder();
+ repo.WriteFile("modify.txt", "v1").WriteFile("delete.txt", "bye").Commit("initial");
+ repo.Tag("v1.0");
+
+ repo.WriteFile("modify.txt", "v2").DeleteFile("delete.txt").Commit("changes");
+
+ var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.Tags);
+
+ Assert.Equal(DeploymentAction.Modify, changes.Single(c => c.Path == "modify.txt").Action);
+ Assert.Equal(DeploymentAction.Delete, changes.Single(c => c.Path == "delete.txt").Action);
+ }
+
+ [Fact]
+ public void GetChanges_Tags_WithNoTag_ComparesFromRoot()
+ {
+ using var repo = new GitRepositoryBuilder();
+ repo.WriteFile("one.txt").Commit("initial");
+ repo.WriteFile("two.txt").Commit("second");
+
+ var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.Tags);
+
+ Assert.Contains(changes, c => c.Path == "one.txt");
+ Assert.Contains(changes, c => c.Path == "two.txt");
+ }
+
+ [Fact]
+ public void GetChanges_GitHubMergePr_ComparesFromLastMergeCommit()
+ {
+ using var repo = new GitRepositoryBuilder();
+ repo.WriteFile("base.txt").Commit("initial");
+ repo.WriteFile("merged.txt").Commit("Merge pull request #1 from feature/a");
+ repo.WriteFile("after-merge.txt").Commit("work after merge");
+
+ var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.GitHubMergePR);
+
+ Assert.Contains(changes, c => c.Path == "after-merge.txt");
+ Assert.DoesNotContain(changes, c => c.Path == "base.txt");
+ }
+
+ [Fact]
+ public void GetChanges_IgnoredFilesAreMarkedIgnored()
+ {
+ using var repo = new GitRepositoryBuilder();
+ repo.WriteFile(".kickflipignore", "*.log").Commit("initial");
+ repo.Tag("v1.0");
+ repo.WriteFile("app.log").Commit("add log");
+
+ var changes = CreateService(repo.Path).GetChanges(repo.Path, "/", FindMode.Tags);
+
+ Assert.Equal(DeploymentAction.Ignore, changes.Single(c => c.Path == "app.log").Action);
+ }
+
+ [Fact]
+ public void GetChanges_AppliesDeploymentPathPrefix()
+ {
+ using var repo = new GitRepositoryBuilder();
+ repo.WriteFile("base.txt").Commit("initial");
+ repo.Tag("v1.0");
+ repo.WriteFile("sub/file.txt").Commit("add nested file");
+
+ var changes = CreateService(repo.Path).GetChanges(repo.Path, "/public_html", FindMode.Tags);
+
+ var change = changes.Single(c => c.Path == "sub/file.txt");
+ Assert.Contains("public_html", change.DeploymentPath);
+ }
+}
diff --git a/src/kickflip.Tests/IgnoreServiceTests.cs b/src/kickflip.Tests/IgnoreServiceTests.cs
new file mode 100644
index 0000000..2a6a51a
--- /dev/null
+++ b/src/kickflip.Tests/IgnoreServiceTests.cs
@@ -0,0 +1,39 @@
+using kickflip.Services;
+using kickflip.Tests.TestHelpers;
+
+namespace kickflip.Tests;
+
+public class IgnoreServiceTests
+{
+ [Fact]
+ public void IsIgnored_WithNoIgnoreFile_OnlyIgnoresKickflipIgnoreFiles()
+ {
+ using var directory = new TempDirectory();
+ var service = new IgnoreService(directory.Path);
+
+ Assert.False(service.IsIgnored("index.html"));
+ Assert.True(service.IsIgnored(".kickflipignore"));
+ }
+
+ [Fact]
+ public void IsIgnored_HonoursPatternsInIgnoreFile()
+ {
+ using var directory = new TempDirectory();
+ directory.WriteFile(".kickflipignore", "*.log\nsecrets/**");
+ var service = new IgnoreService(directory.Path);
+
+ Assert.True(service.IsIgnored("app.log"));
+ Assert.True(service.IsIgnored("secrets/password.txt"));
+ Assert.False(service.IsIgnored("index.html"));
+ }
+
+ [Fact]
+ public void IsIgnored_AlwaysIgnoresTheIgnoreFileItself()
+ {
+ using var directory = new TempDirectory();
+ directory.WriteFile(".kickflipignore", "*.log");
+ var service = new IgnoreService(directory.Path);
+
+ Assert.True(service.IsIgnored(".kickflipignore"));
+ }
+}
diff --git a/src/kickflip.Tests/OutputServiceTests.cs b/src/kickflip.Tests/OutputServiceTests.cs
new file mode 100644
index 0000000..54b1d9d
--- /dev/null
+++ b/src/kickflip.Tests/OutputServiceTests.cs
@@ -0,0 +1,64 @@
+using kickflip.Enums;
+using kickflip.Models;
+using kickflip.Services;
+
+namespace kickflip.Tests;
+
+public class OutputServiceTests
+{
+ private static List SampleChanges() =>
+ [
+ new(DeploymentAction.Add, Source.Git, "added.txt", "/added.txt"),
+ new(DeploymentAction.Delete, Source.Git, "gone.txt", "/gone.txt"),
+ new(DeploymentAction.Ignore, Source.Git, "app.log", ""),
+ ];
+
+ [Fact]
+ public void GetChangesMarkdown_RendersMarkdownTableWithEveryFile()
+ {
+ var output = new OutputService().GetChangesMarkdown(SampleChanges());
+
+ Assert.Contains("|", output);
+ Assert.Contains("added.txt", output);
+ Assert.Contains("gone.txt", output);
+ Assert.Contains("app.log", output);
+ Assert.Contains("Deployment", output);
+ }
+
+ [Fact]
+ public void GetChangesConsole_RendersConsoleTableWithHeading()
+ {
+ var output = new OutputService().GetChangesConsole(SampleChanges());
+
+ Assert.Contains("Deployment Changes", output);
+ Assert.Contains("added.txt", output);
+ Assert.Contains("gone.txt", output);
+ }
+
+ [Fact]
+ public void GetChanges_WithEmptyList_StillRendersHeaders()
+ {
+ var service = new OutputService();
+
+ Assert.Contains("Deployment Changes", service.GetChangesConsole([]));
+ Assert.Contains("Change", service.GetChangesMarkdown([]));
+ }
+
+ [Theory]
+ [InlineData(DeploymentAction.Add, "Upload")]
+ [InlineData(DeploymentAction.Modify, "Upload")]
+ [InlineData(DeploymentAction.AddOrModify, "Upload")]
+ [InlineData(DeploymentAction.Delete, "Delete")]
+ [InlineData(DeploymentAction.Ignore, "None")]
+ public void GetChangesConsole_MapsActionsToFriendlyLabels(DeploymentAction action, string expectedLabel)
+ {
+ var changes = new List
+ {
+ new(action, Source.Git, "file.txt", "/file.txt"),
+ };
+
+ var output = new OutputService().GetChangesConsole(changes);
+
+ Assert.Contains(expectedLabel, output);
+ }
+}
diff --git a/src/kickflip.Tests/SftpDeploymentServiceTests.cs b/src/kickflip.Tests/SftpDeploymentServiceTests.cs
new file mode 100644
index 0000000..7e53dfa
--- /dev/null
+++ b/src/kickflip.Tests/SftpDeploymentServiceTests.cs
@@ -0,0 +1,83 @@
+using kickflip.Enums;
+using kickflip.Models;
+using kickflip.Services;
+using kickflip.Tests.TestHelpers;
+
+namespace kickflip.Tests;
+
+///
+/// The deployment service talks to a real SFTP server for live runs, which we
+/// cannot stand up in unit tests. These tests focus on the dry-run behaviour,
+/// which is the safety-critical path: a dry run must never connect to the
+/// remote server nor touch the local file system, yet must report success.
+///
+public class SftpDeploymentServiceTests
+{
+ private static SftpDeploymentService CreateService() =>
+ new("nonexistent.invalid", 22, "user", "password", "/public_html");
+
+ [Fact]
+ public void DeployChanges_DryRun_DoesNotConnectAndReportsSuccess()
+ {
+ using var directory = new TempDirectory();
+ directory.WriteFile("index.html");
+
+ var changes = new List
+ {
+ new(DeploymentAction.Add, Source.Git, "index.html", "/public_html/index.html"),
+ new(DeploymentAction.Delete, Source.Git, "old.html", "/public_html/old.html"),
+ new(DeploymentAction.Ignore, Source.Git, "app.log", ""),
+ };
+
+ // A hostname that cannot resolve means any attempt to connect would throw.
+ // If the dry run returns success without throwing, we know it did not connect.
+ var result = CreateService().DeployChanges(directory.Path, changes, isDryRun: true);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void DeployChanges_DryRun_DoesNotModifyLocalFiles()
+ {
+ using var directory = new TempDirectory();
+ var filePath = directory.WriteFile("index.html", "original");
+
+ var changes = new List
+ {
+ new(DeploymentAction.Add, Source.Git, "index.html", "/public_html/index.html"),
+ new(DeploymentAction.Delete, Source.Git, "index.html", "/public_html/index.html"),
+ };
+
+ CreateService().DeployChanges(directory.Path, changes, isDryRun: true);
+
+ Assert.True(File.Exists(filePath));
+ Assert.Equal("original", File.ReadAllText(filePath));
+ }
+
+ [Fact]
+ public void DeployChanges_DryRun_WithNoChanges_ReturnsSuccess()
+ {
+ using var directory = new TempDirectory();
+
+ var result = CreateService().DeployChanges(directory.Path, [], isDryRun: true);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void DeployChanges_LiveRun_WithUnresolvableHost_FailsWithoutThrowing()
+ {
+ using var directory = new TempDirectory();
+ directory.WriteFile("index.html");
+
+ var changes = new List
+ {
+ new(DeploymentAction.Ignore, Source.Git, "app.log", ""),
+ };
+
+ // Only an ignored change means Connect() is called but no upload/delete.
+ // Connecting to an invalid host throws inside Connect; ensure we surface it.
+ Assert.ThrowsAny(() =>
+ CreateService().DeployChanges(directory.Path, changes, isDryRun: false));
+ }
+}
diff --git a/src/kickflip.Tests/TestHelpers/CliRunner.cs b/src/kickflip.Tests/TestHelpers/CliRunner.cs
new file mode 100644
index 0000000..44fc79c
--- /dev/null
+++ b/src/kickflip.Tests/TestHelpers/CliRunner.cs
@@ -0,0 +1,48 @@
+using System.Diagnostics;
+
+namespace kickflip.Tests.TestHelpers;
+
+///
+/// Runs the compiled kickflip CLI as an external process so the whole app can
+/// be exercised end-to-end (argument parsing, command wiring and handlers).
+///
+public static class CliRunner
+{
+ public record Result(int ExitCode, string StandardOutput, string StandardError)
+ {
+ public string Output => StandardOutput + StandardError;
+ }
+
+ public static Result Run(params string[] arguments)
+ {
+ var assemblyPath = Path.Combine(AppContext.BaseDirectory, "kickflip.dll");
+ if (!File.Exists(assemblyPath))
+ {
+ throw new FileNotFoundException($"Could not find the kickflip CLI assembly at {assemblyPath}");
+ }
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ startInfo.ArgumentList.Add(assemblyPath);
+ foreach (var argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ using var process = new Process { StartInfo = startInfo };
+ process.Start();
+
+ var standardOutput = process.StandardOutput.ReadToEnd();
+ var standardError = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+
+ return new Result(process.ExitCode, standardOutput, standardError);
+ }
+}
diff --git a/src/kickflip.Tests/TestHelpers/GitRepositoryBuilder.cs b/src/kickflip.Tests/TestHelpers/GitRepositoryBuilder.cs
new file mode 100644
index 0000000..3733804
--- /dev/null
+++ b/src/kickflip.Tests/TestHelpers/GitRepositoryBuilder.cs
@@ -0,0 +1,50 @@
+using LibGit2Sharp;
+
+namespace kickflip.Tests.TestHelpers;
+
+///
+/// Builds a real (on-disk) git repository so the git based find modes can be
+/// exercised end-to-end without mocking LibGit2Sharp.
+///
+public sealed class GitRepositoryBuilder : IDisposable
+{
+ private readonly TempDirectory _tempDirectory = new();
+ private readonly Signature _signature = new("Test", "test@example.com", DateTimeOffset.Now);
+
+ public string Path => _tempDirectory.Path;
+
+ public GitRepositoryBuilder()
+ {
+ Repository.Init(Path);
+ }
+
+ public GitRepositoryBuilder WriteFile(string relativePath, string contents = "content")
+ {
+ _tempDirectory.WriteFile(relativePath, contents);
+ return this;
+ }
+
+ public GitRepositoryBuilder DeleteFile(string relativePath)
+ {
+ _tempDirectory.DeleteFile(relativePath);
+ return this;
+ }
+
+ public Commit Commit(string message)
+ {
+ using var repo = new Repository(Path);
+ Commands.Stage(repo, "*");
+ return repo.Commit(message, _signature, _signature, new CommitOptions { AllowEmptyCommit = true });
+ }
+
+ public void Tag(string name)
+ {
+ using var repo = new Repository(Path);
+ repo.ApplyTag(name);
+ }
+
+ public void Dispose()
+ {
+ _tempDirectory.Dispose();
+ }
+}
diff --git a/src/kickflip.Tests/TestHelpers/TempDirectory.cs b/src/kickflip.Tests/TestHelpers/TempDirectory.cs
new file mode 100644
index 0000000..6f8319a
--- /dev/null
+++ b/src/kickflip.Tests/TestHelpers/TempDirectory.cs
@@ -0,0 +1,59 @@
+namespace kickflip.Tests.TestHelpers;
+
+///
+/// Creates a unique temporary directory that is deleted when disposed.
+/// Used to give tests an isolated file system sandbox.
+///
+public sealed class TempDirectory : IDisposable
+{
+ public string Path { get; }
+
+ public TempDirectory()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "kickflip-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+
+ public string WriteFile(string relativePath, string contents = "")
+ {
+ var fullPath = System.IO.Path.Combine(Path, relativePath);
+ var directory = System.IO.Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllText(fullPath, contents);
+ return fullPath;
+ }
+
+ public void DeleteFile(string relativePath)
+ {
+ var fullPath = System.IO.Path.Combine(Path, relativePath);
+ if (File.Exists(fullPath))
+ {
+ File.Delete(fullPath);
+ }
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(Path))
+ {
+ // Git repositories mark objects read-only, clear before delete.
+ foreach (var file in Directory.EnumerateFiles(Path, "*", SearchOption.AllDirectories))
+ {
+ File.SetAttributes(file, FileAttributes.Normal);
+ }
+
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+ catch
+ {
+ // Best effort cleanup; ignore failures during teardown.
+ }
+ }
+}
diff --git a/src/kickflip.Tests/UtilitiesTests.cs b/src/kickflip.Tests/UtilitiesTests.cs
new file mode 100644
index 0000000..45511c2
--- /dev/null
+++ b/src/kickflip.Tests/UtilitiesTests.cs
@@ -0,0 +1,27 @@
+namespace kickflip.Tests;
+
+public class UtilitiesTests
+{
+ [Theory]
+ [InlineData("/public_html", "index.html", "/public_html/index.html")]
+ [InlineData("/public_html/", "/index.html", "/public_html/index.html")]
+ [InlineData("/", "index.html", "/index.html")]
+ [InlineData("", "index.html", "index.html")]
+ [InlineData("/public_html", "", "/public_html")]
+ public void UrlCombine_CombinesPathsWithSingleSeparator(string url1, string url2, string expected)
+ {
+ var result = kickflip.Utilities.UrlCombine(url1, url2);
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void UrlCombine_NormalisesDirectorySeparatorsToForwardSlashes()
+ {
+ var nested = "sub" + Path.DirectorySeparatorChar + "file.txt";
+
+ var result = kickflip.Utilities.UrlCombine("/public_html", nested);
+
+ Assert.Equal("/public_html/sub/file.txt", result);
+ }
+}