فهرست منبع

Mavnezz feature/git webui integration (#249)

* feat: Add git status indicator and controls to web UI header

Add a minimal git integration to the web UI that shows the current
branch name, change status, and provides save/diff/discard/history
controls directly in the header.

- GitService wrapping git CLI for status, commit, diff, restore, log
- NullGitService for WASM Viewer where git is not available
- GitStatusIndicator component with auto-polling every 5s
- Branch name clickable to open commit history modal
- Save button with dropdown for diff view and discard
- Color-coded diff output and changed file listing
- Gracefully hidden when git is not installed or not a repo

Ref #131

* feat: Add remote sync, push/pull, diff, discard and history to git UI

- Add push/pull support with automatic upstream tracking on first push
- Add separate Sync button for manual remote fetch (no auto-polling)
- Add diff viewer modal with color-coded output
- Add discard changes with confirmation prompt
- Add commit history modal via branch name click
- Split save dropdown (diff/discard) from sync dropdown (push/pull)

* 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

* Cleaned up

* Updated docs

---------

Co-authored-by: mavnezz <githubb.com@stuch.me>
Tim Jones 3 هفته پیش
والد
کامیت
df9c41019e

+ 2 - 22
README.md

@@ -1,28 +1,12 @@
 [![RackPeek demo](./assets/rackpeek_banner_thin.png)](./assets/rackpeek_banner_thin.png)
 
-![Version](https://img.shields.io/badge/Version-1.0.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
+![Version](https://img.shields.io/badge/Version-1.3.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
 [![Join our Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/egXRPdesee) [![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20RackPeek%20Online-2ea44f?logo=githubpages&logoColor=white)](https://timmoth.github.io/RackPeek/) [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-rackpeek-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/aptacode/rackpeek/)
 
-```
-Announcing v1.0.0, officially out of beta.  
-
-Thanks to everyone who tried early versions, opened issues, suggested changes, or used it in their lab and shared feedback.  
-   
-Appreciate all the support.
-```
-
-RackPeek is a lightweight, opinionated CLI tool / webui for documenting and managing home lab and small-scale IT infrastructure.
+RackPeek is a webui & CLI tool for documenting and managing home lab and small-scale IT infrastructure.
 
 It helps you track hardware, services, networks, and their relationships in a clear, scriptable, and reusable way without enterprise bloat or proprietary lock-in or drowning in unnecessary metadata or process.
 
-## Roadmap
-- Proxmox config / auto system creation
-- docker-gen ingestion / auto service creation
-- Support for IoT and networked devices (amongst other new hardware types)
-- Enhanced networking and port mapping
-- Git integration (version-controlled, shared configuration)
-- Diagramming tools
-
 ### The roadmap for the next wave of features is actively being discussed, please make your voice heard! 
 
 [![DB Tech — Finally Document Your Home Lab the Easy Way (Docker Install)](https://img.shields.io/badge/DB%20Tech%20[video]-Finally%20Document%20Your%20Home%20Lab%20the%20Easy%20Way-blue?style=for-the-badge)](https://www.youtube.com/watch?v=RJtMO8kIsqU)
@@ -92,10 +76,6 @@ volumes:
   [**Versioning**](https://timmoth.github.io/RackPeek/docs/versioning)
 
 
-## Contribution Guide
-
-We are now gearing up for the full v1.0.0 release, so development focus is on stability / bug fixes / essential core missing features. Please raise any suggestions / bugs / feedback in the Github issues.
-
 ## Questionnaire
 
 We’re gathering feedback from homelabbers to validate direction and prioritize features.  

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

@@ -0,0 +1,10 @@
+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);

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

@@ -0,0 +1,19 @@
+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);
+}

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

@@ -0,0 +1,213 @@
+using LibGit2Sharp;
+using LibGit2Sharp.Handlers;
+
+namespace RackPeek.Domain.Git;
+
+public interface IGitCredentialsProvider {
+    CredentialsHandler GetHandler();
+}
+public sealed class GitHubTokenCredentialsProvider(string username, string token) : IGitCredentialsProvider {
+    private readonly string _username = username ?? throw new ArgumentNullException(nameof(username));
+    private readonly string _token = token ?? throw new ArgumentNullException(nameof(token));
+
+    public CredentialsHandler GetHandler() {
+        return (_, _, _) => new UsernamePasswordCredentials {
+            Username = _username,
+            Password = _token
+        };
+    }
+}
+
+public sealed class LibGit2GitRepository(
+    string configDirectory,
+    IGitCredentialsProvider credentialsProvider) : IGitRepository {
+    private readonly CredentialsHandler _credentials = credentialsProvider.GetHandler();
+
+    private bool _isAvailable = Repository.IsValid(configDirectory);
+
+    public bool IsAvailable => _isAvailable;
+
+    public void Init() {
+        Repository.Init(configDirectory);
+        _isAvailable = true;
+    }
+
+    private Repository OpenRepo() => new(configDirectory);
+
+    private static Signature GetSignature(Repository repo) {
+        var name = repo.Config.Get<string>("user.name")?.Value ?? "RackPeek";
+        var email = repo.Config.Get<string>("user.email")?.Value ?? "rackpeek@local";
+
+        return new Signature(name, email, DateTimeOffset.Now);
+    }
+
+    private static Remote GetRemote(Repository repo) => repo.Network.Remotes["origin"] ?? repo.Network.Remotes.First();
+
+    public GitRepoStatus GetStatus() {
+        if (!_isAvailable)
+            return GitRepoStatus.NotAvailable;
+
+        using Repository repo = OpenRepo();
+
+        return repo.RetrieveStatus().IsDirty
+            ? GitRepoStatus.Dirty
+            : GitRepoStatus.Clean;
+    }
+
+    public void StageAll() {
+        using Repository repo = OpenRepo();
+        Commands.Stage(repo, "*");
+    }
+
+    public void Commit(string message) {
+        using Repository repo = OpenRepo();
+
+        Signature signature = GetSignature(repo);
+        repo.Commit(message, signature, signature);
+    }
+
+    public string GetDiff() {
+        using Repository repo = OpenRepo();
+
+        Patch patch = repo.Diff.Compare<Patch>(
+            repo.Head.Tip?.Tree,
+            DiffTargets.Index | DiffTargets.WorkingDirectory);
+
+        return patch?.Content ?? string.Empty;
+    }
+
+    public string[] GetChangedFiles() {
+        using Repository repo = OpenRepo();
+
+        return repo.RetrieveStatus()
+            .Where(e => e.State != FileStatus.Ignored)
+            .Select(e => $"{GetPrefix(e.State)}  {e.FilePath}")
+            .ToArray();
+    }
+
+    private static string GetPrefix(FileStatus state) => state switch {
+        FileStatus.NewInWorkdir or FileStatus.NewInIndex => "A",
+        FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => "D",
+        FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex => "R",
+        _ when state.HasFlag(FileStatus.ModifiedInWorkdir)
+          || state.HasFlag(FileStatus.ModifiedInIndex) => "M",
+        _ => "?"
+    };
+
+    public void RestoreAll() {
+        using Repository repo = OpenRepo();
+
+        repo.CheckoutPaths(
+            repo.Head.FriendlyName,
+            ["*"],
+            new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
+
+        repo.RemoveUntrackedFiles();
+    }
+
+    public string GetCurrentBranch() {
+        using Repository repo = OpenRepo();
+        return repo.Head.FriendlyName;
+    }
+
+    public GitLogEntry[] GetLog(int count) {
+        using Repository repo = OpenRepo();
+
+        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 Repository repo = OpenRepo();
+        return repo.Network.Remotes.Any();
+    }
+
+    public GitSyncStatus FetchAndGetSyncStatus() {
+        using Repository repo = OpenRepo();
+
+        if (!repo.Network.Remotes.Any())
+            return new(0, 0, false);
+
+        Remote remote = GetRemote(repo);
+
+        Commands.Fetch(
+            repo,
+            remote.Name,
+            remote.FetchRefSpecs.Select(r => r.Specification),
+            new FetchOptions { CredentialsProvider = _credentials },
+            null);
+
+        Branch? tracking = repo.Head.TrackedBranch;
+
+        if (tracking is null)
+            return new(repo.Commits.Count(), 0, true);
+
+        HistoryDivergence divergence = repo.ObjectDatabase.CalculateHistoryDivergence(
+            repo.Head.Tip,
+            tracking.Tip);
+
+        return new(
+            divergence.AheadBy ?? 0,
+            divergence.BehindBy ?? 0,
+            true);
+    }
+
+    public void Push() {
+        using Repository repo = OpenRepo();
+
+        Remote remote = GetRemote(repo);
+
+        repo.Network.Push(
+            remote,
+            $"refs/heads/{repo.Head.FriendlyName}",
+            new PushOptions { CredentialsProvider = _credentials });
+
+        if (repo.Head.TrackedBranch is null) {
+            Branch remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
+
+            if (remoteBranch != null) {
+                repo.Branches.Update(repo.Head,
+                    b => b.TrackedBranch = remoteBranch.CanonicalName);
+            }
+        }
+    }
+
+    public void Pull() {
+        using Repository repo = OpenRepo();
+
+        Commands.Pull(
+            repo,
+            GetSignature(repo),
+            new PullOptions {
+                FetchOptions = new FetchOptions {
+                    CredentialsProvider = _credentials
+                }
+            });
+    }
+
+    public void AddRemote(string name, string url) {
+        using Repository repo = OpenRepo();
+        repo.Network.Remotes.Add(name, url);
+    }
+
+    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";
+    }
+}

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

@@ -0,0 +1,19 @@
+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) { }
+}

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

@@ -0,0 +1,25 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class AddRemoteUseCase(IGitRepository repo) : IUseCase {
+    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}");
+        }
+    }
+}

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

@@ -0,0 +1,21 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class CommitAllUseCase(IGitRepository repo) : IUseCase {
+    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}");
+        }
+    }
+}

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

@@ -0,0 +1,16 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class InitRepoUseCase(IGitRepository repo) : IUseCase {
+    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}");
+        }
+    }
+}

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

@@ -0,0 +1,19 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class PullUseCase(IGitRepository repo) : IUseCase {
+    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}");
+        }
+    }
+}

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

@@ -0,0 +1,20 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+
+public class PushUseCase(IGitRepository repo) : IUseCase {
+    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}");
+        }
+    }
+}

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

@@ -0,0 +1,16 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class RestoreAllUseCase(IGitRepository repo) : IUseCase {
+    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}");
+        }
+    }
+}

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

@@ -7,10 +7,12 @@
     </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.Configuration" 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" />
     </ItemGroup>
 
 </Project>

+ 2 - 0
RackPeek.Domain/RpkConstants.cs

@@ -2,4 +2,6 @@ namespace RackPeek.Domain;
 
 public static class RpkConstants {
     public const string Version = "v1.3.0";
+
+    public static bool HasGitServices = false;
 }

+ 27 - 0
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -1,5 +1,7 @@
 using System.Reflection;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Connections;
@@ -20,6 +22,31 @@ public interface IResourceUseCase<T> where T : Resource {
 }
 
 public static class ServiceCollectionExtensions {
+
+    public static IServiceCollection AddGitServices(
+        this IServiceCollection services,
+        IConfiguration config,
+        string? yamlPath = null) {
+        var gitToken = config["GIT_TOKEN"];
+        if (!string.IsNullOrEmpty(gitToken) && !string.IsNullOrWhiteSpace(yamlPath)) {
+            var gitUsername = config["GIT_USERNAME"] ?? "git";
+
+            services.AddSingleton<IGitCredentialsProvider>(
+                _ => new GitHubTokenCredentialsProvider(gitUsername, gitToken));
+
+            services.AddSingleton<IGitRepository>(sp => {
+                IGitCredentialsProvider creds = sp.GetRequiredService<IGitCredentialsProvider>();
+                return new LibGit2GitRepository(yamlPath, creds);
+            });
+            RpkConstants.HasGitServices = true;
+        }
+        else {
+            RpkConstants.HasGitServices = false;
+            services.AddSingleton<IGitRepository, NullGitRepository>();
+        }
+
+        return services;
+    }
     public static IServiceCollection AddResourceUseCases(
         this IServiceCollection services,
         Assembly assembly) {

+ 2 - 0
RackPeek.Web.Viewer/Program.cs

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Components.Web;
 using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
 using RackPeek.Domain;
+using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl;
@@ -24,6 +25,7 @@ public class Program {
             };
         });
 
+        builder.Services.AddGitServices(builder.Configuration);
 
         builder.Services.AddScoped<ITextFileStore, WasmTextFileStore>();
 

+ 3 - 1
RackPeek.Web/Program.cs

@@ -2,6 +2,7 @@ using System.Text.Json.Serialization;
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Hosting.StaticWebAssets;
 using RackPeek.Domain;
+using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Web.Api;
@@ -53,9 +54,10 @@ public class Program {
             };
         });
 
+        builder.Services.AddGitServices(builder.Configuration, yamlPath);
+
         var resources = new ResourceCollection();
         builder.Services.AddSingleton(resources);
-
         builder.Services.AddScoped<RackPeekConfigMigrationDeserializer>();
         builder.Services.AddScoped<IResourceYamlMigrationService, ResourceYamlMigrationService>();
 

+ 696 - 0
Shared.Rcl/Layout/GitStatusIndicator.razor

@@ -0,0 +1,696 @@
+@using RackPeek.Domain.Git
+@using RackPeek.Domain.Git.UseCases
+@inject InitRepoUseCase InitRepo
+@inject CommitAllUseCase CommitAll
+@inject RestoreAllUseCase RestoreAll
+@inject PushUseCase PushUseCase
+@inject PullUseCase PullUseCase
+@inject AddRemoteUseCase AddRemoteUseCase
+@inject IGitRepository GitRepo
+@implements IDisposable
+
+@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">
+
+        @* Branch name — clickable to open history *@
+        @if (!string.IsNullOrEmpty(_branch))
+        {
+            <button class="text-zinc-400 text-xs hover:text-emerald-400 transition"
+                    data-testid="git-branch"
+                    @onclick="ToggleHistoryAsync">
+                @_branch
+            </button>
+        }
+
+        @if (_status == GitRepoStatus.Clean)
+        {
+            <span class="inline-block w-2 h-2 rounded-full bg-emerald-400"
+                  data-testid="git-status-dot-clean"
+                  title="All changes committed">
+            </span>
+            <span class="text-zinc-500 text-xs" data-testid="git-status-text">
+                Saved
+            </span>
+        }
+        else if (_status == GitRepoStatus.Dirty)
+        {
+            <span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse"
+                  data-testid="git-status-dot-dirty"
+                  title="Uncommitted changes">
+            </span>
+
+            @* Save button with dropdown toggle *@
+            <div class="relative flex" data-testid="git-save-group">
+                <button class="px-2 py-1 text-xs rounded-l bg-emerald-600 hover:bg-emerald-500 text-white transition disabled:opacity-50"
+                        disabled="@_isBusy"
+                        data-testid="git-save-button"
+                        @onclick="CommitAsync">
+                    @(_isCommitting ? "Saving..." : "Save")
+                </button>
+                <button class="px-1.5 py-1 text-xs rounded-r bg-emerald-700 hover:bg-emerald-600 text-white transition border-l border-emerald-800 disabled:opacity-50"
+                        disabled="@_isBusy"
+                        data-testid="git-save-dropdown"
+                        @onclick="ToggleDropdown">
+                    &#9662;
+                </button>
+
+                @if (_showDropdown)
+                {
+                    <div class="absolute top-full right-0 mt-1 bg-zinc-800 border border-zinc-700 rounded shadow-lg z-50 min-w-[120px]"
+                         data-testid="git-dropdown-menu">
+                        <button class="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-700 hover:text-white transition"
+                                data-testid="git-diff-button"
+                                @onclick="OpenDiffAsync">
+                            Diff
+                        </button>
+                        @if (!_confirmDiscard)
+                        {
+                            <button class="w-full text-left px-3 py-2 text-xs text-zinc-400 hover:bg-zinc-700 hover:text-red-400 transition"
+                                    data-testid="git-discard-button"
+                                    @onclick="() => _confirmDiscard = true">
+                                Discard
+                            </button>
+                        }
+                        else
+                        {
+                            <div class="px-3 py-2 flex items-center gap-2">
+                                <span class="text-red-400 text-xs">Sure?</span>
+                                <button class="px-2 py-0.5 text-xs rounded bg-red-600 hover:bg-red-500 text-white transition"
+                                        data-testid="git-discard-confirm"
+                                        @onclick="DiscardAsync">
+                                    Yes
+                                </button>
+                                <button class="px-2 py-0.5 text-xs rounded text-zinc-400 hover:text-white transition"
+                                        data-testid="git-discard-cancel"
+                                        @onclick="() => _confirmDiscard = false">
+                                    No
+                                </button>
+                            </div>
+                        }
+                    </div>
+                }
+            </div>
+        }
+
+        @* 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"
+                        disabled="@_isSyncing"
+                        data-testid="git-sync-button"
+                        @onclick="ToggleSyncAsync">
+                    @if (_isFetching)
+                    {
+                        <span>Checking...</span>
+                    }
+                    else if (_syncStatus.Ahead > 0 || _syncStatus.Behind > 0)
+                    {
+                        <span>
+                            Sync
+                            @if (_syncStatus.Ahead > 0) { <span class="text-emerald-400">↑@_syncStatus.Ahead</span> }
+                            @if (_syncStatus.Behind > 0) { <span class="text-blue-400">↓@_syncStatus.Behind</span> }
+                        </span>
+                    }
+                    else
+                    {
+                        <span>Sync</span>
+                    }
+                </button>
+
+                @if (_showSyncDropdown)
+                {
+                    <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.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> }
+                                @if (_syncStatus.Ahead > 0 && _syncStatus.Behind > 0) { <span> · </span> }
+                                @if (_syncStatus.Behind > 0) { <span class="text-blue-400">↓@_syncStatus.Behind behind</span> }
+                            </div>
+                        }
+                        else
+                        {
+                            <div class="px-3 py-2 text-xs text-zinc-500 border-b border-zinc-700">
+                                Up to date
+                            </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)"
+                                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 || _syncStatus.Error is not null)"
+                                data-testid="git-pull-button"
+                                @onclick="PullAsync">
+                            @(_isPulling ? "Pulling..." : "Pull")
+                        </button>
+                    </div>
+                }
+            </div>
+        }
+
+        @if (_errorMessage is not null)
+        {
+            <span class="text-red-400 text-xs" data-testid="git-error">
+                @_errorMessage
+            </span>
+        }
+    </div>
+
+    @* Dropdown backdrop — closes all dropdowns on outside click *@
+    @if (_showDropdown || _showSyncDropdown)
+    {
+        <div class="fixed inset-0 z-40" @onclick="CloseAllDropdowns"></div>
+    }
+
+    @* Diff Modal *@
+    @if (_showDiff)
+    {
+        <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
+             data-testid="git-diff-overlay"
+             @onclick="CloseDiff">
+            <div class="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col"
+                 @onclick:stopPropagation="true">
+
+                <div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
+                    <div class="flex items-center gap-3">
+                        <span class="text-sm font-semibold text-zinc-200">Changes</span>
+                        <span class="text-xs text-zinc-500">@_changedFiles.Length file(s)</span>
+                    </div>
+                    <button class="text-zinc-400 hover:text-white transition text-lg"
+                            data-testid="git-diff-close"
+                            @onclick="CloseDiff">
+                        &times;
+                    </button>
+                </div>
+
+                @if (_changedFiles.Length > 0)
+                {
+                    <div class="px-4 py-2 border-b border-zinc-800 flex flex-wrap gap-2">
+                        @foreach (var file in _changedFiles)
+                        {
+                            var status = file.Length >= 2 ? file[..2].Trim() : "?";
+                            var name = file.Length >= 3 ? file[3..] : file;
+                            var color = status switch
+                            {
+                                "M" => "text-amber-400",
+                                "A" or "?" => "text-emerald-400",
+                                "D" => "text-red-400",
+                                _ => "text-zinc-400"
+                            };
+                            <span class="text-xs">
+                                <span class="@color font-bold">@status</span>
+                                <span class="text-zinc-300">@name</span>
+                            </span>
+                        }
+                    </div>
+                }
+
+                <div class="overflow-auto flex-1 p-4">
+                    <pre class="text-xs leading-relaxed whitespace-pre-wrap">@((MarkupString)FormatDiff(_diffContent))</pre>
+                </div>
+            </div>
+        </div>
+    }
+
+    @* History Modal *@
+    @if (_showHistory)
+    {
+        <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
+             data-testid="git-history-overlay"
+             @onclick="CloseHistory">
+            <div class="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl w-[90vw] max-w-3xl max-h-[80vh] flex flex-col"
+                 @onclick:stopPropagation="true">
+
+                <div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
+                    <div class="flex items-center gap-3">
+                        <span class="text-sm font-semibold text-zinc-200">History</span>
+                        <span class="text-xs text-zinc-500">@_branch</span>
+                        <span class="text-xs text-zinc-600">@_logEntries.Length commits</span>
+                    </div>
+                    <button class="text-zinc-400 hover:text-white transition text-lg"
+                            data-testid="git-history-close"
+                            @onclick="CloseHistory">
+                        &times;
+                    </button>
+                </div>
+
+                <div class="overflow-auto flex-1">
+                    @if (_logEntries.Length == 0)
+                    {
+                        <div class="p-4 text-zinc-500 text-sm">No commits yet.</div>
+                    }
+                    else
+                    {
+                        <div class="divide-y divide-zinc-800">
+                            @foreach (var entry in _logEntries)
+                            {
+                                <div class="px-4 py-3 hover:bg-zinc-800/50 transition" data-testid="git-log-entry">
+                                    <div class="flex items-center gap-3">
+                                        <span class="text-xs text-emerald-400 font-mono shrink-0">@entry.Hash</span>
+                                        <span class="text-sm text-zinc-200 truncate">@entry.Message</span>
+                                    </div>
+                                    <div class="flex items-center gap-3 mt-1">
+                                        <span class="text-xs text-zinc-500">@entry.Author</span>
+                                        <span class="text-xs text-zinc-600">@entry.Date</span>
+                                    </div>
+                                </div>
+                            }
+                        </div>
+                    }
+                </div>
+            </div>
+        </div>
+    }
+}
+
+@code {
+    private GitRepoStatus _status = GitRepoStatus.NotAvailable;
+    private string _branch = string.Empty;
+    private bool _isCommitting;
+    private bool _isRestoring;
+    private bool _confirmDiscard;
+    private bool _showDropdown;
+    private string? _errorMessage;
+    private PeriodicTimer? _timer;
+    private CancellationTokenSource? _cts;
+
+    private bool _showDiff;
+    private string _diffContent = string.Empty;
+    private string[] _changedFiles = [];
+
+    private bool _showHistory;
+    private GitLogEntry[] _logEntries = [];
+
+    private bool _hasRemote;
+    private GitSyncStatus _syncStatus = new(0, 0, false);
+    private bool _showSyncDropdown;
+    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 = GitRepo.GetStatus();
+
+        if (_status == GitRepoStatus.NotAvailable)
+            return;
+
+        _branch = GitRepo.GetCurrentBranch();
+        _hasRemote = GitRepo.HasRemote();
+
+        _cts?.Cancel();
+        _timer?.Dispose();
+        
+        _cts = new CancellationTokenSource();
+        _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
+        _ = PollStatusAsync(_cts.Token);
+
+        await Task.CompletedTask;
+    }
+
+    private async Task PollStatusAsync(CancellationToken ct)
+    {
+        try
+        {
+            while (_timer != null && await _timer.WaitForNextTickAsync(ct))
+            {
+                if (_isBusy)
+                    continue;
+
+                var newStatus = GitRepo.GetStatus();
+
+                if (newStatus != _status)
+                {
+                    _status = newStatus;
+                    _confirmDiscard = false;
+                    _showDropdown = false;
+
+                    await InvokeAsync(StateHasChanged);
+                }
+            }
+        }
+        catch (OperationCanceledException)
+        {
+        }
+    }
+
+    private async Task InitRepoAsync()
+    {
+        _errorMessage = null;
+        _isInitializing = true;
+
+        try
+        {
+            var error = await InitRepo.ExecuteAsync();
+
+            if (error is not null)
+            {
+                _errorMessage = error;
+                return;
+            }
+
+            _status = GitRepo.GetStatus();
+            _branch = GitRepo.GetCurrentBranch();
+            _hasRemote = GitRepo.HasRemote();
+
+            _cts?.Cancel();
+            _timer?.Dispose();
+
+            _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;
+        _confirmDiscard = false;
+        _showDropdown = false;
+
+        try
+        {
+            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 = GitRepo.GetStatus();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Unexpected error: {ex.Message}";
+        }
+        finally
+        {
+            _isCommitting = false;
+        }
+    }
+
+    private void ToggleDropdown()
+    {
+        _showDropdown = !_showDropdown;
+        _showSyncDropdown = false;
+        _confirmDiscard = false;
+    }
+
+    private void ToggleSyncAsync()
+    {
+        if (_showSyncDropdown)
+        {
+            _showSyncDropdown = false;
+            return;
+        }
+
+        _showDropdown = false;
+        _errorMessage = null;
+        _isFetching = true;
+        _showSyncDropdown = true;
+
+        try
+        {
+            _syncStatus = GitRepo.FetchAndGetSyncStatus();
+            if (_syncStatus.Error is not null)
+                _errorMessage = _syncStatus.Error;
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Fetch error: {ex.Message}";
+        }
+        finally
+        {
+            _isFetching = false;
+        }
+    }
+
+    private void CloseAllDropdowns()
+    {
+        _showDropdown = false;
+        _showSyncDropdown = false;
+        _confirmDiscard = false;
+    }
+
+    private void OpenDiffAsync()
+    {
+        _showDropdown = false;
+        _changedFiles = GitRepo.GetChangedFiles();
+        _diffContent = GitRepo.GetDiff();
+        _showDiff = true;
+    }
+
+    private void CloseDiff()
+    {
+        _showDiff = false;
+        _diffContent = string.Empty;
+        _changedFiles = [];
+    }
+
+    private void ToggleHistoryAsync()
+    {
+        if (_showHistory)
+        {
+            CloseHistory();
+            return;
+        }
+
+        _showDropdown = false;
+        _showSyncDropdown = false;
+        _logEntries = GitRepo.GetLog(20);
+        _showHistory = true;
+    }
+
+    private void CloseHistory()
+    {
+        _showHistory = false;
+        _logEntries = [];
+    }
+
+    private async Task DiscardAsync()
+    {
+        _errorMessage = null;
+        _isRestoring = true;
+        _confirmDiscard = false;
+        _showDropdown = false;
+
+        try
+        {
+            var error = await RestoreAll.ExecuteAsync();
+            if (error is not null)
+                _errorMessage = error;
+
+            _status = GitRepo.GetStatus();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Unexpected error: {ex.Message}";
+        }
+        finally
+        {
+            _isRestoring = false;
+        }
+    }
+
+    private async Task PushAsync()
+    {
+        _errorMessage = null;
+        _isPushing = true;
+
+        try
+        {
+            var error = await PushUseCase.ExecuteAsync();
+            if (error is not null)
+                _errorMessage = error;
+
+            _syncStatus = GitRepo.FetchAndGetSyncStatus();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Push error: {ex.Message}";
+        }
+        finally
+        {
+            _isPushing = false;
+        }
+    }
+
+    private async Task PullAsync()
+    {
+        _errorMessage = null;
+        _isPulling = true;
+
+        try
+        {
+            var error = await PullUseCase.ExecuteAsync();
+            if (error is not null)
+                _errorMessage = error;
+
+            _syncStatus = GitRepo.FetchAndGetSyncStatus();
+            _status = GitRepo.GetStatus();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Pull error: {ex.Message}";
+        }
+        finally
+        {
+            _isPulling = false;
+        }
+    }
+
+    private static string FormatDiff(string diff)
+    {
+        if (string.IsNullOrWhiteSpace(diff))
+            return "<span class=\"text-zinc-500\">No diff available</span>";
+
+        var lines = diff.Split('\n');
+        var sb = new System.Text.StringBuilder();
+
+        foreach (var line in lines)
+        {
+            var escaped = System.Net.WebUtility.HtmlEncode(line);
+            if (line.StartsWith('+') && !line.StartsWith("+++"))
+                sb.AppendLine($"<span class=\"text-emerald-400\">{escaped}</span>");
+            else if (line.StartsWith('-') && !line.StartsWith("---"))
+                sb.AppendLine($"<span class=\"text-red-400\">{escaped}</span>");
+            else if (line.StartsWith("@@"))
+                sb.AppendLine($"<span class=\"text-blue-400\">{escaped}</span>");
+            else if (line.StartsWith("diff "))
+                sb.AppendLine($"<span class=\"text-amber-300 font-bold\">{escaped}</span>");
+            else
+                sb.AppendLine($"<span class=\"text-zinc-400\">{escaped}</span>");
+        }
+
+        return sb.ToString();
+    }
+
+    public void Dispose()
+    {
+        _cts?.Cancel();
+        _cts?.Dispose();
+        _timer?.Dispose();
+    }
+}

+ 62 - 54
Shared.Rcl/Layout/MainLayout.razor

@@ -26,60 +26,68 @@
             </div>
         </NavLink>
 
-        <nav class="space-x-6 text-sm" data-testid="main-nav">
-
-            <NavLink href=""
-                     Match="NavLinkMatch.All"
-                     data-testid="nav-home"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold">
-                Home
-            </NavLink>
-
-            <NavLink href="cli"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-cli">
-                CLI
-            </NavLink>
-
-            <NavLink href="yaml"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-yaml">
-                Yaml
-            </NavLink>
-
-            <NavLink href="hardware/tree"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-hardware">
-                Hardware
-            </NavLink>
-
-            <NavLink href="systems/list"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-systems">
-                Systems
-            </NavLink>
-
-            <NavLink href="services/list"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-services">
-                Services
-            </NavLink>
-
-            <NavLink href="docs"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-docs">
-                Docs
-            </NavLink>
-
-
-        </nav>
+        <div class="flex items-center gap-6">
+            @if (RpkConstants.HasGitServices)
+            {
+                <GitStatusIndicator/>
+            }
+
+            <nav class="space-x-6 text-sm" data-testid="main-nav">
+
+                <NavLink href=""
+                         Match="NavLinkMatch.All"
+                         data-testid="nav-home"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold">
+                    Home
+                </NavLink>
+
+                <NavLink href="cli"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-cli">
+                    CLI
+                </NavLink>
+
+                <NavLink href="yaml"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-yaml">
+                    Yaml
+                </NavLink>
+
+                <NavLink href="hardware/tree"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-hardware">
+                    Hardware
+                </NavLink>
+
+                <NavLink href="systems/list"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-systems">
+                    Systems
+                </NavLink>
+
+                <NavLink href="services/list"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-services">
+                    Services
+                </NavLink>
+
+                <NavLink href="docs"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-docs">
+                    Docs
+                </NavLink>
+
+
+
+            </nav>
+        </div>
     </header>
 
     <main class="p-6" data-testid="page-content">

+ 1 - 0
Shared.Rcl/wwwroot/raw_docs/docs-index.json

@@ -2,6 +2,7 @@
   "overview.md",
   "resource-levels.md",
   "install-guide.md",
+  "git-integration.md",
   "ansible-generator-guide.md",
   "docker-gen-guide.md",
   "cli-commands.md",

+ 44 - 0
Shared.Rcl/wwwroot/raw_docs/git-integration.md

@@ -0,0 +1,44 @@
+# Git Integration Guide
+
+RackPeek can automatically save and sync its configuration using Git.
+To enable this you need a GitHub Personal Access Token with permission to push to the repository that will store your config.
+
+Create a fine-grained access token on GitHub. Select the repository that will contain your RackPeek config and grant **Contents: Read and Write** access. Copy the token when it is created.
+
+Provide the token to the container using the `GIT_TOKEN` environment variable. You should also provide your GitHub username with `GIT_USERNAME`.
+
+Example using Docker Compose:
+
+```yaml
+version: "3.9"
+
+services:
+  rackpeek:
+    image: aptacode/rackpeek:latest
+    container_name: rackpeek
+    ports:
+      - "8080:8080"
+    volumes:
+      - rackpeek-config:/app/config
+    environment:
+      - GIT_TOKEN=your_token_here
+      - GIT_USERNAME=your_github_username
+    restart: unless-stopped
+
+volumes:
+  rackpeek-config:
+```
+
+Example using the Docker CLI:
+
+```bash
+docker run -d \
+  --name rackpeek \
+  -p 8080:8080 \
+  -v rackpeek-config:/app/config \
+  -e GIT_TOKEN=your_token_here \
+  -e GIT_USERNAME=your_github_username \
+  aptacode/rackpeek:latest
+```
+
+Open RackPeek in the browser, enable Git when prompted, then add the repository remote URL. RackPeek will commit and sync configuration changes automatically.