Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion PolyPilot.Tests/ConnectionSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ public void DefaultValues_NewFields_AreCorrect()
Assert.Null(settings.ServerPassword);
Assert.False(settings.DirectSharingEnabled);
Assert.Equal(CliSourceMode.BuiltIn, settings.CliSource);
Assert.Null(settings.RepositoryStorageRoot);
}

[Fact]
Expand All @@ -154,7 +155,8 @@ public void Save_Load_RoundTrip_WithNewFields()
Port = 4321,
ServerPassword = "mypass",
DirectSharingEnabled = true,
CliSource = CliSourceMode.System
CliSource = CliSourceMode.System,
RepositoryStorageRoot = "D:\\DevDrive\\PolyPilot"
};

var json = JsonSerializer.Serialize(original);
Expand All @@ -164,6 +166,7 @@ public void Save_Load_RoundTrip_WithNewFields()
Assert.Equal("mypass", loaded!.ServerPassword);
Assert.True(loaded.DirectSharingEnabled);
Assert.Equal(CliSourceMode.System, loaded.CliSource);
Assert.Equal("D:\\DevDrive\\PolyPilot", loaded.RepositoryStorageRoot);
}

[Fact]
Expand All @@ -178,6 +181,19 @@ public void BackwardCompatibility_OldJsonWithoutNewFields()
Assert.Null(loaded.ServerPassword);
Assert.False(loaded.DirectSharingEnabled);
Assert.Equal(CliSourceMode.BuiltIn, loaded.CliSource);
Assert.Null(loaded.RepositoryStorageRoot);
}

[Fact]
public void NormalizeRepositoryStorageRoot_Whitespace_ReturnsNull()
{
Assert.Null(ConnectionSettings.NormalizeRepositoryStorageRoot(" "));
}

[Fact]
public void NormalizeRepositoryStorageRoot_TrimmedPath_ReturnsTrimmed()
{
Assert.Equal("C:\\Dev", ConnectionSettings.NormalizeRepositoryStorageRoot(" C:\\Dev "));
}

[Fact]
Expand Down
112 changes: 112 additions & 0 deletions PolyPilot.Tests/RepoManagerStorageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Reflection;
using System.Text.Json;
using PolyPilot.Models;
using PolyPilot.Services;

namespace PolyPilot.Tests;

public class RepoManagerStorageTests
{
private static RepoManager CreateRepoManagerWithState(RepositoryState state)
{
var rm = new RepoManager();
var stateField = typeof(RepoManager).GetField("_state", BindingFlags.NonPublic | BindingFlags.Instance)!;
var loadedField = typeof(RepoManager).GetField("_loaded", BindingFlags.NonPublic | BindingFlags.Instance)!;
stateField.SetValue(rm, state);
loadedField.SetValue(rm, true);
return rm;
}

private static void InvokeBackfillWorktreeClonePaths(RepoManager rm, RepositoryInfo repo)
{
var method = typeof(RepoManager).GetMethod("BackfillWorktreeClonePaths", BindingFlags.NonPublic | BindingFlags.Instance)!;
method.Invoke(rm, new object[] { repo });
}

[Fact]
public void BackfillWorktreeClonePaths_FillsMissingPaths()
{
var repo = new RepositoryInfo { Id = "owner-repo", BareClonePath = "/old/repos/owner-repo.git" };
var state = new RepositoryState
{
Repositories = new List<RepositoryInfo> { repo },
Worktrees = new List<WorktreeInfo>
{
new() { Id = "wt1", RepoId = "owner-repo", Path = "/old/worktrees/owner-repo-a1b2c3d4" },
new() { Id = "wt2", RepoId = "other-repo", Path = "/old/worktrees/other-repo-e5f6g7h8" }
}
};
var rm = CreateRepoManagerWithState(state);

InvokeBackfillWorktreeClonePaths(rm, repo);

Assert.Equal("/old/repos/owner-repo.git", state.Worktrees[0].BareClonePath);
Assert.Null(state.Worktrees[1].BareClonePath);
}

[Fact]
public void BackfillWorktreeClonePaths_DoesNotOverwriteExistingPath()
{
var repo = new RepositoryInfo { Id = "owner-repo", BareClonePath = "/new/repos/owner-repo.git" };
var state = new RepositoryState
{
Repositories = new List<RepositoryInfo> { repo },
Worktrees = new List<WorktreeInfo>
{
new()
{
Id = "wt1",
RepoId = "owner-repo",
Path = "/old/worktrees/owner-repo-a1b2c3d4",
BareClonePath = "/old/repos/owner-repo.git"
}
}
};
var rm = CreateRepoManagerWithState(state);

InvokeBackfillWorktreeClonePaths(rm, repo);

Assert.Equal("/old/repos/owner-repo.git", state.Worktrees[0].BareClonePath);
}

[Fact]
public void RepositoryState_WorktreeBareClonePath_RoundTripsJson()
{
var original = new RepositoryState
{
Repositories = new List<RepositoryInfo>
{
new() { Id = "owner-repo", BareClonePath = "/repos/owner-repo.git", Url = "https://github.com/owner/repo" }
},
Worktrees = new List<WorktreeInfo>
{
new() { Id = "wt1", RepoId = "owner-repo", Path = "/worktrees/owner-repo-1234", BareClonePath = "/repos/owner-repo.git" }
}
};

var json = JsonSerializer.Serialize(original);
var roundTrip = JsonSerializer.Deserialize<RepositoryState>(json);

Assert.NotNull(roundTrip);
Assert.Single(roundTrip!.Worktrees);
Assert.Equal("/repos/owner-repo.git", roundTrip.Worktrees[0].BareClonePath);
}

[Fact]
public async Task RecloneAllRepositoriesToCurrentRootAsync_NoRepos_Completes()
{
var rm = CreateRepoManagerWithState(new RepositoryState());
await rm.RecloneAllRepositoriesToCurrentRootAsync();
Assert.Empty(rm.Repositories);
}

[Fact]
public void PathsEqual_IgnoresTrailingSeparator()
{
var method = typeof(RepoManager).GetMethod("PathsEqual", BindingFlags.NonPublic | BindingFlags.Static)!;
var root = Path.Combine(Path.GetTempPath(), "polypilot-repo-root");
var withTrailing = root + Path.DirectorySeparatorChar;
var equal = (bool)method.Invoke(null, new object[] { withTrailing, root })!;
Assert.True(equal);
}
}
111 changes: 110 additions & 1 deletion PolyPilot/Components/Pages/Settings.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
@inject QrScannerService QrScanner
@inject GitAutoUpdateService GitAutoUpdate
@inject FiestaService FiestaService
@inject RepoManager RepoManager
@inject NavigationManager Nav
@inject IJSRuntime JS
@inject IServiceProvider ServiceProvider
Expand Down Expand Up @@ -384,6 +385,40 @@
</div>
}

@if (PlatformHelper.IsDesktop)
{
<div class="settings-section @(SectionVisible("repository repo clone worktree storage root directory dev drive") ? "" : "search-hidden")">
<h3>Repository Storage</h3>
<p class="mode-hint">Root folder used for managed bare clones and worktrees.</p>
<div class="url-input">
<label>Storage Root:</label>
<input type="text" @bind="settings.RepositoryStorageRoot" placeholder="@RepoManager.GetEffectiveStorageRoot()" class="form-input wide" />
</div>
<div class="server-buttons">
<button class="start-btn" @onclick="BrowseRepositoryStorageRoot">Browse…</button>
<button class="stop-btn" @onclick="UseDefaultRepositoryStorageRoot">Use Default</button>
</div>
@if (RepositoryStorageRootChanged)
{
<div class="mode-changed-hint">
<span>Existing sessions/worktrees stay where they are. New worktrees use the new root after save.</span>
</div>
@if (RepoManager.Repositories.Count > 0)
{
<div class="mode-changed-hint">
<button class="reconnect-inline-btn" @onclick="RecloneAllRegisteredRepos" disabled="@recloneBusy">
@(recloneBusy ? "Re-cloning..." : "Re-clone all registered repos in new root")
</button>
@if (!string.IsNullOrWhiteSpace(recloneProgress))
{
<span>@recloneProgress</span>
}
</div>
}
}
</div>
}

<div class="settings-section">
<div class="save-row">
<button class="save-btn" @onclick="SaveAndApply">
Expand Down Expand Up @@ -543,6 +578,8 @@
private bool tunnelLoggedIn;
private bool tunnelBusy;
private bool showToken;
private bool recloneBusy;
private string? recloneProgress;
private bool showQrCode;
private bool showDirectQrCode;
private string? qrCodeDataUri;
Expand All @@ -555,6 +592,7 @@
private CliSourceMode _initialCliSource;
private ConnectionMode _initialMode;
private bool cliSourceChanged;
private string _initialRepositoryStorageRoot = "";
private int fontSize = 20;
private string fiestaLinkName = "";
private string fiestaLinkUrl = "";
Expand Down Expand Up @@ -605,7 +643,7 @@
// Also check all section keywords within the group
return groupKeyword switch
{
"connection" => SectionVisible("transport mode embedded persistent remote server port start stop pid devtunnel share tunnel mobile qr url token connect save reconnect"),
"connection" => SectionVisible("transport mode embedded persistent remote server port start stop pid devtunnel share tunnel mobile qr url token connect save reconnect repository repo clone worktree storage root directory"),
"ui" => SectionVisible("chat message layout default reversed both left theme font size text zoom"),
"developer" => SectionVisible("auto update main git watch relaunch rebuild"),
"plugins" => SectionVisible("plugins provider extension dll assembly trust enable disable"),
Expand Down Expand Up @@ -697,8 +735,10 @@
protected override void OnInitialized()
{
settings = ConnectionSettings.Load();
settings.RepositoryStorageRoot = ConnectionSettings.NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot);
_initialCliSource = settings.CliSource;
_initialMode = settings.Mode;
_initialRepositoryStorageRoot = NormalizePathForCompare(settings.RepositoryStorageRoot);
DevTunnelService.OnStateChanged += OnTunnelStateChanged;
GitAutoUpdate.OnStateChanged += OnAutoUpdateStateChanged;
FiestaService.OnStateChanged += OnFiestaStateChanged;
Expand Down Expand Up @@ -896,6 +936,67 @@
settings.Save();
}

private static string NormalizePathForCompare(string? path)
{
var normalized = ConnectionSettings.NormalizeRepositoryStorageRoot(path);
if (string.IsNullOrWhiteSpace(normalized))
return "";
return normalized.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}

private bool RepositoryStorageRootChanged =>
!string.Equals(NormalizePathForCompare(settings.RepositoryStorageRoot), _initialRepositoryStorageRoot, StringComparison.OrdinalIgnoreCase);

private static bool IsStorageRootValid(string? path) =>
string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path.Trim());

private async Task BrowseRepositoryStorageRoot()
{
var dir = await FolderPickerService.PickFolderAsync();
if (!string.IsNullOrWhiteSpace(dir))
settings.RepositoryStorageRoot = dir;
}

private void UseDefaultRepositoryStorageRoot()
{
settings.RepositoryStorageRoot = null;
}

private async Task RecloneAllRegisteredRepos()
{
settings.RepositoryStorageRoot = ConnectionSettings.NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot);
if (!IsStorageRootValid(settings.RepositoryStorageRoot))
{
ShowStatus("Repository storage root must be an absolute path", "error", 5000);
return;
}

settings.Save();
recloneBusy = true;
recloneProgress = null;
StateHasChanged();

try
{
await RepoManager.RecloneAllRepositoriesToCurrentRootAsync(progress =>
{
recloneProgress = progress;
_ = InvokeAsync(StateHasChanged);
});
_initialRepositoryStorageRoot = NormalizePathForCompare(settings.RepositoryStorageRoot);
ShowStatus("Repositories re-cloned to new root", "success", 3500);
}
catch (Exception ex)
{
ShowStatus($"Re-clone failed: {Models.ErrorMessageHelper.Humanize(ex)}", "error", 8000);
}
finally
{
recloneBusy = false;
StateHasChanged();
}
}

private static string ShortenPath(string path)
{
// Show just the last 2-3 segments for readability
Expand Down Expand Up @@ -1212,6 +1313,13 @@

private async Task SaveAndApply()
{
settings.RepositoryStorageRoot = ConnectionSettings.NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot);
if (!IsStorageRootValid(settings.RepositoryStorageRoot))
{
ShowStatus("Repository storage root must be an absolute path", "error", 5000);
return;
}

if (settings.Mode == ConnectionMode.Persistent && !serverAlive)
{
ShowStatus("Start the persistent server first", "error", 5000);
Expand All @@ -1236,6 +1344,7 @@
await CopilotService.ReconnectAsync(settings);
_initialMode = settings.Mode;
_initialCliSource = settings.CliSource;
_initialRepositoryStorageRoot = NormalizePathForCompare(settings.RepositoryStorageRoot);
cliSourceChanged = false;
ShowStatus("Connected!", "success");
Nav.NavigateTo("/");
Expand Down
9 changes: 9 additions & 0 deletions PolyPilot/Models/ConnectionSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public class ConnectionSettings
public bool AutoUpdateFromMain { get; set; } = false;
public CliSourceMode CliSource { get; set; } = CliSourceMode.BuiltIn;
public VsCodeVariant Editor { get; set; } = VsCodeVariant.Stable;
public string? RepositoryStorageRoot { get; set; }
public List<string> DisabledMcpServers { get; set; } = new();
public List<string> DisabledPlugins { get; set; } = new();
public PluginSettings Plugins { get; set; } = new();
Expand Down Expand Up @@ -167,6 +168,7 @@ public static ConnectionSettings Load()
// Ensure loaded mode is valid for this platform
if (!PlatformHelper.AvailableModes.Contains(settings.Mode))
settings.Mode = PlatformHelper.DefaultMode;
settings.RepositoryStorageRoot = NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot);

NormalizeEnumFields(settings);

Expand All @@ -177,6 +179,13 @@ public static ConnectionSettings Load()
return settings;
}

public static string? NormalizeRepositoryStorageRoot(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
return path.Trim();
}

/// <summary>Normalize invalid enum values to safe defaults. Testable separately from Load().</summary>
internal static void NormalizeEnumFields(ConnectionSettings settings)
{
Expand Down
2 changes: 2 additions & 0 deletions PolyPilot/Models/RepositoryInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public class WorktreeInfo
public string RepoId { get; set; } = "";
public string Branch { get; set; } = "";
public string Path { get; set; } = "";
/// <summary>Path to the bare clone backing this worktree.</summary>
public string? BareClonePath { get; set; }
/// <summary>Session name using this worktree as CWD, if any.</summary>
public string? SessionName { get; set; }
/// <summary>GitHub PR number if this worktree was created from a PR.</summary>
Expand Down
Loading