فهرست منبع

feat: Refactor git module to hexagonal architecture with libgit2sharp

Replace CLI-based git implementation with LibGit2Sharp native library,
restructure the git module following hexagonal architecture, DDD, CQS
and SRP principles.

Architecture:
- Port: IGitRepository interface for low-level repository access
- Adapter: LibGit2GitRepository (libgit2sharp) and NullGitRepository (WASM)
- UseCases (commands): InitRepo, CommitAll, RestoreAll, Push, Pull, AddRemote
- Queries (reads): GetStatus, GetBranch, GetDiff, GetChangedFiles, GetLog, GetSyncStatus

Features:
- Git init via web UI with confirmation dialog
- Add HTTPS remote via web UI with URL validation (SSH not supported)
- Manual sync button with fetch, push and pull controls
- Sync status display (ahead/behind) with fetch error reporting
- Token-based auth via GIT_TOKEN environment variable for Docker/CI
- 5-second local status polling (no remote fetch in polling loop)

Removed:
- CLI-based GitService, IGitService and NullGitService
mavnezz 3 هفته پیش
والد
کامیت
f17ad11ea7

+ 0 - 234
RackPeek.Domain/Git/GitService.cs

@@ -1,234 +0,0 @@
-using System.Diagnostics;
-
-namespace RackPeek.Domain.Git;
-
-public sealed class GitService : IGitService
-{
-    private readonly string _workingDirectory;
-    private readonly bool _isAvailable;
-
-    public GitService(string configDirectory)
-    {
-        _workingDirectory = configDirectory;
-        _isAvailable = CheckGitAvailable();
-    }
-
-    public bool IsAvailable => _isAvailable;
-
-    public async Task<GitRepoStatus> GetStatusAsync()
-    {
-        if (!_isAvailable)
-            return GitRepoStatus.NotAvailable;
-
-        var (exitCode, output) = await RunGitAsync("status", "--porcelain");
-        if (exitCode != 0)
-            return GitRepoStatus.NotAvailable;
-
-        return string.IsNullOrWhiteSpace(output)
-            ? GitRepoStatus.Clean
-            : GitRepoStatus.Dirty;
-    }
-
-    public async Task<string?> CommitAllAsync(string message)
-    {
-        if (!_isAvailable)
-            return "Git is not available.";
-
-        var (addExit, addOutput) = await RunGitAsync("add", "-A");
-        if (addExit != 0)
-            return $"git add failed: {addOutput}";
-
-        var (commitExit, commitOutput) = await RunGitAsync("commit", "-m", message);
-        if (commitExit != 0)
-        {
-            if (commitOutput.Contains("nothing to commit"))
-                return null;
-            return $"git commit failed: {commitOutput}";
-        }
-
-        return null;
-    }
-
-    public async Task<string[]> GetChangedFilesAsync()
-    {
-        if (!_isAvailable)
-            return [];
-
-        var (exitCode, output) = await RunGitAsync("status", "--porcelain");
-        if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
-            return [];
-
-        return output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
-    }
-
-    public async Task<string> GetDiffAsync()
-    {
-        if (!_isAvailable)
-            return string.Empty;
-
-        // Show both staged and unstaged changes, plus untracked files
-        var (_, trackedDiff) = await RunGitAsync("diff", "HEAD");
-        var (_, untrackedFiles) = await RunGitAsync("ls-files", "--others", "--exclude-standard");
-
-        var result = trackedDiff;
-        if (!string.IsNullOrWhiteSpace(untrackedFiles))
-        {
-            var files = untrackedFiles.Split('\n', StringSplitOptions.RemoveEmptyEntries);
-            foreach (var file in files)
-            {
-                var (_, content) = await RunGitAsync("diff", "--no-index", "/dev/null", file);
-                if (!string.IsNullOrWhiteSpace(content))
-                    result = string.IsNullOrWhiteSpace(result) ? content : $"{result}\n{content}";
-            }
-        }
-
-        return result;
-    }
-
-    public async Task<string?> RestoreAllAsync()
-    {
-        if (!_isAvailable)
-            return "Git is not available.";
-
-        // Restore tracked files
-        var (restoreExit, restoreOutput) = await RunGitAsync("checkout", "--", ".");
-        if (restoreExit != 0)
-            return $"git restore failed: {restoreOutput}";
-
-        // Remove untracked files
-        var (cleanExit, cleanOutput) = await RunGitAsync("clean", "-fd");
-        if (cleanExit != 0)
-            return $"git clean failed: {cleanOutput}";
-
-        return null;
-    }
-
-    public async Task<string> GetCurrentBranchAsync()
-    {
-        if (!_isAvailable)
-            return string.Empty;
-
-        var (exitCode, output) = await RunGitAsync("branch", "--show-current");
-        return exitCode == 0 ? output : string.Empty;
-    }
-
-    public async Task<GitLogEntry[]> GetLogAsync(int count = 20)
-    {
-        if (!_isAvailable)
-            return [];
-
-        var (exitCode, output) = await RunGitAsync(
-            "log", $"-{count}", "--format=%h\t%s\t%an\t%ar");
-
-        if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
-            return [];
-
-        return output
-            .Split('\n', StringSplitOptions.RemoveEmptyEntries)
-            .Select(line =>
-            {
-                var parts = line.Split('\t', 4);
-                return parts.Length >= 4
-                    ? new GitLogEntry(parts[0], parts[1], parts[2], parts[3])
-                    : new GitLogEntry(parts.ElementAtOrDefault(0) ?? "", line, "", "");
-            })
-            .ToArray();
-    }
-
-    public async Task<bool> HasRemoteAsync()
-    {
-        if (!_isAvailable) return false;
-        var (exitCode, output) = await RunGitAsync("remote");
-        return exitCode == 0 && !string.IsNullOrWhiteSpace(output);
-    }
-
-    public async Task<GitSyncStatus> GetSyncStatusAsync()
-    {
-        if (!_isAvailable || !await HasRemoteAsync())
-            return new GitSyncStatus(0, 0, false);
-
-        // Fetch latest remote state (silent, no merge)
-        await RunGitAsync("fetch", "--quiet");
-
-        // Check if upstream tracking is configured
-        var (upstreamExit, _) = await RunGitAsync("rev-parse", "--abbrev-ref", "@{upstream}");
-        if (upstreamExit != 0)
-        {
-            // No upstream configured — count local commits as ahead
-            var (logExit, logOutput) = await RunGitAsync("rev-list", "--count", "HEAD");
-            var localCommits = logExit == 0 && int.TryParse(logOutput, out var c) ? c : 0;
-            return new GitSyncStatus(localCommits, 0, true);
-        }
-
-        var (aheadExit, aheadOutput) = await RunGitAsync("rev-list", "--count", "@{upstream}..HEAD");
-        var (behindExit, behindOutput) = await RunGitAsync("rev-list", "--count", "HEAD..@{upstream}");
-
-        var ahead = aheadExit == 0 && int.TryParse(aheadOutput, out var a) ? a : 0;
-        var behind = behindExit == 0 && int.TryParse(behindOutput, out var b) ? b : 0;
-
-        return new GitSyncStatus(ahead, behind, true);
-    }
-
-    public async Task<string?> PushAsync()
-    {
-        if (!_isAvailable) return "Git is not available.";
-        if (!await HasRemoteAsync()) return "No remote configured.";
-
-        // Use -u on first push to set upstream tracking
-        var (upstreamExit, _) = await RunGitAsync("rev-parse", "--abbrev-ref", "@{upstream}");
-        var (exitCode, output) = upstreamExit != 0
-            ? await RunGitAsync("push", "-u", "origin", "HEAD")
-            : await RunGitAsync("push");
-        return exitCode != 0 ? $"git push failed: {output}" : null;
-    }
-
-    public async Task<string?> PullAsync()
-    {
-        if (!_isAvailable) return "Git is not available.";
-        if (!await HasRemoteAsync()) return "No remote configured.";
-
-        var (exitCode, output) = await RunGitAsync("pull");
-        return exitCode != 0 ? $"git pull failed: {output}" : null;
-    }
-
-    private bool CheckGitAvailable()
-    {
-        try
-        {
-            var gitCheck = RunGitAsync("--version").GetAwaiter().GetResult();
-            if (gitCheck.ExitCode != 0) return false;
-
-            var repoCheck = RunGitAsync("rev-parse", "--is-inside-work-tree")
-                .GetAwaiter().GetResult();
-            return repoCheck.ExitCode == 0;
-        }
-        catch
-        {
-            return false;
-        }
-    }
-
-    private async Task<(int ExitCode, string Output)> RunGitAsync(params string[] arguments)
-    {
-        var psi = new ProcessStartInfo
-        {
-            FileName = "git",
-            WorkingDirectory = _workingDirectory,
-            RedirectStandardOutput = true,
-            RedirectStandardError = true,
-            UseShellExecute = false,
-            CreateNoWindow = true
-        };
-
-        foreach (var arg in arguments)
-            psi.ArgumentList.Add(arg);
-
-        using var process = Process.Start(psi)!;
-        var stdout = await process.StandardOutput.ReadToEndAsync();
-        var stderr = await process.StandardError.ReadToEndAsync();
-        await process.WaitForExitAsync();
-
-        var output = string.IsNullOrWhiteSpace(stdout) ? stderr : stdout;
-        return (process.ExitCode, output.Trim());
-    }
-}

+ 11 - 0
RackPeek.Domain/Git/GitStatus.cs

@@ -0,0 +1,11 @@
+namespace RackPeek.Domain.Git;
+
+public enum GitRepoStatus
+{
+    NotAvailable,
+    Clean,
+    Dirty
+}
+
+public record GitLogEntry(string Hash, string Message, string Author, string Date);
+public record GitSyncStatus(int Ahead, int Behind, bool HasRemote, string? Error = null);

+ 20 - 0
RackPeek.Domain/Git/IGitRepository.cs

@@ -0,0 +1,20 @@
+namespace RackPeek.Domain.Git;
+
+public interface IGitRepository
+{
+    bool IsAvailable { get; }
+    void Init();
+    GitRepoStatus GetStatus();
+    void StageAll();
+    void Commit(string message);
+    string GetDiff();
+    string[] GetChangedFiles();
+    void RestoreAll();
+    string GetCurrentBranch();
+    GitLogEntry[] GetLog(int count);
+    bool HasRemote();
+    GitSyncStatus FetchAndGetSyncStatus();
+    void Push();
+    void Pull();
+    void AddRemote(string name, string url);
+}

+ 0 - 27
RackPeek.Domain/Git/IGitService.cs

@@ -1,27 +0,0 @@
-namespace RackPeek.Domain.Git;
-
-public enum GitRepoStatus
-{
-    NotAvailable,
-    Clean,
-    Dirty
-}
-
-public interface IGitService
-{
-    bool IsAvailable { get; }
-    Task<GitRepoStatus> GetStatusAsync();
-    Task<string?> CommitAllAsync(string message);
-    Task<string[]> GetChangedFilesAsync();
-    Task<string> GetDiffAsync();
-    Task<string?> RestoreAllAsync();
-    Task<string> GetCurrentBranchAsync();
-    Task<GitLogEntry[]> GetLogAsync(int count = 20);
-    Task<bool> HasRemoteAsync();
-    Task<GitSyncStatus> GetSyncStatusAsync();
-    Task<string?> PushAsync();
-    Task<string?> PullAsync();
-}
-
-public record GitLogEntry(string Hash, string Message, string Author, string Date);
-public record GitSyncStatus(int Ahead, int Behind, bool HasRemote);

+ 236 - 0
RackPeek.Domain/Git/LibGit2GitRepository.cs

@@ -0,0 +1,236 @@
+using LibGit2Sharp;
+
+namespace RackPeek.Domain.Git;
+
+public sealed class LibGit2GitRepository : IGitRepository
+{
+    private readonly string _repoPath;
+    private bool _isAvailable;
+
+    public LibGit2GitRepository(string configDirectory)
+    {
+        _repoPath = configDirectory;
+        _isAvailable = Repository.IsValid(configDirectory);
+    }
+
+    public bool IsAvailable => _isAvailable;
+
+    public void Init()
+    {
+        Repository.Init(_repoPath);
+        _isAvailable = true;
+    }
+
+    public GitRepoStatus GetStatus()
+    {
+        if (!_isAvailable)
+            return GitRepoStatus.NotAvailable;
+
+        using var repo = new Repository(_repoPath);
+        return repo.RetrieveStatus().IsDirty ? GitRepoStatus.Dirty : GitRepoStatus.Clean;
+    }
+
+    public void StageAll()
+    {
+        using var repo = new Repository(_repoPath);
+        Commands.Stage(repo, "*");
+    }
+
+    public void Commit(string message)
+    {
+        using var repo = new Repository(_repoPath);
+        Signature signature = GetSignature(repo);
+        repo.Commit(message, signature, signature);
+    }
+
+    public string GetDiff()
+    {
+        using var repo = new Repository(_repoPath);
+        Patch changes = repo.Diff.Compare<Patch>(
+            repo.Head.Tip?.Tree,
+            DiffTargets.Index | DiffTargets.WorkingDirectory);
+        return changes?.Content ?? string.Empty;
+    }
+
+    public string[] GetChangedFiles()
+    {
+        using var repo = new Repository(_repoPath);
+        RepositoryStatus status = repo.RetrieveStatus();
+        return status
+            .Where(e => e.State != FileStatus.Ignored)
+            .Select(e =>
+            {
+                var prefix = e.State switch
+                {
+                    FileStatus.NewInWorkdir or FileStatus.NewInIndex => "A",
+                    FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => "D",
+                    FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex => "R",
+                    _ when e.State.HasFlag(FileStatus.ModifiedInWorkdir)
+                        || e.State.HasFlag(FileStatus.ModifiedInIndex) => "M",
+                    _ => "?"
+                };
+                return $"{prefix}  {e.FilePath}";
+            })
+            .ToArray();
+    }
+
+    public void RestoreAll()
+    {
+        using var repo = new Repository(_repoPath);
+        var options = new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force };
+        repo.CheckoutPaths(repo.Head.FriendlyName, new[] { "*" }, options);
+        repo.RemoveUntrackedFiles();
+    }
+
+    public string GetCurrentBranch()
+    {
+        using var repo = new Repository(_repoPath);
+        return repo.Head.FriendlyName;
+    }
+
+    public GitLogEntry[] GetLog(int count)
+    {
+        using var repo = new Repository(_repoPath);
+        if (repo.Head.Tip is null)
+            return [];
+
+        return repo.Commits
+            .Take(count)
+            .Select(c => new GitLogEntry(
+                c.Sha[..7],
+                c.MessageShort,
+                c.Author.Name,
+                FormatRelativeDate(c.Author.When)))
+            .ToArray();
+    }
+
+    public bool HasRemote()
+    {
+        using var repo = new Repository(_repoPath);
+        return repo.Network.Remotes.Any();
+    }
+
+    public GitSyncStatus FetchAndGetSyncStatus()
+    {
+        using var repo = new Repository(_repoPath);
+
+        if (!repo.Network.Remotes.Any())
+            return new GitSyncStatus(0, 0, false);
+
+        Remote remote = repo.Network.Remotes["origin"]
+            ?? repo.Network.Remotes.First();
+
+        var fetchOptions = new FetchOptions();
+        ConfigureCredentials(fetchOptions);
+        IEnumerable<string> refSpecs = remote.FetchRefSpecs.Select(r => r.Specification);
+        Commands.Fetch(repo, remote.Name, refSpecs, fetchOptions, null);
+
+        Branch? tracking = repo.Head.TrackedBranch;
+        if (tracking is null)
+        {
+            var localCount = repo.Head.Tip is not null
+                ? repo.Commits.Count()
+                : 0;
+            return new GitSyncStatus(localCount, 0, true);
+        }
+
+        HistoryDivergence divergence = repo.ObjectDatabase.CalculateHistoryDivergence(
+            repo.Head.Tip, tracking.Tip);
+
+        var ahead = divergence.AheadBy ?? 0;
+        var behind = divergence.BehindBy ?? 0;
+
+        return new GitSyncStatus(ahead, behind, true);
+    }
+
+    public void Push()
+    {
+        using var repo = new Repository(_repoPath);
+
+        Remote remote = repo.Network.Remotes["origin"]
+            ?? repo.Network.Remotes.First();
+
+        var pushOptions = new PushOptions();
+        ConfigureCredentials(pushOptions);
+
+        var pushRefSpec = $"refs/heads/{repo.Head.FriendlyName}";
+        repo.Network.Push(remote, pushRefSpec, pushOptions);
+
+        if (repo.Head.TrackedBranch is null)
+        {
+            Branch? remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
+            if (remoteBranch is not null)
+            {
+                repo.Branches.Update(repo.Head,
+                    b => b.TrackedBranch = remoteBranch.CanonicalName);
+            }
+        }
+    }
+
+    public void Pull()
+    {
+        using var repo = new Repository(_repoPath);
+
+        var pullOptions = new PullOptions
+        {
+            FetchOptions = new FetchOptions()
+        };
+        ConfigureCredentials(pullOptions.FetchOptions);
+
+        Signature signature = GetSignature(repo);
+        Commands.Pull(repo, signature, pullOptions);
+    }
+
+    public void AddRemote(string name, string url)
+    {
+        using var repo = new Repository(_repoPath);
+        repo.Network.Remotes.Add(name, url);
+    }
+
+    private static Signature GetSignature(Repository repo)
+    {
+        Configuration config = repo.Config;
+        var name = config.Get<string>("user.name")?.Value ?? "RackPeek";
+        var email = config.Get<string>("user.email")?.Value ?? "rackpeek@local";
+        return new Signature(name, email, DateTimeOffset.Now);
+    }
+
+    private static void ConfigureCredentials(FetchOptions options)
+    {
+        var token = Environment.GetEnvironmentVariable("GIT_TOKEN");
+        if (!string.IsNullOrEmpty(token))
+        {
+            options.CredentialsProvider = (_, _, _) =>
+                new UsernamePasswordCredentials
+                {
+                    Username = "git",
+                    Password = token
+                };
+        }
+    }
+
+    private static void ConfigureCredentials(PushOptions options)
+    {
+        var token = Environment.GetEnvironmentVariable("GIT_TOKEN");
+        if (!string.IsNullOrEmpty(token))
+        {
+            options.CredentialsProvider = (_, _, _) =>
+                new UsernamePasswordCredentials
+                {
+                    Username = "git",
+                    Password = token
+                };
+        }
+    }
+
+    private static string FormatRelativeDate(DateTimeOffset date)
+    {
+        TimeSpan diff = DateTimeOffset.Now - date;
+        if (diff.TotalMinutes < 1) return "just now";
+        if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} minutes ago";
+        if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} hours ago";
+        if (diff.TotalDays < 30) return $"{(int)diff.TotalDays} days ago";
+        if (diff.TotalDays < 365) return $"{(int)(diff.TotalDays / 30)} months ago";
+        return $"{(int)(diff.TotalDays / 365)} years ago";
+    }
+}

+ 20 - 0
RackPeek.Domain/Git/NullGitRepository.cs

@@ -0,0 +1,20 @@
+namespace RackPeek.Domain.Git;
+
+public sealed class NullGitRepository : IGitRepository
+{
+    public bool IsAvailable => false;
+    public void Init() { }
+    public GitRepoStatus GetStatus() => GitRepoStatus.NotAvailable;
+    public void StageAll() { }
+    public void Commit(string message) { }
+    public string GetDiff() => string.Empty;
+    public string[] GetChangedFiles() => [];
+    public void RestoreAll() { }
+    public string GetCurrentBranch() => string.Empty;
+    public GitLogEntry[] GetLog(int count) => [];
+    public bool HasRemote() => false;
+    public GitSyncStatus FetchAndGetSyncStatus() => new(0, 0, false);
+    public void Push() { }
+    public void Pull() { }
+    public void AddRemote(string name, string url) { }
+}

+ 0 - 17
RackPeek.Domain/Git/NullGitService.cs

@@ -1,17 +0,0 @@
-namespace RackPeek.Domain.Git;
-
-public sealed class NullGitService : IGitService
-{
-    public bool IsAvailable => false;
-    public Task<GitRepoStatus> GetStatusAsync() => Task.FromResult(GitRepoStatus.NotAvailable);
-    public Task<string?> CommitAllAsync(string message) => Task.FromResult<string?>("Not available.");
-    public Task<string[]> GetChangedFilesAsync() => Task.FromResult(Array.Empty<string>());
-    public Task<string> GetDiffAsync() => Task.FromResult(string.Empty);
-    public Task<string?> RestoreAllAsync() => Task.FromResult<string?>("Not available.");
-    public Task<string> GetCurrentBranchAsync() => Task.FromResult(string.Empty);
-    public Task<GitLogEntry[]> GetLogAsync(int count = 20) => Task.FromResult(Array.Empty<GitLogEntry>());
-    public Task<bool> HasRemoteAsync() => Task.FromResult(false);
-    public Task<GitSyncStatus> GetSyncStatusAsync() => Task.FromResult(new GitSyncStatus(0, 0, false));
-    public Task<string?> PushAsync() => Task.FromResult<string?>("Not available.");
-    public Task<string?> PullAsync() => Task.FromResult<string?>("Not available.");
-}

+ 11 - 0
RackPeek.Domain/Git/Queries/GetBranchQuery.cs

@@ -0,0 +1,11 @@
+namespace RackPeek.Domain.Git.Queries;
+
+public interface IGetBranchQuery
+{
+    string Execute();
+}
+
+public class GetBranchQuery(IGitRepository repo) : IGetBranchQuery
+{
+    public string Execute() => repo.IsAvailable ? repo.GetCurrentBranch() : string.Empty;
+}

+ 11 - 0
RackPeek.Domain/Git/Queries/GetChangedFilesQuery.cs

@@ -0,0 +1,11 @@
+namespace RackPeek.Domain.Git.Queries;
+
+public interface IGetChangedFilesQuery
+{
+    string[] Execute();
+}
+
+public class GetChangedFilesQuery(IGitRepository repo) : IGetChangedFilesQuery
+{
+    public string[] Execute() => repo.IsAvailable ? repo.GetChangedFiles() : [];
+}

+ 11 - 0
RackPeek.Domain/Git/Queries/GetDiffQuery.cs

@@ -0,0 +1,11 @@
+namespace RackPeek.Domain.Git.Queries;
+
+public interface IGetDiffQuery
+{
+    string Execute();
+}
+
+public class GetDiffQuery(IGitRepository repo) : IGetDiffQuery
+{
+    public string Execute() => repo.IsAvailable ? repo.GetDiff() : string.Empty;
+}

+ 12 - 0
RackPeek.Domain/Git/Queries/GetLogQuery.cs

@@ -0,0 +1,12 @@
+namespace RackPeek.Domain.Git.Queries;
+
+public interface IGetLogQuery
+{
+    GitLogEntry[] Execute(int count = 20);
+}
+
+public class GetLogQuery(IGitRepository repo) : IGetLogQuery
+{
+    public GitLogEntry[] Execute(int count = 20) =>
+        repo.IsAvailable ? repo.GetLog(count) : [];
+}

+ 11 - 0
RackPeek.Domain/Git/Queries/GetStatusQuery.cs

@@ -0,0 +1,11 @@
+namespace RackPeek.Domain.Git.Queries;
+
+public interface IGetStatusQuery
+{
+    GitRepoStatus Execute();
+}
+
+public class GetStatusQuery(IGitRepository repo) : IGetStatusQuery
+{
+    public GitRepoStatus Execute() => repo.GetStatus();
+}

+ 24 - 0
RackPeek.Domain/Git/Queries/GetSyncStatusQuery.cs

@@ -0,0 +1,24 @@
+namespace RackPeek.Domain.Git.Queries;
+
+public interface IGetSyncStatusQuery
+{
+    GitSyncStatus Execute();
+}
+
+public class GetSyncStatusQuery(IGitRepository repo) : IGetSyncStatusQuery
+{
+    public GitSyncStatus Execute()
+    {
+        if (!repo.IsAvailable || !repo.HasRemote())
+            return new GitSyncStatus(0, 0, false);
+
+        try
+        {
+            return repo.FetchAndGetSyncStatus();
+        }
+        catch (Exception ex)
+        {
+            return new GitSyncStatus(0, 0, true, $"Fetch failed: {ex.Message}");
+        }
+    }
+}

+ 34 - 0
RackPeek.Domain/Git/UseCases/AddRemoteUseCase.cs

@@ -0,0 +1,34 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public interface IAddRemoteUseCase
+{
+    Task<string?> ExecuteAsync(string url);
+}
+
+public class AddRemoteUseCase(IGitRepository repo) : IAddRemoteUseCase
+{
+    public Task<string?> ExecuteAsync(string url)
+    {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        if (string.IsNullOrWhiteSpace(url))
+            return Task.FromResult<string?>("URL is required.");
+
+        if (!url.Trim().StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+            return Task.FromResult<string?>("Only HTTPS URLs are supported.");
+
+        if (repo.HasRemote())
+            return Task.FromResult<string?>("Remote already configured.");
+
+        try
+        {
+            repo.AddRemote("origin", url.Trim());
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex)
+        {
+            return Task.FromResult<string?>($"Add remote failed: {ex.Message}");
+        }
+    }
+}

+ 30 - 0
RackPeek.Domain/Git/UseCases/CommitAllUseCase.cs

@@ -0,0 +1,30 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public interface ICommitAllUseCase
+{
+    Task<string?> ExecuteAsync(string message);
+}
+
+public class CommitAllUseCase(IGitRepository repo) : ICommitAllUseCase
+{
+    public Task<string?> ExecuteAsync(string message)
+    {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        try
+        {
+            repo.StageAll();
+
+            if (repo.GetStatus() != GitRepoStatus.Dirty)
+                return Task.FromResult<string?>(null);
+
+            repo.Commit(message);
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex)
+        {
+            return Task.FromResult<string?>($"Commit failed: {ex.Message}");
+        }
+    }
+}

+ 25 - 0
RackPeek.Domain/Git/UseCases/InitRepoUseCase.cs

@@ -0,0 +1,25 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public interface IInitRepoUseCase
+{
+    Task<string?> ExecuteAsync();
+}
+
+public class InitRepoUseCase(IGitRepository repo) : IInitRepoUseCase
+{
+    public Task<string?> ExecuteAsync()
+    {
+        if (repo.IsAvailable)
+            return Task.FromResult<string?>(null);
+
+        try
+        {
+            repo.Init();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex)
+        {
+            return Task.FromResult<string?>($"Init failed: {ex.Message}");
+        }
+    }
+}

+ 28 - 0
RackPeek.Domain/Git/UseCases/PullUseCase.cs

@@ -0,0 +1,28 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public interface IPullUseCase
+{
+    Task<string?> ExecuteAsync();
+}
+
+public class PullUseCase(IGitRepository repo) : IPullUseCase
+{
+    public Task<string?> ExecuteAsync()
+    {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        if (!repo.HasRemote())
+            return Task.FromResult<string?>("No remote configured.");
+
+        try
+        {
+            repo.Pull();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex)
+        {
+            return Task.FromResult<string?>($"Pull failed: {ex.Message}");
+        }
+    }
+}

+ 28 - 0
RackPeek.Domain/Git/UseCases/PushUseCase.cs

@@ -0,0 +1,28 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public interface IPushUseCase
+{
+    Task<string?> ExecuteAsync();
+}
+
+public class PushUseCase(IGitRepository repo) : IPushUseCase
+{
+    public Task<string?> ExecuteAsync()
+    {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        if (!repo.HasRemote())
+            return Task.FromResult<string?>("No remote configured.");
+
+        try
+        {
+            repo.Push();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex)
+        {
+            return Task.FromResult<string?>($"Push failed: {ex.Message}");
+        }
+    }
+}

+ 25 - 0
RackPeek.Domain/Git/UseCases/RestoreAllUseCase.cs

@@ -0,0 +1,25 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public interface IRestoreAllUseCase
+{
+    Task<string?> ExecuteAsync();
+}
+
+public class RestoreAllUseCase(IGitRepository repo) : IRestoreAllUseCase
+{
+    public Task<string?> ExecuteAsync()
+    {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        try
+        {
+            repo.RestoreAll();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex)
+        {
+            return Task.FromResult<string?>($"Restore failed: {ex.Message}");
+        }
+    }
+}

+ 5 - 4
RackPeek.Domain/RackPeek.Domain.csproj

@@ -7,10 +7,11 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="DocMigrator.Yaml" Version="10.0.3"/>
-        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3"/>
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3"/>
-        <PackageReference Include="YamlDotNet" Version="16.3.0"/>
+        <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
+        <PackageReference Include="LibGit2Sharp" Version="0.31.0" />
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
+        <PackageReference Include="YamlDotNet" Version="16.3.0" />
     </ItemGroup>
 
 </Project>

+ 15 - 1
RackPeek.Web.Viewer/Program.cs

@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Components.Web;
 using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
 using RackPeek.Domain;
 using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+using RackPeek.Domain.Git.Queries;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl;
@@ -26,7 +28,19 @@ public class Program {
         });
 
 
-        builder.Services.AddSingleton<IGitService>(new NullGitService());
+        builder.Services.AddSingleton<IGitRepository>(new NullGitRepository());
+        builder.Services.AddSingleton<IInitRepoUseCase, InitRepoUseCase>();
+        builder.Services.AddSingleton<ICommitAllUseCase, CommitAllUseCase>();
+        builder.Services.AddSingleton<IRestoreAllUseCase, RestoreAllUseCase>();
+        builder.Services.AddSingleton<IPushUseCase, PushUseCase>();
+        builder.Services.AddSingleton<IPullUseCase, PullUseCase>();
+        builder.Services.AddSingleton<IAddRemoteUseCase, AddRemoteUseCase>();
+        builder.Services.AddSingleton<IGetStatusQuery, GetStatusQuery>();
+        builder.Services.AddSingleton<IGetBranchQuery, GetBranchQuery>();
+        builder.Services.AddSingleton<IGetDiffQuery, GetDiffQuery>();
+        builder.Services.AddSingleton<IGetChangedFilesQuery, GetChangedFilesQuery>();
+        builder.Services.AddSingleton<IGetLogQuery, GetLogQuery>();
+        builder.Services.AddSingleton<IGetSyncStatusQuery, GetSyncStatusQuery>();
         builder.Services.AddScoped<ITextFileStore, WasmTextFileStore>();
 
         var resources = new ResourceCollection();

+ 15 - 1
RackPeek.Web/Program.cs

@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Hosting.StaticWebAssets;
 using RackPeek.Domain;
 using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+using RackPeek.Domain.Git.Queries;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Web.Api;
@@ -28,7 +30,19 @@ public class Program {
 
         Directory.CreateDirectory(yamlPath);
 
-        builder.Services.AddSingleton<IGitService>(new GitService(yamlPath));
+        builder.Services.AddSingleton<IGitRepository>(new LibGit2GitRepository(yamlPath));
+        builder.Services.AddSingleton<IInitRepoUseCase, InitRepoUseCase>();
+        builder.Services.AddSingleton<ICommitAllUseCase, CommitAllUseCase>();
+        builder.Services.AddSingleton<IRestoreAllUseCase, RestoreAllUseCase>();
+        builder.Services.AddSingleton<IPushUseCase, PushUseCase>();
+        builder.Services.AddSingleton<IPullUseCase, PullUseCase>();
+        builder.Services.AddSingleton<IAddRemoteUseCase, AddRemoteUseCase>();
+        builder.Services.AddSingleton<IGetStatusQuery, GetStatusQuery>();
+        builder.Services.AddSingleton<IGetBranchQuery, GetBranchQuery>();
+        builder.Services.AddSingleton<IGetDiffQuery, GetDiffQuery>();
+        builder.Services.AddSingleton<IGetChangedFilesQuery, GetChangedFilesQuery>();
+        builder.Services.AddSingleton<IGetLogQuery, GetLogQuery>();
+        builder.Services.AddSingleton<IGetSyncStatusQuery, GetSyncStatusQuery>();
 
         var yamlFilePath = Path.Combine(yamlPath, yamlFileName);
 

+ 182 - 28
Shared.Rcl/Layout/GitStatusIndicator.razor

@@ -1,8 +1,54 @@
 @using RackPeek.Domain.Git
-@inject IGitService GitService
+@using RackPeek.Domain.Git.UseCases
+@using RackPeek.Domain.Git.Queries
+@inject IGetStatusQuery GetStatus
+@inject IGetBranchQuery GetBranch
+@inject IGetDiffQuery GetDiff
+@inject IGetChangedFilesQuery GetChangedFiles
+@inject IGetLogQuery GetLog
+@inject IGetSyncStatusQuery GetSyncStatus
+@inject IInitRepoUseCase InitRepo
+@inject ICommitAllUseCase CommitAll
+@inject IRestoreAllUseCase RestoreAll
+@inject IPushUseCase PushUseCase
+@inject IPullUseCase PullUseCase
+@inject IAddRemoteUseCase AddRemoteUseCase
+@inject IGitRepository GitRepo
 @implements IDisposable
 
-@if (_status != GitRepoStatus.NotAvailable)
+@if (_status == GitRepoStatus.NotAvailable)
+{
+    <div class="flex items-center gap-2 text-sm" data-testid="git-init-indicator">
+        @if (_confirmInit)
+        {
+            <span class="text-zinc-400 text-xs">Enable git tracking?</span>
+            <button class="px-2 py-0.5 text-xs rounded bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
+                    disabled="@_isInitializing"
+                    data-testid="git-init-confirm"
+                    @onclick="InitRepoAsync">
+                @(_isInitializing ? "..." : "Yes")
+            </button>
+            <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
+                    data-testid="git-init-cancel"
+                    @onclick="() => _confirmInit = false">
+                No
+            </button>
+        }
+        else
+        {
+            <button class="px-2 py-1 text-xs rounded text-zinc-500 hover:text-emerald-400 hover:bg-zinc-800 transition"
+                    data-testid="git-init-button"
+                    @onclick="() => _confirmInit = true">
+                Enable Git
+            </button>
+        }
+        @if (_errorMessage is not null)
+        {
+            <span class="text-red-400 text-xs" data-testid="git-error">@_errorMessage</span>
+        }
+    </div>
+}
+else
 {
     <div class="relative flex items-center gap-2 text-sm" data-testid="git-status-indicator">
 
@@ -86,8 +132,41 @@
             </div>
         }
 
-        @* Sync button — separate from save, only when remote exists *@
-        @if (_hasRemote)
+        @* Sync / Remote section *@
+        @if (!_hasRemote)
+        {
+            <div class="relative flex items-center gap-1" data-testid="git-remote-group">
+                @if (_showAddRemote)
+                {
+                    <input type="text"
+                           class="px-2 py-1 text-xs rounded bg-zinc-800 border border-zinc-700 text-zinc-200 placeholder-zinc-500 w-56 focus:outline-none focus:border-emerald-500"
+                           placeholder="https://github.com/user/repo.git"
+                           @bind="_remoteUrl"
+                           @bind:event="oninput"
+                           data-testid="git-remote-url" />
+                    <button class="px-2 py-0.5 text-xs rounded bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
+                            disabled="@(string.IsNullOrWhiteSpace(_remoteUrl))"
+                            data-testid="git-remote-save"
+                            @onclick="AddRemoteAsync">
+                        Add
+                    </button>
+                    <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
+                            data-testid="git-remote-cancel"
+                            @onclick="CancelAddRemote">
+                        &times;
+                    </button>
+                }
+                else
+                {
+                    <button class="px-2 py-1 text-xs rounded text-zinc-500 hover:text-emerald-400 hover:bg-zinc-800 transition"
+                            data-testid="git-add-remote-button"
+                            @onclick="() => _showAddRemote = true">
+                        Add Remote
+                    </button>
+                }
+            </div>
+        }
+        else if (_hasRemote)
         {
             <div class="relative flex" data-testid="git-sync-group">
                 <button class="px-2 py-1 text-xs rounded text-zinc-400 hover:text-white hover:bg-zinc-700 transition disabled:opacity-50"
@@ -116,7 +195,13 @@
                 {
                     <div class="absolute top-full right-0 mt-1 bg-zinc-800 border border-zinc-700 rounded shadow-lg z-50 min-w-[140px]"
                          data-testid="git-sync-dropdown">
-                        @if (_syncStatus.Ahead > 0 || _syncStatus.Behind > 0)
+                        @if (_syncStatus.Error is not null)
+                        {
+                            <div class="px-3 py-2 text-xs text-red-400 border-b border-zinc-700">
+                                Fetch failed
+                            </div>
+                        }
+                        else if (_syncStatus.Ahead > 0 || _syncStatus.Behind > 0)
                         {
                             <div class="px-3 py-2 text-xs text-zinc-500 border-b border-zinc-700">
                                 @if (_syncStatus.Ahead > 0) { <span class="text-emerald-400">↑@_syncStatus.Ahead ahead</span> }
@@ -131,13 +216,13 @@
                             </div>
                         }
                         <button class="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 hover:text-white transition disabled:opacity-50"
-                                disabled="@(_isSyncing || _syncStatus.Ahead == 0)"
+                                disabled="@(_isSyncing)"
                                 data-testid="git-push-button"
                                 @onclick="PushAsync">
                             @(_isPushing ? "Pushing..." : "Push")
                         </button>
                         <button class="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 hover:text-white transition disabled:opacity-50"
-                                disabled="@(_isSyncing || _syncStatus.Behind == 0)"
+                                disabled="@(_isSyncing || _syncStatus.Behind == 0 || _syncStatus.Error is not null)"
                                 data-testid="git-pull-button"
                                 @onclick="PullAsync">
                             @(_isPulling ? "Pulling..." : "Pull")
@@ -286,23 +371,29 @@
     private bool _isFetching;
     private bool _isPushing;
     private bool _isPulling;
+    private bool _isInitializing;
+    private bool _confirmInit;
+    private bool _showAddRemote;
+    private string _remoteUrl = string.Empty;
 
     private bool _isBusy => _isCommitting || _isRestoring || _isSyncing;
     private bool _isSyncing => _isPushing || _isPulling || _isFetching;
 
     protected override async Task OnInitializedAsync()
     {
-        _status = await GitService.GetStatusAsync();
+        _status = GetStatus.Execute();
 
         if (_status == GitRepoStatus.NotAvailable)
             return;
 
-        _branch = await GitService.GetCurrentBranchAsync();
-        _hasRemote = await GitService.HasRemoteAsync();
+        _branch = GetBranch.Execute();
+        _hasRemote = GitRepo.HasRemote();
 
         _cts = new CancellationTokenSource();
         _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
         _ = PollStatusAsync(_cts.Token);
+
+        await Task.CompletedTask;
     }
 
     private async Task PollStatusAsync(CancellationToken ct)
@@ -313,7 +404,7 @@
             {
                 if (_isBusy) continue;
 
-                var newStatus = await GitService.GetStatusAsync();
+                GitRepoStatus newStatus = GetStatus.Execute();
                 if (newStatus != _status)
                 {
                     _status = newStatus;
@@ -326,7 +417,68 @@
         catch (OperationCanceledException) { }
     }
 
-    private async Task CommitAsync()
+    private async Task InitRepoAsync()
+    {
+        _errorMessage = null;
+        _isInitializing = true;
+
+        try
+        {
+            var error = await InitRepo.ExecuteAsync();
+            if (error is not null)
+            {
+                _errorMessage = error;
+                return;
+            }
+
+            _status = GetStatus.Execute();
+            _branch = GetBranch.Execute();
+            _hasRemote = GitRepo.HasRemote();
+
+            _cts = new CancellationTokenSource();
+            _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
+            _ = PollStatusAsync(_cts.Token);
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Init error: {ex.Message}";
+        }
+        finally
+        {
+            _isInitializing = false;
+        }
+    }
+
+    private void CancelAddRemote()
+    {
+        _showAddRemote = false;
+        _remoteUrl = string.Empty;
+    }
+
+    private async Task AddRemoteAsync()
+    {
+        _errorMessage = null;
+
+        try
+        {
+            var error = await AddRemoteUseCase.ExecuteAsync(_remoteUrl);
+            if (error is not null)
+            {
+                _errorMessage = error;
+                return;
+            }
+
+            _hasRemote = true;
+            _showAddRemote = false;
+            _remoteUrl = string.Empty;
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Remote error: {ex.Message}";
+        }
+    }
+
+        private async Task CommitAsync()
     {
         _errorMessage = null;
         _isCommitting = true;
@@ -335,13 +487,13 @@
 
         try
         {
-            var error = await GitService.CommitAllAsync(
+            var error = await CommitAll.ExecuteAsync(
                 $"rackpeek: save config {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
 
             if (error is not null)
                 _errorMessage = error;
 
-            _status = await GitService.GetStatusAsync();
+            _status = GetStatus.Execute();
         }
         catch (Exception ex)
         {
@@ -360,7 +512,7 @@
         _confirmDiscard = false;
     }
 
-    private async Task ToggleSyncAsync()
+    private void ToggleSyncAsync()
     {
         if (_showSyncDropdown)
         {
@@ -375,7 +527,9 @@
 
         try
         {
-            _syncStatus = await GitService.GetSyncStatusAsync();
+            _syncStatus = GetSyncStatus.Execute();
+            if (_syncStatus.Error is not null)
+                _errorMessage = _syncStatus.Error;
         }
         catch (Exception ex)
         {
@@ -394,11 +548,11 @@
         _confirmDiscard = false;
     }
 
-    private async Task OpenDiffAsync()
+    private void OpenDiffAsync()
     {
         _showDropdown = false;
-        _changedFiles = await GitService.GetChangedFilesAsync();
-        _diffContent = await GitService.GetDiffAsync();
+        _changedFiles = GetChangedFiles.Execute();
+        _diffContent = GetDiff.Execute();
         _showDiff = true;
     }
 
@@ -409,7 +563,7 @@
         _changedFiles = [];
     }
 
-    private async Task ToggleHistoryAsync()
+    private void ToggleHistoryAsync()
     {
         if (_showHistory)
         {
@@ -419,7 +573,7 @@
 
         _showDropdown = false;
         _showSyncDropdown = false;
-        _logEntries = await GitService.GetLogAsync();
+        _logEntries = GetLog.Execute();
         _showHistory = true;
     }
 
@@ -438,11 +592,11 @@
 
         try
         {
-            var error = await GitService.RestoreAllAsync();
+            var error = await RestoreAll.ExecuteAsync();
             if (error is not null)
                 _errorMessage = error;
 
-            _status = await GitService.GetStatusAsync();
+            _status = GetStatus.Execute();
         }
         catch (Exception ex)
         {
@@ -461,11 +615,11 @@
 
         try
         {
-            var error = await GitService.PushAsync();
+            var error = await PushUseCase.ExecuteAsync();
             if (error is not null)
                 _errorMessage = error;
 
-            _syncStatus = await GitService.GetSyncStatusAsync();
+            _syncStatus = GetSyncStatus.Execute();
         }
         catch (Exception ex)
         {
@@ -484,12 +638,12 @@
 
         try
         {
-            var error = await GitService.PullAsync();
+            var error = await PullUseCase.ExecuteAsync();
             if (error is not null)
                 _errorMessage = error;
 
-            _syncStatus = await GitService.GetSyncStatusAsync();
-            _status = await GitService.GetStatusAsync();
+            _syncStatus = GetSyncStatus.Execute();
+            _status = GetStatus.Execute();
         }
         catch (Exception ex)
         {