BoardOil.Dev/DevDatabase.cs
Rama main · réplica de lectura del repositorio original.
using System.Diagnostics;
using System.Text.RegularExpressions;
namespace BoardOil.Dev;
internal enum DevDatabaseMode
{
Branch,
Shared,
Temporary
}
internal sealed record DevDatabaseSelection(
DevDatabaseMode Mode,
string DisplayName,
string DatabasePath,
string? TemporaryDirectory);
internal sealed class DevDatabaseManager : IDisposable
{
private readonly string repoRoot;
private readonly string mainDatabasePath;
private readonly string mainBranchName;
private readonly string currentBranchName;
private readonly bool seedFromMain;
private string? temporaryDirectory;
public DevDatabaseManager(string repoRoot)
{
this.repoRoot = repoRoot;
mainDatabasePath = Path.Combine(repoRoot, ".data", "dev", "boardoil.dev.db");
mainBranchName = Environment.GetEnvironmentVariable("BOARDOIL_MAIN_BRANCH_NAME") ?? "main";
currentBranchName = GitBranchResolver.GetCurrentBranchName(repoRoot);
seedFromMain = Environment.GetEnvironmentVariable("BOARDOIL_DEV_DB_SEED_FROM_MAIN") != "0";
var configuredMode = Environment.GetEnvironmentVariable("BOARDOIL_DEV_DB_MODE");
Current = SelectInitialMode(configuredMode);
}
public DevDatabaseSelection Current { get; private set; }
public DevDatabaseSelection Select(DevDatabaseMode mode)
{
DeleteTemporaryDatabase();
Current = CreateSelection(mode);
return Current;
}
public void PrepareCurrentDatabase()
{
var databaseDirectory = Path.GetDirectoryName(Current.DatabasePath)
?? throw new InvalidOperationException("The development database path has no parent directory.");
Directory.CreateDirectory(databaseDirectory);
if (Current.Mode != DevDatabaseMode.Branch || Current.DatabasePath == mainDatabasePath)
{
return;
}
DevDatabaseSeeder.SeedIfNeeded(mainDatabasePath, Current.DatabasePath, seedFromMain);
}
public string GetDisplayPath()
{
if (Current.Mode == DevDatabaseMode.Temporary)
{
return Current.DatabasePath;
}
return Path.GetRelativePath(repoRoot, Current.DatabasePath);
}
public void Dispose()
{
DeleteTemporaryDatabase();
}
private DevDatabaseSelection SelectInitialMode(string? configuredMode)
{
if (configuredMode?.Equals("shared", StringComparison.OrdinalIgnoreCase) == true)
{
return CreateSelection(DevDatabaseMode.Shared);
}
if (configuredMode?.Equals("temporary", StringComparison.OrdinalIgnoreCase) == true
|| configuredMode?.Equals("temp", StringComparison.OrdinalIgnoreCase) == true)
{
return CreateSelection(DevDatabaseMode.Temporary);
}
return CreateSelection(DevDatabaseMode.Branch);
}
private DevDatabaseSelection CreateSelection(DevDatabaseMode mode)
{
if (mode == DevDatabaseMode.Shared)
{
return new DevDatabaseSelection(mode, "shared", mainDatabasePath, null);
}
if (mode == DevDatabaseMode.Temporary)
{
temporaryDirectory = Directory.CreateTempSubdirectory("boardoil-dev-").FullName;
var databasePath = Path.Combine(temporaryDirectory, "boardoil.dev.db");
return new DevDatabaseSelection(mode, "fresh temporary", databasePath, temporaryDirectory);
}
var branchPath = DevDatabasePaths.ResolveBranchDatabasePath(
repoRoot,
currentBranchName,
mainBranchName);
return new DevDatabaseSelection(mode, $"branch ({currentBranchName})", branchPath, null);
}
private void DeleteTemporaryDatabase()
{
if (temporaryDirectory is null)
{
return;
}
try
{
Directory.Delete(temporaryDirectory, recursive: true);
}
catch (DirectoryNotFoundException)
{
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
temporaryDirectory = null;
}
}
internal static partial class DevDatabasePaths
{
public static string ResolveBranchDatabasePath(
string repoRoot,
string currentBranchName,
string mainBranchName)
{
var mainDatabasePath = Path.Combine(repoRoot, ".data", "dev", "boardoil.dev.db");
if (currentBranchName == mainBranchName)
{
return mainDatabasePath;
}
var branchDirectoryName = SanitiseBranchName(currentBranchName);
return Path.Combine(
repoRoot,
".data",
"dev",
"branches",
branchDirectoryName,
"boardoil.dev.db");
}
public static string SanitiseBranchName(string branchName)
{
var sanitised = UnsafePathCharacterRegex().Replace(branchName, "_");
sanitised = RepeatedUnderscoreRegex().Replace(sanitised, "_").Trim('_');
return string.IsNullOrWhiteSpace(sanitised) ? "unknown" : sanitised;
}
[GeneratedRegex("[^A-Za-z0-9._-]+")]
private static partial Regex UnsafePathCharacterRegex();
[GeneratedRegex("_+")]
private static partial Regex RepeatedUnderscoreRegex();
}
internal static class DevDatabaseSeeder
{
public static void SeedIfNeeded(
string sourceDatabasePath,
string targetDatabasePath,
bool enabled,
bool preferSqliteBackup = true)
{
if (!enabled || File.Exists(targetDatabasePath) || !File.Exists(sourceDatabasePath))
{
return;
}
var targetDirectory = Path.GetDirectoryName(targetDatabasePath)
?? throw new InvalidOperationException("The branch database path has no parent directory.");
Directory.CreateDirectory(targetDirectory);
if (preferSqliteBackup && TryCreateSqliteBackup(sourceDatabasePath, targetDatabasePath))
{
return;
}
File.Copy(sourceDatabasePath, targetDatabasePath);
CopySidecarIfPresent(sourceDatabasePath, targetDatabasePath, "-wal");
CopySidecarIfPresent(sourceDatabasePath, targetDatabasePath, "-shm");
}
private static bool TryCreateSqliteBackup(string sourceDatabasePath, string targetDatabasePath)
{
var sqlite = ExecutableLocator.Find("sqlite3");
if (sqlite is null)
{
return false;
}
try
{
var escapedTargetPath = targetDatabasePath.Replace("'", "''", StringComparison.Ordinal);
var startInfo = new ProcessStartInfo
{
FileName = sqlite,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add(sourceDatabasePath);
startInfo.ArgumentList.Add($".backup '{escapedTargetPath}'");
using var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
process.WaitForExit();
Task.WhenAll(outputTask, errorTask).GetAwaiter().GetResult();
if (process.ExitCode == 0 && File.Exists(targetDatabasePath))
{
return true;
}
}
catch (Exception exception) when (exception is InvalidOperationException or System.ComponentModel.Win32Exception)
{
}
DeleteIfPresent(targetDatabasePath);
DeleteIfPresent($"{targetDatabasePath}-wal");
DeleteIfPresent($"{targetDatabasePath}-shm");
return false;
}
private static void CopySidecarIfPresent(string sourcePath, string targetPath, string suffix)
{
var sourceSidecar = $"{sourcePath}{suffix}";
if (File.Exists(sourceSidecar))
{
File.Copy(sourceSidecar, $"{targetPath}{suffix}");
}
}
private static void DeleteIfPresent(string path)
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
internal static class GitBranchResolver
{
public static string GetCurrentBranchName(string repoRoot)
{
var branchName = RunGit(repoRoot, "rev-parse", "--abbrev-ref", "HEAD");
if (string.IsNullOrWhiteSpace(branchName))
{
return "unknown";
}
if (branchName != "HEAD")
{
return branchName;
}
var shortSha = RunGit(repoRoot, "rev-parse", "--short", "HEAD");
return string.IsNullOrWhiteSpace(shortSha) ? "detached" : $"detached-{shortSha}";
}
private static string? RunGit(string repoRoot, params string[] arguments)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = ExecutableLocator.Find("git") ?? "git",
WorkingDirectory = repoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit();
return process.ExitCode == 0 ? output.Trim() : null;
}
catch (Exception exception) when (exception is InvalidOperationException or System.ComponentModel.Win32Exception)
{
return null;
}
}
}