diff --git a/BannedSymbols.txt b/BannedSymbols.txt
index c4b3e9282..6b742347b 100644
--- a/BannedSymbols.txt
+++ b/BannedSymbols.txt
@@ -1,2 +1,5 @@
M:System.Environment.GetFolderPath(System.Environment.SpecialFolder);Take a UserHome.
M:System.Environment.GetFolderPath(System.Environment.SpecialFolder,System.Environment.SpecialFolderOption);Take a UserHome.
+P:System.Environment.CurrentDirectory;Take a WorkingDirectory.
+M:System.IO.Directory.GetCurrentDirectory;Take a WorkingDirectory.
+M:System.IO.Directory.SetCurrentDirectory(System.String);Take a WorkingDirectory.
diff --git a/src/Capacitor.Cli.Core/CapacitorContextServices.cs b/src/Capacitor.Cli.Core/CapacitorContextServices.cs
index 85b91cf75..092871fc4 100644
--- a/src/Capacitor.Cli.Core/CapacitorContextServices.cs
+++ b/src/Capacitor.Cli.Core/CapacitorContextServices.cs
@@ -13,10 +13,12 @@ public static IServiceCollection AddCapacitorContext(
this IServiceCollection services,
ConfigRoot config,
UserHome home,
+ WorkingDirectory workdir,
DaemonStore daemons,
ProfileContext profiles) {
services.AddSingleton(config);
services.AddSingleton(home);
+ services.AddSingleton(workdir);
services.AddSingleton(daemons);
services.AddSingleton(profiles);
diff --git a/src/Capacitor.Cli.Core/Config/AppConfig.cs b/src/Capacitor.Cli.Core/Config/AppConfig.cs
index c67e63044..e001b5723 100644
--- a/src/Capacitor.Cli.Core/Config/AppConfig.cs
+++ b/src/Capacitor.Cli.Core/Config/AppConfig.cs
@@ -48,7 +48,7 @@ public static class AppConfig {
// so the deprecation notice in LoadProfileConfig fires at most once per run.
static bool _v1MigrationSignalled;
- public static string RepoRoot => GetGitRepoRoot() ?? Environment.CurrentDirectory;
+ public static string RepoRootOf(WorkingDirectory workdir) => GetGitRepoRoot(workdir.Path) ?? workdir.Path;
///
/// Resolve server URL using only the active profile (or KCAP_PROFILE /
@@ -80,7 +80,8 @@ public static async Task ResolveActiveProfile(string[] args, Con
return new(resolved, loaded);
}
- public static async Task ResolveForRepo(string[] args, ConfigRoot root, ProfileOverrides env, int gitTimeoutMs = 5000) {
+ public static async Task ResolveForRepo(
+ string[] args, ConfigRoot root, ProfileOverrides env, WorkingDirectory workdir, int gitTimeoutMs = 5000) {
var idx = Array.IndexOf(args, "--server-url");
var cliServerUrl = (idx >= 0 && idx + 1 < args.Length) ? args[idx + 1] : null;
@@ -107,7 +108,7 @@ public static async Task ResolveForRepo(string[] args, ConfigRoo
{
var config = await LoadProfileConfig(root);
- var repoRoot = GetGitRepoRoot(gitTimeoutMs) ?? Environment.CurrentDirectory;
+ var repoRoot = GetGitRepoRoot(workdir.Path, gitTimeoutMs) ?? workdir.Path;
RepoConfig? repoConfig = null;
var repoConfigPath = Path.Combine(repoRoot, ".kcap.json");
@@ -121,7 +122,7 @@ public static async Task ResolveForRepo(string[] args, ConfigRoo
}
}
- var remoteUrls = GetGitRemoteUrls(gitTimeoutMs);
+ var remoteUrls = GetGitRemoteUrls(workdir.Path, gitTimeoutMs);
var resolver = new ProfileResolver(
config,
@@ -143,9 +144,10 @@ public static async Task ResolveForRepo(string[] args, ConfigRoo
}
}
- static string[] GetGitRemoteUrls(int timeoutMs = 5000) {
+ static string[] GetGitRemoteUrls(string cwd, int timeoutMs = 5000) {
try {
var psi = new ProcessStartInfo("git", "remote -v") {
+ WorkingDirectory = cwd,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
@@ -175,9 +177,10 @@ static string[] GetGitRemoteUrls(int timeoutMs = 5000) {
}
}
- static string? GetGitRepoRoot(int timeoutMs = 5000) {
+ static string? GetGitRepoRoot(string cwd, int timeoutMs = 5000) {
try {
var psi = new ProcessStartInfo("git", "rev-parse --show-toplevel") {
+ WorkingDirectory = cwd,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
diff --git a/src/Capacitor.Cli.Core/Harness/Codex/CodexConfigToml.cs b/src/Capacitor.Cli.Core/Harness/Codex/CodexConfigToml.cs
index af1e9a843..a09a7001d 100644
--- a/src/Capacitor.Cli.Core/Harness/Codex/CodexConfigToml.cs
+++ b/src/Capacitor.Cli.Core/Harness/Codex/CodexConfigToml.cs
@@ -493,8 +493,6 @@ static Change Update(string configPath, Func mutate, out Except
try {
// First-time users have no ~/.codex; create it before the atomic rename.
- // GetDirectoryName is null/empty for a directory-less path — skip the
- // create in that case (the file lands in the current directory).
var dir = Path.GetDirectoryName(configPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
WriteTomlAtomic(configPath, root);
diff --git a/src/Capacitor.Cli.Core/WorkingDirectory.cs b/src/Capacitor.Cli.Core/WorkingDirectory.cs
new file mode 100644
index 000000000..ac3f4d42d
--- /dev/null
+++ b/src/Capacitor.Cli.Core/WorkingDirectory.cs
@@ -0,0 +1,20 @@
+namespace Capacitor.Cli.Core;
+
+///
+/// The checkout this process acts on, resolved at an entry point and passed down. The single
+/// working-directory resolution in the codebase, so a module never depends on where the process
+/// happens to be standing — it acts on a checkout because its composer named one.
+///
+/// Anything shelling out to git passes as the child's working directory:
+/// a child inherits the process cwd otherwise, which puts the ambient value back in the answer.
+///
+public sealed class WorkingDirectory(string path) {
+ /// The directory itself. Not guaranteed to exist.
+ public string Path { get; } = path;
+
+ /// This process's working directory. Call once, in Main or the composition root.
+ public static WorkingDirectory FromProcess() =>
+#pragma warning disable RS0030 // the cwd resolution the ban points every other site at
+ new(Directory.GetCurrentDirectory());
+#pragma warning restore RS0030
+}
diff --git a/src/Capacitor.Cli/Commands/AgentCommand.cs b/src/Capacitor.Cli/Commands/AgentCommand.cs
index f0f5a9b11..9045717db 100644
--- a/src/Capacitor.Cli/Commands/AgentCommand.cs
+++ b/src/Capacitor.Cli/Commands/AgentCommand.cs
@@ -19,7 +19,7 @@ internal readonly record struct AgentRow(
///
internal sealed class AgentCommand(
DaemonStore store, ConfigRoot config, ProfileContext profiles, UserHome home,
- HarnessRegistry harnesses, BinaryProbe binaries) {
+ HarnessRegistry harnesses, BinaryProbe binaries, WorkingDirectory workdir) {
internal static readonly string[] KnownSubcommands = ["start", "ls", "stop", "attach"];
/// Verbs that only ever belonged to the pre-rename `agent` daemon group, minus the
@@ -90,7 +90,7 @@ async Task RunAsync(string[] args, string? baseUrl) {
var sock = store.SocketPath(name);
var work = parsed.Worktree ? WorkLocation.OwnedWorktree : WorkLocation.BorrowedCwd;
var (cols, rows) = TermSize();
- var spawn = FrameCodec.Spawn(parsed.Vendor, work, parsed.Private, Environment.CurrentDirectory, parsed.Passthrough, cols, rows);
+ var spawn = FrameCodec.Spawn(parsed.Vendor, work, parsed.Private, workdir.Path, parsed.Passthrough, cols, rows);
return parsed.Detached
? await SpawnDetachedAsync(sock, spawn)
diff --git a/src/Capacitor.Cli/Commands/CommandServices.cs b/src/Capacitor.Cli/Commands/CommandServices.cs
index 5162fae4b..624c4cc74 100644
--- a/src/Capacitor.Cli/Commands/CommandServices.cs
+++ b/src/Capacitor.Cli/Commands/CommandServices.cs
@@ -7,6 +7,7 @@
using Capacitor.Cli.Core.Setup;
using Capacitor.Cli.Core.Telemetry;
using Microsoft.Extensions.DependencyInjection;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
@@ -18,12 +19,12 @@ public static class CommandServices {
/// does not pay for it.
///
public static IServiceCollection AddCapacitorCli(
- this IServiceCollection services, ConfigRoot config, UserHome home, DaemonStore daemons,
+ this IServiceCollection services, ConfigRoot config, UserHome home, WorkingDirectory workdir, DaemonStore daemons,
ProfileContext profiles, ProfileOverrides env, MachineAuth machine,
AuthEndpoints endpoints, HookClock clock, string? baseUrl,
TelemetryStartup telemetryStartup) {
services
- .AddCapacitorContext(config, home, daemons, profiles)
+ .AddCapacitorContext(config, home, workdir, daemons, profiles)
.AddCapacitorCommands();
services.AddSingleton(endpoints);
@@ -33,6 +34,9 @@ public static IServiceCollection AddCapacitorCli(
services.AddSingleton(_ => WatcherPaths.FromEnvironment(config));
services.AddSingleton();
+ // Singleton deliberately: per-resolution routers would each start with an empty memo.
+ services.AddSingleton();
+
// Factories because only a handful of commands take either. The registry is built over the
// same probe instance, so a harness binary and a configured path search one PATH.
services.AddSingleton(_ => BinaryProbe.FromEnvironment());
diff --git a/src/Capacitor.Cli/Commands/CurateCommand.cs b/src/Capacitor.Cli/Commands/CurateCommand.cs
index 2440b84cb..da99e9d83 100644
--- a/src/Capacitor.Cli/Commands/CurateCommand.cs
+++ b/src/Capacitor.Cli/Commands/CurateCommand.cs
@@ -1,18 +1,21 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Curation;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
-class CurateCommand(ConfigRoot config, IRepositoriesApi repositories) {
+class CurateCommand(
+ ConfigRoot config, IRepositoriesApi repositories, GitProviderRouter router,
+ WorkingDirectory workdir) {
/// One page is all this command reads; hitting it exactly is what the warning below
/// reports, so the request and the check must name the same number.
const int PageLimit = 100;
public async Task HandleApply(bool dryRun, bool yes) {
- var cwd = Environment.CurrentDirectory;
+ var cwd = workdir.Path;
- // 1. Authoritative repo-root gate (never AppConfig.RepoRoot).
+ // 1. Authoritative repo-root gate: the tree itself, never the fallback RepoRootOf applies.
var repoRoot = GitRepository.FindRoot(cwd);
if (repoRoot is null) {
await Console.Error.WriteLineAsync("Not inside a git repository — run `kcap curate apply` from a repo.");
@@ -20,7 +23,7 @@ public async Task HandleApply(bool dryRun, bool yes) {
}
// 2. Identify the repo for the server key.
- var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd);
+ var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd);
if (repo?.Owner is null || repo.RepoName is null) {
await Console.Error.WriteLineAsync("Could not determine the repo's owner/name from its git remote.");
return 1;
diff --git a/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs
index 4ff8ec19f..79b84a0a3 100644
--- a/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs
+++ b/src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs
@@ -5,6 +5,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands.Harness;
@@ -37,7 +38,8 @@ namespace Capacitor.Cli.Commands.Harness;
///
sealed class AntigravityHookCommand(
ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home,
- HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) {
+ HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers,
+ GitProviderRouter router, WorkingDirectory workdir) {
readonly AgentHookPoster _poster = new(config, profiles, http, watchers);
string Url => profiles.Resolution.ServerUrl!;
@@ -169,10 +171,10 @@ HookBudget budget
forwarded["default_visibility"] = visibility;
SessionStartInventory.Stamp(forwarded, config, harnesses);
- var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString());
+ var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString());
if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos
- && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) {
+ && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) {
DisabledSessions.Mark(sessionId, config);
return 0;
}
@@ -303,7 +305,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId) =>
try {
var store = SessionStartMemoryLeaseStore.Create(config, clock.Time);
- var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time);
+ var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time);
return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync(
LifecycleFor(sessionId),
diff --git a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs
index 0b848ebd5..75354230f 100644
--- a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs
+++ b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs
@@ -9,6 +9,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands.Harness;
@@ -23,7 +24,7 @@ namespace Capacitor.Cli.Commands.Harness;
public sealed class ClaudeHookCommand(
ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home,
HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers,
- IProcessStarter starter) {
+ IProcessStarter starter, GitProviderRouter router, WorkingDirectory workdir) {
string Url => profiles.Resolution.ServerUrl!;
@@ -277,7 +278,7 @@ internal async Task ShouldSuppressCaptureAsync(
internal async Task IsSessionExcludedAsync(Profile? profile, string body, HookBudget budget) {
if (profile?.ExcludedRepos is { Length: > 0 } repos
- && await RepoExclusion.IsExcludedAsync(config, body, repos, budget.Remaining)) {
+ && await RepoExclusion.IsExcludedAsync(router, config, body, repos, budget.Remaining)) {
return true;
}
@@ -406,13 +407,13 @@ internal async Task HandleCore(HttpClient client, AuthStatus authStatus, Ho
if (command == "session-start") {
// Awaited INSIDE the session-start block after EnsureWatcherRunning so it never delays
// transcript-capture start.
- deferredRepoTask = RepositoryDetection.EnrichWithRepositoryInfo(config, body, budget.Remaining, detectPullRequest: false);
+ deferredRepoTask = RepositoryDetection.EnrichWithRepositoryInfo(router, config, body, budget.Remaining, detectPullRequest: false);
} else if (command is "session-end" or "subagent-stop") {
// Budgeted so a slow git probe can't push the bounded POST/spool path past the hook
// deadline. The await below is also budget-bounded as a hard backstop.
- deferredRepoTask = RepositoryDetection.EnrichWithRepositoryInfo(config, body, budget.Remaining, detectPullRequest: false);
+ deferredRepoTask = RepositoryDetection.EnrichWithRepositoryInfo(router, config, body, budget.Remaining, detectPullRequest: false);
} else {
- body = await RepositoryDetection.EnrichWithRepositoryInfo(config, body, detectPullRequest: false);
+ body = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, body, detectPullRequest: false);
}
// Resolve the V2 profile once for repo/path exclusion and
@@ -1112,7 +1113,7 @@ static void NormalizeGuidField(JsonNode node, string fieldName) {
try {
var store = SessionStartMemoryLeaseStore.Create(config, clock.Time);
var provider = new SessionStartMemoryContextProvider(
- new SessionStartMemoryScopeResolver(config, clock.Time), http.ForMemoryAsync, clock.Time);
+ new SessionStartMemoryScopeResolver(router, config, workdir, clock.Time), http.ForMemoryAsync, clock.Time);
return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync(
new SessionMemoryLifecycle(HarnessId.Claude, nativeSessionId, null,
diff --git a/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs
index ff747e17c..3d331eedf 100644
--- a/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs
+++ b/src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs
@@ -8,6 +8,7 @@
// ReSharper disable ShortLivedHttpClient
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands.Harness;
@@ -39,7 +40,8 @@ namespace Capacitor.Cli.Commands.Harness;
///
sealed class CodexHookCommand(
ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home,
- HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) {
+ HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers,
+ GitProviderRouter router, WorkingDirectory workdir) {
readonly AgentHookPoster _poster = new(config, profiles, http, watchers);
string Url => profiles.Resolution.ServerUrl!;
@@ -155,7 +157,7 @@ internal static async Task RunSessionStartHandshakeForTest(Action writeStdout, F
// the injected client factory can throw synchronously.
try {
var store = SessionStartMemoryLeaseStore.Create(config, clock.Time);
- var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time);
+ var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time);
return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync(
new SessionMemoryLifecycle(HarnessId.Codex, sessionId!, LifecycleInstanceId: null,
@@ -343,7 +345,7 @@ async Task HandleSessionStart(JsonNode node, Profile? activeProfile, HookBu
}
SessionStartInventory.Stamp(node.AsObject(), config, harnesses);
- var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, node.ToJsonString());
+ var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, node.ToJsonString());
// Repo exclusion runs here (not above the event switch) so that the
// repository block is already populated by enrichment — RepoExclusion
@@ -353,7 +355,7 @@ async Task HandleSessionStart(JsonNode node, Profile? activeProfile, HookBu
// take the existing disabled-session fast path at the top of Handle
// without paying any git cost.
if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos
- && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) {
+ && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) {
var excludedSessionId = TryGetString(node, "session_id");
if (excludedSessionId is not null) DisabledSessions.Mark(excludedSessionId, config);
diff --git a/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs
index ee1d20cfb..146dc56a0 100644
--- a/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs
+++ b/src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs
@@ -7,6 +7,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands.Harness;
@@ -44,7 +45,8 @@ namespace Capacitor.Cli.Commands.Harness;
///
sealed class CopilotHookCommand(
ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home,
- HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) {
+ HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers,
+ GitProviderRouter router, WorkingDirectory workdir) {
readonly AgentHookPoster _poster = new(config, profiles, http, watchers);
string Url => profiles.Resolution.ServerUrl!;
@@ -112,7 +114,7 @@ internal static void WriteSessionStartOutput(TextWriter writer, string? fragment
try {
var store = SessionStartMemoryLeaseStore.Create(config, clock.Time);
- var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time);
+ var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time);
return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync(
new SessionMemoryLifecycle(HarnessId.Copilot, sessionId, LifecycleInstanceId: null,
@@ -258,12 +260,12 @@ HookBudget budget
}
SessionStartInventory.Stamp(forwarded, config, harnesses);
- var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString());
+ var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString());
// Repo exclusion after enrichment (fast in-payload path) — mark the
// session so per-turn agentStop events skip via DisabledSessions.
if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos
- && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) {
+ && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) {
DisabledSessions.Mark(sessionId, config);
return 0;
}
diff --git a/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs
index 9e194d62a..46fe74af2 100644
--- a/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs
+++ b/src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs
@@ -10,6 +10,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands.Harness;
@@ -23,7 +24,8 @@ namespace Capacitor.Cli.Commands.Harness;
///
public sealed class CursorHookCommand(
ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home,
- HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) {
+ HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers,
+ GitProviderRouter router, WorkingDirectory workdir) {
readonly CursorMarkers _markers = new(config);
string Url => profiles.Resolution.ServerUrl!;
@@ -378,7 +380,7 @@ HookBudget budget
var remaining = budget.Remaining;
if (remaining > TimeSpan.Zero) {
node = JsonNode.Parse(
- await RepositoryDetection.EnrichWithRepositoryInfoFromCwd(config, node.ToJsonString(), workspaceRoot, remaining)
+ await RepositoryDetection.EnrichWithRepositoryInfoFromCwd(router, config, node.ToJsonString(), workspaceRoot, remaining)
) ?? node;
}
}
@@ -557,8 +559,8 @@ HookBudget budget
if (sessionId is null) return null;
// An absent/blank Cursor workspace root must NOT fall through to the scope resolver's
- // Directory.GetCurrentDirectory() fallback: that would derive a repo scope from the hook
- // PROCESS's cwd and could inject an UNRELATED repository's memories into this session.
+ // working-directory fallback: that would derive a repo scope from the hook PROCESS's
+ // directory and could inject an UNRELATED repository's memories into this session.
// With no authoritative workspace root there is no safe scope, so skip injection entirely.
if (string.IsNullOrWhiteSpace(workspaceRoot)) return null;
@@ -583,7 +585,7 @@ HookBudget budget
var store = SessionStartMemoryLeaseStore.Create(config, clock.Time);
// Both lanes send on the hook's own client, which stays this method's caller's to dispose.
- var provider = SessionStartMemoryHookSupport.CompositeProvider(config, _ => Task.FromResult(client), clock.Time);
+ var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, _ => Task.FromResult(client), clock.Time);
return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync(
// ClassificationAuthoritative is hardcoded true, and this is VALID UNDER THE
diff --git a/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs
index 408396207..9b8912fc4 100644
--- a/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs
+++ b/src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs
@@ -8,6 +8,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands.Harness;
@@ -65,7 +66,8 @@ namespace Capacitor.Cli.Commands.Harness;
///
sealed class GeminiHookCommand(
ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home,
- HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) {
+ HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers,
+ GitProviderRouter router, WorkingDirectory workdir) {
readonly AgentHookPoster _poster = new(config, profiles, http, watchers);
string Url => profiles.Resolution.ServerUrl!;
@@ -194,7 +196,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId, string? so
try {
var store = SessionStartMemoryLeaseStore.Create(config, clock.Time);
- var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time);
+ var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time);
return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync(
LifecycleFor(sessionId, source),
@@ -320,10 +322,10 @@ HookBudget budget
}
SessionStartInventory.Stamp(forwarded, config, harnesses);
- var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString());
+ var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString());
if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos
- && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) {
+ && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) {
DisabledSessions.Mark(sessionId, config);
return 0;
}
diff --git a/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs
index 7c85fe085..fb9bdc6a8 100644
--- a/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs
+++ b/src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs
@@ -6,6 +6,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands.Harness;
@@ -37,7 +38,8 @@ namespace Capacitor.Cli.Commands.Harness;
///
sealed class KiroHookCommand(
ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home,
- HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) {
+ HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers,
+ GitProviderRouter router, WorkingDirectory workdir) {
readonly AgentHookPoster _poster = new(config, profiles, http, watchers);
string Url => profiles.Resolution.ServerUrl!;
@@ -104,7 +106,7 @@ internal static void WriteAgentSpawnOutput(TextWriter writer, string? fragment,
try {
var store = SessionStartMemoryLeaseStore.Create(config, clock.Time);
- var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time);
+ var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time);
return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync(
new SessionMemoryLifecycle(HarnessId.Kiro, sessionId, LifecycleInstanceId: null,
@@ -220,10 +222,10 @@ HookBudget budget
}
SessionStartInventory.Stamp(forwarded, config, harnesses);
- var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString());
+ var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString());
if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos
- && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) {
+ && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) {
DisabledSessions.Mark(sessionId, config);
return 0;
}
diff --git a/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs
index 0ecf68888..b659884a3 100644
--- a/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs
+++ b/src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs
@@ -5,6 +5,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands.Harness;
@@ -31,7 +32,8 @@ namespace Capacitor.Cli.Commands.Harness;
///
sealed class OpenCodeHookCommand(
ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home,
- HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) {
+ HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers,
+ GitProviderRouter router, WorkingDirectory workdir) {
readonly AgentHookPoster _poster = new(config, profiles, http, watchers);
string Url => profiles.Resolution.ServerUrl!;
@@ -128,10 +130,10 @@ HookBudget budget
}
SessionStartInventory.Stamp(forwarded, config, harnesses);
- var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString());
+ var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString());
if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos
- && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) {
+ && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) {
DisabledSessions.Mark(sessionId, config);
return 0;
}
@@ -255,7 +257,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId) =>
try {
var store = SessionStartMemoryLeaseStore.Create(config, clock.Time);
- var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time);
+ var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time);
return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync(
LifecycleFor(sessionId),
diff --git a/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs
index 865dc6b84..dab7ccb73 100644
--- a/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs
+++ b/src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs
@@ -7,6 +7,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands.Harness;
@@ -36,7 +37,8 @@ namespace Capacitor.Cli.Commands.Harness;
///
sealed class PiHookCommand(
ConfigRoot config, ProfileContext profiles, HookClock clock, UserHome home,
- HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers) {
+ HarnessRegistry harnesses, HostedAgent hosted, ICapacitorHttpClient http, WatcherManager watchers,
+ GitProviderRouter router, WorkingDirectory workdir) {
readonly AgentHookPoster _poster = new(config, profiles, http, watchers);
string Url => profiles.Resolution.ServerUrl!;
@@ -141,10 +143,10 @@ HookBudget budget
if (activeProfile?.DefaultVisibility is { } visibility) forwarded["default_visibility"] = visibility;
SessionStartInventory.Stamp(forwarded, config, harnesses);
- var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(config, forwarded.ToJsonString());
+ var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(router, config, forwarded.ToJsonString());
if (activeProfile?.ExcludedRepos is { Length: > 0 } excludedRepos
- && await RepoExclusion.IsExcludedAsync(config, enriched, excludedRepos)) {
+ && await RepoExclusion.IsExcludedAsync(router, config, enriched, excludedRepos)) {
DisabledSessions.Mark(sessionId, config);
return 0;
}
@@ -325,7 +327,7 @@ internal static SessionMemoryLifecycle LifecycleFor(string file, string? reason)
try {
var store = SessionStartMemoryLeaseStore.Create(config, clock.Time);
- var provider = SessionStartMemoryHookSupport.CompositeProvider(config, http.ForMemoryAsync, clock.Time);
+ var provider = SessionStartMemoryHookSupport.CompositeProvider(router, config, workdir, http.ForMemoryAsync, clock.Time);
return await new SessionStartMemoryOrchestrator(store, provider, clock.Time).GetFragmentAsync(
LifecycleFor(file, reason),
diff --git a/src/Capacitor.Cli/Commands/ImportCommand.cs b/src/Capacitor.Cli/Commands/ImportCommand.cs
index 45d632630..ecaf80ee8 100644
--- a/src/Capacitor.Cli/Commands/ImportCommand.cs
+++ b/src/Capacitor.Cli/Commands/ImportCommand.cs
@@ -13,12 +13,13 @@
using Capacitor.Cli.Harness.Claude;
using Capacitor.Cli.Harness.Cursor;
using Spectre.Console;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
class ImportCommand(
ConfigRoot config, ProfileContext profiles, UserHome home, HarnessRegistry harnesses,
- ICapacitorHttpClient http) {
+ ICapacitorHttpClient http, GitProviderRouter router) {
///
/// Maximum parallel worker count for the Importing phase. Both the
/// channel-based dispatcher in ImportChainsAsync and the TTY slot-row
@@ -764,7 +765,7 @@ public async Task HandleImport(
// --- Sources ---
// A caller that names none means Claude only.
- sources ??= [new ClaudeImportSource(config, harnesses.Of().Paths.Projects)];
+ sources ??= [new ClaudeImportSource(config, harnesses.Of().Paths.Projects, router)];
// --- No-source exit policy ---
var available = sources.Where(s => s.IsAvailable).ToList();
@@ -933,7 +934,7 @@ await Parallel.ForEachAsync(
async (cwd, _) => {
try {
// Import only needs owner/repo here — skip the PR/MR provider round-trip.
- var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false);
+ var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false);
repoByCwd[cwd] = repo is { Owner: { } o, RepoName: { } n } ? (o, n) : null;
} catch {
repoByCwd[cwd] = null;
@@ -2686,7 +2687,7 @@ string sessionId
async ValueTask DetectOne(string cwd) {
// Import only needs owner/repo here — skip the PR/MR provider round-trip.
- var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false);
+ var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false);
repoByCwd[cwd] = repo is { Owner: { } o, RepoName: { } n } ? (o, n) : null;
}
@@ -3109,7 +3110,7 @@ internal enum SessionImportOutcome { Loaded, Resumed, Errored }
if (cwd is not null) {
// The imported session-start payload carries no PR fields (only owner/repo/branch/user),
// so skip the PR/MR provider round-trip.
- var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false);
+ var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false);
if (repo is not null || codexRepo is not null) {
var repoNode = new JsonObject();
@@ -3146,7 +3147,7 @@ internal enum SessionImportOutcome { Loaded, Resumed, Errored }
session.Vendor.VendorId,
session.FilePath,
GitRepository.FindRoot,
- root => RepositoryDetection.DetectRepositoryAsync(config, root, detectPullRequest: false));
+ root => RepositoryDetection.DetectRepositoryAsync(router, config, root, detectPullRequest: false));
if (evidenceNode is not null) startHook["repository"] = evidenceNode;
}
diff --git a/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs b/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs
index 0427928a7..a4b08a22a 100644
--- a/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs
+++ b/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs
@@ -10,6 +10,7 @@
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
@@ -19,7 +20,7 @@ namespace Capacitor.Cli.Commands;
/// rejection reasons. Structure cloned from McpMemoryServer.
///
sealed class McpAnalyticsServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http,
- TelemetryStartup startup) {
+ TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) {
internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn;
internal const string NotSupportedMessage =
@@ -37,7 +38,7 @@ static string TimeoutHintFor(string toolName) =>
public async Task RunAsync() {
var baseUrl = profiles.Resolution.ServerUrl!;
- var repository = new CwdRepository(config, Directory.GetCurrentDirectory());
+ var repository = new CwdRepository(config, workdir.Path, router);
var tools = BuildToolsList();
// Best-effort, and recorded even when the read throws: a stale token on disk must never
diff --git a/src/Capacitor.Cli/Commands/McpFlowsServer.cs b/src/Capacitor.Cli/Commands/McpFlowsServer.cs
index 2d8e92e43..18fe1d458 100644
--- a/src/Capacitor.Cli/Commands/McpFlowsServer.cs
+++ b/src/Capacitor.Cli/Commands/McpFlowsServer.cs
@@ -11,11 +11,13 @@
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Http;
using Capacitor.Cli.Core.Telemetry;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
class McpFlowsServer(
- ConfigRoot config, ProfileContext profiles, TokenStore store, ICapacitorHttpClient http, TelemetryStartup startup) {
+ ConfigRoot config, ProfileContext profiles, TokenStore store, ICapacitorHttpClient http,
+ TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) {
public async Task RunAsync(string? driverArg = null) {
var baseUrl = profiles.Resolution.ServerUrl!;
@@ -25,14 +27,14 @@ public async Task RunAsync(string? driverArg = null) {
// session id and the working directory come from the same resolution, so a flow can never be
// attributed to one session while being reviewed in another session's checkout.
var requester = HarnessRequesterContext.Resolve();
- var cwd = requester.ProjectDir ?? Directory.GetCurrentDirectory();
+ var cwd = requester.ProjectDir ?? workdir.Path;
var repoRoot = GitRepository.FindRoot(cwd);
// Prefer the `--driver` stamp from this server's own registration (deterministic for the JSON
// harnesses); fall back to env inference for Claude/Codex, whose registrations are unstamped.
var driverVendor = DriverVendor.Infer(driverArg);
var tools = BuildToolsList();
- var repository = new CwdRepository(config, cwd);
+ var repository = new CwdRepository(config, cwd, router);
// Best-effort, and recorded even when the read throws: a stale token on disk must never
// block the server from starting, and an absent property is a different value in a funnel
diff --git a/src/Capacitor.Cli/Commands/McpMemoryServer.cs b/src/Capacitor.Cli/Commands/McpMemoryServer.cs
index 03c3f50c6..1cf9a4843 100644
--- a/src/Capacitor.Cli/Commands/McpMemoryServer.cs
+++ b/src/Capacitor.Cli/Commands/McpMemoryServer.cs
@@ -10,17 +10,18 @@
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
sealed class McpMemoryServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http,
- TelemetryStartup startup) {
+ TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) {
internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn;
public async Task RunAsync() {
var baseUrl = profiles.Resolution.ServerUrl!;
- var repository = new CwdRepository(config, Directory.GetCurrentDirectory());
+ var repository = new CwdRepository(config, workdir.Path, router);
var machineId = await ResolveMachineIdAsync();
var tools = BuildToolsList();
diff --git a/src/Capacitor.Cli/Commands/McpReviewServer.cs b/src/Capacitor.Cli/Commands/McpReviewServer.cs
index 4bbb64060..09c8ef552 100644
--- a/src/Capacitor.Cli/Commands/McpReviewServer.cs
+++ b/src/Capacitor.Cli/Commands/McpReviewServer.cs
@@ -12,11 +12,12 @@
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
sealed class McpReviewServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http,
- TelemetryStartup startup) {
+ TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) {
///
/// Run with an explicit session-default PR (used by kcap review <pr>).
/// Tool calls may still override the default by passing a pr argument.
@@ -149,8 +150,8 @@ async Task TimedDispatchToolCallAsync(JsonNode callId, JsonObject callRe
async Task DetectPrFromGitAsync() {
try {
- var cwd = Directory.GetCurrentDirectory();
- var repoInfo = await RepositoryDetection.DetectRepositoryAsync(config, cwd);
+ var cwd = workdir.Path;
+ var repoInfo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd);
if (repoInfo?.Owner is not null && repoInfo.RepoName is not null && repoInfo.PrNumber is not null) {
return new PrIdentity(repoInfo.Owner, repoInfo.RepoName, repoInfo.PrNumber.Value);
diff --git a/src/Capacitor.Cli/Commands/McpSessionsServer.cs b/src/Capacitor.Cli/Commands/McpSessionsServer.cs
index fab32d8ab..bd53a36ac 100644
--- a/src/Capacitor.Cli/Commands/McpSessionsServer.cs
+++ b/src/Capacitor.Cli/Commands/McpSessionsServer.cs
@@ -10,17 +10,18 @@
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
sealed class McpSessionsServer(ConfigRoot config, ProfileContext profiles, TokenStore tokens, ICapacitorHttpClient http,
- TelemetryStartup startup) {
+ TelemetryStartup startup, GitProviderRouter router, WorkingDirectory workdir) {
internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn;
public async Task RunAsync() {
var baseUrl = profiles.Resolution.ServerUrl!;
- var repository = new CwdRepository(config, Directory.GetCurrentDirectory());
+ var repository = new CwdRepository(config, workdir.Path, router);
var tools = BuildToolsList();
// Best-effort, and recorded even when the read throws: a stale token on disk must never
diff --git a/src/Capacitor.Cli/Commands/PluginCommand.cs b/src/Capacitor.Cli/Commands/PluginCommand.cs
index a0d340418..03f18ed86 100644
--- a/src/Capacitor.Cli/Commands/PluginCommand.cs
+++ b/src/Capacitor.Cli/Commands/PluginCommand.cs
@@ -18,7 +18,7 @@
namespace Capacitor.Cli.Commands;
-public sealed class PluginCommand(PluginEnvironment env) {
+public sealed class PluginCommand(PluginEnvironment env, WorkingDirectory workdir) {
static readonly JsonSerializerOptions WriteOpts = new() { WriteIndented = true };
const string CodexHookCommand = "kcap hook --codex";
@@ -98,7 +98,7 @@ async Task InstallClaude(string[] args) {
var scope = args.Contains("--project") ? "project" : "user";
var settingsPath = scope == "project"
- ? Path.Combine(Environment.CurrentDirectory, ".claude", "settings.local.json")
+ ? Path.Combine(workdir.Path, ".claude", "settings.local.json")
: env.Harnesses.Of().Paths.UserSettings;
// --if-installed: refresh-only mode used by the npm postinstall hook.
@@ -158,7 +158,7 @@ async Task RemoveClaude(string[] args) {
var scope = args.Contains("--project") ? "project" : "user";
var settingsPath = scope == "project"
- ? Path.Combine(Environment.CurrentDirectory, ".claude", "settings.local.json")
+ ? Path.Combine(workdir.Path, ".claude", "settings.local.json")
: env.Harnesses.Of().Paths.UserSettings;
if (!File.Exists(settingsPath)) {
@@ -329,7 +329,7 @@ async Task InstallCodex(string[] args) {
var scope = args.Contains("--project") ? "project" : "user";
var hooksPath = scope == "project"
- ? Path.Combine(Environment.CurrentDirectory, ".codex", "hooks.json")
+ ? Path.Combine(workdir.Path, ".codex", "hooks.json")
: codex.UserHooksJson;
// --if-installed: refresh-only mode used by the npm postinstall hook and
@@ -507,7 +507,7 @@ async Task RemoveCodex(string[] args) {
var scope = args.Contains("--project") ? "project" : "user";
var hooksPath = scope == "project"
- ? Path.Combine(Environment.CurrentDirectory, ".codex", "hooks.json")
+ ? Path.Combine(workdir.Path, ".codex", "hooks.json")
: codex.UserHooksJson;
var hooksRemoved = false;
diff --git a/src/Capacitor.Cli/Commands/RecapCommand.cs b/src/Capacitor.Cli/Commands/RecapCommand.cs
index 7900608b9..ea68911c1 100644
--- a/src/Capacitor.Cli/Commands/RecapCommand.cs
+++ b/src/Capacitor.Cli/Commands/RecapCommand.cs
@@ -2,15 +2,17 @@
using System.Text.Json;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
// ReSharper disable MethodHasAsyncOverload
namespace Capacitor.Cli.Commands;
-class RecapCommand(ConfigRoot config, ISessionsApi sessionsApi, IRepositoriesApi repositoriesApi) {
+class RecapCommand(
+ ConfigRoot config, ISessionsApi sessionsApi, IRepositoriesApi repositoriesApi,
+ GitProviderRouter router, WorkingDirectory workdir) {
public async Task HandleRepoRecap(int limit = 10) {
- var cwd = Directory.GetCurrentDirectory();
- var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd);
+ var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, workdir.Path);
if (repo?.Owner is null || repo.RepoName is null) {
Console.Error.WriteLine("Not in a git repository with a remote origin.");
diff --git a/src/Capacitor.Cli/Commands/SessionsCommand.cs b/src/Capacitor.Cli/Commands/SessionsCommand.cs
index 717d9b560..137e96782 100644
--- a/src/Capacitor.Cli/Commands/SessionsCommand.cs
+++ b/src/Capacitor.Cli/Commands/SessionsCommand.cs
@@ -4,10 +4,13 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
-class SessionsCommand(ConfigRoot config, ProfileContext profiles, ICapacitorHttpClient http) {
+class SessionsCommand(
+ ConfigRoot config, ProfileContext profiles, ICapacitorHttpClient http, GitProviderRouter router,
+ WorkingDirectory workdir) {
public async Task HandleAsync(string[] args) {
var options = SessionsArgs.Parse(args, out var error);
@@ -23,7 +26,8 @@ public async Task HandleAsync(string[] args) {
if (options.Repo is null) {
var repo = await RepositoryDetection.DetectRepositoryAsync(
- config, Directory.GetCurrentDirectory(), detectPullRequest: false);
+ router,
+ config, workdir.Path, detectPullRequest: false);
if (repo?.Owner is null || repo.RepoName is null) {
await Console.Error.WriteLineAsync("Not in a git repository with a remote origin.");
diff --git a/src/Capacitor.Cli/Commands/SetupCommand.cs b/src/Capacitor.Cli/Commands/SetupCommand.cs
index 518516e67..c4a5b3d34 100644
--- a/src/Capacitor.Cli/Commands/SetupCommand.cs
+++ b/src/Capacitor.Cli/Commands/SetupCommand.cs
@@ -33,6 +33,7 @@
using Capacitor.Cli.Core.Http;
using Microsoft.Extensions.DependencyInjection;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
@@ -238,6 +239,7 @@ sealed class SetupImportLane(
UserHome home,
ICapacitorHttpClient http,
HarnessRegistry harnesses,
+ GitProviderRouter router,
Func>? runner = null) : IFirstRunImportLane {
/// One invocation's arguments, so a test can assert what each level asked for without
/// running an import.
@@ -253,9 +255,9 @@ internal sealed record Pass(
ImportCommand.ImportDiscoveryResult? found = null;
// Quiet, because the caller owns the terminal for the duration and the figures go to a screen.
- var exit = await new ImportCommand(config, profiles, home, harnesses, http).HandleImport(
+ var exit = await new ImportCommand(config, profiles, home, harnesses, http, router).HandleImport(
filterCwd: null,
- sources: SetupCommand.BuildImportSources(config, harnesses, vendors),
+ sources: SetupCommand.BuildImportSources(config, harnesses, router, vendors),
discoverOnly: true,
discoverJson: true,
windowsAsOf: asOf,
@@ -306,9 +308,9 @@ internal static ReportFirstRunImportRequest Report(ImportCommand.ImportDiscovery
async Task Run(Pass pass) {
ImportCommand.ImportRunOutcome? outcome = null;
- await new ImportCommand(config, profiles, home, harnesses, http).HandleImport(
+ await new ImportCommand(config, profiles, home, harnesses, http, router).HandleImport(
filterCwd: null,
- sources: SetupCommand.BuildImportSources(config, harnesses, pass.Vendors),
+ sources: SetupCommand.BuildImportSources(config, harnesses, router, pass.Vendors),
since: pass.Since,
scope: new ImportScope.Repo([.. pass.Repos.Select(c => (c.Owner, c.Name))]),
skipConfirmation: true,
@@ -419,7 +421,7 @@ public sealed class SetupCommand(
UserHome home, HarnessRegistry harnesses, AgentsPaths agents, ICapacitorHttpClient http,
TenantProvisioningClient provisioning, AuthProviderDiscovery discovery, CliTelemetry telemetry,
AuthEndpoints endpoints, IOnboardingFacadeFactory facades, ISetupImportRunner imports,
- ChosenServerHttp chosenHttp) {
+ ChosenServerHttp chosenHttp, GitProviderRouter router, WorkingDirectory workdir) {
public async Task HandleAsync(string[] args) {
var serverUrlArg = GetArg(args, "--server-url");
@@ -498,11 +500,11 @@ await Console.Error.WriteLineAsync(
// unrelated to any project, or — worse — under a subdirectory of the repo if we
// used cwd directly, which means two devs running setup from different subdirs
// install hooks in different places.
- var gitRoot = GitRepository.FindRoot(Environment.CurrentDirectory);
+ var gitRoot = GitRepository.FindRoot(workdir.Path);
if (legacyProjectScope && gitRoot is null) {
await Console.Error.WriteLineAsync(
- $"--plugin-scope project requires a git working tree, but '{Environment.CurrentDirectory}' is not inside one.");
+ $"--plugin-scope project requires a git working tree, but '{workdir.Path}' is not inside one.");
await Console.Error.WriteLineAsync(
"Either re-run `kcap setup` from inside your repo, or drop --plugin-scope project to install user-scope hooks.");
return 1;
@@ -973,7 +975,8 @@ await SetupDaemonService.RunAsync(
// detectPullRequest:false — Step 6 only needs (owner, name) to scope the repo import;
// PR/MR detection would run extra provider probes/subprocesses for nothing here.
var currentRepoDetected = await RepositoryDetection.DetectRepositoryAsync(
- config, Environment.CurrentDirectory, detectPullRequest: false);
+ router,
+ config, workdir.Path, detectPullRequest: false);
(string Owner, string Name)? currentRepo = currentRepoDetected is { Owner: { } o, RepoName: { } n }
? (o, n)
: null;
@@ -1032,7 +1035,7 @@ await RunImportStepAsync(
// RepositoryDetection.DetectRepositoryAsync), which weakens grouping in the UI.
if (gitRoot is null) {
AnsiConsole.MarkupLine(
- $"\n [yellow]Tip:[/] you ran setup outside a git working tree ([dim]{Markup.Escape(Environment.CurrentDirectory)}[/]).");
+ $"\n [yellow]Tip:[/] you ran setup outside a git working tree ([dim]{Markup.Escape(workdir.Path)}[/]).");
AnsiConsole.MarkupLine(
" Hooks fire from any directory, but sessions recorded outside a repo won't include owner/repo/branch context.");
AnsiConsole.MarkupLine(
@@ -1249,18 +1252,19 @@ internal ServiceProvider HttpForChosenServer(string serverUrl, ProfileContext? c
/// nothing. Filtering the sources rather than the counts afterwards is what makes a reported figure
/// already scoped to what the user kept.
internal static IReadOnlyList BuildImportSources(
- ConfigRoot config, HarnessRegistry harnesses, IReadOnlyCollection? vendors = null) {
+ ConfigRoot config, HarnessRegistry harnesses, GitProviderRouter router,
+ IReadOnlyCollection? vendors = null) {
var cursor = harnesses.Of().Paths;
var opencode = harnesses.Of().Paths;
IReadOnlyList all = [
- new ClaudeImportSource(config, harnesses.Of().Paths.Projects),
- new CodexImportSource(config, harnesses.Of().Paths.Sessions),
- new CursorImportSource(config, cursor.ProjectsDir, cursor.WorkspaceStorageDir),
- new CopilotImportSource(config, harnesses.Of().Paths),
+ new ClaudeImportSource(config, harnesses.Of().Paths.Projects, router),
+ new CodexImportSource(config, harnesses.Of().Paths.Sessions, router),
+ new CursorImportSource(config, cursor.ProjectsDir, cursor.WorkspaceStorageDir, router),
+ new CopilotImportSource(config, harnesses.Of().Paths, router),
new GeminiImportSource(harnesses.Of().Paths.TmpDir),
- new KiroImportSource(config, harnesses.Of().Paths.SessionsDir),
- new PiImportSource(config, harnesses.Of().Paths.SessionsDir),
+ new KiroImportSource(config, harnesses.Of().Paths.SessionsDir, router),
+ new PiImportSource(config, harnesses.Of().Paths.SessionsDir, router),
new OpenCodeImportSource(
Path.Combine(opencode.DataDir, "opencode.db"),
opencode.ImportLedgerJson),
@@ -1488,7 +1492,7 @@ async Task RunBrowserFlowStepAsync(string serverUrl, string
config, harnesses,
Environment.MachineName, await LoginShellFindsCliAsync());
- importing = new SetupImportLane(config, ImportContext(profiles, serverUrl), home, flowHttp, harnesses);
+ importing = new SetupImportLane(config, ImportContext(profiles, serverUrl), home, flowHttp, harnesses, router);
using var progress = new SpectreFirstRunFlowProgress();
diff --git a/src/Capacitor.Cli/Commands/SetupImportRunner.cs b/src/Capacitor.Cli/Commands/SetupImportRunner.cs
index da5755022..13a8c9562 100644
--- a/src/Capacitor.Cli/Commands/SetupImportRunner.cs
+++ b/src/Capacitor.Cli/Commands/SetupImportRunner.cs
@@ -2,23 +2,25 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Http;
using Microsoft.Extensions.DependencyInjection;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
/// The real import, aimed at the server this run chose rather than the one startup resolved.
sealed class SetupImportRunner(
- ConfigRoot config, UserHome home, HarnessRegistry harnesses, ChosenServerHttp http) : ISetupImportRunner {
+ ConfigRoot config, UserHome home, HarnessRegistry harnesses, ChosenServerHttp http,
+ GitProviderRouter router) : ISetupImportRunner {
public async Task RunAsync(ImportInvocation inv) {
await using var scoped = http.For(inv.Profiles.Resolution.ServerUrl ?? "", inv.Profiles);
return await new ImportCommand(
- config, inv.Profiles, home, harnesses, scoped.GetRequiredService())
+ config, inv.Profiles, home, harnesses, scoped.GetRequiredService(), router)
.HandleImport(
filterCwd: null,
filterSession: null,
minLines: 15,
generateSummaries: false,
- sources: SetupCommand.BuildImportSources(config, harnesses),
+ sources: SetupCommand.BuildImportSources(config, harnesses, router),
explicitVendorSelection: false,
since: null,
scope: new ImportScope.Repo(inv.Repo.Owner, inv.Repo.Name),
diff --git a/src/Capacitor.Cli/Commands/SkillsCommand.cs b/src/Capacitor.Cli/Commands/SkillsCommand.cs
index d4b863d90..32bacdab7 100644
--- a/src/Capacitor.Cli/Commands/SkillsCommand.cs
+++ b/src/Capacitor.Cli/Commands/SkillsCommand.cs
@@ -6,6 +6,7 @@
using Capacitor.Cli.Core.Harness.Kiro;
using Capacitor.Cli.Core.Http;
using Capacitor.Cli.Core.Skills;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
@@ -17,7 +18,8 @@ namespace Capacitor.Cli.Commands;
/// are untouchable. Nothing is ever written into a repo.
///
class SkillsCommand(
- ConfigRoot config, HarnessRegistry harnesses, AgentsPaths agents, IRepositoriesApi repositories) {
+ ConfigRoot config, HarnessRegistry harnesses, AgentsPaths agents, IRepositoriesApi repositories,
+ GitProviderRouter router, WorkingDirectory workdir) {
// The background refresh keys off each manifest's synced_at, so a burst of session starts
// costs one network round-trip per interval per target, not one per session.
static readonly TimeSpan AutoSyncInterval = TimeSpan.FromHours(6);
@@ -34,13 +36,13 @@ internal static IReadOnlyList Targets(HarnessRegistry harnesses, A
];
public async Task HandleSync(bool dryRun, bool auto = false) {
- var cwd = Environment.CurrentDirectory;
+ var cwd = workdir.Path;
if (GitRepository.FindRoot(cwd) is null) {
await Console.Error.WriteLineAsync("Not inside a git repository — run `kcap skills sync` from a repo.");
return 1;
}
- var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd);
+ var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd);
if (repo?.Owner is null || repo.RepoName is null) {
await Console.Error.WriteLineAsync("Could not determine the repo's owner/name from its git remote.");
return 1;
diff --git a/src/Capacitor.Cli/Commands/TranscriptFileClassification.cs b/src/Capacitor.Cli/Commands/TranscriptFileClassification.cs
index b471fe0db..a266d18ab 100644
--- a/src/Capacitor.Cli/Commands/TranscriptFileClassification.cs
+++ b/src/Capacitor.Cli/Commands/TranscriptFileClassification.cs
@@ -2,6 +2,7 @@
using System.Text.Json;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Commands;
@@ -14,6 +15,7 @@ namespace Capacitor.Cli.Commands;
///
internal static class TranscriptFileClassification {
public static async Task> ClassifyAsync(
+ GitProviderRouter router,
ConfigRoot config,
UserHome home,
HttpClient httpClient,
@@ -30,7 +32,7 @@ internal static class TranscriptFileClassification {
var tasks = new List>(transcripts.Count);
foreach (var (sessionId, filePath, encodedCwd) in transcripts) {
- tasks.Add(ClassifyOneAsync(config, home, httpClient, baseUrl, sessionId, filePath, encodedCwd, minLines, excludedRepos, excludedPaths, probeGate, vendor, onProbed, ct));
+ tasks.Add(ClassifyOneAsync(router, config, home, httpClient, baseUrl, sessionId, filePath, encodedCwd, minLines, excludedRepos, excludedPaths, probeGate, vendor, onProbed, ct));
}
var results = await Task.WhenAll(tasks);
@@ -39,6 +41,7 @@ internal static class TranscriptFileClassification {
}
static async Task ClassifyOneAsync(
+ GitProviderRouter router,
ConfigRoot config,
UserHome home,
HttpClient httpClient,
@@ -55,13 +58,14 @@ internal static class TranscriptFileClassification {
CancellationToken ct
) {
try {
- return await ClassifyOneCoreAsync(config, home, httpClient, baseUrl, sessionId, filePath, encodedCwd, minLines, excludedRepos, excludedPaths, probeGate, vendor, ct);
+ return await ClassifyOneCoreAsync(router, config, home, httpClient, baseUrl, sessionId, filePath, encodedCwd, minLines, excludedRepos, excludedPaths, probeGate, vendor, ct);
} finally {
onProbed?.Invoke();
}
}
static async Task ClassifyOneCoreAsync(
+ GitProviderRouter router,
ConfigRoot config,
UserHome home,
HttpClient httpClient,
@@ -201,7 +205,7 @@ CancellationToken ct
if (cwd is not null) {
if (excludedRepos is { Length: > 0 }) {
// Classification only needs owner/repo for the exclusion key — skip PR detection.
- var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false);
+ var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false);
if (repo?.Owner is not null && repo.RepoName is not null) {
var key = $"{repo.Owner}/{repo.RepoName}";
diff --git a/src/Capacitor.Cli/Commands/UninstallCommand.cs b/src/Capacitor.Cli/Commands/UninstallCommand.cs
index 0c2becfac..db3281a37 100644
--- a/src/Capacitor.Cli/Commands/UninstallCommand.cs
+++ b/src/Capacitor.Cli/Commands/UninstallCommand.cs
@@ -32,7 +32,8 @@ namespace Capacitor.Cli.Commands;
///
public sealed class UninstallCommand(
DaemonStore store, ConfigRoot config, ProfileContext profiles, UserHome home,
- HarnessRegistry harnesses, BinaryProbe binaries, AgentsPaths agents, WatcherManager watchers) {
+ HarnessRegistry harnesses, BinaryProbe binaries, AgentsPaths agents, WatcherManager watchers,
+ WorkingDirectory workdir) {
public async Task HandleAsync(string[] args) {
var skipPrompt = args.Contains("--yes") || args.Contains("-y");
var keepConfig = args.Contains("--keep-config");
@@ -41,11 +42,11 @@ public async Task HandleAsync(string[] args) {
string? projectRoot = null;
if (includeProject) {
- projectRoot = GitRepository.FindRoot(Environment.CurrentDirectory);
+ projectRoot = GitRepository.FindRoot(workdir.Path);
if (projectRoot is null) {
await Console.Error.WriteLineAsync(
- $"--project requires a git working tree, but '{Environment.CurrentDirectory}' is not inside one.");
+ $"--project requires a git working tree, but '{workdir.Path}' is not inside one.");
await Console.Error.WriteLineAsync(
"Re-run from inside your repo, or drop --project to only remove user-level configuration.");
@@ -129,7 +130,7 @@ await Console.Out.WriteLineAsync(
if (await new CleanupCommand(watchers).HandleCleanup() != 0) hadFailures = true;
var env = PluginEnvironment.FromProcess(await AppConfig.LoadProfileConfig(config), home, harnesses);
- var pluginCommand = new PluginCommand(env);
+ var pluginCommand = new PluginCommand(env, workdir);
// User-level agent integrations. Each remove command is idempotent and
// no-ops if the target file doesn't exist, so it's safe to call all of
diff --git a/src/Capacitor.Cli/Commands/UseCommand.cs b/src/Capacitor.Cli/Commands/UseCommand.cs
index fe8062556..3d63dfb32 100644
--- a/src/Capacitor.Cli/Commands/UseCommand.cs
+++ b/src/Capacitor.Cli/Commands/UseCommand.cs
@@ -5,7 +5,7 @@
namespace Capacitor.Cli.Commands;
-public sealed class UseCommand(ConfigRoot config) {
+public sealed class UseCommand(ConfigRoot config, WorkingDirectory workdir) {
public async Task HandleAsync(string[] args) {
if (args.Length < 2) {
await Console.Error.WriteLineAsync("Usage: kcap use [--global] [--save]");
@@ -15,9 +15,12 @@ public async Task HandleAsync(string[] args) {
var name = args[1];
var global = args.Contains("--global");
var save = args.Contains("--save");
- var repoPath = global ? null : AppConfig.RepoRoot;
+ // Resolved at most once, and not at all for a global selection that saves nothing:
+ // RepoRootOf shells out to git, which a change needing no repository must not wait on.
+ var repoRoot = !global || save ? AppConfig.RepoRootOf(workdir) : null;
+ var repoPath = global ? null : repoRoot;
- return await SetProfile(name, repoPath, global, save, save ? AppConfig.RepoRoot : null);
+ return await SetProfile(name, repoPath, global, save, save ? repoRoot : null);
}
internal async Task SetProfile(
diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs
index f50df8084..c077f52f0 100644
--- a/src/Capacitor.Cli/Commands/WatchCommand.cs
+++ b/src/Capacitor.Cli/Commands/WatchCommand.cs
@@ -20,6 +20,7 @@
using Capacitor.Cli.Harness.Cursor;
using Capacitor.Cli.Harness.Gemini;
using Capacitor.Cli.Harness.OpenCode;
+using Capacitor.Cli.PrDetection;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
@@ -29,7 +30,8 @@ namespace Capacitor.Cli.Commands;
partial class WatchCommand(
ConfigRoot config, ProfileContext profiles, HarnessRegistry harnesses,
- ICapacitorHttpClient http, ICredentialSource credentials, WatcherManager watchers) {
+ ICapacitorHttpClient http, ICredentialSource credentials, WatcherManager watchers,
+ GitProviderRouter router) {
readonly CursorMarkers _markers = new(config);
string Url => profiles.Resolution.ServerUrl!;
@@ -501,7 +503,7 @@ void ArmParentMonitor(int ppid) {
// Detect repository info upfront if cwd is provided (session watchers only, not agents)
if (cwd is not null) {
- state.Repository = await RepositoryDetection.DetectRepositoryAsync(config, cwd);
+ state.Repository = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd);
state.LastRepoDetection = DateTimeOffset.UtcNow;
}
@@ -511,7 +513,7 @@ void ArmParentMonitor(int ppid) {
// watcher is always spawned with cwd: null too and has never had its own repo detection.
if (vendor == "claude" && agentId is null && (cwd is null || GitRepository.FindRoot(cwd) is null)) {
state.EvidenceScanner = new RepoEvidenceScanner(
- GitRepository.FindRoot, root => RepositoryDetection.DetectRepositoryAsync(config, root),
+ GitRepository.FindRoot, root => RepositoryDetection.DetectRepositoryAsync(router, config, root),
p => p.Owner is not null && p.RepoName is not null);
try {
@@ -728,7 +730,7 @@ await BackfillCodexWatcherStateAsync(
// Periodically refresh repository info (every 60s)
if (cwd is not null && DateTimeOffset.UtcNow - state.LastRepoDetection > TimeSpan.FromSeconds(60)) {
- var detected = await RepositoryDetection.DetectRepositoryAsync(config, cwd);
+ var detected = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd);
// An evidence-derived repo may only be replaced by another real detection,
// never cleared back to null by a launch-cwd probe that still finds nothing.
diff --git a/src/Capacitor.Cli/CwdRepository.cs b/src/Capacitor.Cli/CwdRepository.cs
index 57a4bbb26..d885b4148 100644
--- a/src/Capacitor.Cli/CwdRepository.cs
+++ b/src/Capacitor.Cli/CwdRepository.cs
@@ -9,7 +9,7 @@ namespace Capacitor.Cli;
/// name only, and the provider round-trip would otherwise run in every agent session that spawns
/// the server, tool call or not. Not thread-safe; a concurrent first use resolves twice.
///
-sealed class CwdRepository(ConfigRoot config, string cwd, CommandRunner? run = null) {
+sealed class CwdRepository(ConfigRoot config, string cwd, GitProviderRouter router, CommandRunner? run = null) {
bool _resolved;
RepositoryPayload? _repository;
@@ -17,7 +17,7 @@ sealed class CwdRepository(ConfigRoot config, string cwd, CommandRunner? run = n
public async ValueTask GetAsync() {
if (_resolved) return _repository;
- _repository = await RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false, run: run);
+ _repository = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false, run: run);
_resolved = true;
return _repository;
diff --git a/src/Capacitor.Cli/Harness/Claude/ClaudeImportSource.cs b/src/Capacitor.Cli/Harness/Claude/ClaudeImportSource.cs
index 19c725ba2..a5ab825ab 100644
--- a/src/Capacitor.Cli/Harness/Claude/ClaudeImportSource.cs
+++ b/src/Capacitor.Cli/Harness/Claude/ClaudeImportSource.cs
@@ -1,6 +1,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Harness.Claude;
@@ -12,7 +13,8 @@ namespace Capacitor.Cli.Harness.Claude;
/// with vendor = "claude". Claude sessions are imported per chain, so
/// is never the entry point — ImportChainsAsync is.
///
-internal sealed class ClaudeImportSource(ConfigRoot config, string projectsDir) : IImportSource {
+internal sealed class ClaudeImportSource(
+ ConfigRoot config, string projectsDir, GitProviderRouter router) : IImportSource {
readonly string _projectsDir = projectsDir;
public HarnessId Vendor => HarnessId.Claude;
@@ -87,6 +89,7 @@ CancellationToken ct
}
return await TranscriptFileClassification.ClassifyAsync(
+ router,
config,
ctx.Home,
ctx.HttpClient,
diff --git a/src/Capacitor.Cli/Harness/Codex/CodexImportSource.cs b/src/Capacitor.Cli/Harness/Codex/CodexImportSource.cs
index 26a675125..a45bfb900 100644
--- a/src/Capacitor.Cli/Harness/Codex/CodexImportSource.cs
+++ b/src/Capacitor.Cli/Harness/Codex/CodexImportSource.cs
@@ -3,6 +3,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Harness.Codex;
using Capacitor.Cli.Harness.Claude;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Harness.Codex;
@@ -15,7 +16,8 @@ namespace Capacitor.Cli.Harness.Codex;
/// with vendor = "codex". Codex sessions are imported per chain, so
/// is never the entry point — ImportChainsAsync is.
///
-internal sealed class CodexImportSource(ConfigRoot config, string sessionsDir) : IImportSource {
+internal sealed class CodexImportSource(
+ ConfigRoot config, string sessionsDir, GitProviderRouter router) : IImportSource {
readonly string _sessionsDir = sessionsDir;
public HarnessId Vendor => HarnessId.Codex;
@@ -102,6 +104,7 @@ CancellationToken ct
}
return await TranscriptFileClassification.ClassifyAsync(
+ router,
config,
ctx.Home,
ctx.HttpClient,
diff --git a/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs b/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs
index 24bb7d093..ac513172e 100644
--- a/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs
+++ b/src/Capacitor.Cli/Harness/Copilot/CopilotImportSource.cs
@@ -6,6 +6,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Harness.Copilot;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Harness.Copilot;
@@ -33,10 +34,11 @@ internal sealed class CopilotImportSource : IImportSource {
public CopilotImportSource(
ConfigRoot config,
CopilotPaths paths,
+ GitProviderRouter router,
Func>? repoDetector = null
) {
_paths = paths;
- _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false));
+ _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false));
}
static StringComparison PathComparison =>
diff --git a/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs b/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs
index 73a030f39..a2ced2bcc 100644
--- a/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs
+++ b/src/Capacitor.Cli/Harness/Cursor/CursorImportSource.cs
@@ -6,6 +6,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Harness.Cursor;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Harness.Cursor;
@@ -53,6 +54,7 @@ public CursorImportSource(
ConfigRoot config,
string projectsDir,
string workspaceStorageDir,
+ GitProviderRouter router,
Func>? repoDetector = null
) {
_config = config;
@@ -68,7 +70,7 @@ public CursorImportSource(
// grouping under their repo — they just never carry pr_number/pr_title/pr_url/pr_head_ref.
// The LIVE Cursor hook path (CursorHookCommand → EnrichWithRepositoryInfoFromCwd) is a
// separate call site untouched by this default and keeps live PR detection.
- _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false));
+ _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false));
}
///
diff --git a/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs b/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs
index b88d791dd..4a55958ae 100644
--- a/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs
+++ b/src/Capacitor.Cli/Harness/Kiro/KiroImportSource.cs
@@ -7,6 +7,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Harness.Kiro;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Harness.Kiro;
@@ -26,10 +27,11 @@ internal sealed class KiroImportSource : IImportSource {
public KiroImportSource(
ConfigRoot config,
string sessionsDir,
+ GitProviderRouter router,
Func>? repoDetector = null
) {
_sessionsDir = sessionsDir;
- _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false));
+ _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false));
}
static StringComparison PathComparison =>
diff --git a/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs b/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs
index 797cd9d68..b2c3372d3 100644
--- a/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs
+++ b/src/Capacitor.Cli/Harness/Pi/PiImportSource.cs
@@ -7,6 +7,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Harness.Pi;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Harness.Pi;
@@ -32,10 +33,11 @@ internal sealed class PiImportSource : IImportSource {
public PiImportSource(
ConfigRoot config,
string sessionsDir,
+ GitProviderRouter router,
Func>? repoDetector = null
) {
_sessionsDir = sessionsDir;
- _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(config, cwd, detectPullRequest: false));
+ _repoDetector = repoDetector ?? (cwd => RepositoryDetection.DetectRepositoryAsync(router, config, cwd, detectPullRequest: false));
}
static StringComparison PathComparison =>
diff --git a/src/Capacitor.Cli/PrDetection/GitProviderRouter.cs b/src/Capacitor.Cli/PrDetection/GitProviderRouter.cs
index 03b7cbf57..8e86f2fb9 100644
--- a/src/Capacitor.Cli/PrDetection/GitProviderRouter.cs
+++ b/src/Capacitor.Cli/PrDetection/GitProviderRouter.cs
@@ -8,21 +8,27 @@ internal enum GitProviderKind { GitHub, GitLab, Unknown }
///
/// Maps a remote host to a provider. SaaS hosts route directly; a custom host is
/// probed once via `gh auth status --json hosts` (GitHub if listed, else best-effort
-/// GitLab). The decision is memoized per host for the process lifetime so the
-/// ImportCommand bulk loop can't multiply the probe.
+/// GitLab), and the answer is remembered.
+///
+/// Injected, never constructed at a call site: one per process is what makes remembering
+/// worth anything. The watcher re-detects every 60s for as long as it runs, so a custom host would
+/// otherwise pay for that probe on every refresh.
+///
+/// Public only because two hook commands are, and a public constructor cannot take a less
+/// accessible parameter; everything it does stays internal.
///
-internal static class GitProviderRouter {
- static readonly ConcurrentDictionary Memo = new(StringComparer.OrdinalIgnoreCase);
+public sealed class GitProviderRouter {
+ readonly ConcurrentDictionary _memo = new(StringComparer.OrdinalIgnoreCase);
- public static async Task ResolveAsync(string? host, string cwd, TimeSpan cap, CommandRunner run) {
+ internal async Task ResolveAsync(string? host, string cwd, TimeSpan cap, CommandRunner run) {
if (string.IsNullOrEmpty(host)) return GitProviderKind.Unknown;
if (host == "github.com") return GitProviderKind.GitHub;
if (host == "gitlab.com") return GitProviderKind.GitLab;
- if (Memo.TryGetValue(host, out var cached)) return cached;
+ if (_memo.TryGetValue(host, out var cached)) return cached;
var kind = await ProbeAsync(host, cwd, cap, run);
- Memo[host] = kind;
+ _memo[host] = kind;
return kind;
}
@@ -40,6 +46,4 @@ static async Task ProbeAsync(string host, string cwd, TimeSpan
// Not a known GitHub host → assume GitLab and let the detector no-op if unauthenticated.
return GitProviderKind.GitLab;
}
-
- internal static void ResetMemoForTests() => Memo.Clear();
}
diff --git a/src/Capacitor.Cli/Program.cs b/src/Capacitor.Cli/Program.cs
index 98c64a866..80a27456a 100644
--- a/src/Capacitor.Cli/Program.cs
+++ b/src/Capacitor.Cli/Program.cs
@@ -13,6 +13,7 @@
using Microsoft.Extensions.DependencyInjection;
using ReviewCommand = Capacitor.Cli.Commands.ReviewCommand;
using WatchCommand = Capacitor.Cli.Commands.WatchCommand;
+using Capacitor.Cli.PrDetection;
if (args.Length < 1) {
await PrintUsage();
@@ -75,8 +76,9 @@
var isHook = command == "hook";
// Resolved once here and passed onward; nothing downstream resolves a root for itself.
-var config = ConfigRoot.FromEnvironment();
-var home = UserHome.FromEnvironment();
+var config = ConfigRoot.FromEnvironment();
+var home = UserHome.FromEnvironment();
+var workdir = WorkingDirectory.FromProcess();
// Claude kills a SessionEnd hook after 1.5 s (ClaudeSessionEndHandoff), so the hand-off sits
// ahead of ResolveServerUrl's git probes and the global spool drain, each of which can spend it.
@@ -103,7 +105,7 @@
var machineEnv = MachineAuth.FromEnvironment();
var endpoints = AuthEndpoints.FromEnvironment();
-var profiles = await AppConfig.ResolveForRepo(args, config, serverEnv, gitTimeoutMs: isHook || isRefreshHandoff ? 1000 : 5000);
+var profiles = await AppConfig.ResolveForRepo(args, config, serverEnv, workdir, gitTimeoutMs: isHook || isRefreshHandoff ? 1000 : 5000);
var baseUrl = profiles.Resolution.ServerUrl;
// An app-spawned CLI child must not emit CLI-labeled telemetry nor consume the one-time privacy
@@ -115,7 +117,7 @@
// asks for a command rather than handing each one its arguments.
var services = new ServiceCollection()
.AddCapacitorCli(
- config, home, daemonPaths, profiles, serverEnv, machineEnv, endpoints, clock, baseUrl,
+ config, home, workdir, daemonPaths, profiles, serverEnv, machineEnv, endpoints, clock, baseUrl,
telemetryStartup);
await using var sp = services.BuildValidated();
@@ -650,7 +652,8 @@
// Build sources
var explicitVendorSelection = vsel.Vendors.Count > 0;
var sources = SetupCommand.BuildImportSources(
- config, sp.GetRequiredService(), explicitVendorSelection ? vsel.Vendors : null);
+ config, sp.GetRequiredService(), sp.GetRequiredService(),
+ explicitVendorSelection ? vsel.Vendors : null);
// --- Scope resolution ---
var profileConfig = profiles.Snapshot;
@@ -659,7 +662,7 @@
var activeProfile = profiles.Name;
var storedOrg = profileConfig.Profiles.GetValueOrDefault(activeProfile)?.ImportOrg;
- var currentRepoDetected = await RepositoryDetection.DetectRepositoryAsync(config, Environment.CurrentDirectory);
+ var currentRepoDetected = await RepositoryDetection.DetectRepositoryAsync(sp.GetRequiredService(), config, workdir.Path);
(string Owner, string Name)? currentRepo = currentRepoDetected is { Owner: { } o, RepoName: { } n }
? (o, n)
: null;
diff --git a/src/Capacitor.Cli/RepoExclusion.cs b/src/Capacitor.Cli/RepoExclusion.cs
index e24dfabfe..7d8814856 100644
--- a/src/Capacitor.Cli/RepoExclusion.cs
+++ b/src/Capacitor.Cli/RepoExclusion.cs
@@ -1,5 +1,6 @@
using System.Text.Json.Nodes;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli;
@@ -9,7 +10,8 @@ static class RepoExclusion {
/// Returns true if the repo is excluded (caller should skip processing).
///
public static async Task IsExcludedAsync(
- ConfigRoot config, string body, string[]? excludedRepos, TimeSpan? budget = null) {
+ GitProviderRouter router, ConfigRoot config, string body, string[]? excludedRepos,
+ TimeSpan? budget = null) {
if (excludedRepos is null or { Length: 0 }) return false;
try {
@@ -31,7 +33,7 @@ public static async Task IsExcludedAsync(
if (cwd is null) return false;
// Exclusion matches on owner/repo only → skip the PR round-trip (~600ms to GitHub).
- var repo = await RepositoryDetection.DetectRepositoryAsync(config, cwd, budget, detectPullRequest: false);
+ var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, cwd, budget, detectPullRequest: false);
if (repo?.Owner is not null && repo.RepoName is not null) {
return excludedRepos.Contains($"{repo.Owner}/{repo.RepoName}", StringComparer.OrdinalIgnoreCase);
diff --git a/src/Capacitor.Cli/RepositoryDetection.cs b/src/Capacitor.Cli/RepositoryDetection.cs
index 093d79f91..e59dbda4c 100644
--- a/src/Capacitor.Cli/RepositoryDetection.cs
+++ b/src/Capacitor.Cli/RepositoryDetection.cs
@@ -19,6 +19,7 @@ static class RepositoryDetection {
internal static CommandRunner DefaultRunner => RunCommandAsync;
public static async Task EnrichWithRepositoryInfo(
+ GitProviderRouter router,
ConfigRoot config, string json, TimeSpan? budget = null, bool detectPullRequest = true,
CommandRunner? run = null) {
try {
@@ -34,7 +35,7 @@ public static async Task EnrichWithRepositoryInfo(
return json;
}
- var repo = await DetectRepositoryAsync(config, cwd, budget, detectPullRequest, run);
+ var repo = await DetectRepositoryAsync(router, config, cwd, budget, detectPullRequest, run);
if (repo is null) {
return json;
@@ -66,12 +67,12 @@ public static async Task EnrichWithRepositoryInfo(
/// Fail-open: forwards the original payload unchanged on any error or non-git dir.
///
public static async Task EnrichWithRepositoryInfoFromCwd(
- ConfigRoot config, string json, string cwd, TimeSpan? budget = null) {
+ GitProviderRouter router, ConfigRoot config, string json, string cwd, TimeSpan? budget = null) {
try {
if (string.IsNullOrEmpty(cwd)) return json;
if (JsonNode.Parse(json) is not JsonObject obj) return json;
- var repo = await DetectRepositoryAsync(config, cwd, budget);
+ var repo = await DetectRepositoryAsync(router, config, cwd, budget);
if (repo is null) return json;
obj["repository"] = BuildRepositoryNode(repo);
@@ -120,10 +121,9 @@ static bool RepoPayloadEquals(RepositoryPayload a, RepositoryPayload b) =>
// detectPullRequest=false skips the live PR/MR provider detection (the `gh pr view` / `glab api`
// round-trip) while still resolving base repo info (owner/repo/user/branch/host). Bulk import
- // passes false: it never emits PR fields, so that per-cwd round-trip is pure wasted latency
- // `run` is an injectable command runner (defaults to the real process spawner) so the
- // git/provider spawns are unit-testable.
+ // passes false: it never emits PR fields, so that per-cwd round-trip is pure wasted latency.
public static async Task DetectRepositoryAsync(
+ GitProviderRouter router,
ConfigRoot config, string cwd, TimeSpan? budget = null, bool detectPullRequest = true,
CommandRunner? run = null) {
if (budget is { } b0 && b0 <= TimeSpan.Zero) return null;
@@ -208,7 +208,7 @@ static bool RepoPayloadEquals(RepositoryPayload a, RepositoryPayload b) =>
// Import passes detectPullRequest:false: it discards PR fields, so the round-trip is
// wasted latency. ResolveAndDetectPrAsync owns the split of providerCap across probes.
if (detectPullRequest && providerCap > TimeSpan.Zero && host is not null) {
- var pr = await ResolveAndDetectPrAsync(host, owner, repoName, branch, cwd, providerCap, run);
+ var pr = await ResolveAndDetectPrAsync(router, host, owner, repoName, branch, cwd, providerCap, run);
if (pr is not null) {
prNumber = pr.Number;
@@ -243,6 +243,7 @@ static bool RepoPayloadEquals(RepositoryPayload a, RepositoryPayload b) =>
/// is a seam for tests (defaults to ).
///
internal static async Task ResolveAndDetectPrAsync(
+ GitProviderRouter router,
string host,
string? owner,
string? repoName,
@@ -256,7 +257,7 @@ static bool RepoPayloadEquals(RepositoryPayload a, RepositoryPayload b) =>
var getTs = getTimestamp ?? Stopwatch.GetTimestamp;
var start = getTs();
- var kind = await GitProviderRouter.ResolveAsync(host, cwd, providerCap, run);
+ var kind = await router.ResolveAsync(host, cwd, providerCap, run);
var detectCap = Remaining();
if (detectCap <= TimeSpan.Zero) return null;
diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs
index 7cce26bc1..90978b202 100644
--- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs
+++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryHookSupport.cs
@@ -1,5 +1,6 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.SessionStartMemory;
@@ -27,11 +28,13 @@ internal static class SessionStartMemoryHookSupport {
/// without memory context on any authenticated deployment.
///
public static ISessionStartContextProvider CompositeProvider(
+ GitProviderRouter router,
ConfigRoot config,
+ WorkingDirectory workdir,
Func> client,
TimeProvider time,
ISessionStartMemoryScopeResolver? scopeResolver = null) {
- var resolver = scopeResolver ?? new SessionStartMemoryScopeResolver(config, time);
+ var resolver = scopeResolver ?? new SessionStartMemoryScopeResolver(router, config, workdir, time);
var memory = new SessionStartMemoryContextProvider(resolver, client, time);
var guidelines = new SessionStartGuidelinesLane(client);
diff --git a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs
index aa6aa2947..a08744161 100644
--- a/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs
+++ b/src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryScopeResolver.cs
@@ -1,8 +1,11 @@
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.SessionStartMemory;
-internal sealed class SessionStartMemoryScopeResolver(ConfigRoot config, TimeProvider time) : ISessionStartMemoryScopeResolver {
+internal sealed class SessionStartMemoryScopeResolver(
+ GitProviderRouter router, ConfigRoot config, WorkingDirectory workdir, TimeProvider time)
+ : ISessionStartMemoryScopeResolver {
public async Task ResolveAsync(string? cwd, TimeSpan budget, CancellationToken ct) {
var started = time.GetTimestamp();
TimeSpan Remaining() {
@@ -13,8 +16,8 @@ TimeSpan Remaining() {
string? repoHash = null;
string? machine = null;
try {
- var path = string.IsNullOrWhiteSpace(cwd) ? Directory.GetCurrentDirectory() : cwd;
- var repo = await RepositoryDetection.DetectRepositoryAsync(config, path, Remaining(), detectPullRequest: false);
+ var path = string.IsNullOrWhiteSpace(cwd) ? workdir.Path : cwd;
+ var repo = await RepositoryDetection.DetectRepositoryAsync(router, config, path, Remaining(), detectPullRequest: false);
if (repo?.Owner is not null && repo.RepoName is not null)
repoHash = RepoHashHelper.ComputeRepoHash(repo.Owner, repo.RepoName);
} catch { }
diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Config/ResolveForRepoTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Config/ResolveForRepoTests.cs
index 7464d6556..d7f978db0 100644
--- a/test/Capacitor.Cli.Core.Tests.Unit/Config/ResolveForRepoTests.cs
+++ b/test/Capacitor.Cli.Core.Tests.Unit/Config/ResolveForRepoTests.cs
@@ -3,9 +3,7 @@
namespace Capacitor.Cli.Core.Tests.Unit.Config;
///
-/// The short-circuit that skips repo discovery when an explicit URL is given. Bare
-/// [NotInParallel]: the repo is discovered from the working directory, which is
-/// process-global.
+/// The short-circuit that skips repo discovery when an explicit URL is given.
///
public class ResolveForRepoTests {
[TempConfigRoot] public required TempConfigRoot Config { get; init; }
@@ -27,19 +25,11 @@ async Task WriteRepoPinnedToAnotherProfile() {
Repo.CreateFile(".kcap.json", """{"profile":"pinned"}""");
}
- async Task ResolveIn(string[] args, ProfileOverrides env) {
- var originalCwd = Environment.CurrentDirectory;
- try {
- Environment.CurrentDirectory = Repo.Path;
+ async Task ResolveIn(string[] args, ProfileOverrides env) =>
+ (await AppConfig.ResolveForRepo(args, Config.Root, env, new WorkingDirectory(Repo.Path), gitTimeoutMs: 1000))
+ .Resolution.ServerUrl;
- return (await AppConfig.ResolveForRepo(args, Config.Root, env, gitTimeoutMs: 1000))
- .Resolution.ServerUrl;
- } finally {
- Environment.CurrentDirectory = originalCwd;
- }
- }
-
- [Test, NotInParallel]
+ [Test]
public async Task Nothing_overridden_resolves_the_repos_own_profile() {
await WriteRepoPinnedToAnotherProfile();
@@ -49,7 +39,7 @@ public async Task Nothing_overridden_resolves_the_repos_own_profile() {
/// A named override outranks what the repo pins, so the profile named in
/// .kcap.json loses to it. Says nothing about whether discovery ran — both paths feed the
/// resolver the same override, and only the git probe tells them apart.
- [Test, NotInParallel]
+ [Test]
public async Task A_named_override_outranks_the_repos_own_profile() {
await WriteRepoPinnedToAnotherProfile();
@@ -60,7 +50,7 @@ public async Task A_named_override_outranks_the_repos_own_profile() {
/// An empty --server-url names no server: the resolver discards it and falls
/// through, so withholding the repo inputs from that fall-through would silently answer the
/// active profile instead of the one the repo pins.
- [Test, NotInParallel]
+ [Test]
public async Task An_empty_server_url_flag_still_resolves_the_repos_profile() {
await WriteRepoPinnedToAnotherProfile();
diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs
index 9e7823819..0cb3a6667 100644
--- a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs
+++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexConfigTomlTests.cs
@@ -120,26 +120,6 @@ public async Task EnableNetworkAccess_is_idempotent() {
await Assert.That(second).IsEqualTo(CodexConfigToml.Change.Unchanged);
}
- [Test]
- [NotInParallel("CwdMutation")]
- public async Task EnableNetworkAccess_writes_when_config_path_has_no_directory_component() {
- // GetDirectoryName("config.toml") is empty; CreateDirectory("") would throw and
- // silently turn the write into Change.Failed without the guard.
- using var tmp = new TempDir();
- var originalCwd = Environment.CurrentDirectory;
-
- try {
- Environment.CurrentDirectory = tmp.Path;
-
- var change = CodexConfigToml.EnableNetworkAccess(["**.kcap.ai"], "config.toml");
-
- await Assert.That(change).IsEqualTo(CodexConfigToml.Change.Updated);
- await Assert.That(File.Exists(tmp.PathTo("config.toml"))).IsTrue();
- } finally {
- Environment.CurrentDirectory = originalCwd;
- }
- }
-
[Test]
public async Task EnableNetworkAccess_empty_allowlist_is_noop() {
using var tmp = new TempDir();
diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexProjectKeyTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexProjectKeyTests.cs
index 3e106b73a..9fc912532 100644
--- a/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexProjectKeyTests.cs
+++ b/test/Capacitor.Cli.Core.Tests.Unit/Harness/Codex/CodexProjectKeyTests.cs
@@ -35,8 +35,8 @@ public async Task NormalizeProjectKey_preserves_case_on_unix() {
[Test]
public async Task NormalizeProjectKey_is_absolute_and_collapsed() {
- var key = CodexPaths.NormalizeProjectKey(
- Path.Combine(Directory.GetCurrentDirectory(), "sub", "..", "leaf"));
+ using var tmp = new TempDir();
+ var key = CodexPaths.NormalizeProjectKey(tmp.PathTo("sub", "..", "leaf"));
await Assert.That(Path.IsPathFullyQualified(key)).IsTrue();
await Assert.That(key).DoesNotContain("..");
diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Setup/BinaryProbeTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Setup/BinaryProbeTests.cs
index 4a29b9b56..ea8d4be16 100644
--- a/test/Capacitor.Cli.Core.Tests.Unit/Setup/BinaryProbeTests.cs
+++ b/test/Capacitor.Cli.Core.Tests.Unit/Setup/BinaryProbeTests.cs
@@ -57,7 +57,9 @@ public async Task Resolve_returns_a_fully_qualified_path_for_relative_input() {
var staged = await Stage(tmp.PathTo(Launchable("probe")));
// A path with a directory component but no root resolves against the cwd, not the search path.
+#pragma warning disable RS0030 // the base Resolve() itself resolves against; naming it is the test
var relative = Path.GetRelativePath(Directory.GetCurrentDirectory(), staged);
+#pragma warning restore RS0030
var resolved = BinaryProbe.Searching(null).Resolve(relative);
await Assert.That(resolved).IsNotNull();
diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/PtySpawnTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/PtySpawnTests.cs
index 6576e2556..2a70d4236 100644
--- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/PtySpawnTests.cs
+++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/PtySpawnTests.cs
@@ -192,7 +192,7 @@ static IntPtr Preflight(string exe, string?[] argv, int? execveatSupported = nul
static int Spawn(IntPtr plan, out UnixPtyInterop.PtySpawnResult result, string? cwd = null,
int expectedParent = -1, int cancelFd = -1) {
var expected = expectedParent == -1 ? Environment.ProcessId : expectedParent;
- return UnixPtyInterop.pty_spawn(plan, EmptyEnvp(), cwd ?? Directory.GetCurrentDirectory(), 40, 120, expected, cancelFd, out result);
+ return UnixPtyInterop.pty_spawn(plan, EmptyEnvp(), cwd ?? AppContext.BaseDirectory, 40, 120, expected, cancelFd, out result);
}
static void Free(IntPtr plan) { var p = plan; UnixPtyInterop.pty_plan_free(ref p); }
diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessSpawnTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessSpawnTests.cs
index abfe52260..f3d6ef1bd 100644
--- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessSpawnTests.cs
+++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessSpawnTests.cs
@@ -17,7 +17,7 @@ public async Task Spawn_produces_a_running_process_with_a_captured_identity() {
// test that let UnixPtyProcessFactory own an undisposed static singleton hung indefinitely).
using var spawner = new UnixSpawnerThread();
var factory = new UnixPtyProcessFactory(spawner);
- var proc = factory.Spawn("sleep", ["5"], Directory.GetCurrentDirectory());
+ var proc = factory.Spawn("sleep", ["5"], AppContext.BaseDirectory);
try {
await Assert.That(proc.Pid).IsGreaterThan(0);
await Assert.That(proc.StartIdentity).IsNotNull();
@@ -64,7 +64,7 @@ public async Task Terminate_kills_the_leaders_whole_process_group() {
// that leaks in production. Only a signal to the GROUP reaches it.
var proc = factory.Spawn(
"/bin/sh", ["-c", "(trap '' HUP; exec sleep 300) & echo \"CHILD:$!:DONE\"; wait"],
- Directory.GetCurrentDirectory());
+ AppContext.BaseDirectory);
try {
var childPid = await ReadReportedChildPidAsync(proc);
await Assert.That(childPid).IsGreaterThan(0);
diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixSpawnerThreadTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixSpawnerThreadTests.cs
index 94d4a0658..6627f777f 100644
--- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixSpawnerThreadTests.cs
+++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixSpawnerThreadTests.cs
@@ -62,7 +62,7 @@ public async Task Agent_survives_unrelated_pool_thread_churn_while_the_thread_li
var rc = UnixPtyInterop.pty_preflight("/bin/sleep", ["sleep", "3", null], [null], execveatSupported, out var plan);
await Assert.That(rc).IsEqualTo(0);
- var result = spawner.SpawnOn(plan, [null], Directory.GetCurrentDirectory(), 40, 120, Environment.ProcessId, -1);
+ var result = spawner.SpawnOn(plan, [null], AppContext.BaseDirectory, 40, 120, Environment.ProcessId, -1);
try {
await Assert.That(result.Pid).IsGreaterThan(0);
for (var i = 0; i < 5; i++) await Task.Run(() => { });
diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Windows/ConPtyJobObjectTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Windows/ConPtyJobObjectTests.cs
index 5fbd25207..3da35503f 100644
--- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Windows/ConPtyJobObjectTests.cs
+++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Windows/ConPtyJobObjectTests.cs
@@ -18,7 +18,7 @@ public async Task Disposing_the_process_kills_child_and_grandchild() {
// immediate child — is under test.
await using var proc = ConPtyProcess.Spawn(
"cmd.exe", ["/c", "start /min cmd.exe /c timeout /t 60 >NUL & timeout /t 60 >NUL"],
- Directory.GetCurrentDirectory());
+ AppContext.BaseDirectory);
await Task.Delay(500); // let the grandchild actually spawn before we kill the job
@@ -44,7 +44,7 @@ public async Task Job_sets_no_breakaway_flag_so_escape_is_impossible() {
// NOT by joining this test host to proc's killing job and disposing it (which would
// close the last handle and have the OS kill the host). Reading the flags is host-safe;
// proc.DisposeAsync closes proc's own killing job, killing proc's child, never the host.
- await using var proc = ConPtyProcess.Spawn("cmd.exe", ["/c", "exit"], Directory.GetCurrentDirectory());
+ await using var proc = ConPtyProcess.Spawn("cmd.exe", ["/c", "exit"], AppContext.BaseDirectory);
var limitFlags = ConPtyJobObjectTestHelper.QueryJobLimitFlags(ConPtyInteropTestAccessor.JobHandle(proc));
@@ -68,7 +68,7 @@ public async Task A_daemon_already_inside_an_outer_job_still_nests() {
var outerJob = ConPtyInterop.CreateJobObjectW(IntPtr.Zero, null);
ConPtyJobObjectTestHelper.AssignSelfToJob(outerJob);
- await using var proc = ConPtyProcess.Spawn("cmd.exe", ["/c", "timeout /t 5 >NUL"], Directory.GetCurrentDirectory());
+ await using var proc = ConPtyProcess.Spawn("cmd.exe", ["/c", "timeout /t 5 >NUL"], AppContext.BaseDirectory);
// Nesting succeeded iff the spawn didn't throw AND the child is (transitively) a
// member of the outer job too — checked via the native IsProcessInJob.
@@ -100,7 +100,7 @@ public async Task Spawn_fails_closed_when_create_process_fails() {
var threw = false;
try {
- await using var proc = ConPtyProcess.Spawn(missing, [], Directory.GetCurrentDirectory());
+ await using var proc = ConPtyProcess.Spawn(missing, [], AppContext.BaseDirectory);
} catch (InvalidOperationException) {
threw = true; // Spawn threw → failed closed, no uncontained child created
}
diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorBracketedPasteTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorBracketedPasteTests.cs
index e4cce87d2..5a8759728 100644
--- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorBracketedPasteTests.cs
+++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorBracketedPasteTests.cs
@@ -11,6 +11,11 @@ namespace Capacitor.Cli.Daemon.Tests.Unit.Services;
public class AgentOrchestratorBracketedPasteTests {
[TempDir] public required TempDir Worktree { get; init; }
+ /// A directory under the fixture, never the fixture itself: cleanup of a standalone
+ /// worktree deletes the path it is given, and a fixture that vanishes under its own test takes
+ /// the reason for every later failure with it.
+ string WorktreePath => Worktree.CreateDir("worktree");
+
const string PasteStart = "\x1b[200~";
const string PasteEnd = "\x1b[201~";
@@ -24,8 +29,8 @@ public async Task HandleSendInput_wraps_the_message_in_a_bracketed_paste_and_sub
await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary());
var agent = new AgentInstance(
- "agent-paste", null, "", null, Worktree.Path, "codex",
- new PtyHostedAgentRuntime("codex", pty, approvalsDisabled: true), new WorktreeInfo(Worktree.Path, "", Worktree.Path, IsStandalone: true), new CancellationTokenSource());
+ "agent-paste", null, "", null, WorktreePath, "codex",
+ new PtyHostedAgentRuntime("codex", pty, approvalsDisabled: true), new WorktreeInfo(WorktreePath, "", WorktreePath, IsStandalone: true), new CancellationTokenSource());
orch.RegisterAgentForTest(agent);
await orch.HandleSendInputForTest(new SendInputCommand("agent-paste", message, null));
diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs
index a8d8e55a1..7e5c26dd2 100644
--- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs
+++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs
@@ -122,13 +122,14 @@ public async Task Borrowed_cwd_cleanup_does_not_delete_user_dir_or_branch() {
[Test]
public async Task Owned_worktree_cleanup_still_removes_it() {
using var tmp = new TempDir();
+ string worktree = tmp.CreateDir("worktree");
var server = new CaptureServerConnection();
await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary());
var agent = new AgentInstance(
- "owned-1", null, "", null, tmp.Path, "claude",
- new PtyHostedAgentRuntime("claude", new StubPtyProcess()), new WorktreeInfo(tmp.Path, "", tmp.Path, IsStandalone: true), new CancellationTokenSource()
+ "owned-1", null, "", null, worktree, "claude",
+ new PtyHostedAgentRuntime("claude", new StubPtyProcess()), new WorktreeInfo(worktree, "", worktree, IsStandalone: true), new CancellationTokenSource()
) {
Work = WorkLocation.OwnedWorktree
};
@@ -136,7 +137,9 @@ public async Task Owned_worktree_cleanup_still_removes_it() {
orch.RegisterAgentForTest(agent);
await orch.CleanupAgentForTest("owned-1");
- await Assert.That(Directory.Exists(tmp.Path)).IsFalse();
+ await Assert.That(Directory.Exists(worktree)).IsFalse();
+ // Scoped to the worktree: the cleanup owns what it was handed, not the tree above it.
+ await Assert.That(Directory.Exists(tmp.Path)).IsTrue();
}
[Test]
@@ -525,13 +528,14 @@ public async Task Private_agents_are_excluded_from_live_agent_ids() {
[Arguments("cursor")]
public async Task Attach_to_a_runtime_with_no_terminal_is_refused_by_name(string vendor) {
using var worktree = new TempDir();
+ string worktreePath = worktree.CreateDir("worktree");
var server = new CaptureServerConnection();
await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary());
var runtime = new NoRawInputRuntime(vendor);
var agent = new AgentInstance(
- "hosted-1", null, "", null, worktree.Path, vendor,
- runtime, new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource()
+ "hosted-1", null, "", null, worktreePath, vendor,
+ runtime, new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource()
);
orch.RegisterAgentForTest(agent);
@@ -563,6 +567,7 @@ public async Task Attach_to_a_runtime_with_no_terminal_is_refused_by_name(string
[Test]
public async Task Attach_to_a_terminal_runtime_that_rejects_raw_input_gets_an_error_frame_instead_of_crashing() {
using var worktree = new TempDir();
+ string worktreePath = worktree.CreateDir("worktree");
var server = new CaptureServerConnection();
await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary());
@@ -571,8 +576,8 @@ public async Task Attach_to_a_terminal_runtime_that_rejects_raw_input_gets_an_er
// disagree.
var runtime = new NoRawInputRuntime("claude", emitsTerminalOutput: true);
var agent = new AgentInstance(
- "pty-1", null, "", null, worktree.Path, "claude",
- runtime, new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource()
+ "pty-1", null, "", null, worktreePath, "claude",
+ runtime, new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource()
);
orch.RegisterAgentForTest(agent);
diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorStopDiagnosticsTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorStopDiagnosticsTests.cs
index 62f6967e2..50290e172 100644
--- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorStopDiagnosticsTests.cs
+++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorStopDiagnosticsTests.cs
@@ -10,6 +10,11 @@ namespace Capacitor.Cli.Daemon.Tests.Unit.Services;
public class AgentOrchestratorStopDiagnosticsTests {
[TempDir] public required TempDir Worktree { get; init; }
+ /// A directory under the fixture, never the fixture itself: cleanup of a standalone
+ /// worktree deletes the path it is given, and a fixture that vanishes under its own test takes
+ /// the reason for every later failure with it.
+ string WorktreePath => Worktree.CreateDir("worktree");
+
sealed class CapturingOrchestratorLogger : ILogger {
public List Messages { get; } = [];
public IDisposable? BeginScope(TState state) where TState : notnull => null;
@@ -34,9 +39,9 @@ public async Task Graceful_exit_timeout_warning_names_the_agents_own_vendor(stri
// from WaitForExitAsync immediately — i.e. exactly the "graceful window elapsed without the
// CLI exiting" state, with no real 15s wait.
orch.RegisterAgentForTest(new AgentInstance(
- $"agent-{vendor}", null, "", null, Worktree.Path, vendor,
+ $"agent-{vendor}", null, "", null, WorktreePath, vendor,
new FakeHostedAgentRuntime(vendor, emitsTerminalOutput: false),
- new WorktreeInfo(Worktree.Path, "", Worktree.Path, IsStandalone: true), new CancellationTokenSource()));
+ new WorktreeInfo(WorktreePath, "", WorktreePath, IsStandalone: true), new CancellationTokenSource()));
await orch.HandleStopAgent($"agent-{vendor}");
@@ -55,9 +60,9 @@ public async Task Graceful_exit_timeout_warning_still_names_claude_for_a_claude_
new Dictionary(), logger: log);
orch.RegisterAgentForTest(new AgentInstance(
- "agent-claude", null, "", null, Worktree.Path, "claude",
+ "agent-claude", null, "", null, WorktreePath, "claude",
new FakeHostedAgentRuntime("claude", emitsTerminalOutput: false),
- new WorktreeInfo(Worktree.Path, "", Worktree.Path, IsStandalone: true), new CancellationTokenSource()));
+ new WorktreeInfo(WorktreePath, "", WorktreePath, IsStandalone: true), new CancellationTokenSource()));
await orch.HandleStopAgent("agent-claude");
diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorVendorTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorVendorTests.cs
index 2ccc5e289..4b257de3d 100644
--- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorVendorTests.cs
+++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorVendorTests.cs
@@ -63,13 +63,14 @@ public async Task Git_spawn_failure_reports_the_working_directory_and_PATH_resol
[Test]
public async Task ReRegister_retries_a_transient_per_agent_failure_then_succeeds() {
using var worktree = new TempDir();
+ string worktreePath = worktree.CreateDir("worktree");
var server = new CaptureServerConnection { AgentRegisteredFailTimes = 1 };
await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary());
orch.RegisterAgentForTest(new AgentInstance(
- "agent-rereg", null, "", null, worktree.Path, "claude",
- new PtyHostedAgentRuntime("claude", new StubPtyProcess()), new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource()
+ "agent-rereg", null, "", null, worktreePath, "claude",
+ new PtyHostedAgentRuntime("claude", new StubPtyProcess()), new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource()
));
// The orchestrator wires ReRegisterAgentsHook in its ctor; invoking it runs the same
@@ -85,13 +86,14 @@ public async Task ReRegister_retries_a_transient_per_agent_failure_then_succeeds
[Test]
public async Task ReRegister_reports_pty_transport_for_a_pty_codex_runtime() {
using var worktree = new TempDir();
+ string worktreePath = worktree.CreateDir("worktree");
var server = new CaptureServerConnection();
await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, new SpyPtyProcessFactory(), new Dictionary());
orch.RegisterAgentForTest(new AgentInstance(
- "agent-codex-pty", null, "", null, worktree.Path, "codex",
- new PtyHostedAgentRuntime("codex", new StubPtyProcess()), new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource()
+ "agent-codex-pty", null, "", null, worktreePath, "codex",
+ new PtyHostedAgentRuntime("codex", new StubPtyProcess()), new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource()
));
await server.ReRegisterAgentsHook!();
@@ -544,6 +546,7 @@ public async Task Non_codex_launch_echoes_no_posture() {
[Test]
public async Task Reregistration_resends_the_same_applied_posture() {
using var worktree = new TempDir();
+ string worktreePath = worktree.CreateDir("worktree");
// A server restart wipes the in-memory echo; the reconnect path rebuilds it from the
// AgentInstance, so the pair must survive rather than silently becoming null.
var server = new CaptureServerConnection();
@@ -552,9 +555,9 @@ public async Task Reregistration_resends_the_same_applied_posture() {
await using var orch = AgentOrchestratorHarness.BuildOrchestrator(server, ptyFactory, new Dictionary());
orch.RegisterAgentForTest(new AgentInstance(
- "agent-rereg-posture", null, "", null, worktree.Path, "codex",
+ "agent-rereg-posture", null, "", null, worktreePath, "codex",
new PtyHostedAgentRuntime("codex", new StubPtyProcess()),
- new WorktreeInfo(worktree.Path, "", worktree.Path, IsStandalone: true), new CancellationTokenSource()
+ new WorktreeInfo(worktreePath, "", worktreePath, IsStandalone: true), new CancellationTokenSource()
) {
SandboxPolicy = "danger-full-access", ApprovalPolicy = "never"
});
diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalPermissionBridgeTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalPermissionBridgeTests.cs
index 4acc8f969..f9baf3860 100644
--- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalPermissionBridgeTests.cs
+++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalPermissionBridgeTests.cs
@@ -341,33 +341,38 @@ public async Task ServerFailureFallsBackToDeny() {
}
}
+ /// A port below every platform's ephemeral range — Linux allocates from 32768, macOS
+ /// and Windows from 49152 — so nothing else on the machine is handed it while this test holds
+ /// the gap between releasing it and binding it again.
+ const int RebindablePort = 28137;
+
[Test, NotInParallel(nameof(LocalPermissionBridgeTests))]
public async Task StopAsyncReleasesPort() {
- var (bridge, _) = CreateBridge();
+ var (bridge, _) = CreateBridgeOn(new FakeLoopbackPortSource(RebindablePort));
+
TcpListener? probe = null;
var disposed = false;
try {
await bridge.StartAsync(CancellationToken.None);
- var port = new Uri(bridge.BaseUrl!).Port;
+ // The bridge retries onto an ephemeral port when its first choice is taken, and a probe
+ // rebinding a port the bridge never held would pass whatever StopAsync did.
+ await Assert.That(new Uri(bridge.BaseUrl!).Port).IsEqualTo(RebindablePort);
+
await bridge.StopAsync(CancellationToken.None);
- // After stop, the port should accept a fresh bind. If StopAsync didn't release
- // it, this would either throw or hang.
- probe = new TcpListener(IPAddress.Loopback, port);
+ probe = new TcpListener(IPAddress.Loopback, RebindablePort);
probe.Start();
- // Keep the replacement listener bound while disposing the bridge. This reproduces
- // the suite-level race where StopAsync released the port, another fixture claimed it,
- // and the old listener's later Close() threw EADDRINUSE.
+ // Disposed while the replacement listener holds the port: shutting down a bridge that
+ // no longer owns what it bound must not fault.
await bridge.DisposeAsync();
disposed = true;
} finally {
probe?.Stop();
- // Ensure cleanup still runs if setup or the assertion above fails. Dispose is
- // intentionally idempotent, so retrying after a partial shutdown is safe.
+ // Dispose is idempotent, so cleaning up after a partial shutdown is safe.
if (!disposed) await bridge.DisposeAsync();
}
}
diff --git a/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs b/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs
index f5ca33f6c..1b35aa821 100644
--- a/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/AntigravitySessionStartTests.cs
@@ -7,6 +7,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -70,7 +71,7 @@ public async Task PreInvocation_posts_session_start_with_profile_visibility() {
}
""";
- var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload),
+ var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload),
new StringWriter());
await Assert.That(exit).IsEqualTo(0);
@@ -117,7 +118,7 @@ public async Task PreInvocation_for_excluded_path_is_skipped_without_posting() {
}
""";
- var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload),
+ var exit = await new AntigravityHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"], new StringReader(payload),
new StringWriter());
await Assert.That(exit).IsEqualTo(0);
diff --git a/test/Capacitor.Cli.Tests.Integration/AntigravitySkippedChildOverrideRoutedLoopTests.cs b/test/Capacitor.Cli.Tests.Integration/AntigravitySkippedChildOverrideRoutedLoopTests.cs
index 6eeddbbec..da37d6267 100644
--- a/test/Capacitor.Cli.Tests.Integration/AntigravitySkippedChildOverrideRoutedLoopTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/AntigravitySkippedChildOverrideRoutedLoopTests.cs
@@ -6,6 +6,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -165,7 +166,7 @@ public async Task already_loaded_root_with_new_child_content_counts_loaded_and_i
var exitCode = 0;
var stdout = await CaptureStdoutAsync(async () => {
- exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [antigravity, gemini],
diff --git a/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs b/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs
index 67e22163a..58c06989b 100644
--- a/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/ClaudeHookStdoutTests.cs
@@ -6,6 +6,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -60,7 +61,7 @@ static string SessionStartPayloadWithoutTranscriptPath() =>
// concurrently-running test writes to Console can contaminate it.
async Task RunSessionStartAsync() {
var stdout = new StringWriter();
- await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()), stdout: stdout);
+ await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()), stdout: stdout);
return stdout.ToString();
}
diff --git a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs
index 6e30722d6..64e1511f9 100644
--- a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartHandshakeOnPostFailureTests.cs
@@ -5,6 +5,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -57,7 +58,7 @@ public async Task A_rejected_lifecycle_post_still_satisfies_the_blocking_stdout_
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
// The rejection is still reported — this is not "pretend it worked".
await Assert.That(exit).IsEqualTo(1);
diff --git a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs
index 81d768d90..4e271f45d 100644
--- a/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/CodexSessionStartVisibilityTests.cs
@@ -6,6 +6,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -52,7 +53,7 @@ public async Task SessionStart_stamps_default_visibility_from_active_profile() {
}
""";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start/codex").UsingPost());
@@ -97,7 +98,7 @@ public async Task SessionStart_for_excluded_repo_is_skipped_and_marks_session_di
using var capture = ConsoleOutput.StartCapture();
try {
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
// No /hooks/session-start/codex POST.
diff --git a/test/Capacitor.Cli.Tests.Integration/CopilotImportSourceImportTests.cs b/test/Capacitor.Cli.Tests.Integration/CopilotImportSourceImportTests.cs
index 995a034f2..034582fdd 100644
--- a/test/Capacitor.Cli.Tests.Integration/CopilotImportSourceImportTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/CopilotImportSourceImportTests.cs
@@ -5,6 +5,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core.Harness.Copilot;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -62,7 +63,7 @@ public async Task ImportSession_AlreadyLoaded_replay_is_a_no_op_suppressed_by_th
using var client = new HttpClient();
var source = new CopilotImportSource(Config.Root, CopilotLayout,
- repoDetector: _ => Task.FromResult(null));
+ repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter());
var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(discovered.Count).IsEqualTo(1);
diff --git a/test/Capacitor.Cli.Tests.Integration/CursorImportPrTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorImportPrTests.cs
index 0487034c5..70f72820c 100644
--- a/test/Capacitor.Cli.Tests.Integration/CursorImportPrTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/CursorImportPrTests.cs
@@ -4,6 +4,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -78,7 +79,7 @@ public async Task Import_attaches_repo_but_records_no_pull_request() {
detectCalls++;
return Task.FromResult(
new RepositoryPayload { Owner = "acme", RepoName = "widgets" });
- });
+ }, router: new GitProviderRouter());
using var client = new HttpClient();
diff --git a/test/Capacitor.Cli.Tests.Integration/CursorPrivatizeLifecycleFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorPrivatizeLifecycleFailureTests.cs
index b50e02ef1..3cdaefa72 100644
--- a/test/Capacitor.Cli.Tests.Integration/CursorPrivatizeLifecycleFailureTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/CursorPrivatizeLifecycleFailureTests.cs
@@ -5,6 +5,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -99,9 +100,9 @@ public async Task private_run_privatizes_even_when_lifecycle_post_fails_after_co
_server.Given(Request.Create().WithPath("/api/sessions/*/visibility").UsingPut())
.RespondWith(Response.Create().WithStatusCode(200));
- var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir);
+ var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir, router: new GitProviderRouter());
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [source],
@@ -138,9 +139,9 @@ public async Task private_retry_run_privatizes_an_already_loaded_session_with_no
_server.Given(Request.Create().WithPath("/api/sessions/*/visibility").UsingPut())
.RespondWith(Response.Create().WithStatusCode(200));
- var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir);
+ var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir, router: new GitProviderRouter());
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [source],
@@ -174,9 +175,9 @@ public async Task non_private_run_never_calls_set_visibility() {
_server.Given(Request.Create().WithPath("/api/sessions/*/visibility").UsingPut())
.RespondWith(Response.Create().WithStatusCode(200));
- var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir);
+ var source = new CursorImportSource(Config.Root, WriteOneCursorSession(), WorkspaceStorageDir, router: new GitProviderRouter());
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [source],
@@ -265,9 +266,9 @@ public async Task private_run_privatizes_when_child_watermark_probe_fails_both_o
_server.Given(Request.Create().WithPath("/api/sessions/*/visibility").UsingPut())
.RespondWith(Response.Create().WithStatusCode(200));
- var source = new CursorImportSource(Config.Root, WriteParentWithCorrelatedChild(), WorkspaceStorageDir);
+ var source = new CursorImportSource(Config.Root, WriteParentWithCorrelatedChild(), WorkspaceStorageDir, router: new GitProviderRouter());
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [source],
diff --git a/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs
index 375b4c7d3..bc106a7a5 100644
--- a/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/CursorSessionStartVisibilityTests.cs
@@ -6,6 +6,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -59,7 +60,7 @@ async Task RunSessionStartAndCaptureBodyAsync(string defaultVisibility
using var client = new HttpClient();
var spool = new HookSpool(tmp.CreateDir("spool").Path);
- var exit = await new CursorHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).HandleCore(client, new StringReader(body), spool);
+ var exit = await new CursorHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(client, new StringReader(body), spool);
await Assert.That(exit).IsEqualTo(0);
var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start/cursor").UsingPost());
diff --git a/test/Capacitor.Cli.Tests.Integration/CursorSuppressedRepoImportTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorSuppressedRepoImportTests.cs
index c8403e7e7..e7e9e80e5 100644
--- a/test/Capacitor.Cli.Tests.Integration/CursorSuppressedRepoImportTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/CursorSuppressedRepoImportTests.cs
@@ -4,6 +4,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -66,7 +67,7 @@ public async Task Suppression_simulating_reimport_still_posts_repository_and_rep
WriteOneCursorSessionWithWorkspace(),
WorkspaceStorageDir,
repoDetector: _ => Task.FromResult(
- new RepositoryPayload { Owner = "acme", RepoName = "widgets" }));
+ new RepositoryPayload { Owner = "acme", RepoName = "widgets" }), router: new GitProviderRouter());
using var client = new HttpClient();
diff --git a/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs b/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs
index 11ca8aa9d..fa269900f 100644
--- a/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/CursorTailingWatcherTests.cs
@@ -6,6 +6,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -44,7 +45,7 @@ namespace Capacitor.Cli.Tests.Integration;
///
[NotInParallel] // KCAP_WATCHER_DIR is process-global, and other classes pin it too.
public class CursorTailingWatcherTests {
- WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()));
+ WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter());
CursorMarkers Markers => new(Config.Root);
@@ -188,7 +189,7 @@ public async Task Reactivation_ViaSessionStartHook_SpawnsAFreshWatcher() {
var spool = new HookSpool(tmp.PathTo("spool"));
var body = $$"""{"hook_event_name":"sessionStart","session_id":"{{sessionId}}","transcript_path":"{{transcriptPath.Replace(@"\", @"\\")}}"}""";
- var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(client, new StringReader(body), spool);
+ var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(client, new StringReader(body), spool);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(spawner.Keys).IsEquivalentTo([sessionId]);
@@ -222,7 +223,7 @@ public async Task Reactivation_ViaNonSessionStartHook_SpawnsAFreshWatcher() {
var spool = new HookSpool(tmp.PathTo("spool"));
var body = $$"""{"hook_event_name":"postToolUse","session_id":"{{sessionId}}","transcript_path":"{{transcriptPath.Replace(@"\", @"\\")}}","tool_name":"Bash"}""";
- var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(client, new StringReader(body), spool);
+ var exit = await new CursorHookCommand(Config.Root, Resolutions.At(server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(client, new StringReader(body), spool);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(spawner.Keys).IsEquivalentTo([sessionId]);
diff --git a/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs
index 5fe0fae03..e71d96548 100644
--- a/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/GeminiSessionStartHandshakeOnPostFailureTests.cs
@@ -6,6 +6,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -99,7 +100,7 @@ public async Task A_rejected_lifecycle_post_still_delivers_the_memory_index_and_
// The real memory factory, resolving against this test's own config root: discovery finds no
// /auth/config, falls back to a token store that holds nothing, and hands back an
// unauthenticated client — which is exactly what the stub wants, without a seam.
- var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()))
+ var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.Handle(new StringReader(payload));
return (exit, capture.GetCapturedOutput());
diff --git a/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs b/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs
index 8dd1656ec..aa106a30d 100644
--- a/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/GeminiStderrShadowedOnPostFailureTests.cs
@@ -6,6 +6,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -95,7 +96,7 @@ static string SelectedByGemini(string stdout, string stderr) =>
using var capture = ConsoleOutput.StartFullCapture();
- var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()))
+ var exit = await new GeminiHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.Handle(new StringReader(payload));
return (exit, capture.GetCapturedOutput(), capture.GetCapturedError());
diff --git a/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs b/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs
index 920a2de37..2aa8ff523 100644
--- a/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/ImportEndReassertTests.cs
@@ -2,6 +2,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -24,7 +25,7 @@ public class ImportEndReassertTests : IDisposable {
// These tests exercise chaining and repo resolution, not profile selection.
ImportCommand Import() =>
- new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient());
+ new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter());
readonly WireMockServer _server = WireMockServer.Start();
readonly TempDir _tmp = new();
readonly string _tempDir;
@@ -76,6 +77,7 @@ static string WriteTranscript(string dir, string sessionId) {
};
var classified = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
diff --git a/test/Capacitor.Cli.Tests.Integration/KiroImportSourceImportTests.cs b/test/Capacitor.Cli.Tests.Integration/KiroImportSourceImportTests.cs
index 5e983d2e1..03f52ced6 100644
--- a/test/Capacitor.Cli.Tests.Integration/KiroImportSourceImportTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/KiroImportSourceImportTests.cs
@@ -4,6 +4,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -56,7 +57,7 @@ public async Task ImportSession_AlreadyLoaded_replay_is_a_no_op_suppressed_by_th
using var client = new HttpClient();
var source = new KiroImportSource(Config.Root,
root,
- repoDetector: _ => Task.FromResult(null));
+ repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter());
var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(discovered.Count).IsEqualTo(1);
diff --git a/test/Capacitor.Cli.Tests.Integration/PiImportSourceImportTests.cs b/test/Capacitor.Cli.Tests.Integration/PiImportSourceImportTests.cs
index bb1d9ede5..d6e401dfb 100644
--- a/test/Capacitor.Cli.Tests.Integration/PiImportSourceImportTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/PiImportSourceImportTests.cs
@@ -5,6 +5,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -76,7 +77,7 @@ public async Task ImportSession_posts_lifecycle_and_transcript_with_pi_vendor()
var source = new PiImportSource(Config.Root,
sessionsDir,
- repoDetector: _ => Task.FromResult(null));
+ repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter());
var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(discovered.Count).IsEqualTo(1);
@@ -143,7 +144,7 @@ public async Task ImportSession_resumes_from_server_watermark_when_partial() {
var source = new PiImportSource(Config.Root,
sessionsDir,
- repoDetector: _ => Task.FromResult(null));
+ repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter());
var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
var classified = await source.ClassifyAsync(
@@ -176,7 +177,7 @@ public async Task Classify_treats_trailing_branch_summary_as_import_relevant_whe
var source = new PiImportSource(Config.Root,
sessionsDir,
- repoDetector: _ => Task.FromResult(null));
+ repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter());
var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
var classified = await source.ClassifyAsync(
@@ -208,7 +209,7 @@ public async Task ImportSession_AlreadyLoaded_replay_is_a_no_op_suppressed_by_th
var source = new PiImportSource(Config.Root,
sessionsDir,
- repoDetector: _ => Task.FromResult(null));
+ repoDetector: _ => Task.FromResult(null), router: new GitProviderRouter());
var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
var classified = await source.ClassifyAsync(
diff --git a/test/Capacitor.Cli.Tests.Integration/RoutedPrivatizeMembershipTests.cs b/test/Capacitor.Cli.Tests.Integration/RoutedPrivatizeMembershipTests.cs
index 95453b19d..58284e4b9 100644
--- a/test/Capacitor.Cli.Tests.Integration/RoutedPrivatizeMembershipTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/RoutedPrivatizeMembershipTests.cs
@@ -5,6 +5,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -101,7 +102,7 @@ public async Task a_routed_replay_is_not_imported_when_it_could_not_be_made_priv
.RespondWith(Response.Create().WithStatusCode(500));
var stdout = await CaptureStdoutAsync(async () => {
- await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [new ChildContentOutsidePrivateScopeSource()],
@@ -122,7 +123,7 @@ public async Task a_skipped_replay_outside_the_child_content_scope_is_still_priv
var exitCode = 0;
var stdout = await CaptureStdoutAsync(async () => {
- exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [new ChildContentOutsidePrivateScopeSource()],
diff --git a/test/Capacitor.Cli.Tests.Integration/RoutedReplayPrivatizeTests.cs b/test/Capacitor.Cli.Tests.Integration/RoutedReplayPrivatizeTests.cs
index dadac7194..323637c15 100644
--- a/test/Capacitor.Cli.Tests.Integration/RoutedReplayPrivatizeTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/RoutedReplayPrivatizeTests.cs
@@ -5,6 +5,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -129,7 +130,7 @@ void StubAntigravityAlreadyLoadedWithNewChild(int sessionEndStatus = 200) {
StubVisibilityPut();
}
- Task RunAntigravityImport(bool forcePrivate) => new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ Task RunAntigravityImport(bool forcePrivate) => new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [new AntigravityImportSource(new(new(_agHome), ""))],
@@ -243,7 +244,7 @@ public async Task private_run_privatizes_a_gemini_replay_whose_session_end_faile
.RespondWith(Response.Create().WithStatusCode(500));
StubVisibilityPut();
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [new GeminiImportSource(_geminiHome)],
diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs
index b7140b03b..0b9a5c002 100644
--- a/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs
@@ -6,6 +6,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -83,7 +84,7 @@ public async Task Advertises_the_capability_and_renders_returned_notices() {
""");
var stdout = new StringWriter();
- await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(Payload()), stdout: stdout);
+ await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(Payload()), stdout: stdout);
// Capability advertised on the request.
await Assert.That(PostedBody()["coordination_notices"]?.GetValue()).IsEqualTo("v1");
@@ -102,7 +103,7 @@ public async Task Opt_out_suppresses_capability_and_render() {
GivenServerReturns("""{ "coordination_notices": [ { "text": "someone else is on this bug" } ] }""");
var stdout = new StringWriter();
- await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(Payload()), stdout: stdout);
+ await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(Payload()), stdout: stdout);
await Assert.That(PostedBody()["coordination_notices"]).IsNull();
await Assert.That(stdout.ToString()).DoesNotContain("## Coordination notices");
@@ -116,7 +117,7 @@ public async Task Malformed_notices_field_does_not_fail_the_hook() {
GivenServerReturns("""{ "coordination_notices": "v1" }""");
var stdout = new StringWriter();
- await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(Payload()), stdout: stdout);
+ await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(Payload()), stdout: stdout);
// Capability still advertised; render silently emits nothing (fail-open, no crash).
await Assert.That(PostedBody()["coordination_notices"]?.GetValue()).IsEqualTo("v1");
diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs
index 285d82301..86ef8614a 100644
--- a/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/SessionStartMemoryRedirectTests.cs
@@ -8,6 +8,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -91,7 +92,7 @@ public async Task The_memory_index_fetch_does_not_follow_a_redirect() {
using var capture = ConsoleOutput.StartCapture();
- var exit = await new GeminiHookCommand(Config.Root, profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, sp.GetRequiredService(), TestWatchers.For(Config.Root, profiles, sp.GetRequiredService()))
+ var exit = await new GeminiHookCommand(Config.Root, profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, sp.GetRequiredService(), TestWatchers.For(Config.Root, profiles, sp.GetRequiredService()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.Handle(new StringReader(payload));
return (exit, capture.GetCapturedOutput());
diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs
index 3750dc4de..1acd07317 100644
--- a/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/SessionStartVisibilityTests.cs
@@ -6,6 +6,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -51,7 +52,7 @@ public async Task Stamps_private_visibility_from_active_profile_v2_config() {
_server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost())
.RespondWith(Response.Create().WithStatusCode(200).WithBody("{}"));
- await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()));
+ await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()));
var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost());
await Assert.That(requests.Count).IsEqualTo(1);
@@ -76,7 +77,7 @@ public async Task Lowercases_mixedcase_visibility_from_v2_config() {
_server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost())
.RespondWith(Response.Create().WithStatusCode(200).WithBody("{}"));
- await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()));
+ await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()));
var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost());
await Assert.That(requests.Count).IsEqualTo(1);
@@ -101,7 +102,7 @@ public async Task Falls_back_to_org_public_when_v2_config_visibility_is_invalid(
_server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost())
.RespondWith(Response.Create().WithStatusCode(200).WithBody("{}"));
- await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()));
+ await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()));
var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost());
await Assert.That(requests.Count).IsEqualTo(1);
@@ -124,7 +125,7 @@ public async Task Stamps_harness_inventory_onto_session_start_body() {
_server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost())
.RespondWith(Response.Create().WithStatusCode(200).WithBody("{}"));
- await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()));
+ await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(SessionStartPayloadWithoutTranscriptPath()));
var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost());
await Assert.That(requests.Count).IsEqualTo(1);
@@ -164,7 +165,7 @@ public async Task Skips_session_start_when_repo_is_excluded_by_active_profile_v2
}
""";
- await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).Handle(new StringReader(payload));
+ await new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost());
await Assert.That(requests.Count).IsEqualTo(0);
diff --git a/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs b/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs
index 4fb46ae2a..d933f7fe1 100644
--- a/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/SpoolOutageRecoveryTests.cs
@@ -5,6 +5,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -69,7 +70,7 @@ static string StopPayload(string sid = Sid) =>
// HandleCore takes a pre-built HttpClient so we bypass auth entirely.
Task Invoke(HttpClient client, string payload) =>
- new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance)
+ new ClaudeHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.HandleCore(client, AuthStatus.Ok, MakeSpool(), new StringReader(payload));
IEnumerable SpoolFiles =>
diff --git a/test/Capacitor.Cli.Tests.Integration/WatcherHubCredentialTests.cs b/test/Capacitor.Cli.Tests.Integration/WatcherHubCredentialTests.cs
index 44d2589d8..90cba9953 100644
--- a/test/Capacitor.Cli.Tests.Integration/WatcherHubCredentialTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/WatcherHubCredentialTests.cs
@@ -2,6 +2,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -21,7 +22,7 @@ public class WatcherHubCredentialTests : IDisposable {
public void Dispose() => _server.Stop();
WatchCommand Watch(string? bearer) =>
- new(Config.Root, Resolutions.At(_server.Url!, Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(bearer), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()));
+ new(Config.Root, Resolutions.At(_server.Url!, Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(bearer), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter());
/// Refused, so the attempt ends at negotiate — which has already sent what we came for.
void StubNegotiate() =>
diff --git a/test/Capacitor.Cli.Tests.Integration/WatcherParentExitPostTests.cs b/test/Capacitor.Cli.Tests.Integration/WatcherParentExitPostTests.cs
index 892df501b..5d2010fda 100644
--- a/test/Capacitor.Cli.Tests.Integration/WatcherParentExitPostTests.cs
+++ b/test/Capacitor.Cli.Tests.Integration/WatcherParentExitPostTests.cs
@@ -3,6 +3,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Integration;
@@ -22,7 +23,7 @@ public class WatcherParentExitPostTests : IDisposable {
// Instance, not static: the parent-exit POST resolves its client against a config dir, so it
// must be this test's own root — which a static helper cannot see, TUnit injecting it after
// construction.
- WatchCommand Watch() => new(Config.Root, Resolutions.At(_server.Url!, Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()));
+ WatchCommand Watch() => new(Config.Root, Resolutions.At(_server.Url!, Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter());
public void Dispose() => _server.Stop();
diff --git a/test/Capacitor.Cli.Tests.Unit.NativeTestHost/Program.cs b/test/Capacitor.Cli.Tests.Unit.NativeTestHost/Program.cs
index c9ae482d8..277f7bf2e 100644
--- a/test/Capacitor.Cli.Tests.Unit.NativeTestHost/Program.cs
+++ b/test/Capacitor.Cli.Tests.Unit.NativeTestHost/Program.cs
@@ -14,7 +14,7 @@
// the outer test kills (SIGKILL) to observe PDEATHSIG, so a graceful Dispose() never runs
// and never needs to.
var factory = new UnixPtyProcessFactory(new UnixSpawnerThread());
- var proc = factory.Spawn("sleep", ["30"], Directory.GetCurrentDirectory());
+ var proc = factory.Spawn("sleep", ["30"], AppContext.BaseDirectory);
Console.WriteLine($"PID={proc.Pid}");
Console.Out.Flush();
Thread.Sleep(Timeout.Infinite); // block until the outer test kills THIS process
@@ -54,7 +54,7 @@
using var spawner = new UnixSpawnerThread();
var factory = new UnixPtyProcessFactory(spawner);
- var child = factory.Spawn("sleep", ["5"], Directory.GetCurrentDirectory());
+ var child = factory.Spawn("sleep", ["5"], AppContext.BaseDirectory);
try {
var childIdentity = child.StartIdentity;
if (string.IsNullOrEmpty(childIdentity)) {
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/CommandContainerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/CommandContainerTests.cs
index 7b882d282..985eeafd1 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/CommandContainerTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/CommandContainerTests.cs
@@ -2,6 +2,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core.Config;
using Microsoft.Extensions.DependencyInjection;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -19,7 +20,7 @@ public class CommandContainerTests {
ServiceProvider Build(string? baseUrl) =>
new ServiceCollection()
.AddCapacitorCli(
- Config.Root, Home, Daemons.Store,
+ Config.Root, Home, new WorkingDirectory(AppContext.BaseDirectory), Daemons.Store,
baseUrl is null ? Resolutions.None(Config.Root) : Resolutions.At(baseUrl, Config.Root),
ProfileOverrides.None, MachineAuth.None, AuthEndpoints.Defaults,
new HookClock(TimeProvider.System), baseUrl, NoTelemetry.Startup)
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs
index 265ddf00a..c7a6db4d4 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/FlowsDriverSchemaConformanceTests.cs
@@ -314,7 +314,7 @@ static async Task InstallAndRead(Arm arm) {
// rewriting the profile's network-access config.
? ["plugin", "install", arm.Flag, "--skip-codex-network-access"]
: ["plugin", "install", arm.Flag, "--if-installed"];
- var exit = await new PluginCommand(env).HandleAsync(argv);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(argv);
if (exit != 0) throw new InvalidOperationException($"{arm.Name}: installer exited {exit}");
var path = arm.ConfigPath(env);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs
index db0542198..11c50331d 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/AntigravityHookCommandTests.cs
@@ -1,6 +1,7 @@
using Capacitor.Cli.Commands.Harness;
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands.Harness;
@@ -34,7 +35,7 @@ public async Task EventArg_is_null_when_missing_or_a_flag_follows() {
[Test]
public async Task Missing_event_exits_zero_without_touching_network() {
// Control hooks must always exit 0 so Antigravity doesn't treat the hook as failed.
- var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity"], new StringReader(""), new StringWriter());
+ var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity"], new StringReader(""), new StringWriter());
await Assert.That(rc).IsEqualTo(0);
}
@@ -42,7 +43,7 @@ public async Task Missing_event_exits_zero_without_touching_network() {
public async Task PreInvocation_with_non_string_fields_fails_open() {
// conversationId as a non-string shape must not throw (GetValue would);
// it fails open to a no-op.
- var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"],
+ var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"],
new StringReader("""{"conversationId":123,"transcriptPath":{"nested":true}}"""),
new StringWriter());
await Assert.That(rc).IsEqualTo(0);
@@ -55,14 +56,14 @@ public async Task PreInvocation_with_non_string_fields_fails_open() {
[Arguments("PostToolUse")]
public async Task Non_PreInvocation_events_are_no_ops(string ev) {
// These must return 0 and never read stdin / hit the network.
- var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", ev],
+ var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", ev],
new ThrowingReader(), new StringWriter());
await Assert.That(rc).IsEqualTo(0);
}
[Test]
public async Task PreInvocation_with_malformed_payload_fails_open() {
- var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"],
+ var rc = await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"],
new StringReader("{ not json"), new StringWriter());
await Assert.That(rc).IsEqualTo(0);
}
@@ -70,11 +71,11 @@ public async Task PreInvocation_with_malformed_payload_fails_open() {
[Test]
public async Task PreInvocation_without_conversation_or_transcript_is_a_no_op() {
// No conversationId → nothing to key on.
- await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"],
+ await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"],
new StringReader("""{"transcriptPath":"/t.jsonl"}"""), new StringWriter())).IsEqualTo(0);
// conversationId but no transcriptPath → nothing to tail.
- await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient())).Handle(["hook", "--antigravity", "PreInvocation"],
+ await Assert.That(await new AntigravityHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:0", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["hook", "--antigravity", "PreInvocation"],
new StringReader("""{"conversationId":"abc"}"""), new StringWriter())).IsEqualTo(0);
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs
index a688652fd..719d93bf6 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs
@@ -14,6 +14,7 @@
using WireMock.Server;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands.Harness;
@@ -65,7 +66,7 @@ public async Task pre_tool_use_is_decided_before_any_client_is_created() {
"version: 1\nrules:\n - match: { kind: shell, command: \"git push --force*\" }\n outcome: deny\n");
var stdout = new StringWriter();
- var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing())
+ var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.HandleWithDeps(fx.Spool, new StringReader(
$$"""{"hook_event_name":"PreToolUse","session_id":"{{Sid}}","tool_name":"Bash","tool_input":{"command":"git push --force"},"cwd":"/tmp"}"""),
() => throw new InvalidOperationException("the seam must decide before a client exists"),
@@ -90,7 +91,7 @@ public async Task pre_tool_use_fails_open_when_the_branch_throws() {
"version: 1\nrules:\n - match: { kind: shell, command: \"git push --force*\" }\n outcome: deny\n");
var stdout = new ClosedPipeWriter();
- var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing())
+ var exit = await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.HandleWithDeps(fx.Spool, new StringReader(
$$"""{"hook_event_name":"PreToolUse","session_id":"{{Sid}}","tool_name":"Bash","tool_input":{"command":"git push --force"},"cwd":"/tmp"}"""),
() => throw new InvalidOperationException("the seam must decide before a client exists"),
@@ -113,7 +114,7 @@ public async Task session_start_surfaces_a_degraded_policy_snapshot() {
using var fx = new Fixture(Config.Root);
var stdout = new StringWriter { NewLine = "\n" };
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
$$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""),
stdout: stdout);
@@ -134,7 +135,7 @@ public async Task session_start_merges_a_policy_degradation_into_the_401_notice(
using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized);
var stdout = new StringWriter { NewLine = "\n" };
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
$$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""),
stdout: stdout);
@@ -159,7 +160,7 @@ public async Task session_start_freezes_the_policy_snapshot_on_the_degraded_arm(
var stdout = new StringWriter { NewLine = "\n" };
// Unusable URL: no client is ever built, so HandleWithDeps returns from the degraded arm.
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("not-a-url", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("not-a-url", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing())
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("not-a-url", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("not-a-url", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.HandleWithDeps(fx.Spool, new StringReader(
$$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""),
() => throw new InvalidOperationException("no client is buildable for an unusable URL"),
@@ -181,7 +182,7 @@ public async Task stop_clears_the_turn_journal() {
await Assert.That(File.Exists(Config.Root.Path("policy", "journal", $"{Sid}.json"))).IsTrue();
using var fx = new Fixture(Config.Root);
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
$$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""));
@@ -230,7 +231,7 @@ public async Task memory_store_initialization_failure_does_not_suppress_session_
using var fx = new Fixture(Config.Root);
MemoryStoreProbe.Poison(Config.Root);
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
$$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""));
@@ -242,7 +243,7 @@ public async Task memory_store_initialization_failure_does_not_suppress_session_
public async Task disabled_memory_index_does_not_construct_the_lease_store() {
using var fx = new Fixture(Config.Root);
fx.MemoryIndexBody = """[{"memory_id":"m1","slug":"s","audience":"org","description":"d","kind":"preference"}]"""; // decoy — must never be fetched
- var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing());
+ var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { DisableMemoryIndex = true }, serverUrl: "http://localhost"), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
var exit = await hook.HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
@@ -263,7 +264,7 @@ public async Task update_check_off_suppresses_the_in_agent_nudge_even_when_serve
using var fx = new Fixture(Config.Root) { RespondJson = """{"version": "999.0.0"}""" };
var stdout = new StringWriter();
- var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing());
+ var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = false }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
var exit = await hook.HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
@@ -286,7 +287,7 @@ public async Task update_check_on_still_emits_the_in_agent_nudge_for_a_newer_ser
using var fx = new Fixture(Config.Root) { RespondJson = """{"version": "999.0.0"}""" };
var stdout = new StringWriter();
- var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing());
+ var hook = new ClaudeHookCommand(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.Of(new Profile { UpdateCheck = true }, serverUrl: fx.MemoryServerUrl), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
var exit = await hook.HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
@@ -759,7 +760,7 @@ public async Task session_start_on_401_exits_zero_and_nudges_the_user_to_log_in(
using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized);
var stdout = new StringWriter { NewLine = "\n" };
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
$$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""),
@@ -781,7 +782,7 @@ public async Task session_start_on_401_is_spooled_for_replay_after_login() {
using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized);
var stdout = new StringWriter { NewLine = "\n" };
- await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
$$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""),
stdout: stdout);
@@ -820,7 +821,7 @@ public async Task session_end_whose_spool_write_fails_reports_the_drop_not_a_spo
var unwritable = new HookSpool(tmp.PathTo("blocker", "spool")); // a directory under a file
using var capture = ConsoleOutput.StartErrorCapture("\n");
- await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ await new ClaudeHookCommand(Config.Root, fx.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, fx.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, unwritable, new StringReader(
$$"""{"hook_event_name":"SessionEnd","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""));
@@ -838,13 +839,13 @@ public async Task spooled_entry_that_hits_a_401_on_drain_is_replayed_once_the_se
rejecting.Spool.Append(Sid, "session-end", $$"""{"session_id":"{{Sid}}"}""");
var stdout = new StringWriter { NewLine = "\n" };
- await new ClaudeHookCommand(Config.Root, rejecting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, rejecting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ await new ClaudeHookCommand(Config.Root, rejecting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, rejecting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
rejecting.Client, AuthStatus.Ok, rejecting.Spool, new StringReader(
$$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""),
stdout: stdout);
await Assert.That(rejecting.Spool.HasBacklog(Sid)).IsTrue();
- await new ClaudeHookCommand(Config.Root, accepting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ await new ClaudeHookCommand(Config.Root, accepting.Profiles, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
accepting.Client, AuthStatus.Ok, rejecting.Spool, new StringReader(
$$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""),
stdout: stdout);
@@ -886,7 +887,7 @@ public async Task session_end_spooled_when_client_creation_exceeds_budget() {
// 13.4s already elapsed → session-end remaining = 15 - 13.4 - 1.5 ≈ 0.1s cap.
var sw = System.Diagnostics.Stopwatch.StartNew();
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(13.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleWithDeps(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(13.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps(
fx.Spool,
new StringReader($$"""{"hook_event_name":"SessionEnd","session_id":"{{Sid}}","transcript_path":"/none","cwd":"/tmp"}"""),
slowFactory);
@@ -1008,7 +1009,7 @@ public async Task subagent_stop_spooled_when_client_creation_exceeds_budget() {
Task.Delay(TimeSpan.FromSeconds(30)).ContinueWith(_ => new AuthAttempt(new HttpClient(), AuthStatus.Ok), TaskScheduler.Default);
// 3.4s already elapsed → subagent-stop remaining = 5 - 3.4 - 1.5 ≈ 0.1s cap.
var sw = System.Diagnostics.Stopwatch.StartNew();
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(3.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleWithDeps(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), Aged(TimeSpan.FromSeconds(3.4)), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps(
fx.Spool,
new StringReader($$"""{"hook_event_name":"SubagentStop","session_id":"{{Sid}}","agent_id":"{{AgentId}}","transcript_path":"/none","cwd":"/tmp"}"""),
slowFactory);
@@ -1081,7 +1082,7 @@ public async Task session_start_with_expired_auth_exits_zero_and_emits_the_expir
using var fx = new Fixture(Config.Root);
var stdout = new StringWriter { NewLine = "\n" };
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Expired, fx.Spool, new StringReader(
$$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""),
@@ -1097,7 +1098,7 @@ public async Task session_start_with_wrong_server_auth_exits_zero_and_emits_the_
using var fx = new Fixture(Config.Root);
var stdout = new StringWriter { NewLine = "\n" };
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.WrongServer, fx.Spool, new StringReader(
$$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}","cwd":"/tmp"}"""),
@@ -1113,7 +1114,7 @@ public async Task stop_with_expired_auth_exits_zero_without_a_notice() {
using var fx = new Fixture(Config.Root);
var stdout = new StringWriter { NewLine = "\n" };
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Expired, fx.Spool, new StringReader(
$$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""),
stdout: stdout);
@@ -1134,7 +1135,7 @@ public async Task stop_on_401_exits_zero_and_nudges_the_user_to_log_in() {
using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized);
var stdout = new StringWriter { NewLine = "\n" };
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
$$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""),
stdout: stdout);
@@ -1150,7 +1151,7 @@ public async Task notification_on_401_exits_zero_without_a_notice() {
using var fx = new Fixture(Config.Root, HttpStatusCode.Unauthorized);
var stdout = new StringWriter { NewLine = "\n" };
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
$$"""{"hook_event_name":"Notification","session_id":"{{Sid}}","cwd":"/tmp"}"""),
stdout: stdout);
@@ -1166,7 +1167,7 @@ public async Task stop_on_500_still_exits_non_zero_without_a_notice() {
using var fx = new Fixture(Config.Root, HttpStatusCode.InternalServerError);
var stdout = new StringWriter { NewLine = "\n" };
- var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ var exit = await new ClaudeHookCommand(Config.Root, Resolutions.At("http://localhost", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://localhost", Config.Root), new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, AuthStatus.Ok, fx.Spool, new StringReader(
$$"""{"hook_event_name":"Stop","session_id":"{{Sid}}","cwd":"/tmp"}"""),
stdout: stdout);
@@ -1260,7 +1261,7 @@ public Fixture(ConfigRoot config, HttpStatusCode postStatus = HttpStatusCode.OK,
public Task HandleAsync(string stdin, TimeSpan elapsed = default) {
StubMemoryServer();
- return new ClaudeHookCommand(Config, Profiles, Aged(elapsed), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing()).HandleCore(
+ return new ClaudeHookCommand(Config, Profiles, Aged(elapsed), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), FakeProcessStarter.Refusing(), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
Client, AuthStatus.Ok, Spool, new StringReader(stdin));
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs
index a2b83cdad..191ee55b1 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookInputWaitRelayTests.cs
@@ -8,6 +8,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands.Harness;
@@ -39,7 +40,7 @@ static HookClock Aged(TimeSpan elapsed) {
async Task RunAsync(HostedAgent hosted, string eventName, HookClock? clock = null, string extraFields = "") {
using var client = new HttpClient(new OkHandler());
var payload = $$$"""{"hook_event_name":"{{{eventName}}}","session_id":"{{{Sid}}}","cwd":"/tmp","tool_name":"Bash","tool_input":{"command":"ls"}{{{extraFields}}}}""";
- return await new ClaudeHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), clock ?? new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance)
+ return await new ClaudeHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), clock ?? new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.HandleWithDeps(new HookSpool(Config.Root), new StringReader(payload), () => Task.FromResult(new AuthAttempt(client, AuthStatus.Ok, null, null)), new StringWriter());
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs
index d7b38439c..f3a9b4cff 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs
@@ -5,6 +5,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands.Harness;
@@ -46,7 +47,7 @@ public async Task SessionStart_posts_to_session_start_codex_with_normalized_sess
}
""";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
@@ -93,7 +94,7 @@ public async Task Stop_posts_to_hooks_stop_and_emits_continue_json() {
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
// Stop now POSTs /hooks/stop exactly once, carrying the full payload.
@@ -137,7 +138,7 @@ public async Task Stop_still_emits_continue_json_when_server_is_slow() {
using var capture = ConsoleOutput.StartCapture();
var sw = System.Diagnostics.Stopwatch.StartNew();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
sw.Stop();
await Assert.That(exit).IsEqualTo(0);
@@ -170,7 +171,7 @@ public async Task Stop_still_emits_continue_json_when_auth_discovery_is_slow() {
using var capture = ConsoleOutput.StartCapture();
var sw = System.Diagnostics.Stopwatch.StartNew();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
sw.Stop();
await Assert.That(exit).IsEqualTo(0);
@@ -198,7 +199,7 @@ public async Task Stop_with_malformed_base_url_still_emits_continue_and_returns_
// Scheme-less URL → IsAcceptableUrl is false → PostBestEffortAsync returns
// before any client is built, spending no budget or lease.
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At("localhost:5108", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("localhost:5108", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At("localhost:5108", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("localhost:5108", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
var doc = JsonDocument.Parse(capture.GetCapturedOutput());
@@ -234,7 +235,7 @@ public async Task PermissionRequest_records_event_and_yields_decision_to_codex()
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
@@ -279,7 +280,7 @@ public async Task PermissionRequest_returns_quickly_when_server_is_slow() {
using var capture = ConsoleOutput.StartCapture();
var sw = System.Diagnostics.Stopwatch.StartNew();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
sw.Stop();
await Assert.That(exit).IsEqualTo(0);
@@ -320,7 +321,7 @@ public async Task PermissionRequest_returns_quickly_when_auth_discovery_is_slow(
using var capture = ConsoleOutput.StartCapture();
var sw = System.Diagnostics.Stopwatch.StartNew();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
sw.Stop();
await Assert.That(exit).IsEqualTo(0);
@@ -341,7 +342,7 @@ public async Task UserPromptSubmit_PreToolUse_PostToolUse_are_swallowed() {
}
""";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
}
@@ -353,7 +354,7 @@ public async Task UserPromptSubmit_PreToolUse_PostToolUse_are_swallowed() {
public async Task Unknown_event_returns_zero_and_no_request() {
var payload = """{"hook_event_name": "BogusEvent", "session_id": "abc"}""";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
await Assert.That(_server.LogEntries.Count).IsEqualTo(0);
@@ -363,7 +364,7 @@ public async Task Unknown_event_returns_zero_and_no_request() {
public async Task Missing_hook_event_name_returns_zero_silently() {
var payload = """{"session_id": "abc"}""";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
await Assert.That(_server.LogEntries.Count).IsEqualTo(0);
@@ -373,7 +374,7 @@ public async Task Missing_hook_event_name_returns_zero_silently() {
public async Task Malformed_json_returns_zero_silently() {
var payload = "{not json";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
}
@@ -383,7 +384,7 @@ public async Task Malformed_json_returns_zero_silently() {
public async Task Hook_event_name_as_number_returns_zero_without_crash() {
var payload = """{"hook_event_name": 99, "session_id": "abc", "transcript_path": "/tmp/r.jsonl"}""";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
await Assert.That(_server.LogEntries.Count).IsEqualTo(0);
@@ -402,7 +403,7 @@ public async Task Stop_with_numeric_session_id_returns_zero_without_crash() {
_server.Given(Request.Create().WithPath("/hooks/session-end/codex").UsingPost())
.RespondWith(Response.Create().WithStatusCode(200).WithBody("{}"));
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
@@ -447,7 +448,7 @@ public async Task Handle_skips_dispatch_when_session_is_disabled() {
using var capture = ConsoleOutput.StartCapture();
try {
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
@@ -480,7 +481,7 @@ public async Task Stop_skips_the_watcher_for_an_envelope_sourced_hosted_session(
Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", "1");
var payload = """{"hook_event_name":"Stop","session_id":"g1-stop-suppressed","transcript_path":"/tmp/r.jsonl","cwd":"/tmp"}""";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
// The watcher never spawns for an envelope-sourced session...
@@ -512,7 +513,7 @@ public async Task Stop_spawns_the_watcher_without_the_hosted_appserver_marker()
Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", null);
var payload = """{"hook_event_name":"Stop","session_id":"g1-stop-control","transcript_path":"/tmp/r.jsonl","cwd":"/tmp"}""";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
await Assert.That(spawner.Spawned.Count).IsEqualTo(1);
@@ -534,7 +535,7 @@ public async Task SessionStart_skips_the_watcher_for_an_envelope_sourced_hosted_
Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", "1");
var payload = """{"hook_event_name":"SessionStart","session_id":"g1-start-suppressed","transcript_path":"/tmp/r.jsonl","cwd":"/tmp"}""";
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner)).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
// No watcher for an envelope-sourced session; the session-start POST is untouched.
@@ -570,7 +571,7 @@ public async Task PermissionRequest_with_daemon_url_set_posts_to_bridge_and_forw
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
);
await Assert.That(exit).IsEqualTo(0);
@@ -592,7 +593,7 @@ public async Task PermissionRequest_in_an_envelope_sourced_hosted_session_yields
using var marker = EnvScope.Exclusive("KCAP_HOSTED_APPSERVER", "1");
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()))
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1","tool_name":"shell","tool_input":{"command":"ls"}}"""));
await Assert.That(exit).IsEqualTo(0);
@@ -612,7 +613,7 @@ public async Task PermissionRequest_with_daemon_url_emits_deny_and_exits_nonzero
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
);
await Assert.That(exit).IsEqualTo(1);
@@ -626,7 +627,7 @@ public async Task PermissionRequest_with_daemon_url_emits_deny_on_connection_ref
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
);
await Assert.That(exit).IsEqualTo(1);
@@ -640,7 +641,7 @@ public async Task PermissionRequest_with_non_loopback_daemon_url_emits_deny_with
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
);
await Assert.That(exit).IsEqualTo(1);
@@ -662,7 +663,7 @@ public async Task PermissionRequest_with_a_blank_daemon_url_yields_to_codex_rath
var hosted = new HostedAgent(null, IsRendered: false, DaemonBridge.Parse(""));
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()))
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1","tool_name":"shell","tool_input":{"command":"ls"}}"""));
await Assert.That(exit).IsEqualTo(0);
@@ -676,7 +677,7 @@ public async Task PermissionRequest_with_https_daemon_url_emits_deny_without_pos
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
);
await Assert.That(exit).IsEqualTo(1);
@@ -704,7 +705,7 @@ public async Task PermissionRequest_with_daemon_url_does_not_double_post_to_serv
using var capture = ConsoleOutput.StartCapture();
- await new CodexHookCommand(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
+ await new CodexHookCommand(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At($"http://127.0.0.1:{server.Ports[0]}", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")
);
await Assert.That(server.LogEntries.Count).IsEqualTo(0); // server NOT touched
@@ -725,7 +726,7 @@ public async Task KcapSkip_SessionStart_emits_continue_json_and_skips_server() {
using var capture = ConsoleOutput.StartCapture();
try {
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"SessionStart","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}"""));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"SessionStart","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}"""));
await Assert.That(exit).IsEqualTo(0);
@@ -746,7 +747,7 @@ public async Task KcapSkip_Stop_emits_continue_json_and_skips_server() {
using var capture = ConsoleOutput.StartCapture();
try {
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"Stop","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}"""));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"Stop","session_id":"abc","cwd":"/tmp","transcript_path":"/tmp/r.jsonl"}"""));
await Assert.That(exit).IsEqualTo(0);
@@ -770,7 +771,7 @@ public async Task KcapSkip_PermissionRequest_emits_empty_object_and_skips_server
using var capture = ConsoleOutput.StartCapture();
try {
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"abc","tool_name":"shell"}"""));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"abc","tool_name":"shell"}"""));
await Assert.That(exit).IsEqualTo(0);
@@ -793,7 +794,7 @@ public async Task KcapSkip_PreToolUse_is_silent_and_skips_server() {
using var capture = ConsoleOutput.StartCapture();
try {
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader("""{"hook_event_name":"PreToolUse","session_id":"abc","tool_name":"shell"}"""));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader("""{"hook_event_name":"PreToolUse","session_id":"abc","tool_name":"shell"}"""));
await Assert.That(exit).IsEqualTo(0);
await Assert.That(capture.GetCapturedOutput()).IsEqualTo(string.Empty);
@@ -842,7 +843,7 @@ public async Task The_permission_request_bridge_draws_the_loopback_lane() {
using var capture = ConsoleOutput.StartCapture();
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, http, TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), http)).Handle(new StringReader(payload));
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, http, TestWatchers.For(Config.Root, Resolutions.At(_server.Url!, Config.Root), http), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
await Assert.That(exit).IsEqualTo(0);
await Assert.That(http.Lanes).IsEquivalentTo(new[] { "Loopback" });
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs
index 166825ae3..68e559228 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookInputWaitRelayTests.cs
@@ -5,6 +5,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands.Harness;
@@ -27,7 +28,7 @@ public async Task Hosted_turn_boundaries_relay_to_the_daemon_bridge(string event
using var capture = ConsoleOutput.StartCapture();
var hosted = new HostedAgent("agent-1", IsRendered: false, new DaemonBridge.Loopback($"http://127.0.0.1:{bridge.Ports[0]}/tok"));
- var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()))
+ var exit = await new CodexHookCommand(Config.Root, Resolutions.At("http://server.example", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://server.example", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.Handle(new StringReader($$"""{"hook_event_name":"{{eventName}}","session_id":"019e0322-05fc-7570-be65-75719c3ea861","cwd":"/tmp"}"""));
await Assert.That(exit).IsEqualTo(0);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs
index fc8880273..2fb931fb7 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/CursorHookCommandTests.cs
@@ -12,6 +12,7 @@
using Microsoft.Extensions.Time.Testing;
using Capacitor.Cli.Core.Http;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands.Harness;
@@ -126,7 +127,7 @@ public async Task spooled_entry_that_hits_a_401_on_drain_is_replayed_once_the_se
await rejecting.HandleAsync($$"""{"hook_event_name":"postToolUse","session_id":"{{Sid}}","tool_name":"Glob"}""");
await Assert.That(rejecting.Spool.HasBacklog(Sid)).IsTrue();
- await new CursorHookCommand(Config.Root, accepting.Profiles, new HookClock(accepting.Clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient())).HandleCore(
+ await new CursorHookCommand(Config.Root, accepting.Profiles, new HookClock(accepting.Clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, accepting.Profiles, new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
accepting.Client,
stdin: new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{Sid}}","tool_name":"Glob"}"""),
spool: rejecting.Spool);
@@ -178,7 +179,7 @@ public async Task telemetry_hook_does_not_recovery_spawn_while_an_earlier_canoni
});
using var client = new HttpClient(handler);
- var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(
+ var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
client,
new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{sid}}","tool_name":"Bash","transcript_path":"/tmp/{{sid}}.jsonl"}"""),
spool);
@@ -347,7 +348,7 @@ public async Task a_work_budget_spent_before_dispatch_still_spools_the_event() {
var clock = new HookClock(spent); // anchors on construction — advance AFTER it
spent.Advance(CursorHookCommand.Ceiling - HookBudget.Safety);
- var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()))
+ var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.HandleWithDeps(
new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""),
_ => Task.FromResult(new AuthAttempt(fx.Client, AuthStatus.Ok)),
@@ -369,7 +370,7 @@ public async Task expired_budget_returns_zero_not_throws() {
var clock = new HookClock(spent);
spent.Advance(CursorHookCommand.Ceiling);
- var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleCore(
+ var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client,
new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""),
fx.Spool
@@ -530,7 +531,7 @@ public async Task HardCap_before_resolve_emits_nothing() {
// noticing. clientFactory/spoolFactory stand in for real auth/spool setup so the
// test stays hermetic while still exercising the REAL entry point's cap+emit logic.
var clock = new FakeTimeProvider();
- var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()))
+ var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.HandleWithDeps(new NeverCompletingReader(),
_ => Task.FromResult(new AuthAttempt(fx.Client, AuthStatus.Ok)),
() => fx.Spool);
@@ -557,7 +558,7 @@ public async Task HandleCore_deadline_win_cancels_the_abandoned_inners_token() {
using var fx = new Fixture(Config.Root);
var reader = new CancelObservingReader();
- var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleCore(fx.Client, reader, fx.Spool);
+ var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(fx.Client, reader, fx.Spool);
await Assert.That(exit).IsEqualTo(0);
// The read never resolved (no hook_event_name was ever parsed), so there is
@@ -581,7 +582,7 @@ public async Task HardCap_after_resolve_sessionStart_emits_empty_once() {
fx.HoldOnPost = TimeSpan.FromMilliseconds(300);
var sw = System.Diagnostics.Stopwatch.StartNew();
- var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleWithDeps(
+ var exit = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps(
new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""),
_ => Task.FromResult(new AuthAttempt(fx.Client, AuthStatus.Ok)),
() => fx.Spool);
@@ -608,7 +609,7 @@ public async Task HardCap_during_client_setup_emits_nothing_and_no_late_write()
var sw = System.Diagnostics.Stopwatch.StartNew();
var clock = new FakeTimeProvider();
- var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()))
+ var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory))
.HandleWithDeps(
new StringReader("""{"hook_event_name":"sessionStart","session_id":"abc"}"""),
_ => neverAuths.Task,
@@ -788,36 +789,30 @@ public async Task OncePerConversation() {
}
[Test, NotInParallel]
- public async Task AbsentWorkspaceRoot_skips_provider_even_when_process_cwd_is_a_repo() {
- var originalCwd = Environment.CurrentDirectory;
- // A real git repo WITH a remote as the process cwd: were the guard missing, the shared
- // scope resolver's Directory.GetCurrentDirectory() fallback would derive THIS repo's scope
- // and fetch its (unrelated) memories into the Cursor session. The guard must prevent any fetch.
+ public async Task AbsentWorkspaceRoot_skips_provider_even_when_the_working_directory_is_a_repo() {
+ // A real git repo WITH a remote as the hook's working directory: were the guard missing, the
+ // shared scope resolver's fallback would derive THIS repo's scope and fetch its (unrelated)
+ // memories into the Cursor session. The guard must prevent any fetch.
using var repoDir = MakeTempRepoWithRemote("https://github.com/example/leak-check.git");
using var capture = ConsoleOutput.StartCapture();
- try {
- Environment.CurrentDirectory = repoDir;
- using var fx = new Fixture(Config.Root);
- fx.MemoryIndexBody = "[]"; // decoy — never fetched because the guard short-circuits first
- var sid = Guid.NewGuid().ToString("N");
+ using var fx = new Fixture(Config.Root) { Workdir = repoDir };
+ fx.MemoryIndexBody = "[]"; // decoy — never fetched because the guard short-circuits first
+ var sid = Guid.NewGuid().ToString("N");
- // No workspace_roots field at all. Generous budget (see Ready_fragment_emitted's note
- // on the tight ~0.5s margin at the 2s default under full-suite CPU contention).
- var exit = await fx.HandleAsync(
- $$"""{"hook_event_name":"sessionStart","session_id":"{{sid}}"}""");
+ // No workspace_roots field at all. Generous budget (see Ready_fragment_emitted's note
+ // on the tight ~0.5s margin at the 2s default under full-suite CPU contention).
+ var exit = await fx.HandleAsync(
+ $$"""{"hook_event_name":"sessionStart","session_id":"{{sid}}"}""");
- await Assert.That(exit).IsEqualTo(0);
- // The guard means the provider is NEVER called when no authoritative workspace root is
- // supplied — so the process cwd's repo memories can never leak — and the response is {}.
- await Assert.That(fx.MemoryIndexRequested).IsFalse();
- await Assert.That(capture.GetCapturedOutput()).IsEqualTo("{}\n");
- } finally {
- Environment.CurrentDirectory = originalCwd;
- }
+ await Assert.That(exit).IsEqualTo(0);
+ // The guard means the provider is NEVER called when no authoritative workspace root is
+ // supplied — so that repo's memories can never leak — and the response is {}.
+ await Assert.That(fx.MemoryIndexRequested).IsFalse();
+ await Assert.That(capture.GetCapturedOutput()).IsEqualTo("{}\n");
}
- // Creates a throwaway git repo with a controlled origin remote so a test can put the process
- // cwd inside a repository the scope resolver would otherwise detect.
+ // Creates a throwaway git repo with a controlled origin remote, so a test can hand the hook a
+ // repository the scope resolver would otherwise detect.
static GitRepo MakeTempRepoWithRemote(string originUrl) {
var repo = GitRepo.Create();
@@ -862,7 +857,7 @@ public async Task CancelledFetch_leaves_lease_uncommitted() {
// The real scope resolver runs: its git spawn is bounded by a Stopwatch, so its wall-clock
// cost cannot eat a budget that only moves when this test says so.
var elapsed = System.Diagnostics.Stopwatch.StartNew();
- var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleCore(
+ var call = new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
hangingClient, new StringReader(payload), fx.Spool);
// Wait (bounded, real-time) for the request to ENTER the handler, then fire the budget clock.
@@ -888,7 +883,7 @@ public async Task CancelledFetch_leaves_lease_uncommitted() {
clock.Advance(TimeSpan.FromSeconds(31));
fx.MemoryIndexBody = "[]";
- var exit2 = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient())).HandleCore(
+ var exit2 = await new CursorHookCommand(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new HookClock(clock), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(Fixture.StubUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
fx.Client, new StringReader(payload), fx.Spool);
await Assert.That(exit2).IsEqualTo(0);
// The index GET fires again on fx.Client — proving the first, cancelled attempt's
@@ -1059,8 +1054,11 @@ public Fixture(ConfigRoot config, HttpStatusCode postStatus = HttpStatusCode.OK,
public HostedAgent Hosted { get; init; } = HostedAgent.Terminal;
+ /// The checkout the hook acts on when a payload carries no workspace root.
+ public string Workdir { get; init; } = AppContext.BaseDirectory;
+
public Task HandleAsync(string stdin) =>
- new CursorHookCommand(Config, Profiles, new HookClock(Clock), _home, TestHarnesses.Under(_home), Hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient())).HandleCore(
+ new CursorHookCommand(Config, Profiles, new HookClock(Clock), _home, TestHarnesses.Under(_home), Hosted, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Profiles, new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(Workdir)).HandleCore(
Client,
stdin: new StringReader(stdin),
spool: Spool);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs
index 6f965df52..334e1755b 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportChainsTests.cs
@@ -6,6 +6,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -16,7 +17,7 @@ public class ImportChainsTests : IDisposable {
// These tests exercise chaining and repo resolution, not profile selection.
ImportCommand Import() =>
- new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient());
+ new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter());
readonly WireMockServer _server = WireMockServer.Start();
// TUnit creates a new class instance per test, so _tempDir is always unique.
readonly TempDir _tmp = new();
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportClassifyTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportClassifyTests.cs
index 6f859d5a0..3c52972dd 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportClassifyTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportClassifyTests.cs
@@ -2,6 +2,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -49,6 +50,7 @@ public async Task ClassifyAsync_maps_404_to_New() {
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
@@ -80,6 +82,7 @@ public async Task ClassifyAsync_maps_204_to_AlreadyLoaded() {
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
@@ -113,6 +116,7 @@ public async Task ClassifyAsync_maps_200_with_last_line_to_Partial() {
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
@@ -143,6 +147,7 @@ public async Task ClassifyAsync_maps_short_transcript_to_TooShort() {
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
@@ -171,6 +176,7 @@ public async Task ClassifyAsync_maps_server_error_to_ProbeError() {
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
@@ -202,6 +208,7 @@ public async Task ClassifyAsync_identifies_kcap_subsession() {
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
@@ -241,6 +248,7 @@ await File.WriteAllLinesAsync(
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
@@ -270,6 +278,7 @@ public async Task ClassifyAsync_invokes_onProbed_callback_once_per_transcript()
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
@@ -306,6 +315,7 @@ public async Task ClassifyAsync_reclassifies_Partial_to_AlreadyLoaded_when_no_ne
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client, _server.Url!, transcripts,
@@ -337,6 +347,7 @@ public async Task ClassifyAsync_keeps_Partial_when_local_transcript_has_new_line
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client, _server.Url!, transcripts,
@@ -380,6 +391,7 @@ await File.WriteAllLinesAsync(
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client, _server.Url!, transcripts,
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportDiscoveryAgeTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportDiscoveryAgeTests.cs
index 60bb18123..f5cf7c816 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportDiscoveryAgeTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportDiscoveryAgeTests.cs
@@ -18,6 +18,7 @@
using Capacitor.Cli.Harness.Kiro;
using Capacitor.Cli.Harness.OpenCode;
using Capacitor.Cli.Harness.Pi;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -52,7 +53,7 @@ public async Task Codex_is_dated_by_the_day_directory_the_rollout_sits_in() {
var day = tmp.CreateDir("sessions", "2026", "01", "05");
var roll = day.CreateFile("rollout-abc.jsonl", "{}");
- var age = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions).DiscoveryAge(Session(HarnessId.Codex, null, roll));
+ var age = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions, router: new GitProviderRouter()).DiscoveryAge(Session(HarnessId.Codex, null, roll));
// Not the file's mtime, which is now: --since prunes Codex on the directory alone.
await Assert.That(age!.Value.UtcDateTime.Date).IsEqualTo(new DateTime(2026, 1, 5));
@@ -66,7 +67,7 @@ public async Task Claude_is_dated_by_the_transcripts_first_timestamp_not_its_mti
/*lang=json*/ "{\"type\":\"user\",\"timestamp\":\"2026-08-01T10:00:00Z\",\"message\":{\"content\":\"later\"}}",
]);
- var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects).DiscoveryAge(Session(HarnessId.Claude, null, path));
+ var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter()).DiscoveryAge(Session(HarnessId.Claude, null, path));
// A session started in January and appended to today belongs to January, which is the window
// --since places it in. Taking mtime would count it inside a 30-day window it is not in.
@@ -86,7 +87,7 @@ public async Task Claude_keeps_scanning_past_a_malformed_line_and_beyond_the_fir
var path = tmp.CreateFile("session.jsonl", [.. lines]);
- var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects).DiscoveryAge(Session(HarnessId.Claude, null, path));
+ var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter()).DiscoveryAge(Session(HarnessId.Claude, null, path));
await Assert.That(age!.Value.UtcDateTime.Date).IsEqualTo(new DateTime(2026, 1, 5));
}
@@ -96,7 +97,7 @@ public async Task Claude_falls_back_to_the_last_write_when_no_timestamp_can_be_r
using var tmp = new TempDir();
var path = tmp.CreateFile("garbage.jsonl", "not json at all");
- var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects).DiscoveryAge(Session(HarnessId.Claude, null, path));
+ var age = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter()).DiscoveryAge(Session(HarnessId.Claude, null, path));
// Same fallback the --since filter takes when the metadata carries no timestamp.
await Assert.That(age).IsNotNull();
@@ -128,9 +129,9 @@ public async Task Sources_that_resolve_a_first_timestamp_are_dated_by_it(string
/// Every source but Claude and Codex, which resolve no timestamp during discovery.
IImportSource SourceFor(string vendor) => vendor switch {
"gemini" => new GeminiImportSource(GeminiHarness.FromEnvironment(Home).Paths.TmpDir),
- "kiro" => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir),
- "pi" => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir),
- "copilot" => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths),
+ "kiro" => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()),
+ "pi" => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()),
+ "copilot" => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()),
"antigravity" => new AntigravityImportSource(AntigravityHarness.Over(GeminiHarness.FromEnvironment(Home)).Paths),
"opencode" => new OpenCodeImportSource(
Path.Combine(OpenCodeHarness.FromEnvironment(Home).Paths.DataDir, "opencode.db"),
@@ -142,7 +143,7 @@ public async Task Sources_that_resolve_a_first_timestamp_are_dated_by_it(string
CursorImportSource NewCursorSource() {
var paths = CursorHarness.FromEnvironment(Home).Paths;
- return new(Config.Root, paths.ProjectsDir, paths.WorkspaceStorageDir);
+ return new(Config.Root, paths.ProjectsDir, paths.WorkspaceStorageDir, router: new GitProviderRouter());
}
[Test]
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportResolveReposSubSessionTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportResolveReposSubSessionTests.cs
index 399983368..b4b9f6a2d 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportResolveReposSubSessionTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportResolveReposSubSessionTests.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -48,7 +49,7 @@ await File.WriteAllLinesAsync(
var sessionCwds = new Dictionary(StringComparer.Ordinal);
- await new ImportCommand(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient())
+ await new ImportCommand(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter())
.ResolveTranscriptReposAsync(
transcripts,
codex: false,
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportSkipTitleTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportSkipTitleTests.cs
index 6c384d582..de77af6df 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportSkipTitleTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportSkipTitleTests.cs
@@ -4,6 +4,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -72,11 +73,11 @@ Task RunImport(string name, bool skipTitle) {
[.. Enumerable.Range(0, 20).Select(i =>
$$$"""{"type":"user","timestamp":"2026-03-15T10:00:00Z","cwd":"/tmp/skip-title-proj","message":{"content":"add a retry to the import loop {{{i}}}"}}""")]);
- return new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home, BinaryProbe.FromEnvironment()), new FixedCapacitorHttpClient())
+ return new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home, BinaryProbe.FromEnvironment()), new FixedCapacitorHttpClient(), router: new GitProviderRouter())
.HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir.Path)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir.Path, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
skipTitle: skipTitle);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVendorSelectionOutputTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVendorSelectionOutputTests.cs
index 9a33a7236..1147bbf4a 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVendorSelectionOutputTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVendorSelectionOutputTests.cs
@@ -1,6 +1,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core.Harness;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -38,7 +39,7 @@ public Task ImportSessionAsync(
}
Task Run(params IImportSource[] sources) =>
- new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient())
+ new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter())
.HandleImport(
filterCwd: null,
sources: sources,
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs
index ab43da935..51eb91847 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportVisibilityTests.cs
@@ -20,6 +20,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -49,7 +50,7 @@ public class ImportVisibilityTests : IDisposable {
// These tests exercise chaining and repo resolution, not profile selection.
ImportCommand Import() =>
- new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient());
+ new(Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter());
readonly WireMockServer _server = WireMockServer.Start();
readonly TempDir _tmp = new();
readonly string _tempDir;
@@ -199,10 +200,10 @@ public async Task HandleImport_chain_new_session_stamps_default_visibility_when_
var projectsDir = Path.Combine(_tempDir, "claude-projects-pos");
WriteClaudeSession(projectsDir, "vis-chain-handle-pos");
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
forcePrivate: false,
@@ -233,10 +234,10 @@ public async Task HandleImport_chain_forcePrivate_stamps_private_even_when_trans
var projectsDir = Path.Combine(_tempDir, "claude-projects-neg");
WriteClaudeSession(projectsDir, "vis-chain-handle-neg");
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
forcePrivate: true,
@@ -269,10 +270,10 @@ public async Task HandleImport_chain_forcePrivate_privatizes_a_resume_whose_sess
var projectsDir = Path.Combine(_tempDir, "claude-projects-resume");
WriteClaudeSession(projectsDir, "vis-chain-resume-fail");
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
forcePrivate: true,
@@ -305,10 +306,10 @@ public async Task HandleImport_forcePrivate_leaves_alone_a_session_the_run_never
var projectsDir = Path.Combine(_tempDir, "claude-projects-short-private");
WriteClaudeSession(projectsDir, "vis-private-too-short", lines: 3);
- await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 500,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
forcePrivate: true
@@ -331,10 +332,10 @@ public async Task HandleImport_forcePrivate_privatizes_an_existing_session_befor
var projectsDir = Path.Combine(_tempDir, "claude-projects-window");
WriteClaudeSession(projectsDir, "vis-window");
- await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
forcePrivate: true
@@ -368,10 +369,10 @@ public async Task HandleImport_forcePrivate_does_not_pre_privatize_a_session_tha
var projectsDir = Path.Combine(_tempDir, "claude-projects-newonly");
WriteClaudeSession(projectsDir, "vis-new-only");
- await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
forcePrivate: true
@@ -400,10 +401,10 @@ public async Task HandleImport_shareWithOrg_writes_an_explicit_org_visibility()
var projectsDir = Path.Combine(_tempDir, "claude-projects-shared");
WriteClaudeSession(projectsDir, "vis-chain-shared");
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
forcePrivate: false,
@@ -432,10 +433,10 @@ public async Task HandleImport_writes_no_explicit_visibility_when_neither_stop_w
var projectsDir = Path.Combine(_tempDir, "claude-projects-plain");
WriteClaudeSession(projectsDir, "vis-chain-plain");
- await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true
);
@@ -457,10 +458,10 @@ public async Task HandleImport_shareWithOrg_reaches_a_session_this_run_only_revi
var projectsDir = Path.Combine(_tempDir, "claude-projects-already");
WriteClaudeSession(projectsDir, "vis-already-shared");
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
shareWithOrg: true
@@ -491,10 +492,10 @@ public async Task HandleImport_shareWithOrg_leaves_alone_a_session_the_run_never
var projectsDir = Path.Combine(_tempDir, "claude-projects-short");
WriteClaudeSession(projectsDir, "vis-too-short", lines: 3);
- await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 500,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
shareWithOrg: true
@@ -518,10 +519,10 @@ public async Task HandleImport_reports_a_lost_visibility_write_through_the_outco
ImportCommand.ImportRunOutcome? outcome = null;
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
shareWithOrg: true,
@@ -548,10 +549,10 @@ public async Task HandleImport_reports_a_measured_zero_when_nothing_matches_the_
ImportCommand.ImportRunOutcome? outcome = null;
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.Repo([("kurrent-io", "nothing-here-by-that-name")]),
skipConfirmation: true,
onFinished: o => outcome = o
@@ -578,10 +579,10 @@ public async Task HandleImport_reports_a_clean_run_as_nothing_failed() {
ImportCommand.ImportRunOutcome? outcome = null;
- await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
shareWithOrg: true,
@@ -620,10 +621,10 @@ public async Task HandleImport_forcePrivate_does_not_upload_into_a_session_it_co
ImportCommand.ImportRunOutcome? outcome = null;
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
forcePrivate: true,
@@ -657,10 +658,10 @@ public async Task HandleImport_forcePrivate_reports_a_session_it_had_to_skip() {
using var errors = ConsoleOutput.StartErrorCapture();
- await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
forcePrivate: true
@@ -703,7 +704,7 @@ public async Task Copilot_new_session_stamps_default_visibility() {
using var client = new HttpClient();
var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public");
- await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths).ImportSessionAsync(c, ctx, CancellationToken.None);
+ await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None);
var body = SessionStartBody("copilot");
await Assert.That(body["default_visibility"]?.GetValue()).IsEqualTo("org_public");
@@ -718,13 +719,13 @@ public async Task Copilot_partial_and_already_loaded_sessions_omit_default_visib
var partialPath = WriteTranscript("copilot-partial.jsonl");
var partial = RoutedClassification("copilot-partial-1", ImportCommand.ClassificationStatus.Partial,
new() { ["TranscriptPath"] = partialPath }, resumeFromLine: 2);
- await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths).ImportSessionAsync(partial, ctx, CancellationToken.None);
+ await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()).ImportSessionAsync(partial, ctx, CancellationToken.None);
await Assert.That(SessionStartBody("copilot").ContainsKey("default_visibility")).IsFalse();
var alreadyPath = WriteTranscript("copilot-already.jsonl");
var already = RoutedClassification("copilot-already-1", ImportCommand.ClassificationStatus.AlreadyLoaded,
new() { ["TranscriptPath"] = alreadyPath }, totalLines: 5);
- await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths).ImportSessionAsync(already, ctx, CancellationToken.None);
+ await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()).ImportSessionAsync(already, ctx, CancellationToken.None);
var alreadyBody = JsonNode.Parse(
_server.LogEntries.Where(e => e.RequestMessage.Path == "/hooks/session-start/copilot")
@@ -742,7 +743,7 @@ public async Task Copilot_forcePrivate_stamps_private_over_the_step3_default() {
using var client = new HttpClient();
var ctx = new ImportContext(client, _server.Url!, ForcePrivate: true, DefaultVisibility: "org_public");
- await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths).ImportSessionAsync(c, ctx, CancellationToken.None);
+ await new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None);
await Assert.That(SessionStartBody("copilot")["default_visibility"]?.GetValue())
.IsEqualTo("private");
@@ -805,7 +806,7 @@ public async Task Kiro_new_session_stamps_default_visibility() {
using var client = new HttpClient();
var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public");
- await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None);
+ await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None);
var body = SessionStartBody("kiro");
await Assert.That(body["default_visibility"]?.GetValue()).IsEqualTo("org_public");
@@ -820,7 +821,7 @@ public async Task Kiro_partial_session_omits_default_visibility() {
using var client = new HttpClient();
var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public");
- await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None);
+ await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None);
await Assert.That(SessionStartBody("kiro").ContainsKey("default_visibility")).IsFalse();
}
@@ -834,7 +835,7 @@ public async Task Kiro_forcePrivate_stamps_private_over_the_step3_default() {
using var client = new HttpClient();
var ctx = new ImportContext(client, _server.Url!, ForcePrivate: true, DefaultVisibility: "org_public");
- await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None);
+ await new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None);
await Assert.That(SessionStartBody("kiro")["default_visibility"]?.GetValue())
.IsEqualTo("private");
@@ -851,7 +852,7 @@ public async Task Pi_new_session_stamps_default_visibility() {
using var client = new HttpClient();
var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public");
- await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None);
+ await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None);
var body = SessionStartBody("pi");
await Assert.That(body["default_visibility"]?.GetValue()).IsEqualTo("org_public");
@@ -866,7 +867,7 @@ public async Task Pi_partial_session_omits_default_visibility() {
using var client = new HttpClient();
var ctx = new ImportContext(client, _server.Url!, ForcePrivate: false, DefaultVisibility: "org_public");
- await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None);
+ await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None);
await Assert.That(SessionStartBody("pi").ContainsKey("default_visibility")).IsFalse();
}
@@ -880,7 +881,7 @@ public async Task Pi_forcePrivate_keeps_existing_private_stamp_and_never_the_org
using var client = new HttpClient();
var ctx = new ImportContext(client, _server.Url!, ForcePrivate: true, DefaultVisibility: "org_public");
- await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir).ImportSessionAsync(c, ctx, CancellationToken.None);
+ await new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()).ImportSessionAsync(c, ctx, CancellationToken.None);
// Pi's existing forcePrivate behavior (stamping the literal "private") is untouched —
// the new guard must never override it with the org-level default.
@@ -1072,7 +1073,7 @@ public async Task Cursor_new_session_stamps_default_visibility() {
await new CursorImportSource(Config.Root,
Path.Combine(_tempDir, "unused-cursor-projects"),
Path.Combine(_tempDir, "unused-cursor-workspace-storage")
- )
+ , router: new GitProviderRouter())
.ImportSessionAsync(c, ctx, CancellationToken.None);
var body = SessionStartBody("cursor");
@@ -1092,7 +1093,7 @@ public async Task Cursor_partial_session_omits_default_visibility() {
await new CursorImportSource(Config.Root,
Path.Combine(_tempDir, "unused-cursor-projects-2"),
Path.Combine(_tempDir, "unused-cursor-workspace-storage-2")
- )
+ , router: new GitProviderRouter())
.ImportSessionAsync(c, ctx, CancellationToken.None);
await Assert.That(SessionStartBody("cursor").ContainsKey("default_visibility")).IsFalse();
@@ -1110,7 +1111,7 @@ public async Task Cursor_forcePrivate_stamps_private_over_the_step3_default() {
await new CursorImportSource(Config.Root,
Path.Combine(_tempDir, "unused-cursor-projects-3"),
Path.Combine(_tempDir, "unused-cursor-workspace-storage-3")
- )
+ , router: new GitProviderRouter())
.ImportSessionAsync(c, ctx, CancellationToken.None);
await Assert.That(SessionStartBody("cursor")["default_visibility"]?.GetValue())
@@ -1135,9 +1136,9 @@ public async Task Cursor_full_round_trip_through_HandleImport_stamps_default_vis
.RespondWith(Response.Create().WithStatusCode(404));
StubAllHookEndpoints();
- var source = new CursorImportSource(Config.Root, projectsDir, Path.Combine(_tempDir, "cursor-workspace-storage-rt"));
+ var source = new CursorImportSource(Config.Root, projectsDir, Path.Combine(_tempDir, "cursor-workspace-storage-rt"), router: new GitProviderRouter());
- var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ var exitCode = await new ImportCommand(Config.Root, Resolutions.At(_server.Url!, Config.Root), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 0,
sources: [source],
@@ -1176,16 +1177,16 @@ sealed record RoutedSourceCase(
Func> MakeSourceMeta);
RoutedSourceCase CopilotCase() =>
- new(HarnessId.Copilot, () => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths), p => new() { ["TranscriptPath"] = p });
+ new(HarnessId.Copilot, () => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()), p => new() { ["TranscriptPath"] = p });
RoutedSourceCase GeminiCase() =>
new(HarnessId.Gemini, () => new GeminiImportSource(GeminiHarness.FromEnvironment(Home).Paths.TmpDir), p => new() { ["TranscriptPath"] = p });
RoutedSourceCase KiroCase() =>
- new(HarnessId.Kiro, () => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir), p => new() { ["TranscriptPath"] = p });
+ new(HarnessId.Kiro, () => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()), p => new() { ["TranscriptPath"] = p });
RoutedSourceCase PiCase() =>
- new(HarnessId.Pi, () => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir), p => new() { ["TranscriptPath"] = p });
+ new(HarnessId.Pi, () => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()), p => new() { ["TranscriptPath"] = p });
RoutedSourceCase AntigravityCase() =>
new(HarnessId.Antigravity, () => new AntigravityImportSource(AntigravityHarness.Over(GeminiHarness.FromEnvironment(Home)).Paths), p => new() { ["TranscriptPath"] = p });
@@ -1194,7 +1195,7 @@ RoutedSourceCase CursorCase() =>
new(HarnessId.Cursor,
() => new CursorImportSource(Config.Root,
Path.Combine(_tempDir, $"unused-cursor-projects-{Guid.NewGuid():N}"),
- Path.Combine(_tempDir, $"unused-cursor-workspace-storage-{Guid.NewGuid():N}")),
+ Path.Combine(_tempDir, $"unused-cursor-workspace-storage-{Guid.NewGuid():N}"), router: new GitProviderRouter()),
p => new() { ["TranscriptPath"] = p, ["WorkspaceFolder"] = "/Users/me/proj" });
async Task AssertAlreadyLoadedOmitsDefaultVisibility(RoutedSourceCase rc) {
@@ -1433,12 +1434,12 @@ public async Task HandleImport_autoSkipExclusions_completes_without_prompting_an
// happened to look like an interactive TTY, this call could block forever on
// Console.ReadLine(). It must not, regardless of ambient TTY state.
var import = new ImportCommand(Config.Root,
- Resolutions.Of(new Profile { ExcludedPaths = [excludedDir] }, "autoskip-test", _server.Url!), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient());
+ Resolutions.Of(new Profile { ExcludedPaths = [excludedDir] }, "autoskip-test", _server.Url!), Home, TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter());
var task = import.HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projectsDir)],
+ sources: [new ClaudeImportSource(Config.Root, projectsDir, router: new GitProviderRouter())],
scope: new ImportScope.All(),
skipConfirmation: true,
autoSkipExclusions: true,
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs
index 7646d7e0c..5dc54ead3 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpAnalyticsServerTests.cs
@@ -1,6 +1,8 @@
using System.Net;
using System.Text.Json.Nodes;
using Capacitor.Cli.Commands;
+using Capacitor.Cli.PrDetection;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -9,7 +11,7 @@ public class McpAnalyticsServerTests {
// Resolutions.None: these tests exercise routing, not profile selection.
McpAnalyticsServer Server() =>
- new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), new FixedCapacitorHttpClient(), NoTelemetry.Startup);
+ new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root), new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
static JsonObject Args(string json) => JsonNode.Parse(json)!.AsObject();
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs
index 5b4425357..d47540e26 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerReviewerVendorsTests.cs
@@ -4,6 +4,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -13,7 +14,7 @@ public class McpFlowsServerReviewerVendorsTests {
// Resolutions.None: these tests exercise routing, not profile selection.
McpFlowsServer Server() =>
new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root),
- new FixedCapacitorHttpClient(), NoTelemetry.Startup);
+ new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
static JsonObject ToolCall() => new() {
["params"] = new JsonObject { ["name"] = "list_reviewer_vendors", ["arguments"] = new JsonObject() }
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs
index 782a16513..8ddd3f439 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerSettlementRetryTests.cs
@@ -4,6 +4,8 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -29,7 +31,7 @@ public class McpFlowsServerSettlementRetryTests {
// Resolutions.None: these tests exercise routing, not profile selection.
McpFlowsServer Server() =>
new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root),
- new FixedCapacitorHttpClient(), NoTelemetry.Startup);
+ new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
// Every wait in both retry lanes runs on the injected clock, so these tests are instant and
// the requested schedule is directly assertable (VirtualFlowRetryClock.Delays).
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs
index 6fb943c7f..00c7a2ad4 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerTests.cs
@@ -4,6 +4,8 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -13,7 +15,7 @@ public class McpFlowsServerTests {
// Resolutions.None: these tests exercise routing, not profile selection.
McpFlowsServer Server() =>
new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root),
- new FixedCapacitorHttpClient(), NoTelemetry.Startup);
+ new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
// The ack retry's 2s wait now runs on the injected clock, so these tests stay instant while
// still asserting the real schedule (VirtualFlowRetryClock.Delays records every requested wait).
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs
index 94d065e5e..4150de1b8 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/McpFlowsServerVendorOverrideTests.cs
@@ -4,6 +4,8 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -17,7 +19,7 @@ public class McpFlowsServerVendorOverrideTests {
// Resolutions.None: these tests exercise routing, not profile selection.
McpFlowsServer Server() =>
new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root),
- new FixedCapacitorHttpClient(), NoTelemetry.Startup);
+ new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
static JsonObject StartArguments(string? vendor = null) {
var args = new JsonObject {
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs
index 9369b1436..2e4e1544e 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ParticipantUnreachableRetryTests.cs
@@ -4,6 +4,8 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -30,7 +32,7 @@ public class ParticipantUnreachableRetryTests {
// Resolutions.None: these tests exercise routing, not profile selection.
McpFlowsServer Server() =>
new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root),
- new FixedCapacitorHttpClient(), NoTelemetry.Startup);
+ new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
static VirtualFlowRetryClock Clock() => new();
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandAntigravityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandAntigravityTests.cs
index 796249c6d..736e94c42 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandAntigravityTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandAntigravityTests.cs
@@ -5,6 +5,7 @@
using Capacitor.Cli.Core.Harness.Gemini;
using Capacitor.Cli.Core.Instructions;
using Capacitor.Cli.Core.Mcp;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -34,7 +35,7 @@ await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpCon
{"mcpServers":{"my-tool":{"command":"my-tool","args":["serve"]}}}
""");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--antigravity", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--antigravity", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject()["mcpServers"]!.AsObject();
@@ -59,7 +60,7 @@ public async Task install_antigravity_installs_instructions_into_shared_gemini_m
Directory.CreateDirectory(Path.GetDirectoryName(env.Harnesses.Of().Paths.InstructionsMd)!);
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--antigravity", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--antigravity", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd);
@@ -74,7 +75,7 @@ public async Task install_antigravity_skip_mcp_flag_leaves_config_untouched() {
var env = TestEnv(home.Path);
SeedStaleHooks(env);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--antigravity", "--if-installed", "--skip-antigravity-mcp"]);
await Assert.That(exit).IsEqualTo(0);
@@ -87,7 +88,7 @@ public async Task install_antigravity_skip_instructions_flag_leaves_gemini_md_un
var env = TestEnv(home.Path);
SeedStaleHooks(env);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--antigravity", "--if-installed", "--skip-antigravity-instructions"]);
await Assert.That(exit).IsEqualTo(0);
@@ -109,7 +110,7 @@ public async Task remove_antigravity_unregisters_mcp_and_strips_instructions() {
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n");
AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.InstructionsMd, KcapAgentInstructions.Body);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--antigravity"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--antigravity"]);
await Assert.That(exit).IsEqualTo(0);
var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject()["mcpServers"]!.AsObject();
@@ -137,7 +138,7 @@ public async Task remove_antigravity_keeps_shared_instructions_when_gemini_insta
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n");
AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.InstructionsMd, KcapAgentInstructions.Body);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--antigravity"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--antigravity"]);
await Assert.That(exit).IsEqualTo(0);
var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandClaudeTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandClaudeTests.cs
index c64318ebd..c8116ebcd 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandClaudeTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandClaudeTests.cs
@@ -26,7 +26,7 @@ public async Task Install_claude_with_if_installed_is_noop_when_no_marker_and_no
using var fakeHome = new TempDir();
var env = TestEnv(fakeHome.Path);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var settingsPath = fakeHome.PathTo(".claude", "settings.json");
@@ -50,7 +50,7 @@ await File.WriteAllTextAsync(settingsPath, """
var env = TestEnv(fakeHome.Path, pluginPath: pluginDir.Path);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
// Marketplace path must now point at the new plugin dir.
@@ -74,7 +74,7 @@ public async Task Install_claude_with_if_installed_is_noop_when_marker_matches_c
claudeDir.CreateFile(ClaudePluginInstaller.MarkerFileName,
CapacitorVersion.Current());
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(["plugin", "install", "--if-installed"]);
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(settingsPath))!.AsObject();
@@ -95,7 +95,7 @@ public async Task Install_claude_with_if_installed_swallows_plugin_resolution_fa
// …but plugin dir resolution fails (null = no plugin available).
var env = TestEnv(fakeHome.Path, pluginPath: null, stderr: capturedErr);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(capturedErr.ToString()).IsEmpty();
}
@@ -107,7 +107,7 @@ public async Task Install_claude_fresh_prints_restart_reminder() {
var stdout = new StringWriter();
var env = TestEnv(fakeHome.Path, pluginPath: pluginDir.Path, stdout: stdout);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install"]);
await Assert.That(exit).IsEqualTo(0);
@@ -133,7 +133,7 @@ public async Task Install_claude_refresh_omits_restart_reminder() {
""");
var env = TestEnv(fakeHome.Path, pluginPath: pluginDir.Path, stdout: stdout);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -155,7 +155,7 @@ public async Task Remove_claude_deletes_marker() {
""");
claudeDir.CreateFile(ClaudePluginInstaller.MarkerFileName, CapacitorVersion.Current());
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(["plugin", "remove"]);
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(claudeDir.PathTo(ClaudePluginInstaller.MarkerFileName))).IsFalse();
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCodexTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCodexTests.cs
index b02b74c6e..967db8e9d 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCodexTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCodexTests.cs
@@ -374,7 +374,7 @@ public async Task Remove_returns_false_when_nothing_to_remove() {
public async Task Install_codex_with_if_installed_is_noop_when_no_marker_and_no_existing_entries() {
using var fakeHome = new TempDir();
- var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--codex", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -401,7 +401,7 @@ await File.WriteAllTextAsync(hooksPath, """
}
""");
- var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--codex", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -431,7 +431,7 @@ await File.WriteAllTextAsync(
codexDir.PathTo(CodexHooksInstaller.MarkerFileName),
CapacitorVersion.Current());
- var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--codex", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -500,7 +500,7 @@ public async Task InstallCodex_prints_hooks_trust_hint_after_success() {
var capturedOut = new StringWriter();
var env = TestEnv(fakeHome.GetResolvedPath(), pluginRoot.Path, stdout: capturedOut);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--codex"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--codex"]);
await Assert.That(exit).IsEqualTo(0);
var stdout = capturedOut.ToString();
@@ -529,7 +529,7 @@ public async Task InstallCodex_fails_before_writing_hooks_when_individual_skill_
var capturedErr = new StringWriter();
var env = TestEnv(fakeHome.GetResolvedPath(), pluginRoot.Path, stderr: capturedErr);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--codex"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--codex"]);
await Assert.That(exit).IsEqualTo(1);
// Atomicity contract: hooks.json must NOT exist after a failed install.
@@ -553,7 +553,7 @@ public async Task InstallCodex_fails_before_writing_hooks_when_plugin_folder_mis
// PluginPath = null signals ResolvePluginPath returned no plugin.
var env = TestEnv(fakeHome.GetResolvedPath(), pluginPath: null, stderr: capturedErr);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--codex"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--codex"]);
await Assert.That(exit).IsEqualTo(1);
// The atomic-install contract: NO hooks.json may exist in the
@@ -571,7 +571,7 @@ public async Task InstallCodex_registers_mcp_servers_in_config_toml() {
using var pluginRoot = new TempDir();
PlantFakePlugin(pluginRoot.Path);
- var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath(), pluginRoot.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath(), pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--codex"]);
await Assert.That(exit).IsEqualTo(0);
@@ -593,7 +593,7 @@ public async Task InstallCodex_if_installed_registers_missing_mcp_servers_when_h
var hooksPath = codexDir.CreateFile("hooks.json", """{"sentinel": "must-survive"}""");
codexDir.CreateFile(CodexHooksInstaller.MarkerFileName, CapacitorVersion.Current());
- var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--codex", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -620,7 +620,7 @@ public async Task InstallCodex_if_installed_registers_missing_mcp_servers_after_
PluginCommand.InstallCodexHooks(hooksPath);
CodexHooksInstaller.DeleteMarker(hooksPath);
- var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--codex", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -641,7 +641,7 @@ public async Task InstallCodex_if_installed_leaves_config_toml_alone_when_never_
args = ["serve"]
""");
- var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--codex", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -655,7 +655,7 @@ public async Task RemoveCodex_user_scope_preserves_unowned_manual_mcp_servers()
using var fakeHome = new TempDir();
var configPath = SeedCodexConfigWithKcapServers(fakeHome.GetResolvedPath());
- var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "remove", "--codex"]);
await Assert.That(exit).IsEqualTo(0);
@@ -674,7 +674,7 @@ public async Task RemoveCodex_project_scope_leaves_user_global_mcp_servers() {
var configPath = SeedCodexConfigWithKcapServers(fakeHome.GetResolvedPath());
var before = await File.ReadAllTextAsync(configPath);
- var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath())).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.GetResolvedPath()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "remove", "--codex", "--project"]);
await Assert.That(exit).IsEqualTo(0);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCopilotTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCopilotTests.cs
index 1134861e0..6a640a596 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCopilotTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCopilotTests.cs
@@ -4,6 +4,7 @@
using Capacitor.Cli.Core.Harness.Copilot;
using Capacitor.Cli.Core.Instructions;
using Capacitor.Cli.Core.Mcp;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -29,7 +30,7 @@ await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfigJ
{"mcpServers":{"my-tool":{"type":"stdio","command":"my-tool","args":["serve"]}}}
""");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject();
@@ -52,7 +53,7 @@ public async Task install_copilot_skip_flag_leaves_mcp_config_untouched() {
PluginCommand.InstallCopilotHooks(env.Harnesses.Of().Paths.KcapHooksJson);
CopilotHooksInstaller.DeleteMarker(env.Harnesses.Of().Paths.KcapHooksJson);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--copilot", "--if-installed", "--skip-copilot-mcp"]);
await Assert.That(exit).IsEqualTo(0);
@@ -65,7 +66,7 @@ public async Task install_copilot_if_installed_does_not_write_mcp_config_when_ne
var env = TestEnv(home.Path);
// No hooks seeded → --if-installed no-ops before touching hooks OR mcp-config.
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(env.Harnesses.Of().Paths.McpConfigJson)).IsFalse();
@@ -80,7 +81,7 @@ public async Task install_copilot_if_installed_heals_mcp_and_instructions_when_h
// still (re)create the separate MCP + instructions files if they're missing (self-heal).
PluginCommand.InstallCopilotHooks(env.Harnesses.Of().Paths.KcapHooksJson); // writes hooks + current marker
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(env.Harnesses.Of().Paths.McpConfigJson)).IsTrue();
@@ -102,7 +103,7 @@ public async Task install_copilot_if_installed_heals_mcp_and_instructions_when_h
Directory.CreateDirectory(env.Harnesses.Of().Paths.KcapHooksJson); // kcap.json is a directory → write fails
await File.WriteAllTextAsync(System.IO.Path.Combine(hooksDir, CopilotHooksInstaller.MarkerFileName), "0.0.0-stale");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0); // refresh swallows the hook-write failure
await Assert.That(File.Exists(env.Harnesses.Of().Paths.McpConfigJson)).IsTrue(); // MCP healed despite the hook failure
@@ -121,7 +122,7 @@ public async Task remove_copilot_unregisters_mcp_servers_preserving_user_entries
seeded["mcpServers"]!["my-tool"] = JsonNode.Parse("""{"type":"stdio","command":"my-tool","args":["serve"]}""");
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson, seeded.ToJsonString());
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--copilot"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--copilot"]);
await Assert.That(exit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject();
@@ -146,13 +147,13 @@ public async Task remove_copilot_retains_marker_on_failed_unregister_then_retry_
// The config is temporarily malformed/unreadable → Unregister fails-closed.
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson, "{ not valid json");
- var failExit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--copilot"]);
+ var failExit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--copilot"]);
await Assert.That(failExit).IsEqualTo(1); // failed MCP unregister propagates
await Assert.That(new McpMarker("copilot", env.Home).Owned(env.Harnesses.Of().Paths.McpConfigJson).ToArray()).IsNotEmpty(); // marker RETAINED for retry
// User fixes the file (kcap entries intact); the retry now succeeds and cleans up.
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson, installed);
- var retryExit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--copilot"]);
+ var retryExit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--copilot"]);
await Assert.That(retryExit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject();
@@ -174,7 +175,7 @@ public async Task install_copilot_installs_instructions_preserving_user_content(
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(env.Harnesses.Of().Paths.InstructionsMd)!);
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--copilot", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd);
@@ -191,7 +192,7 @@ public async Task install_copilot_skip_instructions_flag_leaves_file_untouched()
PluginCommand.InstallCopilotHooks(env.Harnesses.Of().Paths.KcapHooksJson);
CopilotHooksInstaller.DeleteMarker(env.Harnesses.Of().Paths.KcapHooksJson);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--copilot", "--if-installed", "--skip-copilot-instructions"]);
await Assert.That(exit).IsEqualTo(0);
@@ -207,7 +208,7 @@ public async Task remove_copilot_strips_instructions_block_keeping_user_content(
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd, "# My rules\n\nAlways use tabs.\n");
AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.InstructionsMd, KcapAgentInstructions.Body);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--copilot"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--copilot"]);
await Assert.That(exit).IsEqualTo(0);
var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.InstructionsMd);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCursorTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCursorTests.cs
index 996732db1..4345f8dda 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCursorTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandCursorTests.cs
@@ -13,7 +13,7 @@ public async Task install_cursor_if_installed_noops_when_marker_absent() {
using var tmp = new TempHome();
var hooksPath = Path.Combine(tmp.Path, "hooks.json");
- var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--cursor", "--if-installed", "--cursor-hooks-path", hooksPath]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(hooksPath)).IsFalse();
@@ -29,7 +29,7 @@ public async Task install_cursor_if_installed_short_circuits_on_same_version_mar
var marker = CursorHooksInstaller.ReadMarker(hooksPath);
await Assert.That(marker).IsEqualTo(CapacitorVersion.Current());
- var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--cursor", "--if-installed", "--cursor-hooks-path", hooksPath]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.ReadAllText(hooksPath)).IsEqualTo("{}");
@@ -59,7 +59,7 @@ await File.WriteAllTextAsync(mcpPath, """
{"mcpServers":{"my-tool":{"command":"my-tool","args":["serve"]}}}
""");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--cursor", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -83,7 +83,7 @@ public async Task install_cursor_if_installed_does_not_write_mcp_json_when_never
// Hooks were never installed, so the refresh-only postinstall path
// no-ops before ever touching hooks.json OR mcp.json.
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--cursor", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -102,7 +102,7 @@ await File.WriteAllTextAsync(hooksPath, """
{"version":1,"hooks":{"sessionStart":[{"command":"kcap hook --cursor"}]}}
""");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--cursor", "--if-installed", "--skip-cursor-mcp"]);
await Assert.That(exit).IsEqualTo(0);
@@ -130,7 +130,7 @@ await File.WriteAllTextAsync(hooksPath, """
seeded["mcpServers"]!["my-tool"] = JsonNode.Parse("""{"command":"my-tool","args":["serve"]}""");
await File.WriteAllTextAsync(mcpPath, seeded.ToJsonString());
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "remove", "--cursor"]);
await Assert.That(exit).IsEqualTo(0);
@@ -159,13 +159,13 @@ public async Task remove_cursor_retains_marker_on_failed_unregister_then_retry_r
// The config is temporarily malformed/unreadable → Unregister fails-closed.
await File.WriteAllTextAsync(mcpPath, "{ not valid json");
- var failExit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(["plugin", "remove", "--cursor"]);
+ var failExit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--cursor"]);
await Assert.That(failExit).IsEqualTo(1); // failed MCP unregister propagates
await Assert.That(new McpMarker("cursor", fakeHome.Home).Owned(mcpPath).ToArray()).IsNotEmpty(); // marker RETAINED for retry
// User fixes the file (kcap entries intact); the retry now succeeds and cleans up.
await File.WriteAllTextAsync(mcpPath, installed);
- var retryExit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(["plugin", "remove", "--cursor"]);
+ var retryExit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--cursor"]);
await Assert.That(retryExit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(mcpPath))!.AsObject();
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandGeminiTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandGeminiTests.cs
index 7325ab29d..51b4fc3ff 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandGeminiTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandGeminiTests.cs
@@ -4,6 +4,7 @@
using Capacitor.Cli.Core.Harness.Gemini;
using Capacitor.Cli.Core.Instructions;
using Capacitor.Cli.Core.Mcp;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -29,7 +30,7 @@ public async Task install_gemini_registers_mcp_servers_into_shared_settings_pres
};
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, seeded.ToJsonString());
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject();
@@ -62,7 +63,7 @@ public async Task install_gemini_skip_mcp_flag_leaves_settings_without_mcp_serve
PluginCommand.InstallGeminiHooks(env.Harnesses.Of().Paths.SettingsJson);
GeminiHooksInstaller.DeleteMarker(env.Harnesses.Of().Paths.SettingsJson);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--gemini", "--if-installed", "--skip-gemini-mcp"]);
await Assert.That(exit).IsEqualTo(0);
@@ -77,7 +78,7 @@ public async Task install_gemini_if_installed_does_not_write_anything_when_never
var env = TestEnv(home.Path);
// No hooks/marker seeded → --if-installed no-ops before touching settings.json OR GEMINI.md.
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(env.Harnesses.Of().Paths.SettingsJson)).IsFalse();
@@ -93,7 +94,7 @@ public async Task install_gemini_if_installed_heals_mcp_and_instructions_when_ho
// still register the MCP servers (into settings.json) + install the instructions (GEMINI.md).
PluginCommand.InstallGeminiHooks(env.Harnesses.Of().Paths.SettingsJson); // writes hooks + current marker
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject()["mcpServers"]!.AsObject();
@@ -114,7 +115,7 @@ await File.WriteAllTextAsync(
Path.Combine(env.Harnesses.Of().Paths.Root, GeminiHooksInstaller.MarkerFileName), "0.0.0-stale");
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, "{ not valid json");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0); // refresh swallows the hook/MCP failures on the shared file
await Assert.That(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson)).IsEqualTo("{ not valid json"); // untouched
@@ -134,7 +135,7 @@ public async Task install_gemini_if_installed_reinstalls_hooks_when_settings_del
PluginCommand.InstallGeminiHooks(env.Harnesses.Of().Paths.SettingsJson); // hooks + current marker
File.Delete(env.Harnesses.Of().Paths.SettingsJson);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject();
@@ -155,7 +156,7 @@ public async Task remove_gemini_unregisters_mcp_servers_preserving_user_entries(
seeded["mcpServers"]!["my-tool"] = JsonNode.Parse("""{"command":"my-tool","args":["serve"]}""");
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, seeded.ToJsonString());
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]);
await Assert.That(exit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject();
@@ -181,13 +182,13 @@ public async Task remove_gemini_retains_marker_on_failed_unregister_then_retry_r
// settings.json is temporarily malformed → Unregister fails-closed.
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, "{ not valid json");
- var failExit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]);
+ var failExit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]);
await Assert.That(failExit).IsEqualTo(1); // failed unregister propagates
await Assert.That(new McpMarker("gemini", env.Home).Owned(env.Harnesses.Of().Paths.SettingsJson).ToArray()).IsNotEmpty(); // marker RETAINED for retry
// User fixes the file (kcap entries intact); the retry now succeeds and cleans up.
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsJson, installed);
- var retryExit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]);
+ var retryExit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]);
await Assert.That(retryExit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsJson))!.AsObject();
@@ -209,7 +210,7 @@ public async Task install_gemini_installs_instructions_preserving_user_content()
Directory.CreateDirectory(Path.GetDirectoryName(env.Harnesses.Of().Paths.GeminiMd)!);
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.GeminiMd, "# My rules\n\nAlways use tabs.\n");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--gemini", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.GeminiMd);
@@ -226,7 +227,7 @@ public async Task install_gemini_skip_instructions_flag_leaves_gemini_md_untouch
PluginCommand.InstallGeminiHooks(env.Harnesses.Of().Paths.SettingsJson);
GeminiHooksInstaller.DeleteMarker(env.Harnesses.Of().Paths.SettingsJson);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--gemini", "--if-installed", "--skip-gemini-instructions"]);
await Assert.That(exit).IsEqualTo(0);
@@ -242,7 +243,7 @@ public async Task remove_gemini_strips_instructions_block_keeping_user_content()
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.GeminiMd, "# My rules\n\nAlways use tabs.\n");
AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.GeminiMd, KcapAgentInstructions.Body);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]);
await Assert.That(exit).IsEqualTo(0);
var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.GeminiMd);
@@ -262,7 +263,7 @@ public async Task remove_gemini_clears_mcp_marker_even_when_settings_file_absent
await Assert.That(new McpMarker("gemini", env.Home).Owned(env.Harnesses.Of().Paths.SettingsJson).ToArray()).IsNotEmpty();
File.Delete(env.Harnesses.Of().Paths.SettingsJson);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--gemini"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--gemini"]);
await Assert.That(exit).IsEqualTo(0);
// The marker is cleared despite the absent file → a future user-authored mcpServers.kcap-*
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandKiroTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandKiroTests.cs
index 4802dd27b..7371a8d6f 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandKiroTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandKiroTests.cs
@@ -3,6 +3,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core.Harness.Kiro;
using Capacitor.Cli.Core.Mcp;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -31,7 +32,7 @@ await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsMcpJs
{"mcpServers":{"my-tool":{"command":"my-tool","args":["serve"],"autoApprove":["do_thing"]}}}
""");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsMcpJson))!.AsObject()["mcpServers"]!.AsObject();
@@ -56,7 +57,7 @@ public async Task install_kiro_skip_mcp_flag_leaves_config_untouched() {
var env = TestEnv(home.Path);
SeedAgent(env);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--kiro", "--if-installed", "--skip-kiro-mcp"]);
await Assert.That(exit).IsEqualTo(0);
@@ -71,7 +72,7 @@ public async Task install_kiro_creates_settings_dir_when_missing() {
// settings/ dir does not exist yet — Register must create it.
await Assert.That(Directory.Exists(Path.GetDirectoryName(env.Harnesses.Of().Paths.SettingsMcpJson)!)).IsFalse();
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(env.Harnesses.Of().Paths.SettingsMcpJson)).IsTrue();
@@ -88,7 +89,7 @@ public async Task remove_kiro_unregisters_mcp_servers_preserving_user_entries()
seeded["mcpServers"]!["my-tool"] = JsonNode.Parse("""{"command":"my-tool","args":["serve"]}""");
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.SettingsMcpJson, seeded.ToJsonString());
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--kiro"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--kiro"]);
await Assert.That(exit).IsEqualTo(0);
var servers = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.SettingsMcpJson))!.AsObject()["mcpServers"]!.AsObject();
@@ -109,7 +110,7 @@ public async Task install_kiro_if_installed_heals_mcp_only_install_without_cloni
JsonMcpConfigWriter.Register(env.Harnesses.Of().Paths.SettingsMcpJson, partial, McpConfigShape.Standard, cwd: null, new McpMarker("kiro", env.Home));
await Assert.That(File.Exists(env.Harnesses.Of().Paths.KcapAgentJson)).IsFalse(); // no agent installed
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
// The refresh reached RegisterKiroMcpServersAsync (instead of bailing on the missing agent
@@ -129,7 +130,7 @@ public async Task install_kiro_if_installed_noop_when_nothing_installed() {
var env = TestEnv(home.Path);
// Neither agent nor MCP present → refresh must be a pure no-op (never force-installs).
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(env.Harnesses.Of().Paths.SettingsMcpJson)).IsFalse();
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandOpenCodeTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandOpenCodeTests.cs
index d82f53b31..b9250e31c 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandOpenCodeTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandOpenCodeTests.cs
@@ -27,7 +27,7 @@ public async Task Install_opencode_with_if_installed_is_noop_when_not_installed(
using var tmp = new TempDir();
var pluginPath = tmp.PathTo("plugins", "kcap.ts");
- var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--opencode", "--opencode-plugin-path", pluginPath, "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -47,7 +47,7 @@ public async Task Install_opencode_with_if_installed_refreshes_existing_plugin()
// Plugin-only: skip MCP/instructions so this stays isolated to the plugin file
// (their config path derives from ambient OPENCODE_CONFIG_DIR/XDG, not this TempDir).
- var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--opencode", "--opencode-plugin-path", pluginPath, "--if-installed",
"--skip-opencode-mcp", "--skip-opencode-instructions"]);
await Assert.That(exit).IsEqualTo(0);
@@ -67,7 +67,7 @@ public async Task Install_opencode_if_installed_recreates_plugin_when_file_missi
// via the marker, so --if-installed must still RECREATE the missing plugin, not skip it.
dir.CreateFile(".kcap-extension-version", CapacitorVersion.Current());
- var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--opencode", "--opencode-plugin-path", pluginPath, "--if-installed",
"--skip-opencode-mcp", "--skip-opencode-instructions"]);
await Assert.That(exit).IsEqualTo(0);
@@ -87,7 +87,7 @@ public async Task Remove_opencode_deletes_plugin_and_marker() {
await File.WriteAllTextAsync(pluginPath, "export const KcapPlugin = async () => ({})");
await File.WriteAllTextAsync(marker, "1.0.0");
- var exit = await new PluginCommand(TestEnv(tmp.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(tmp.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "remove", "--opencode", "--opencode-plugin-path", pluginPath]);
await Assert.That(exit).IsEqualTo(0);
@@ -113,7 +113,7 @@ await File.WriteAllTextAsync(env.Harnesses.Of().Paths.McpConfig
{"$schema":"https://opencode.ai/config.json","mcp":{"my-tool":{"type":"local","command":["my-tool","serve"],"enabled":true}}}
""");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--opencode", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--opencode", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject();
@@ -139,7 +139,7 @@ public async Task install_opencode_skip_mcp_flag_leaves_config_untouched() {
var env = TestEnv(home.Path);
SeedPlugin(env);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--opencode", "--if-installed", "--skip-opencode-mcp"]);
await Assert.That(exit).IsEqualTo(0);
@@ -155,7 +155,7 @@ public async Task install_opencode_installs_instructions_into_agents_md() {
Directory.CreateDirectory(Path.GetDirectoryName(env.Harnesses.Of().Paths.AgentsMd)!);
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.AgentsMd, "# My rules\n\nAlways use tabs.\n");
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--opencode", "--if-installed"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--opencode", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
var content = await File.ReadAllTextAsync(env.Harnesses.Of().Paths.AgentsMd);
@@ -170,7 +170,7 @@ public async Task install_opencode_skip_instructions_flag_leaves_file_untouched(
var env = TestEnv(home.Path);
SeedPlugin(env);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--opencode", "--if-installed", "--skip-opencode-instructions"]);
await Assert.That(exit).IsEqualTo(0);
@@ -192,7 +192,7 @@ public async Task remove_opencode_unregisters_mcp_and_strips_instructions() {
await File.WriteAllTextAsync(env.Harnesses.Of().Paths.AgentsMd, "# My rules\n\nAlways use tabs.\n");
AgentInstructionsWriter.Write(env.Harnesses.Of().Paths.AgentsMd, KcapAgentInstructions.Body);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "remove", "--opencode"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--opencode"]);
await Assert.That(exit).IsEqualTo(0);
var mcp = JsonNode.Parse(await File.ReadAllTextAsync(env.Harnesses.Of().Paths.McpConfigJson))!.AsObject()["mcp"]!.AsObject();
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandPiTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandPiTests.cs
index 8be530108..40533b22e 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandPiTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandPiTests.cs
@@ -2,6 +2,7 @@
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Harness.Pi;
using Capacitor.Cli.Core.Instructions;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -20,7 +21,7 @@ public class PluginCommandPiTests {
public async Task Install_pi_with_if_installed_is_noop_when_not_installed() {
using var fakeHome = new TempDir();
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--pi", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -40,7 +41,7 @@ public async Task Install_pi_with_if_installed_refreshes_existing_extension() {
var extPath = extDir.PathTo("kcap.ts");
await File.WriteAllTextAsync(extPath, "// stale extension body");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--pi", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -59,7 +60,7 @@ public async Task Remove_pi_deletes_extension_and_marker() {
await File.WriteAllTextAsync(extPath, "export default function(pi){}");
await File.WriteAllTextAsync(marker, "1.0.0");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "remove", "--pi"]);
await Assert.That(exit).IsEqualTo(0);
@@ -77,7 +78,7 @@ public async Task Install_pi_if_installed_installs_mcp_bridge_and_agents_md() {
var extDir = fakeHome.CreateDir(".pi", "agent", "extensions");
extDir.CreateFile("kcap.ts", "// stale ingest");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--pi", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -99,7 +100,7 @@ public async Task Install_pi_if_installed_heals_deleted_mcp_bridge_with_current_
PiMcpExtensionInstaller.WriteMarker(mcpPath);
await Assert.That(File.Exists(mcpPath)).IsFalse();
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--pi", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -112,7 +113,7 @@ public async Task Install_pi_skip_mcp_omits_bridge_but_keeps_instructions() {
var extDir = fakeHome.CreateDir(".pi", "agent", "extensions");
extDir.CreateFile("kcap.ts", "// stale ingest");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--pi", "--if-installed", "--skip-pi-mcp"]);
await Assert.That(exit).IsEqualTo(0);
@@ -126,7 +127,7 @@ public async Task Install_pi_skip_instructions_omits_agents_md_but_keeps_bridge(
var extDir = fakeHome.CreateDir(".pi", "agent", "extensions");
extDir.CreateFile("kcap.ts", "// stale ingest");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--pi", "--if-installed", "--skip-pi-instructions"]);
await Assert.That(exit).IsEqualTo(0);
@@ -144,7 +145,7 @@ public async Task Install_pi_preserves_user_agents_md_content() {
var agents = Path.Combine(agentDir, "AGENTS.md");
await File.WriteAllTextAsync(agents, "# My Pi instructions\nKeep this line.\n");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--pi", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -165,7 +166,7 @@ public async Task Remove_pi_removes_mcp_bridge_and_instructions_block() {
await File.WriteAllTextAsync(agents, "# Mine\n");
AgentInstructionsWriter.Write(agents, "kcap steering body");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "remove", "--pi"]);
await Assert.That(exit).IsEqualTo(0);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandSkillsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandSkillsTests.cs
index a701615f6..f105eb3a8 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandSkillsTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandSkillsTests.cs
@@ -9,7 +9,7 @@ public class PluginCommandSkillsTests {
public async Task Install_with_both_codex_and_skills_flags_returns_error() {
using var tmp = new TempDir();
var capturedErr = new StringWriter();
- var exit = await new PluginCommand(TestEnv(fakeHome: tmp.Path, stderr: capturedErr)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome: tmp.Path, stderr: capturedErr), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--codex", "--skills"]);
await Assert.That(exit).IsEqualTo(1);
await Assert.That(capturedErr.ToString()).Contains("mutually exclusive");
@@ -19,7 +19,7 @@ public async Task Install_with_both_codex_and_skills_flags_returns_error() {
public async Task Remove_with_both_codex_and_skills_flags_returns_error() {
using var tmp = new TempDir();
var capturedErr = new StringWriter();
- var exit = await new PluginCommand(TestEnv(fakeHome: tmp.Path, stderr: capturedErr)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome: tmp.Path, stderr: capturedErr), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "remove", "--codex", "--skills"]);
await Assert.That(exit).IsEqualTo(1);
await Assert.That(capturedErr.ToString()).Contains("mutually exclusive");
@@ -40,7 +40,7 @@ public async Task Install_skills_writes_to_agents_dir_and_cleans_legacy() {
var legacyDir = fakeHome.CreateDir(".codex", "skills");
legacyDir.CreateDir("kcap-recap");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--skills"]);
await Assert.That(exit).IsEqualTo(0);
@@ -65,7 +65,7 @@ public async Task Install_skills_with_if_installed_is_noop_when_marker_absent()
$"---\nname: {name}\n---\nbody");
}
- var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--skills", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -91,7 +91,7 @@ public async Task Install_skills_with_if_installed_refreshes_when_marker_present
target.CreateFile(AgentsSkillsInstaller.MarkerFileName,
"old-version");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--skills", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -130,7 +130,7 @@ await File.WriteAllTextAsync(
"---\nname: kcap-recap\n---\nstale body");
await Assert.That(File.Exists(Path.Combine(target, AgentsSkillsInstaller.MarkerFileName))).IsFalse();
- var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--skills", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -172,7 +172,7 @@ public async Task Install_skills_with_if_installed_is_noop_when_marker_matches_c
target.CreateFile(["kcap-recap", "SKILL.md"],
"stale body — must NOT be overwritten");
- var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path, pluginRoot.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--skills", "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -194,7 +194,7 @@ public async Task Install_skills_with_if_installed_swallows_plugin_resolution_fa
// …but plugin path is null (resolution failed).
var env = TestEnv(fakeHome.Path, pluginPath: null, stderr: capturedErr);
- var exit = await new PluginCommand(env).HandleAsync(
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "install", "--skills", "--if-installed"]);
// Refresh path must never fail npm install — exit 0, nothing on stderr.
@@ -217,7 +217,7 @@ public async Task Remove_skills_clears_agents_and_legacy() {
Directory.CreateDirectory(Path.Combine(legacyDir, name));
}
- var exit = await new PluginCommand(TestEnv(fakeHome.Path)).HandleAsync(
+ var exit = await new PluginCommand(TestEnv(fakeHome.Path), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
["plugin", "remove", "--skills"]);
await Assert.That(exit).IsEqualTo(0);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandStaleAgentTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandStaleAgentTests.cs
index ec26f39d5..b4f987f85 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandStaleAgentTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandStaleAgentTests.cs
@@ -1,6 +1,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Harness.Kiro;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -32,7 +33,7 @@ public async Task A_first_install_names_a_session_that_was_already_running() {
var env = Env(home.Path, pipe, found: [Running]);
SeedAgent(env, installed: false);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(pipe.ToString()).Contains("4821");
@@ -46,7 +47,7 @@ public async Task Re_installing_over_an_existing_agent_says_nothing() {
var env = Env(home.Path, pipe, found: [Running]);
SeedAgent(env, installed: true);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(pipe.ToString())
@@ -64,7 +65,7 @@ public async Task A_first_install_with_nothing_running_says_nothing() {
var env = Env(home.Path, pipe, found: []);
SeedAgent(env, installed: false);
- await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro"]);
+ await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]);
await Assert.That(pipe.ToString()).DoesNotContain("already running");
}
@@ -81,7 +82,7 @@ public async Task An_install_that_failed_claims_nothing_about_future_sessions()
// captured" would be true but useless, and blaming this install for it would be a lie.
Directory.CreateDirectory(env.Harnesses.Of().Paths.KcapAgentJson);
- var exit = await new PluginCommand(env).HandleAsync(["plugin", "install", "--kiro"]);
+ var exit = await new PluginCommand(env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]);
await Assert.That(exit).IsEqualTo(1);
await Assert.That(pipe.ToString()).DoesNotContain("4821");
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandVendorSkillsTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandVendorSkillsTests.cs
index 797c31d0e..8c4de639c 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandVendorSkillsTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/PluginCommandVendorSkillsTests.cs
@@ -29,7 +29,7 @@ public class PluginCommandVendorSkillsTests {
public async Task fresh_install_writes_the_shared_agent_skills(Vendor vendor) {
using var scope = new VendorScope(vendor);
- var exit = await new PluginCommand(scope.Env).HandleAsync(vendor.InstallArgs(scope.Home));
+ var exit = await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(vendor.InstallArgs(scope.Home));
await Assert.That(exit).IsEqualTo(0);
@@ -44,7 +44,7 @@ await Assert.That(AgentsSkillsInstaller.HasSkill(scope.Env.Agents.UserSkillsDir,
public async Task refresh_does_not_create_skills_for_a_vendor_never_installed(Vendor vendor) {
using var scope = new VendorScope(vendor);
- var exit = await new PluginCommand(scope.Env).HandleAsync(
+ var exit = await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
[.. vendor.InstallArgs(scope.Home), "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -60,11 +60,11 @@ public async Task refresh_does_not_resurrect_skills_the_user_removed(Vendor vend
using var scope = new VendorScope(vendor);
// Install for real, then remove the skills the way a user would.
- await new PluginCommand(scope.Env).HandleAsync(vendor.InstallArgs(scope.Home));
- await new PluginCommand(scope.Env).HandleAsync(["plugin", "remove", "--skills"]);
+ await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(vendor.InstallArgs(scope.Home));
+ await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "remove", "--skills"]);
await Assert.That(AgentsSkillsInstaller.IsInstalled(scope.Env.Agents.UserSkillsDir)).IsFalse();
- var exit = await new PluginCommand(scope.Env).HandleAsync(
+ var exit = await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
[.. vendor.InstallArgs(scope.Home), "--if-installed"]);
await Assert.That(exit).IsEqualTo(0);
@@ -79,7 +79,7 @@ await Assert.That(AgentsSkillsInstaller.IsInstalled(scope.Env.Agents.UserSkillsD
public async Task fresh_install_kiro_writes_its_own_skills_tree_not_the_shared_one() {
using var scope = new VendorScope(Vendor.Kiro);
- await new PluginCommand(scope.Env).HandleAsync(["plugin", "install", "--kiro"]);
+ await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--kiro"]);
// Kiro reads ~/.kiro/skills; writing the shared tree instead would be silently useless to it.
await Assert.That(AgentsSkillsInstaller.IsInstalled(scope.Env.Harnesses.Of().Paths.SkillsDir)).IsTrue();
@@ -90,7 +90,7 @@ public async Task fresh_install_kiro_writes_its_own_skills_tree_not_the_shared_o
public async Task fresh_install_antigravity_writes_its_own_skills_tree_not_the_shared_one() {
using var scope = new VendorScope(Vendor.Antigravity);
- await new PluginCommand(scope.Env).HandleAsync(["plugin", "install", "--antigravity"]);
+ await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["plugin", "install", "--antigravity"]);
await Assert.That(AgentsSkillsInstaller.IsInstalled(scope.Env.Harnesses.Of().Paths.SkillsDir)).IsTrue();
await Assert.That(Directory.Exists(scope.Env.Agents.UserSkillsDir)).IsFalse();
@@ -101,7 +101,7 @@ public async Task fresh_install_antigravity_writes_its_own_skills_tree_not_the_s
public async Task the_skip_flag_declines_the_shared_skills(Vendor vendor) {
using var scope = new VendorScope(vendor);
- var exit = await new PluginCommand(scope.Env).HandleAsync(
+ var exit = await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(
[.. vendor.InstallArgs(scope.Home), $"--skip-{vendor.Flag}-skills"]);
await Assert.That(exit).IsEqualTo(0);
@@ -115,14 +115,14 @@ await Assert.That(Directory.Exists(scope.Env.Agents.UserSkillsDir))
public async Task install_sweeps_legacy_codex_skills_even_when_the_tree_is_already_current() {
using var scope = new VendorScope(Vendor.Cursor);
- await new PluginCommand(scope.Env).HandleAsync(Vendor.Cursor.InstallArgs(scope.Home));
+ await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(Vendor.Cursor.InstallArgs(scope.Home));
await Assert.That(AgentsSkillsInstaller.IsCurrent(scope.Env.Agents.UserSkillsDir)).IsTrue();
// A pre-migration machine still carrying the old Codex-only copy.
var legacy = Path.Combine(scope.Env.Harnesses.Of().Paths.SkillsDir, "kcap-recap");
Directory.CreateDirectory(legacy);
- await new PluginCommand(scope.Env).HandleAsync(Vendor.Cursor.InstallArgs(scope.Home));
+ await new PluginCommand(scope.Env, workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(Vendor.Cursor.InstallArgs(scope.Home));
await Assert.That(Directory.Exists(legacy))
.IsFalse()
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs
index a3ccc6bf2..9530ba397 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ReplayChildContentCapabilityTests.cs
@@ -12,6 +12,7 @@
using Capacitor.Cli.Harness.Kiro;
using Capacitor.Cli.Harness.OpenCode;
using Capacitor.Cli.Harness.Pi;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -95,13 +96,13 @@ IImportSource MakeSource(string vendor) {
var scratch = tmp.PathTo("capability-probe");
return vendor switch {
- "claude" => new ClaudeImportSource(Config.Root, scratch),
- "codex" => new CodexImportSource(Config.Root, scratch),
- "copilot" => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths),
- "cursor" => new CursorImportSource(Config.Root, scratch, scratch),
+ "claude" => new ClaudeImportSource(Config.Root, scratch, router: new GitProviderRouter()),
+ "codex" => new CodexImportSource(Config.Root, scratch, router: new GitProviderRouter()),
+ "copilot" => new CopilotImportSource(Config.Root, CopilotHarness.FromEnvironment(Home).Paths, router: new GitProviderRouter()),
+ "cursor" => new CursorImportSource(Config.Root, scratch, scratch, router: new GitProviderRouter()),
"gemini" => new GeminiImportSource(scratch),
- "kiro" => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir),
- "pi" => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir),
+ "kiro" => new KiroImportSource(Config.Root, KiroHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()),
+ "pi" => new PiImportSource(Config.Root, PiHarness.FromEnvironment(Home).Paths.SessionsDir, router: new GitProviderRouter()),
"opencode" => new OpenCodeImportSource(Path.Combine(scratch, "db"), Path.Combine(scratch, "ledger")),
"antigravity" => new AntigravityImportSource(new(new(scratch), "")),
_ => throw new ArgumentOutOfRangeException(nameof(vendor), vendor, "unclassified import source"),
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs
index 123390ed1..9e9f0ec6f 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ReviewerVendorFallbackTests.cs
@@ -4,6 +4,8 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -20,7 +22,7 @@ public class ReviewerVendorFallbackTests {
// Resolutions.None: these tests exercise routing, not profile selection.
McpFlowsServer Server() =>
new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root),
- new FixedCapacitorHttpClient(), NoTelemetry.Startup);
+ new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
// The wire shape TryParseCodedError accepts: a JSON object with a non-empty string "error"
// plus a string "message" — the CLI-side reading of the server's FlowReviewerResultError.
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs
index 4779022e3..1ce3c7ae3 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupChosenServerTests.cs
@@ -4,6 +4,7 @@
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core.Http;
using Microsoft.Extensions.DependencyInjection;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -28,7 +29,7 @@ SetupCommand Started(ProfileContext startup) {
new AuthProviderDiscovery(factory), NoTelemetry.Facade, AuthEndpoints.Defaults,
new FakeFacadeFactory(_ => throw new InvalidOperationException("no façade in these tests")),
FakeImportRunner.Succeeding(),
- new ChosenServerHttp(Config.Root, startup, ProfileOverrides.None, MachineAuth.None));
+ new ChosenServerHttp(Config.Root, startup, ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
}
/// A first run: nothing resolved a server before the command started, which is the case that
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs
index dde88277f..5dc556f30 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs
@@ -8,6 +8,7 @@
using WireMock.ResponseBuilders;
using WireMock.Server;
using Capacitor.Cli.Core.Harness;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -28,12 +29,12 @@ public class SetupCommandTests {
[TempConfigRoot] public required TempConfigRoot Config { get; init; }
/// The command under test, with the import runner each test is pinning.
- SetupCommand Command(ISetupImportRunner imports) =>
+ SetupCommand Command(ISetupImportRunner imports, string workdir) =>
new(Config.Root, Resolutions.None(Config.Root),
AuthFixtures.NewTokenStore(Config.Root), new RecordingBrowser(), Home, TestHarnesses.Under(Home),
new AgentsPaths(Home), new FixedCapacitorHttpClient(), Provisioning, Discovery,
NoTelemetry.Facade, AuthEndpoints.Defaults, RealFacades(), imports,
- new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None));
+ new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter(), workdir: new WorkingDirectory(workdir));
/// The real façade: these tests drive the import and argv legs, not a substituted login.
IOnboardingFacadeFactory RealFacades() =>
@@ -873,7 +874,7 @@ public async Task RunImportStepAsync_RunDecision_InvokesRunnerWithPinnedArgs() {
var runner = FakeImportRunner.Succeeding();
var passed = Resolutions.At("https://example.test", Config.Root);
- await Command(runner).RunImportStepAsync(
+ await Command(runner, Config.Directory).RunImportStepAsync(
currentRepo: ("acme", "widgets"),
authSatisfied: true,
skipImport: false,
@@ -897,7 +898,7 @@ await Command(runner).RunImportStepAsync(
public async Task RunImportStepAsync_InteractiveAccept_InvokesRunner() {
var runner = FakeImportRunner.Succeeding();
- await Command(runner).RunImportStepAsync(
+ await Command(runner, Config.Directory).RunImportStepAsync(
currentRepo: ("acme", "widgets"),
authSatisfied: true,
skipImport: false,
@@ -915,7 +916,7 @@ public async Task RunImportStepAsync_RunnerReturnsNonZero_DoesNotThrowAndComplet
// Completing without an unhandled exception is the assertion: a non-zero exit
// code must be swallowed (warned about, not propagated) so setup still finishes.
- await Command(runner).RunImportStepAsync(
+ await Command(runner, Config.Directory).RunImportStepAsync(
currentRepo: ("acme", "widgets"),
authSatisfied: true,
skipImport: false,
@@ -933,7 +934,7 @@ public async Task RunImportStepAsync_RunnerThrows_DoesNotPropagateAndCompletes()
// Completing without the InvalidOperationException escaping is the assertion —
// import is best-effort and must never fail setup.
- await Command(runner).RunImportStepAsync(
+ await Command(runner, Config.Directory).RunImportStepAsync(
currentRepo: ("acme", "widgets"),
authSatisfied: true,
skipImport: false,
@@ -949,7 +950,7 @@ await Command(runner).RunImportStepAsync(
public async Task RunImportStepAsync_NoCurrentRepo_SkipsWithoutInvokingRunnerOrPrompting() {
var runner = FakeImportRunner.Throwing(new InvalidOperationException("must not run import"));
- await Command(runner).RunImportStepAsync(
+ await Command(runner, Config.Directory).RunImportStepAsync(
currentRepo: null,
authSatisfied: true,
skipImport: false,
@@ -965,7 +966,7 @@ await Command(runner).RunImportStepAsync(
public async Task RunImportStepAsync_SkipImportFlag_SkipsWithoutInvokingRunner() {
var runner = FakeImportRunner.Throwing(new InvalidOperationException("must not run import"));
- await Command(runner).RunImportStepAsync(
+ await Command(runner, Config.Directory).RunImportStepAsync(
currentRepo: ("acme", "widgets"),
authSatisfied: true,
skipImport: true,
@@ -982,16 +983,15 @@ await Command(runner).RunImportStepAsync(
// server, with only the final import call intercepted through the injected runner.
//
// Every test here:
- // • runs from a throwaway git repo (real `git init` + `remote add origin`) so repository
- // detection resolves an owner/repo — HandleAsync reads Environment.CurrentDirectory itself,
- // so the process cwd has to move.
+ // • names a throwaway git repo (real `git init` + `remote add origin`) as the command's
+ // working directory, so repository detection resolves an owner/repo.
// • passes every --skip-*-hooks/-mcp/-instructions/-skills flag, so no coding-agent install
// runs against the injected home.
// • uses auth provider "None" (a WireMock /auth/config stub): with any other provider the
// --server-url path has no way to no-prompt past the login.
//
- // The working directory they move, the environment they probe and the /auth/config cache they
- // stub are all process-global, so no cohort of key-holders can exclude the readers: bare.
+ // The environment they probe and the /auth/config cache they stub are both process-global, so no
+ // cohort of key-holders can exclude the readers: bare.
static string[] SkipAllAgentInstallFlags => [
"--skip-claude-hooks", "--skip-codex-hooks", "--skip-codex-network-access",
@@ -1024,7 +1024,7 @@ public async Task HandleAsync_NoPromptWithServerUrl_AutoImportsWithPinnedInvocat
var args = BuildArgs("--server-url", server.Url!, "--no-prompt", "--default-visibility", "org_public");
- var exit = await Command(runner).HandleAsync(args);
+ var exit = await Command(runner, fixture.RepoDir).HandleAsync(args);
var captured = runner.Captured;
@@ -1056,7 +1056,7 @@ public async Task HandleAsync_SkipImportFlag_SuppressesAutoImport() {
var args = BuildArgs("--server-url", server.Url!, "--no-prompt", "--skip-import");
- var exit = await Command(runner).HandleAsync(args);
+ var exit = await Command(runner, fixture.RepoDir).HandleAsync(args);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(runner.Calls).IsEqualTo(0);
@@ -1077,7 +1077,7 @@ public async Task HandleAsync_SchemeLessServerUrl_ReachesImportRunnerNormalizedW
var args = BuildArgs("--server-url", schemeLessServerUrl, "--no-prompt");
- var exit = await Command(runner).HandleAsync(args);
+ var exit = await Command(runner, fixture.RepoDir).HandleAsync(args);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(runner.Captured).IsNotNull();
@@ -1104,7 +1104,7 @@ public async Task HandleAsync_ConflictingKcapUrlAndProfileEnvVars_DoesNotHijackS
var args = BuildArgs("--server-url", server.Url!, "--no-prompt");
- var exit = await Command(runner).HandleAsync(args);
+ var exit = await Command(runner, fixture.RepoDir).HandleAsync(args);
var captured = runner.Captured;
@@ -1122,21 +1122,15 @@ public async Task HandleAsync_ConflictingKcapUrlAndProfileEnvVars_DoesNotHijackS
///
sealed class HandleAsyncE2EFixture : IAsyncDisposable {
readonly GitRepo _repo;
- readonly string _originalCwd;
public string RepoDir => _repo.Path;
- HandleAsyncE2EFixture(GitRepo repo, string originalCwd) {
- _repo = repo;
- _originalCwd = originalCwd;
- }
+ HandleAsyncE2EFixture(GitRepo repo) => _repo = repo;
public static async Task CreateAsync(string owner, string repo, ConfigRoot configRoot) {
var repoDir = GitRepo.Create();
repoDir.AddRemote($"https://github.com/{owner}/{repo}.git");
- var originalCwd = Environment.CurrentDirectory;
-
var configPath = AppConfig.GetConfigPath(configRoot);
if (File.Exists(configPath)) File.Delete(configPath);
@@ -1146,14 +1140,10 @@ public static async Task CreateAsync(string owner, string
var legacyTokens = configRoot.Path("tokens.json");
if (File.Exists(legacyTokens)) File.Delete(legacyTokens);
- Environment.CurrentDirectory = repoDir.Path;
-
- return new HandleAsyncE2EFixture(repoDir, originalCwd);
+ return new HandleAsyncE2EFixture(repoDir);
}
public ValueTask DisposeAsync() {
- Environment.CurrentDirectory = _originalCwd;
-
_repo.Dispose();
return ValueTask.CompletedTask;
@@ -1290,7 +1280,7 @@ public async Task WorkspaceGuard_is_absent_when_no_workspace_was_asked_for() {
public async Task HandleAsync_rejects_half_a_pair_before_doing_anything() {
using var capture = ConsoleOutput.StartErrorCapture();
- var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import"))).HandleAsync(["setup", "--org", "Acme"]);
+ var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import")), Config.Directory).HandleAsync(["setup", "--org", "Acme"]);
await Assert.That(exit).IsEqualTo(1);
await Assert.That(capture.GetCapturedError()).Contains("--slug");
@@ -1301,7 +1291,7 @@ public async Task HandleAsync_rejects_half_a_pair_before_doing_anything() {
public async Task HandleAsync_rejects_creating_and_pointing_at_a_server_at_once() {
using var capture = ConsoleOutput.StartErrorCapture();
- var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import"))).HandleAsync(
+ var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import")), Config.Directory).HandleAsync(
["setup", "--org", "Acme", "--slug", "acme", "--server-url", "https://other.kcap.ai"]);
await Assert.That(exit).IsEqualTo(1);
@@ -1313,7 +1303,7 @@ public async Task HandleAsync_rejects_creating_and_pointing_at_a_server_at_once(
public async Task HandleAsync_rejects_a_provider_that_cannot_create() {
using var capture = ConsoleOutput.StartErrorCapture();
- var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import"))).HandleAsync(["setup", "--org", "Acme", "--slug", "acme", "--github"]);
+ var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import")), Config.Directory).HandleAsync(["setup", "--org", "Acme", "--slug", "acme", "--github"]);
await Assert.That(exit).IsEqualTo(1);
await Assert.That(capture.GetCapturedError()).Contains("--github");
@@ -1324,7 +1314,7 @@ public async Task HandleAsync_rejects_a_provider_that_cannot_create() {
public async Task HandleAsync_still_requires_a_server_url_with_no_prompt_and_no_answers() {
using var capture = ConsoleOutput.StartErrorCapture();
- var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import"))).HandleAsync(["setup", "--no-prompt"]);
+ var exit = await Command(FakeImportRunner.Throwing(new InvalidOperationException("must not run import")), Config.Directory).HandleAsync(["setup", "--no-prompt"]);
await Assert.That(exit).IsEqualTo(1);
await Assert.That(capture.GetCapturedError()).Contains("--server-url is required");
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs
index d093f6fd5..699de09c0 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupFacadeParityTests.cs
@@ -8,6 +8,7 @@
using Spectre.Console;
using TUnit.Assertions.Enums;
using Profile = Capacitor.Cli.Core.Config.Profile;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -83,7 +84,7 @@ SetupCommand Command(CliTelemetry telemetry, IOnboardingFacadeFactory facades) =
Home, TestHarnesses.Under(Home), new AgentsPaths(Home), new FixedCapacitorHttpClient(),
Provisioning, Discovery, telemetry, AuthEndpoints.Defaults, facades,
FakeImportRunner.Throwing(new InvalidOperationException("these tests stop before the import step")),
- new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None));
+ new ChosenServerHttp(Config.Root, Resolutions.None(Config.Root), ProfileOverrides.None, MachineAuth.None), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
// ── Step 1: RunDiscoveryAsync (GitHub) ──────────────────────────────────
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupImportLaneTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupImportLaneTests.cs
index 1a0f49798..ab1db6bef 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupImportLaneTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupImportLaneTests.cs
@@ -3,6 +3,7 @@
using Capacitor.Cli.Core.FirstRun;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Harness.Claude;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -105,7 +106,7 @@ static FirstRunImportAnswer Answer(
SetupImportLane Lane(Func> runner) =>
new(Config.Root, Resolutions.None(Config.Root), Home, new FixedCapacitorHttpClient(),
- TestHarnesses.Under(Home), runner);
+ TestHarnesses.Under(Home), new GitProviderRouter(), runner);
/// A run that reported its Done grid with nothing failed.
static Task Clean() =>
@@ -251,10 +252,10 @@ public async Task Discovery_resolves_its_windows_against_the_instant_it_is_given
ImportCommand.ImportDiscoveryResult? found = null;
await new ImportCommand(Config.Root, Resolutions.None(Config.Root), Home,
- TestHarnesses.Under(Home), new FixedCapacitorHttpClient()).HandleImport(
+ TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), router: new GitProviderRouter()).HandleImport(
filterCwd: null,
minLines: 1,
- sources: [new ClaudeImportSource(Config.Root, projects)],
+ sources: [new ClaudeImportSource(Config.Root, projects, router: new GitProviderRouter())],
discoverOnly: true,
discoverJson: true,
windowsAsOf: asOf,
@@ -275,7 +276,7 @@ await Assert.That(await ThirtyDayCountAsOf(new DateTimeOffset(2026, 4, 16, 0, 0,
[Test]
public async Task Every_harness_has_a_source_when_nothing_filters_them() {
- var built = SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home));
+ var built = SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), router: new GitProviderRouter());
await Assert.That(built.Select(b => b.Vendor))
.IsEquivalentTo(HarnessRegistry.Identities.Select(h => h.Id));
@@ -285,7 +286,7 @@ await Assert.That(built.Select(b => b.Vendor))
public async Task Only_the_named_vendors_sources_are_built() {
// The filter is applied to what gets scanned, which is what makes a reported figure already
// scoped rather than needing subtraction afterwards.
- var built = SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), [HarnessId.Claude, HarnessId.Codex]);
+ var built = SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), new GitProviderRouter(), [HarnessId.Claude, HarnessId.Codex]);
await Assert.That(built.Select(s => s.Vendor)).IsEquivalentTo([HarnessId.Claude, HarnessId.Codex]);
}
@@ -294,7 +295,7 @@ public async Task Only_the_named_vendors_sources_are_built() {
public async Task An_empty_vendor_list_builds_nothing_rather_than_everything() {
// "Scan nothing" is a real answer — every agent on the machine was left unrecorded — and
// collapsing it to "no filter" would import exactly what the user declined.
- await Assert.That(SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), [])).IsEmpty();
+ await Assert.That(SetupCommand.BuildImportSources(Config.Root, TestHarnesses.Under(Home), new GitProviderRouter(), [])).IsEmpty();
}
// ---- What the run reports back to the flow.
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ShutdownTranscriptSpoolTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ShutdownTranscriptSpoolTests.cs
index cffcc7af0..88ec26bfa 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ShutdownTranscriptSpoolTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ShutdownTranscriptSpoolTests.cs
@@ -1,6 +1,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness.Cursor;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -13,7 +14,7 @@ namespace Capacitor.Cli.Tests.Unit.Commands;
public class ShutdownTranscriptSpoolTests {
[TempHome] public required TempHome Home { get; init; }
- WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()));
+ WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter());
CursorMarkers Markers => new(Config.Root);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SqliteNativeResolverTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SqliteNativeResolverTests.cs
index a1db93f51..afe8ebd59 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/SqliteNativeResolverTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/SqliteNativeResolverTests.cs
@@ -57,9 +57,8 @@ public async Task downloads_from_mirror_verifies_caches_and_loads() {
await Assert.That(IsLoadableSqlite(path)).IsTrue()
.Because("the cached native must be a real, loadable SQLite engine");
- // Second call is cache-only — works even after the mirror disappears.
- Directory.Delete(mirror.Path, true);
- var again = SqliteNativeResolver.EnsureNativeLibrary(rid, mirror.Path, cache.Path, "0.0.0");
+ // Second call is cache-only: a mirror path that does not exist cannot be the source.
+ var again = SqliteNativeResolver.EnsureNativeLibrary(rid, mirror.PathTo("absent"), cache.Path, "0.0.0");
await Assert.That(again).IsEqualTo(path);
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs
index 676d2b327..a9d78567f 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/StatusWaitArgumentTests.cs
@@ -3,6 +3,8 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -22,7 +24,7 @@ public class StatusWaitArgumentTests {
// Resolutions.None: these tests exercise routing, not profile selection.
McpFlowsServer Server() =>
new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root),
- new FixedCapacitorHttpClient(), NoTelemetry.Startup);
+ new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(3);
static readonly TimeSpan PollCap = TimeSpan.FromMinutes(8);
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs
index 116dcc739..d992de603 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/ToolCallBudgetTests.cs
@@ -1,6 +1,8 @@
using System.Net;
using System.Text.Json.Nodes;
using Capacitor.Cli.Commands;
+using Capacitor.Cli.PrDetection;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -22,7 +24,7 @@ public class ToolCallBudgetTests {
// Resolutions.None: these tests exercise routing, not profile selection.
McpFlowsServer Server() =>
new(Config.Root, Resolutions.None(Config.Root), AuthFixtures.NewTokenStore(Config.Root),
- new FixedCapacitorHttpClient(), NoTelemetry.Startup);
+ new FixedCapacitorHttpClient(), NoTelemetry.Startup, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
static JsonObject StartArguments() => new() {
["kind"] = "code-review",
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/UninstallCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/UninstallCommandTests.cs
index 687eb4fc4..d89a51b33 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/UninstallCommandTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/UninstallCommandTests.cs
@@ -12,9 +12,6 @@
namespace Capacitor.Cli.Tests.Unit.Commands;
-// These move the working directory so uninstall finds a project git root, and one captures Console.
-// Both are process-global, so every relative-path resolver and child-spawner is a reader: bare.
-[NotInParallel]
public class UninstallCommandTests {
// Uninstall runs `daemon stop --yes`, which enumerates this directory and kills the PIDs it
// finds; a shared one holding the test runner's own PID makes it kill its own tree.
@@ -103,7 +100,7 @@ await File.WriteAllTextAsync(geminiSettings, """
// Seed config dir with a real file so we can verify deletion.
await File.WriteAllTextAsync(Path.Combine(fixture.ConfigDir, "profiles.json"), "{}");
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes"]);
await Assert.That(exit).IsEqualTo(0);
// Claude: kcap entries gone, user entries preserved, marker removed.
@@ -162,7 +159,7 @@ public async Task User_level_uninstall_removes_pi_extension() {
await File.WriteAllTextAsync(markerPi, CapacitorVersion.Current());
await File.WriteAllTextAsync(userExt, "export default function(pi){}");
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(kcapTs)).IsFalse();
@@ -191,7 +188,7 @@ await File.WriteAllTextAsync(kcapAgent, """
await File.WriteAllTextAsync(marker, CapacitorVersion.Current());
await File.WriteAllTextAsync(userAgent, """{"name":"my-agent"}""");
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(kcapAgent)).IsFalse();
@@ -222,7 +219,7 @@ await File.WriteAllTextAsync(codexHooks, """
}
""");
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsEqualTo(0);
var codexRoot = JsonNode.Parse(await File.ReadAllTextAsync(codexHooks))!.AsObject();
@@ -254,7 +251,7 @@ await File.WriteAllTextAsync(cursorHooks, """
}
""");
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsEqualTo(0);
var cursorRoot = JsonNode.Parse(await File.ReadAllTextAsync(cursorHooks))!.AsObject();
@@ -289,7 +286,7 @@ await File.WriteAllTextAsync(claudeSettings, """
}
""");
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(claudeSettings))!.AsObject();
@@ -320,7 +317,7 @@ await File.WriteAllTextAsync(claudeSettings, """
var sentinel = Path.Combine(fixture.ConfigDir, "profiles.json");
await File.WriteAllTextAsync(sentinel, """{"sentinel":"keep"}""");
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsEqualTo(0);
var root = JsonNode.Parse(await File.ReadAllTextAsync(claudeSettings))!.AsObject();
@@ -361,44 +358,31 @@ await File.WriteAllTextAsync(projectCodex, """
}
""");
- var originalCwd = Environment.CurrentDirectory;
- try {
- Environment.CurrentDirectory = tmp.Path;
-
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--project", "--keep-config"]);
- await Assert.That(exit).IsEqualTo(0);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(tmp.Path)).HandleAsync(["uninstall", "--yes", "--project", "--keep-config"]);
+ await Assert.That(exit).IsEqualTo(0);
- var claudeRoot = JsonNode.Parse(await File.ReadAllTextAsync(projectClaude))!.AsObject();
- await Assert.That(claudeRoot["userLocal"]!.GetValue()).IsEqualTo("keep");
- await Assert.That(claudeRoot["enabledPlugins"]!["kcap@kcap"]).IsNull();
+ var claudeRoot = JsonNode.Parse(await File.ReadAllTextAsync(projectClaude))!.AsObject();
+ await Assert.That(claudeRoot["userLocal"]!.GetValue()).IsEqualTo("keep");
+ await Assert.That(claudeRoot["enabledPlugins"]!["kcap@kcap"]).IsNull();
- var codexRoot = JsonNode.Parse(await File.ReadAllTextAsync(projectCodex))!.AsObject();
- var sessionStart = codexRoot["hooks"]!["SessionStart"]!.AsArray();
- await Assert.That(sessionStart.Count).IsEqualTo(1);
- await Assert.That(sessionStart[0]!["hooks"]![0]!["command"]!.GetValue()).IsEqualTo("user-script");
- } finally {
- Environment.CurrentDirectory = originalCwd;
- }
+ var codexRoot = JsonNode.Parse(await File.ReadAllTextAsync(projectCodex))!.AsObject();
+ var sessionStart = codexRoot["hooks"]!["SessionStart"]!.AsArray();
+ await Assert.That(sessionStart.Count).IsEqualTo(1);
+ await Assert.That(sessionStart[0]!["hooks"]![0]!["command"]!.GetValue()).IsEqualTo("user-script");
}
- [Test]
+ // Bare: the error goes to the process-global Console, whose readers carry no key.
+ [Test, NotInParallel]
public async Task Project_flag_errors_when_not_inside_git_tree() {
await using var fixture = await Fixture.CreateAsync();
// A scratch dir with NO .git anywhere up the tree.
using var tmp = new TempDir();
- var originalCwd = Environment.CurrentDirectory;
using var capture = ConsoleOutput.StartErrorCapture();
- try {
- Environment.CurrentDirectory = tmp.Path;
-
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--project"]);
- await Assert.That(exit).IsEqualTo(1);
- await Assert.That(capture.GetCapturedError()).Contains("--project requires a git working tree");
- } finally {
- Environment.CurrentDirectory = originalCwd;
- }
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(tmp.Path)).HandleAsync(["uninstall", "--yes", "--project"]);
+ await Assert.That(exit).IsEqualTo(1);
+ await Assert.That(capture.GetCapturedError()).Contains("--project requires a git working tree");
}
[Test]
@@ -432,7 +416,7 @@ await File.WriteAllTextAsync(
Path.Combine(cursorDir, CursorHooksInstaller.MarkerFileName),
CapacitorVersion.Current());
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(File.Exists(Path.Combine(claudeDir, ClaudePluginInstaller.MarkerFileName))).IsFalse();
@@ -461,7 +445,7 @@ public async Task User_level_uninstall_purges_cursor_mcp_marker_even_when_json_h
marker.Record(mcpPath, ["kcap-review"]); // simulates a marker surviving a manual JSON edit
await Assert.That(marker.Owned(mcpPath).ToArray()).IsNotEmpty();
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), home, TestHarnesses.Under(home), TestBinaries.None, new AgentsPaths(home), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), home, TestHarnesses.Under(home), TestBinaries.None, new AgentsPaths(home), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(new McpMarker("cursor", home).Owned(mcpPath).ToArray()).IsEmpty();
@@ -489,7 +473,7 @@ public async Task User_level_uninstall_keeps_cursor_mcp_marker_when_unregister_f
marker.Record(mcpPath, ["kcap-review"]);
await Assert.That(marker.Owned(mcpPath).ToArray()).IsNotEmpty();
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), home, TestHarnesses.Under(home), TestBinaries.None, new AgentsPaths(home), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), home, TestHarnesses.Under(home), TestBinaries.None, new AgentsPaths(home), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsNotEqualTo(0); // the failed cursor MCP unregister propagates
// Marker retained → a retry after the user fixes the file can still find + remove the kcap entries.
@@ -523,7 +507,7 @@ public async Task Sweep_removes_kcap_prefixed_skill_folders_not_in_current_sourc
var legacyRetired = Path.Combine(legacyDir, "kcap-also-retired");
Directory.CreateDirectory(legacyRetired);
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes", "--keep-config"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes", "--keep-config"]);
await Assert.That(exit).IsEqualTo(0);
await Assert.That(Directory.Exists(currentSkill)).IsFalse();
@@ -561,7 +545,7 @@ await File.WriteAllTextAsync(settingsPath, """
await File.WriteAllTextAsync(sentinel, """{"sentinel":"survives-partial-failure"}""");
try {
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes"]);
await Assert.That(exit).IsEqualTo(1);
await Assert.That(Directory.Exists(fixture.ConfigDir)).IsTrue();
@@ -590,7 +574,7 @@ await File.WriteAllTextAsync(hooksPath, """
File.SetUnixFileMode(hooksPath, UnixFileMode.UserRead | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
try {
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes"]);
await Assert.That(exit).IsEqualTo(1);
// Failure path skips the config-dir delete so the user can re-run.
@@ -621,7 +605,7 @@ public async Task Sweep_failure_propagates_to_exit_code() {
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
try {
- var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient())).HandleAsync(["uninstall", "--yes"]);
+ var exit = await new UninstallCommand(Daemons.Store, fixture.Root, Resolutions.None(fixture.Root), fixture.UserHome, TestHarnesses.Under(fixture.UserHome), TestBinaries.None, new AgentsPaths(fixture.UserHome), TestWatchers.For(fixture.Root, Resolutions.None(fixture.Root), new FixedCapacitorHttpClient()), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleAsync(["uninstall", "--yes"]);
await Assert.That(exit).IsEqualTo(1);
await Assert.That(Directory.Exists(fixture.ConfigDir)).IsTrue();
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs
index 876ffbb2c..557103f78 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/UnusableUrlGuardTests.cs
@@ -2,6 +2,7 @@
using Capacitor.Cli.Commands.Harness;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Config;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -204,7 +205,7 @@ public async Task Suppresses_a_disabled_session_given_a_dashed_payload_id() {
// normalization, so passing the raw payload id straight through would miss it entirely.
var body = $$"""{"session_id":"{{dashed}}","hook_event_name":"SessionStart"}""";
- await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).ShouldSuppressCaptureAsync(
+ await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).ShouldSuppressCaptureAsync(
dashless, body, "session-start", activeProfile: null, _clock.Budget(Ceiling))).IsTrue();
}
@@ -215,7 +216,7 @@ public async Task Session_end_suppression_also_clears_the_marker() {
var body = $$"""{"session_id":"{{sid}}","hook_event_name":"SessionEnd"}""";
- await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).ShouldSuppressCaptureAsync(
+ await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).ShouldSuppressCaptureAsync(
sid, body, "session-end", activeProfile: null, _clock.Budget(Ceiling))).IsTrue();
// Collapsing the gate into a plain boolean would have dropped this cleanup.
@@ -228,7 +229,7 @@ public async Task Does_not_suppress_an_ordinary_session() {
var sid = Guid.NewGuid().ToString("N");
var body = $$"""{"session_id":"{{sid}}","hook_event_name":"SessionStart"}""";
- await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).ShouldSuppressCaptureAsync(
+ await Assert.That(await new ClaudeHookCommand(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).ShouldSuppressCaptureAsync(
sid, body, "session-start", activeProfile: null, _clock.Budget(Ceiling))).IsFalse();
}
@@ -237,7 +238,7 @@ public async Task Does_not_suppress_an_ordinary_session() {
public async Task Cursor_never_builds_a_client_for_an_unusable_url() {
var entered = false;
- var exit = await new CursorHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient())).HandleWithDeps(
+ var exit = await new CursorHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps(
new StringReader("""{"hook_event_name":"sessionStart","session_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"""),
_ => {
entered = true;
@@ -257,7 +258,7 @@ public async Task Cursor_never_builds_a_client_for_an_unusable_url() {
public async Task Claude_never_builds_a_client_for_an_unusable_url() {
var entered = false;
- var exit = await new ClaudeHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), SystemProcessStarter.Instance).HandleWithDeps(
+ var exit = await new ClaudeHookCommand(Config.Root, Bad, new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Bad, new FixedCapacitorHttpClient()), SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleWithDeps(
new HookSpool(_dir),
stdin: new StringReader($$"""{"hook_event_name":"SessionStart","session_id":"{{Sid}}"}"""),
clientFactory: () => {
diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/UseCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/UseCommandTests.cs
index c1b22a536..035c98b1a 100644
--- a/test/Capacitor.Cli.Tests.Unit/Commands/UseCommandTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Commands/UseCommandTests.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core.Config;
+using Capacitor.Cli.Core;
namespace Capacitor.Cli.Tests.Unit.Commands;
@@ -20,7 +21,7 @@ public async Task Use_InRepo_SetsProfileBinding() {
await File.WriteAllTextAsync(configPath,
JsonSerializer.Serialize(initial, ProfileConfigJsonContextIndented.Default.ProfileConfig));
- var result = await new UseCommand(Config.Root).SetProfile("contoso", repoPath: "/repos/my-project", global: false, save: false, savePath: null);
+ var result = await new UseCommand(Config.Root, workdir: new WorkingDirectory(AppContext.BaseDirectory)).SetProfile("contoso", repoPath: "/repos/my-project", global: false, save: false, savePath: null);
await Assert.That(result).IsEqualTo(0);
@@ -44,7 +45,7 @@ public async Task Use_Global_SetsActiveProfile() {
await File.WriteAllTextAsync(configPath,
JsonSerializer.Serialize(initial, ProfileConfigJsonContextIndented.Default.ProfileConfig));
- var result = await new UseCommand(Config.Root).SetProfile("contoso", repoPath: null, global: true, save: false, savePath: null);
+ var result = await new UseCommand(Config.Root, workdir: new WorkingDirectory(AppContext.BaseDirectory)).SetProfile("contoso", repoPath: null, global: true, save: false, savePath: null);
await Assert.That(result).IsEqualTo(0);
@@ -68,7 +69,7 @@ public async Task Use_Save_WritesRepoConfig() {
await File.WriteAllTextAsync(configPath,
JsonSerializer.Serialize(initial, ProfileConfigJsonContextIndented.Default.ProfileConfig));
- var result = await new UseCommand(Config.Root).SetProfile("contoso", repoPath: repoRoot, global: false, save: true, savePath: repoRoot);
+ var result = await new UseCommand(Config.Root, workdir: new WorkingDirectory(AppContext.BaseDirectory)).SetProfile("contoso", repoPath: repoRoot, global: false, save: true, savePath: repoRoot);
await Assert.That(result).IsEqualTo(0);
@@ -94,7 +95,7 @@ public async Task Use_UnknownProfile_ReturnsError() {
await File.WriteAllTextAsync(configPath,
JsonSerializer.Serialize(initial, ProfileConfigJsonContextIndented.Default.ProfileConfig));
- var result = await new UseCommand(Config.Root).SetProfile("nonexistent", repoPath: "/repos/x", global: false, save: false, savePath: null);
+ var result = await new UseCommand(Config.Root, workdir: new WorkingDirectory(AppContext.BaseDirectory)).SetProfile("nonexistent", repoPath: "/repos/x", global: false, save: false, savePath: null);
await Assert.That(result).IsEqualTo(1);
}
diff --git a/test/Capacitor.Cli.Tests.Unit/CwdRepositoryTests.cs b/test/Capacitor.Cli.Tests.Unit/CwdRepositoryTests.cs
index 700ed160d..7399e60ec 100644
--- a/test/Capacitor.Cli.Tests.Unit/CwdRepositoryTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/CwdRepositoryTests.cs
@@ -30,7 +30,7 @@ static CommandRunner RecordingRunner(List commands, string? origin = "gi
public async Task Construction_spawns_nothing() {
var commands = new List();
- _ = new CwdRepository(Config.Root, Cwd.Path, RecordingRunner(commands));
+ _ = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), RecordingRunner(commands));
await Assert.That(commands).IsEmpty();
}
@@ -38,7 +38,7 @@ public async Task Construction_spawns_nothing() {
[Test]
public async Task Hash_comes_from_origin_without_a_provider_probe() {
var commands = new List();
- var repo = new CwdRepository(Config.Root, Cwd.Path, RecordingRunner(commands));
+ var repo = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), RecordingRunner(commands));
var hash = await repo.GetHashAsync();
@@ -50,7 +50,7 @@ public async Task Hash_comes_from_origin_without_a_provider_probe() {
[Test]
public async Task Resolution_runs_once_per_instance() {
var commands = new List();
- var repo = new CwdRepository(Config.Root, Cwd.Path, RecordingRunner(commands));
+ var repo = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), RecordingRunner(commands));
await repo.GetHashAsync();
var spawned = commands.Count;
@@ -64,7 +64,7 @@ public async Task Resolution_runs_once_per_instance() {
[Test]
public async Task Outside_a_checkout_there_is_no_repository_and_no_hash() {
- var repo = new CwdRepository(Config.Root, Cwd.Path, (_, _, _, _) => Task.FromResult(null));
+ var repo = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), (_, _, _, _) => Task.FromResult(null));
await Assert.That(await repo.GetAsync()).IsNull();
await Assert.That(await repo.GetHashAsync()).IsNull();
@@ -73,7 +73,7 @@ public async Task Outside_a_checkout_there_is_no_repository_and_no_hash() {
[Test]
public async Task A_checkout_without_an_origin_remote_has_no_hash() {
var commands = new List();
- var repo = new CwdRepository(Config.Root, Cwd.Path, RecordingRunner(commands, origin: null));
+ var repo = new CwdRepository(Config.Root, Cwd.Path, new GitProviderRouter(), RecordingRunner(commands, origin: null));
await Assert.That(await repo.GetAsync()).IsNotNull();
await Assert.That(await repo.GetHashAsync()).IsNull();
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs
index 1b646efdf..4bbd25ae4 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Antigravity/AntigravitySessionStartMemoryTests.cs
@@ -4,6 +4,7 @@
using Capacitor.Cli.Tests.Unit.SessionStartMemory;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Antigravity;
@@ -30,7 +31,7 @@ public class AntigravitySessionStartMemoryTests {
// The server URL is the resolution's, so a test proving the url guard fires hands in the bad one
// here rather than as an argument.
AntigravityHookCommand Hook(string serverUrl = "https://example.test") =>
- new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()));
+ new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
[TempConfigRoot] public required TempConfigRoot Config { get; init; }
static string Write(string? fragment) {
@@ -150,7 +151,7 @@ public async Task A_store_that_cannot_be_constructed_resolves_to_null_rather_tha
[Test]
public async Task A_non_PreInvocation_event_writes_nothing_and_exits_zero() {
var sw = new StringWriter();
- var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient())).Handle(["--antigravity", "Stop"], new StringReader("{}"), sw);
+ var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["--antigravity", "Stop"], new StringReader("{}"), sw);
await Assert.That(code).IsEqualTo(0);
await Assert.That(sw.ToString()).IsEqualTo("");
@@ -159,7 +160,7 @@ public async Task A_non_PreInvocation_event_writes_nothing_and_exits_zero() {
[Test]
public async Task A_malformed_payload_writes_nothing_and_exits_zero() {
var sw = new StringWriter();
- var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient())).Handle(["--antigravity", "PreInvocation"], new StringReader("{not json"), sw);
+ var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["--antigravity", "PreInvocation"], new StringReader("{not json"), sw);
await Assert.That(code).IsEqualTo(0);
await Assert.That(sw.ToString()).IsEqualTo("");
@@ -235,7 +236,7 @@ public async Task HandleCore_consults_the_fallback_for_an_empty_workspacePaths_p
var consulted = false;
var sw = new StringWriter();
- var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient())).Handle(["--antigravity", "PreInvocation"],
+ var code = await new AntigravityHookCommand(Config.Root, Resolutions.At("https://example.test", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("https://example.test", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(["--antigravity", "PreInvocation"],
new StringReader($$"""
{"conversationId":"{{conversationId}}","transcriptPath":"/tmp/t.jsonl","workspacePaths":[]}
"""),
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs
index f4f9739cb..0ede1204b 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeHookExclusionGateTests.cs
@@ -3,6 +3,7 @@
using Capacitor.Cli.Commands.Harness;
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Claude;
@@ -25,7 +26,7 @@ ClaudeHookCommand Hook() =>
new(Config.Root, Resolutions.None(Config.Root), _clock, Home, TestHarnesses.Under(Home),
HostedAgent.Terminal, new FixedCapacitorHttpClient(),
TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()),
- SystemProcessStarter.Instance);
+ SystemProcessStarter.Instance, router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
// The gate reads the budget only for the repo probe, which these path-exclusion payloads never
// reach; what they vary is the profile, not the clock. Any live ceiling will do.
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeImportSourceTests.cs
index d15ee6cc9..aca6265b4 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeImportSourceTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeImportSourceTests.cs
@@ -3,6 +3,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Harness.Claude;
using Capacitor.Cli.Harness.Claude;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Claude;
@@ -13,33 +14,33 @@ public class ClaudeImportSourceTests {
[Test]
public async Task vendor_is_claude() {
- var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects);
+ var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter());
await Assert.That(src.Vendor).IsEqualTo(HarnessId.Claude);
}
[Test]
public async Task supports_title_generation() {
- var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects);
+ var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter());
await Assert.That(src.SupportsTitleGeneration).IsTrue();
}
[Test]
public async Task is_available_when_projects_dir_exists() {
using var tmp = new TempDir();
- var src = new ClaudeImportSource(Config.Root, tmp.Path);
+ var src = new ClaudeImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
await Assert.That(src.IsAvailable).IsTrue();
}
[Test]
public async Task is_unavailable_when_projects_dir_missing() {
using var missingDir = TempDir.WithPathTo("kcap-claude-source-missing", out var missing);
- var src = new ClaudeImportSource(Config.Root, missing);
+ var src = new ClaudeImportSource(Config.Root, missing, router: new GitProviderRouter());
await Assert.That(src.IsAvailable).IsFalse();
}
[Test]
public async Task import_session_async_throws_not_implemented() {
- var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects);
+ var src = new ClaudeImportSource(Config.Root, new ClaudePaths(Home, null).Projects, router: new GitProviderRouter());
var classification = new ImportCommand.SessionClassification {
SessionId = "abc",
FilePath = "/tmp/none",
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeMemoryIndexLiveCertTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeMemoryIndexLiveCertTests.cs
index 2fc462b34..72f417edb 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeMemoryIndexLiveCertTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeMemoryIndexLiveCertTests.cs
@@ -295,7 +295,7 @@ internal static string ExtractAssistantAnswer(string stdout) {
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
- WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory
+ WorkingDirectory = workingDirectory ?? Capacitor.Cli.Core.WorkingDirectory.FromProcess().Path
};
foreach (var arg in args) psi.ArgumentList.Add(arg);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportSourceTests.cs
index c570e5df2..44145a3d6 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportSourceTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportSourceTests.cs
@@ -3,6 +3,7 @@
using Capacitor.Cli.Core.Harness.Codex;
using Capacitor.Cli.Core;
using Capacitor.Cli.Harness.Codex;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Codex;
@@ -13,33 +14,33 @@ public class CodexImportSourceTests {
[Test]
public async Task vendor_is_codex() {
- var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions);
+ var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions, router: new GitProviderRouter());
await Assert.That(src.Vendor).IsEqualTo(HarnessId.Codex);
}
[Test]
public async Task supports_title_generation() {
- var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions);
+ var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions, router: new GitProviderRouter());
await Assert.That(src.SupportsTitleGeneration).IsTrue();
}
[Test]
public async Task is_available_when_sessions_dir_exists() {
using var tmp = new TempDir();
- var src = new CodexImportSource(Config.Root, tmp.Path);
+ var src = new CodexImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
await Assert.That(src.IsAvailable).IsTrue();
}
[Test]
public async Task is_unavailable_when_sessions_dir_missing() {
using var missingDir = TempDir.WithPathTo("kcap-codex-source-missing", out var missing);
- var src = new CodexImportSource(Config.Root, missing);
+ var src = new CodexImportSource(Config.Root, missing, router: new GitProviderRouter());
await Assert.That(src.IsAvailable).IsFalse();
}
[Test]
public async Task import_session_async_throws_not_implemented() {
- var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions);
+ var src = new CodexImportSource(Config.Root, CodexHarness.FromEnvironment(Home).Paths.Sessions, router: new GitProviderRouter());
var classification = new ImportCommand.SessionClassification {
SessionId = "abc",
FilePath = "/tmp/none",
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportTests.cs
index 5a0c84cfa..504a502a5 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexImportTests.cs
@@ -6,6 +6,7 @@
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Codex;
@@ -239,6 +240,7 @@ await File.WriteAllLinesAsync(path, [
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
@@ -278,6 +280,7 @@ await File.WriteAllLinesAsync(path, [
using var client = new HttpClient();
var result = await TranscriptFileClassification.ClassifyAsync(
+ new GitProviderRouter(),
Config.Root,
Home,
client,
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs
index e92521869..c87c3a300 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs
@@ -1,6 +1,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core.Harness.Codex;
using Capacitor.Cli.Harness.Codex;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Codex;
@@ -264,7 +265,7 @@ public async Task ImportDiscovery_ExcludesSubagentAndIndeterminate_KeepsTopLevel
Path.Combine(day, $"rollout-2026-08-10T17-24-19-{malformedDashed}.jsonl"),
"not json at all\n");
- var source = new CodexImportSource(Config.Root, tmp.Path);
+ var source = new CodexImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
var discovered = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
var ids = discovered.Select(d => d.SessionId).OrderBy(x => x, StringComparer.Ordinal).ToList();
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Copilot/CopilotImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Copilot/CopilotImportSourceTests.cs
index 5fb37527e..57b70800c 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Copilot/CopilotImportSourceTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Copilot/CopilotImportSourceTests.cs
@@ -4,6 +4,7 @@
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core.Harness.Copilot;
using Capacitor.Cli.Harness.Copilot;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Copilot;
@@ -34,7 +35,7 @@ public async Task discovery_skips_scaffolding_dirs_without_events_jsonl() {
await File.WriteAllTextAsync(
Path.Combine(paths.SessionStateDir, Sid2, "workspace.yaml"), $"id: {Sid2}\ncwd: /work/b\n");
- var source = new CopilotImportSource(Config.Root, paths);
+ var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter());
var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(sessions.Count).IsEqualTo(1);
@@ -52,7 +53,7 @@ public async Task discovery_reads_workspace_yaml_metadata() {
name: "Create a file hello.txt containing 'hello world'",
createdAt: "2026-06-10T20:23:25.556Z");
- var source = new CopilotImportSource(Config.Root, paths);
+ var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter());
var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(sessions.Count).IsEqualTo(1);
@@ -70,7 +71,7 @@ public async Task discovery_prefers_current_root_over_legacy_for_same_session()
WriteSession(paths.LegacySessionStateDir, Sid1, cwd: "/work/legacy");
WriteSession(paths.LegacySessionStateDir, Sid2, cwd: "/work/legacy-only");
- var source = new CopilotImportSource(Config.Root, paths);
+ var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter());
var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(sessions.Count).IsEqualTo(2);
@@ -87,7 +88,7 @@ public async Task session_filter_matches_dashless_and_dashed_input() {
WriteSession(paths.SessionStateDir, Sid1, cwd: "/work/a");
WriteSession(paths.SessionStateDir, Sid2, cwd: "/work/b");
- var source = new CopilotImportSource(Config.Root, paths);
+ var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter());
var byDashed = await source.DiscoverAsync(new DiscoveryFilters(null, Sid1, null, 0), CancellationToken.None);
await Assert.That(byDashed.Count).IsEqualTo(1);
@@ -105,7 +106,7 @@ public async Task cwd_filter_excludes_other_workspaces_and_sessions_without_cwd(
WriteSession(paths.SessionStateDir, Sid1, cwd: "/work/a");
WriteSession(paths.SessionStateDir, Sid2, cwd: null); // no workspace.yaml cwd
- var source = new CopilotImportSource(Config.Root, paths);
+ var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter());
var matched = await source.DiscoverAsync(new DiscoveryFilters("/work/a", null, null, 0), CancellationToken.None);
await Assert.That(matched.Count).IsEqualTo(1);
@@ -119,7 +120,7 @@ public async Task since_filter_gates_on_session_start() {
WriteSession(paths.SessionStateDir, Sid1, cwd: "/work/a", createdAt: "2026-06-01T10:00:00Z");
WriteSession(paths.SessionStateDir, Sid2, cwd: "/work/b", createdAt: "2026-06-09T10:00:00Z");
- var source = new CopilotImportSource(Config.Root, paths);
+ var source = new CopilotImportSource(Config.Root, paths, router: new GitProviderRouter());
var matched = await source.DiscoverAsync(
new DiscoveryFilters(null, null, new DateOnly(2026, 6, 5), 0), CancellationToken.None);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorGuardWiringTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorGuardWiringTests.cs
index d6f4aa71a..1e5f8edec 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorGuardWiringTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorGuardWiringTests.cs
@@ -3,6 +3,7 @@
using Capacitor.Cli.Core.Harness.Cursor;
using Capacitor.Cli.Harness.Cursor;
using Microsoft.AspNetCore.SignalR.Client;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
@@ -19,7 +20,7 @@ public class CursorGuardWiringTests {
[TempHome] public required TempHome Home { get; init; }
WatchCommand? _watch;
- WatchCommand Watch => _watch ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()));
+ WatchCommand Watch => _watch ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter());
CursorMarkers Markers => new(Config.Root);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorImportSourceTests.cs
index 4ec375e26..b9252cbd5 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorImportSourceTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorImportSourceTests.cs
@@ -7,6 +7,7 @@
using Capacitor.Cli.Core.Harness.Cursor;
using Capacitor.Cli.Harness.Cursor;
using TUnit.Core.Enums;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
@@ -29,14 +30,14 @@ public class CursorImportSourceTests {
[Test]
public async Task vendor_is_cursor() {
using var fx = new ProjectsDirFixture();
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
await Assert.That(src.Vendor).IsEqualTo(HarnessId.Cursor);
}
[Test]
public async Task does_not_support_title_generation() {
using var fx = new ProjectsDirFixture();
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
await Assert.That(src.SupportsTitleGeneration).IsFalse();
}
@@ -64,14 +65,14 @@ public async Task session_start_payload_carries_pr_fields_when_repo_has_a_pr() {
[Test]
public async Task is_available_when_projects_dir_exists() {
using var fx = new ProjectsDirFixture();
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
await Assert.That(src.IsAvailable).IsTrue();
}
[Test]
public async Task is_unavailable_when_projects_dir_missing() {
using var missingDir = TempDir.WithPathTo("kcap-cursor-missing", out var missing);
- var src = new CursorImportSource(Config.Root, missing, missing);
+ var src = new CursorImportSource(Config.Root, missing, missing, router: new GitProviderRouter());
await Assert.That(src.IsAvailable).IsFalse();
}
@@ -90,7 +91,7 @@ await Assert.That(CursorImportSource.EncodeWorkspacePath("/Users/me/dev/foo-bar"
[Test]
public async Task discover_returns_empty_when_projects_dir_missing() {
using var missingDir = TempDir.WithPathTo("kcap-cursor-missing", out var missing);
- var src = new CursorImportSource(Config.Root, missing, missing);
+ var src = new CursorImportSource(Config.Root, missing, missing, router: new GitProviderRouter());
var result = await src.DiscoverAsync(Filters(), CancellationToken.None);
await Assert.That(result.Count).IsEqualTo(0);
}
@@ -101,7 +102,7 @@ public async Task discover_walks_jsonl_files() {
fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"x\":1}\n");
fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":2}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var got = await src.DiscoverAsync(Filters(), CancellationToken.None);
await Assert.That(got.Count).IsEqualTo(2);
@@ -116,7 +117,7 @@ public async Task discover_resolves_cwd_via_workspace_storage_when_sanitized_mat
fx.AddWorkspaceJson("hash-aaa", "file:///Users/me/dev/foo");
fx.AddSession("Users-me-dev-foo", "33333333-3333-3333-3333-333333333333", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var got = await src.DiscoverAsync(Filters(), CancellationToken.None);
await Assert.That(got.Count).IsEqualTo(1);
@@ -128,7 +129,7 @@ public async Task discover_leaves_cwd_null_when_sanitized_not_in_workspace_stora
using var fx = new ProjectsDirFixture();
fx.AddSession("Users-someone-else-proj", "44444444-4444-4444-4444-444444444444", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var got = await src.DiscoverAsync(Filters(), CancellationToken.None);
await Assert.That(got.Count).IsEqualTo(1);
@@ -141,7 +142,7 @@ public async Task discover_applies_session_filter_dashless() {
fx.AddSession("Users-me-proj", "55555555-5555-5555-5555-555555555555", "{}\n");
fx.AddSession("Users-me-proj", "66666666-6666-6666-6666-666666666666", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
// Pass the dashed form; the filter must normalize to dashless before matching.
var got = await src.DiscoverAsync(Filters(filterSession: "55555555-5555-5555-5555-555555555555"), CancellationToken.None);
@@ -157,7 +158,7 @@ public async Task discover_applies_cwd_filter_against_resolved_workspace_folder(
fx.AddSession("Users-me-dev-match", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "{}\n");
fx.AddSession("Users-me-dev-other", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var got = await src.DiscoverAsync(Filters(filterCwd: "/Users/me/dev/match"), CancellationToken.None);
await Assert.That(got.Count).IsEqualTo(1);
@@ -173,7 +174,7 @@ public async Task classify_marks_new_when_server_has_no_state() {
"11111111-1111-1111-1111-111111111111",
"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"
);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(
getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound)
@@ -203,7 +204,7 @@ public async Task classify_keeps_file_path_empty_so_orchestrator_routes_to_Impor
using var fx = new ProjectsDirFixture();
fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{}\n{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -227,7 +228,7 @@ public async Task classify_marks_already_loaded_when_server_at_or_past_last_non_
"11111111-1111-1111-1111-111111111111",
"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"
);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
// Three non-blank lines at indexes 0,1,2 → last_line_number=2 means fully loaded.
using var handler = new StubHandler(
@@ -255,7 +256,7 @@ public async Task classify_marks_partial_with_resume_from_when_server_mid_file()
"11111111-1111-1111-1111-111111111111",
"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"
);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(
getResponse: _ => new HttpResponseMessage(HttpStatusCode.OK) {
@@ -278,7 +279,7 @@ await src.DiscoverAsync(Filters(), CancellationToken.None),
public async Task classify_marks_too_short_below_min_lines() {
using var fx = new ProjectsDirFixture();
fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -301,7 +302,7 @@ public async Task classify_returns_probe_error_when_watermark_returns_5xx() {
"11111111-1111-1111-1111-111111111111",
"{\"a\":1}\n{\"b\":2}\n"
);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
using var client = new HttpClient(handler);
@@ -330,7 +331,7 @@ public async Task classify_skips_a_quarantined_standalone_session() {
fx.AddSession("Users-me-proj", sessionIdWithDashes, "{\"a\":1}\n{\"b\":2}\n");
Markers.Quarantine(sessionId, "transcript rewrite detected");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -354,7 +355,7 @@ public async Task classify_does_not_skip_a_non_quarantined_session() {
// test's quarantine marker for that id (see classify_skips_a_quarantined_standalone_session).
fx.AddSession("Users-me-proj", Guid.NewGuid().ToString(), "{\"a\":1}\n{\"b\":2}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -417,7 +418,7 @@ public async Task import_session_posts_lifecycle_then_transcript_then_session_en
"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"
);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List<(string Path, string Body)>();
@@ -490,7 +491,7 @@ public async Task import_session_populates_started_at_and_ended_at_from_file_tim
File.SetCreationTimeUtc(jsonl, created);
File.SetLastWriteTimeUtc(jsonl, modified);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List<(string Path, string Body)>();
@@ -534,7 +535,7 @@ public async Task import_session_returns_failed_when_session_start_post_fails()
using var fx = new ProjectsDirFixture();
var jsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List();
@@ -575,7 +576,7 @@ public async Task import_session_returns_failed_when_session_end_post_fails() {
using var fx = new ProjectsDirFixture();
var jsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(
postCapture: (req, _) => req.RequestUri!.AbsolutePath == "/hooks/session-end/cursor"
@@ -616,7 +617,7 @@ public async Task import_session_emits_lifecycle_only_for_already_loaded_status(
"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"
);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List();
@@ -665,7 +666,7 @@ public async Task already_loaded_parent_reports_sent_child_content_when_attachin
var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n");
var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(
getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound), // child subsession watermark: nothing sent yet
@@ -709,7 +710,7 @@ public async Task already_loaded_parent_with_already_loaded_child_reports_no_sen
var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n");
var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(
// Subsession watermark already covers both child lines (0-indexed last_line_number=1).
@@ -766,7 +767,7 @@ public async Task already_loaded_parent_with_failing_child_watermark_probe_conse
var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n");
var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List();
@@ -822,7 +823,7 @@ public async Task already_loaded_parent_with_known_child_watermark_and_lines_bey
// ingested), so lines 1 and 2 are genuinely new.
var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(
getResponse: _ => new HttpResponseMessage(HttpStatusCode.OK) {
@@ -869,7 +870,7 @@ public async Task cursor_watermark_probe_retries_once_on_transient_500_then_succ
// are genuinely new.
var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var getCalls = 0;
var posted = new List<(string Path, string Body)>();
@@ -933,7 +934,7 @@ public async Task cursor_watermark_probe_falls_open_after_exactly_2_attempts_on_
var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n");
var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var getCalls = 0;
var posted = new List<(string Path, string Body)>();
@@ -992,7 +993,7 @@ public async Task cursor_watermark_probe_does_not_retry_a_404() {
var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n");
var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var getCalls = 0;
@@ -1039,7 +1040,7 @@ public async Task cursor_watermark_probe_does_not_retry_a_429() {
var parentJsonl = fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n");
var childJsonl = fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{\"x\":1}\n{\"y\":2}\n{\"z\":3}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var getCalls = 0;
@@ -1179,7 +1180,7 @@ public async Task import_session_attaches_repository_from_detected_workspace_rep
RemoteUrl = "git@github.com:kurrent-io/kcap-server.git",
}
)
- );
+ , router: new GitProviderRouter());
var posted = new List<(string Path, string Body)>();
using var handler = new StubHandler(
@@ -1224,7 +1225,7 @@ public async Task import_session_omits_repository_when_workspace_repo_undetected
fx.ProjectsDir,
fx.WorkspaceStorageDir,
repoDetector: _ => Task.FromResult(null)
- );
+ , router: new GitProviderRouter());
var posted = new List<(string Path, string Body)>();
using var handler = new StubHandler(
@@ -1269,7 +1270,7 @@ public async Task import_session_omits_repository_when_workspace_repo_undetected
"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":" + childUserText + "}]}}\n" +
"{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var getHandler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var getClient = new HttpClient(getHandler);
var discovered = await src.DiscoverAsync(Filters(), CancellationToken.None);
@@ -1407,7 +1408,7 @@ public async Task import_session_posts_nothing_when_quarantined_between_classify
Markers.Quarantine(sessionId, "transcript rewrite detected");
try {
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List();
using var handler = new StubHandler(
@@ -1445,7 +1446,7 @@ public async Task import_session_proceeds_normally_when_quarantine_identity_is_s
using var fx = new ProjectsDirFixture();
var jsonl = fx.AddSession(
"Users-me-proj", "11111111-1111-1111-1111-111111111111", "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var sessionId = "11111111111111111111111111111111";
var posted = new List();
@@ -1487,7 +1488,7 @@ public async Task import_session_aborts_transcript_but_still_closes_the_session_
var sessionIdWithDashes = Guid.NewGuid().ToString();
var sessionId = CursorImportSource.NormalizeCursorSessionId(sessionIdWithDashes);
var jsonl = fx.AddSession("Users-me-proj", sessionIdWithDashes, "{\"a\":1}\n{\"b\":2}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List();
using var handler = new StubHandler(postCapture: (req, _) => {
@@ -1537,7 +1538,7 @@ public async Task import_session_aborts_the_remaining_batch_when_quarantine_is_w
var sessionId = CursorImportSource.NormalizeCursorSessionId(sessionIdWithDashes);
var lines = string.Concat(Enumerable.Range(0, 150).Select(i => $$"""{"n":{{i}}}""" + "\n"));
var jsonl = fx.AddSession("Users-me-proj", sessionIdWithDashes, lines);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List();
var transcriptPosts = 0;
@@ -1597,7 +1598,7 @@ public async Task import_session_aborts_when_quarantine_is_written_during_the_on
var sessionId = CursorImportSource.NormalizeCursorSessionId(sessionIdWithDashes);
var lines = string.Concat(Enumerable.Range(0, 30).Select(i => $$"""{"n":{{i}}}""" + "\n"));
var jsonl = fx.AddSession("Users-me-proj", sessionIdWithDashes, lines);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List();
var transcriptPosts = 0;
@@ -1658,7 +1659,7 @@ public async Task import_session_closes_and_fails_when_a_child_transcript_quaran
var parentJsonl = fx.AddSession("Users-me-proj", parentIdWithDashes, "{\"a\":1}\n");
var childJsonl = fx.AddSession("Users-me-proj", childIdWithDashes, "{\"b\":1}\n{\"c\":2}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List();
using var handler = new StubHandler(
@@ -1728,7 +1729,7 @@ public async Task import_session_never_starts_a_later_child_once_the_family_is_f
var childAJsonl = fx.AddSession("Users-me-proj", childAIdWithDashes, "{\"b\":1}\n");
var childBJsonl = fx.AddSession("Users-me-proj", childBIdWithDashes, "{\"c\":1}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var bodies = new List<(string Path, string Body)>();
using var handler = new StubHandler(
@@ -1803,7 +1804,7 @@ public async Task classify_resolves_parent_quarantine_via_the_persisted_live_mar
Markers.Quarantine(parentId, "transcript rewrite detected");
try {
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -1861,7 +1862,7 @@ public async Task cross_workspace_prompt_match_does_not_link_child_to_parent_in_
"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":" + childUserText + "}]}}\n" +
"{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -1887,7 +1888,7 @@ public async Task classify_throws_promptly_when_cancelled_during_same_workspace_
fx.AddSession("Users-me-proj", "11111111-1111-1111-1111-111111111111", "{}\n");
fx.AddSession("Users-me-proj", "22222222-2222-2222-2222-222222222222", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -1906,7 +1907,7 @@ public async Task import_session_omits_workspace_roots_when_cwd_unresolved() {
using var fx = new ProjectsDirFixture();
var jsonl = fx.AddSession("unknown-workspace", "11111111-1111-1111-1111-111111111111", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List<(string Path, string Body)>();
@@ -1948,7 +1949,7 @@ public async Task import_session_resumes_from_resume_line_for_partial_status() {
"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"
);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var posted = new List<(string Path, string Body)>();
@@ -2004,7 +2005,7 @@ public async Task classify_sets_excluded_repo_key_when_workspace_repo_matches_ex
repoDetector: _ => Task.FromResult(
new RepositoryPayload { Owner = "acme", RepoName = "secret" }
)
- );
+ , router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -2041,7 +2042,7 @@ public async Task classify_does_not_invoke_repo_detection_when_excluded_repos_em
return Task.FromResult(null);
}
- );
+ , router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -2073,7 +2074,7 @@ public async Task classify_caches_repo_detection_per_workspace_across_sessions()
return Task.FromResult(new RepositoryPayload { Owner = "o", RepoName = "r" });
}
- );
+ , router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -2101,7 +2102,7 @@ public async Task discover_cwd_filter_matches_case_insensitively_on_macos_and_wi
fx.AddWorkspaceJson("hash-aaa", "file:///Users/me/dev/MyProj");
fx.AddSession("Users-me-dev-MyProj", "11111111-1111-1111-1111-111111111111", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
// Caller passes a lower-cased cwd, e.g. from a shell tab-completion.
var got = await src.DiscoverAsync(Filters(filterCwd: "/users/me/dev/myproj"), CancellationToken.None);
@@ -2127,7 +2128,7 @@ public async Task discover_since_filter_uses_file_creation_time_not_last_write()
File.SetCreationTimeUtc(jsonl, thirtyDaysAgo);
File.SetLastWriteTimeUtc(jsonl, DateTime.UtcNow);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var since = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7));
var got = await src.DiscoverAsync(Filters(since: since), CancellationToken.None);
@@ -2144,7 +2145,7 @@ public async Task discover_marks_cwd_null_when_two_workspaces_encode_to_the_same
fx.AddWorkspaceJson("hash-b", "file:///foo-bar");
fx.AddSession("foo-bar", "11111111-1111-1111-1111-111111111111", "{}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
var got = await src.DiscoverAsync(Filters(), CancellationToken.None);
await Assert.That(got.Count).IsEqualTo(1);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs
index e963d12d6..8cbe3826c 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorLiveSubagentIntegrationTests.cs
@@ -5,6 +5,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness.Cursor;
using Capacitor.Cli.Harness.Cursor;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
@@ -173,7 +174,7 @@ public Fixture(ConfigRoot config, HttpStatusCode postStatus = HttpStatusCode.OK)
}
public Task HandleAsync(string sessionId, string eventName, string? transcriptPath, string extraFields = "") =>
- new CursorHookCommand(Config, Resolutions.At("http://localhost", Config), new HookClock(TimeProvider.System), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Resolutions.At("http://localhost", Config), new FixedCapacitorHttpClient())).HandleCore(
+ new CursorHookCommand(Config, Resolutions.At("http://localhost", Config), new HookClock(TimeProvider.System), _home, TestHarnesses.Under(_home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config, Resolutions.At("http://localhost", Config), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
Client,
stdin: new StringReader(
$$"""{"hook_event_name":"{{eventName}}","session_id":"{{sessionId}}","transcript_path":"{{transcriptPath?.Replace(@"\", @"\\")}}"{{extraFields}}}"""
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorMemoryIndexLiveCertTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorMemoryIndexLiveCertTests.cs
index 88fa4dff7..dc3444253 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorMemoryIndexLiveCertTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorMemoryIndexLiveCertTests.cs
@@ -292,7 +292,7 @@ internal static string ExtractAssistantAnswer(string stdout) {
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
- WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory
+ WorkingDirectory = workingDirectory ?? Capacitor.Cli.Core.WorkingDirectory.FromProcess().Path
};
foreach (var arg in args) psi.ArgumentList.Add(arg);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorOrphanedChildStandaloneTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorOrphanedChildStandaloneTests.cs
index b5a73d6d3..24fc5c57f 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorOrphanedChildStandaloneTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorOrphanedChildStandaloneTests.cs
@@ -2,6 +2,7 @@
using System.Text.Json;
using Capacitor.Cli.Commands;
using Capacitor.Cli.Harness.Cursor;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
@@ -43,7 +44,7 @@ public async Task classify_correlates_child_to_parent_even_when_parent_is_outsid
using var fx = new ProjectsDirFixture();
WriteParentAndChild(fx);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -81,7 +82,7 @@ public async Task classify_does_not_correlate_when_parent_and_child_are_in_diffe
"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":" + childUserText + "}]}}\n" +
"{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n");
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var handler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = new HttpClient(handler);
@@ -101,7 +102,7 @@ public async Task orphaned_child_imports_standalone_when_parent_is_not_in_the_ro
using var fx = new ProjectsDirFixture();
WriteParentAndChild(fx);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var getHandler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var getClient = new HttpClient(getHandler);
@@ -153,7 +154,7 @@ public async Task reconciliation_prunes_a_child_excluded_from_the_routed_plan_of
using var fx = new ProjectsDirFixture();
WriteParentAndChild(fx);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var getHandler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var getClient = new HttpClient(getHandler);
@@ -206,7 +207,7 @@ public async Task reconciliation_leaves_nested_child_untouched_when_parent_is_in
using var fx = new ProjectsDirFixture();
WriteParentAndChild(fx);
- var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir);
+ var src = new CursorImportSource(Config.Root, fx.ProjectsDir, fx.WorkspaceStorageDir, router: new GitProviderRouter());
using var getHandler = new StubHandler(getResponse: _ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var getClient = new HttpClient(getHandler);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorReconnectRewindTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorReconnectRewindTests.cs
index eac96fbaf..4f41ac25e 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorReconnectRewindTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorReconnectRewindTests.cs
@@ -3,6 +3,7 @@
using Capacitor.Cli.Core.Harness.Cursor;
using Capacitor.Cli.Harness.Cursor;
using Microsoft.AspNetCore.SignalR.Client;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
@@ -20,7 +21,7 @@ namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
public class CursorReconnectRewindTests {
[TempHome] public required TempHome Home { get; init; }
- WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()));
+ WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter());
CursorMarkers Markers => new(Config.Root);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs
index 85dcad3c5..b8279d554 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorSubagentStaleStateTests.cs
@@ -4,6 +4,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness.Cursor;
using Capacitor.Cli.Harness.Cursor;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
@@ -102,7 +103,7 @@ public async Task A_marker_without_an_ack_suppresses_the_raw_event_and_the_trans
using var client = new HttpClient(handler);
var spool = new HookSpool(tmp.PathTo("spool"));
- await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(
+ await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
client,
new StringReader($$"""{"hook_event_name":"afterAgentThought","session_id":"{{child}}","generation_id":"g","text":"t","transcript_path":"{{childFile.Replace(@"\", @"\\")}}"}"""),
spool);
@@ -162,7 +163,7 @@ public async Task Successful_start_with_a_failed_marker_write_leaves_ack_and_wat
// posted), so a test that bypassed it by calling HandleSubagentChildEventAsync would
// keep passing after the remedy landed — defeating the whole point of a
// characterization test.
- await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(
+ await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
client,
new StringReader($$"""{"hook_event_name":"sessionStart","session_id":"{{child}}","transcript_path":"{{childPath.Replace(@"\", @"\\")}}"}"""),
spool);
@@ -209,7 +210,7 @@ public async Task Spooled_start_with_a_failed_marker_write_dual_routes_on_the_ne
var spool = new HookSpool(tmp.PathTo("spool"));
// Again through the REAL CALLER — see the note in the test above.
- await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(
+ await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
client,
new StringReader($$"""{"hook_event_name":"sessionStart","session_id":"{{child}}","transcript_path":"{{childPath.Replace(@"\", @"\\")}}"}"""),
spool);
@@ -223,7 +224,7 @@ public async Task Spooled_start_with_a_failed_marker_write_dual_routes_on_the_ne
// ordinary top-level route. THE FINDING: two watchers now tail the SAME transcript,
// one under the parent and one as the child's own session.
routes.Clear();
- await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(
+ await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
client,
new StringReader($$"""{"hook_event_name":"afterAgentResponse","session_id":"{{child}}","transcript_path":"{{childPath.Replace(@"\", @"\\")}}"}"""),
spool);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorTopLevelStreamingTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorTopLevelStreamingTests.cs
index 2c18f35a0..de4040370 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorTopLevelStreamingTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorTopLevelStreamingTests.cs
@@ -2,6 +2,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Harness.Cursor;
using Microsoft.AspNetCore.SignalR.Client;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
@@ -18,7 +19,7 @@ namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
public class CursorTopLevelStreamingTests {
[TempHome] public required TempHome Home { get; init; }
- WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()));
+ WatchCommand Watch => field ??= new(Config.Root, Resolutions.None(Config.Root), TestHarnesses.Under(Home), new FixedCapacitorHttpClient(), new FixedCredentialSource(), TestWatchers.For(Config.Root, Resolutions.None(Config.Root), new FixedCapacitorHttpClient()), new GitProviderRouter());
[TempConfigRoot] public required TempConfigRoot Config { get; init; }
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs
index 8c6c66912..e6dd1e039 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Cursor/CursorWatcherSpawnTests.cs
@@ -4,6 +4,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Harness.Cursor;
using Capacitor.Cli.Harness.Cursor;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Cursor;
@@ -25,7 +26,7 @@ public class CursorWatcherSpawnTests {
CursorHookCommand Hook(ConfigRoot root, IWatcherSpawner spawner) =>
new(root, Resolutions.At("http://s", root), new HookClock(TimeProvider.System), Home,
TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(),
- TestWatchers.For(root, Resolutions.At("http://s", root), new FixedCapacitorHttpClient(), spawner));
+ TestWatchers.For(root, Resolutions.At("http://s", root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
[Test]
public async Task SessionEnd_never_spawns() =>
@@ -168,7 +169,7 @@ public async Task Deferred_spool_drain_delivering_a_spooled_subagent_start_spawn
// stays queued. Asserting "no spawn" only means something after a real drain attempt:
// straight after Append no production code has run, so the assertion could not fail.
startFails = true;
- await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(
+ await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
client,
new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{child}}","tool_name":"Bash"}"""),
spool);
@@ -181,7 +182,7 @@ public async Task Deferred_spool_drain_delivering_a_spooled_subagent_start_spawn
// (before the isSubagentChild divert even runs), and that success is what must
// trigger the deferred spawn.
startFails = false;
- await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(
+ await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
client,
new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{child}}","tool_name":"Bash"}"""),
spool);
@@ -240,14 +241,14 @@ public async Task Permanently_dropped_subagent_start_gates_all_child_transcript_
// 2nd invocation: any later hook for this child. HandleCore's generic top-of-method
// spool drain retries the spooled subagent-start FIRST — this time it 400s, which
// HookSpool treats as a permanent Drop (the entry is discarded, not re-queued).
- await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(
+ await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
client,
new StringReader($$"""{"hook_event_name":"afterAgentThought","session_id":"{{child}}","generation_id":"g","text":"t","transcript_path":"{{childFileEscaped}}"}"""),
spool);
// 3rd invocation: another content-less hook. An emptied backlog is not an
// acknowledgement, so no agent-routed backfill may run while the marker is absent.
- await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner)).HandleCore(
+ await new CursorHookCommand(Config.Root, Resolutions.At("http://s", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://s", Config.Root), new FixedCapacitorHttpClient(), spawner), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).HandleCore(
client,
new StringReader($$"""{"hook_event_name":"postToolUse","session_id":"{{child}}","tool_name":"Bash","transcript_path":"{{childFileEscaped}}"}"""),
spool);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs
index 7f634d7b7..bce141888 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiHookOutputContractTests.cs
@@ -2,6 +2,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Commands.Harness;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Gemini;
@@ -185,7 +186,7 @@ async Task RunAsync(string payload) {
// A URL no POST can reach: these paths must all return before any network call, and a test
// that quietly started talking to a live server would be measuring something else.
- await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
return capture.GetCapturedOutput();
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs
index 99c515766..b5e316cb5 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Gemini/GeminiSessionStartMemoryTests.cs
@@ -2,6 +2,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.SessionStartMemory;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Gemini;
@@ -151,7 +152,7 @@ async Task CaptureHandleStdout(string payload) {
using var capture = ConsoleOutput.StartCapture();
// baseUrl is unreachable on purpose: these paths must return before any network work.
- await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient())).Handle(new StringReader(payload));
+ await new GeminiHookCommand(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At("http://127.0.0.1:1", Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory)).Handle(new StringReader(payload));
return capture.GetCapturedOutput();
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Kiro/KiroImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Kiro/KiroImportSourceTests.cs
index 91d7c7232..b33871357 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Kiro/KiroImportSourceTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Kiro/KiroImportSourceTests.cs
@@ -1,6 +1,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Harness.Kiro;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Kiro;
@@ -23,7 +24,7 @@ public async Task discovery_reads_jsonl_and_sibling_json_metadata() {
tmp.CreateFile($"{Dashed}.json",
"""{"cwd":"/work","title":"Hi there","created_at":"2026-06-17T10:30:00Z","session_state":{"rts_model_state":{"model_info":{"model_id":"auto"}}}}""");
- var src = new KiroImportSource(Config.Root, tmp.Path);
+ var src = new KiroImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
await Assert.That(src.IsAvailable).IsTrue();
var found = await src.DiscoverAsync(new DiscoveryFilters(null, null, null, 1), CancellationToken.None);
@@ -43,7 +44,7 @@ public async Task discovery_session_filter_matches_dashless_id() {
using var tmp = new TempDir();
tmp.CreateFile($"{Dashed}.jsonl", "{}\n");
- var src = new KiroImportSource(Config.Root, tmp.Path);
+ var src = new KiroImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
var match = await src.DiscoverAsync(new DiscoveryFilters(null, Dashed.Replace("-", ""), null, 1), CancellationToken.None);
await Assert.That(match.Count).IsEqualTo(1);
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiImportSourceTests.cs
index 748bd1bc6..ff15beec0 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiImportSourceTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiImportSourceTests.cs
@@ -2,6 +2,7 @@
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Harness.Pi;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Pi;
@@ -31,7 +32,7 @@ public async Task discovery_finds_pi_session_from_header() {
using var tmp = new TempDir();
WriteSession(tmp.Path, Sid1, cwd: "/work/a");
- var source = new PiImportSource(Config.Root, tmp.Path);
+ var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(sessions.Count).IsEqualTo(1);
@@ -47,7 +48,7 @@ public async Task discovery_walks_nested_cwd_subdirs() {
WriteSession(tmp.PathTo("proj-a"), Sid1, cwd: "/work/a");
WriteSession(tmp.PathTo("proj-b"), Sid2, cwd: "/work/b");
- var source = new PiImportSource(Config.Root, tmp.Path);
+ var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(sessions.Count).IsEqualTo(2);
@@ -59,7 +60,7 @@ public async Task discovery_skips_non_pi_jsonl() {
// A .jsonl whose first line is not a Pi session header.
tmp.CreateFile("other.jsonl", "{\"type\":\"something\",\"x\":1}\n");
- var source = new PiImportSource(Config.Root, tmp.Path);
+ var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(sessions.Count).IsEqualTo(0);
@@ -77,7 +78,7 @@ public async Task discovery_skips_session_with_non_guid_header_id() {
"""{"type":"message","id":"a1","parentId":null,"message":{"role":"user","content":"hello"}}"""
});
- var source = new PiImportSource(Config.Root, tmp.Path);
+ var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(sessions.Count).IsEqualTo(0);
@@ -95,7 +96,7 @@ public async Task discovery_recovers_session_id_from_filename_when_header_id_mis
"""{"type":"message","id":"a1","parentId":null,"message":{"role":"user","content":"hi"}}"""
});
- var source = new PiImportSource(Config.Root, tmp.Path);
+ var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
var sessions = await source.DiscoverAsync(new DiscoveryFilters(null, null, null, 0), CancellationToken.None);
await Assert.That(sessions.Count).IsEqualTo(1);
@@ -108,7 +109,7 @@ public async Task discovery_applies_session_and_cwd_filters() {
WriteSession(tmp.Path, Sid1, cwd: "/work/a");
WriteSession(tmp.Path, Sid2, cwd: "/work/b");
- var source = new PiImportSource(Config.Root, tmp.Path);
+ var source = new PiImportSource(Config.Root, tmp.Path, router: new GitProviderRouter());
var bySession = await source.DiscoverAsync(new DiscoveryFilters(null, Sid1, null, 0), CancellationToken.None);
await Assert.That(bySession.Count).IsEqualTo(1);
@@ -122,7 +123,7 @@ public async Task discovery_applies_session_and_cwd_filters() {
[Test]
public async Task is_available_false_when_dir_missing() {
using var tmp = new TempDir();
- var source = new PiImportSource(Config.Root, tmp.PathTo("nope"));
+ var source = new PiImportSource(Config.Root, tmp.PathTo("nope"), router: new GitProviderRouter());
await Assert.That(source.IsAvailable).IsFalse();
}
@@ -131,7 +132,7 @@ public async Task does_not_support_title_generation() {
// Pi is a routed source (FilePath=""), so it never reaches the chain
// title worker. Like Copilot/Cursor it relies on the server-side fallback
// title; advertising true would be a no-op contract lie.
- var source = new PiImportSource(Config.Root, "/nonexistent");
+ var source = new PiImportSource(Config.Root, "/nonexistent", router: new GitProviderRouter());
await Assert.That(source.SupportsTitleGeneration).IsFalse();
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs
index 6920b625a..e5559b3d8 100644
--- a/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Harness/Pi/PiSessionStartMemoryTests.cs
@@ -3,6 +3,7 @@
using Capacitor.Cli.SessionStartMemory;
using Capacitor.Cli.Core.Harness;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit.Harness.Pi;
@@ -24,7 +25,7 @@ public class PiSessionStartMemoryTests {
// The server URL is the resolution's, so a test proving the url guard fires hands in the bad one
// here rather than as an argument.
PiHookCommand Hook(string serverUrl = "http://localhost:5100") =>
- new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()));
+ new(Config.Root, Resolutions.At(serverUrl, Config.Root), new HookClock(TimeProvider.System), Home, TestHarnesses.Under(Home), HostedAgent.Terminal, new FixedCapacitorHttpClient(), TestWatchers.For(Config.Root, Resolutions.At(serverUrl, Config.Root), new FixedCapacitorHttpClient()), router: new GitProviderRouter(), workdir: new WorkingDirectory(AppContext.BaseDirectory));
static string Render(string? fragment) => PiHookCommand.RenderMemoryOutput(fragment);
// Byte-identical to pre-feature behaviour on every no-index path (opt-out, failure, spent lease):
diff --git a/test/Capacitor.Cli.Tests.Unit/ImportProviderDetectionTests.cs b/test/Capacitor.Cli.Tests.Unit/ImportProviderDetectionTests.cs
index 57bb02da6..672f8cd33 100644
--- a/test/Capacitor.Cli.Tests.Unit/ImportProviderDetectionTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/ImportProviderDetectionTests.cs
@@ -27,7 +27,7 @@ static CommandRunner Recording(List log) => (cmd, args, _, _) => {
public async Task Import_detection_resolves_repo_without_spawning_a_provider_cli() {
var log = new List();
- var repo = await RepositoryDetection.DetectRepositoryAsync(Config.Root,
+ var repo = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root,
"/fake/import/skip-pr", budget: null, detectPullRequest: false, run: Recording(log));
// Base repo info still resolves from git alone…
@@ -43,7 +43,7 @@ public async Task Import_detection_resolves_repo_without_spawning_a_provider_cli
public async Task Live_detection_still_runs_provider_detection() {
var log = new List();
- var repo = await RepositoryDetection.DetectRepositoryAsync(Config.Root,
+ var repo = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root,
"/fake/live/do-pr", budget: null, detectPullRequest: true, run: Recording(log));
await Assert.That(log.Any(c => c.StartsWith("gh pr view", StringComparison.Ordinal))).IsTrue(); // provider detection ran
diff --git a/test/Capacitor.Cli.Tests.Unit/PrDetection/GitProviderRouterTests.cs b/test/Capacitor.Cli.Tests.Unit/PrDetection/GitProviderRouterTests.cs
index 430fe76e4..ad80e5f90 100644
--- a/test/Capacitor.Cli.Tests.Unit/PrDetection/GitProviderRouterTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/PrDetection/GitProviderRouterTests.cs
@@ -2,19 +2,15 @@
namespace Capacitor.Cli.Tests.Unit.PrDetection;
-// The router memoizes into a production static, and the per-test reset that clears it is itself a
-// process-global mutation — a concurrent peer's reset or memo entry decides what this class observes.
-[NotInParallel]
public class GitProviderRouterTests {
- [Before(Test)]
- public void Reset() => GitProviderRouter.ResetMemoForTests();
+ static GitProviderRouter Router => new();
static CommandRunner Never => (_, _, _, _) => throw new InvalidOperationException("probe should not run for SaaS hosts");
[Test]
public async Task Saas_hosts_route_without_probing() {
- await Assert.That(await GitProviderRouter.ResolveAsync("github.com", "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.GitHub);
- await Assert.That(await GitProviderRouter.ResolveAsync("gitlab.com", "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.GitLab);
+ await Assert.That(await Router.ResolveAsync("github.com", "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.GitHub);
+ await Assert.That(await Router.ResolveAsync("gitlab.com", "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.GitLab);
}
[Test]
@@ -24,41 +20,42 @@ public async Task Custom_host_in_gh_auth_status_is_github() {
await Assert.That(args).IsEqualTo("auth status --json hosts");
return "{\"hosts\":{\"github.com\":[],\"ghe.corp.com\":[]}}";
};
- await Assert.That(await GitProviderRouter.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitHub);
+ await Assert.That(await Router.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitHub);
}
[Test]
public async Task Custom_host_not_in_gh_falls_back_to_gitlab() {
CommandRunner fake = (_, _, _, _) => Task.FromResult("""{"hosts":{"github.com":[]}}""");
- await Assert.That(await GitProviderRouter.ResolveAsync("gitlab.corp.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab);
+ await Assert.That(await Router.ResolveAsync("gitlab.corp.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab);
}
[Test]
public async Task Probe_result_is_memoized_per_host() {
var calls = 0;
CommandRunner fake = (_, _, _, _) => { calls++; return Task.FromResult("""{"hosts":{"ghe.corp.com":[]}}"""); };
- await GitProviderRouter.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake);
- await GitProviderRouter.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake);
- await Assert.That(calls).IsEqualTo(1); // memoized: bulk-import loop can't multiply the probe
+ var router = Router;
+ await router.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake);
+ await router.ResolveAsync("ghe.corp.com", "/c", TimeSpan.FromSeconds(2), fake);
+ await Assert.That(calls).IsEqualTo(1);
}
[Test]
public async Task Null_host_is_unknown() {
- await Assert.That(await GitProviderRouter.ResolveAsync(null, "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.Unknown);
+ await Assert.That(await Router.ResolveAsync(null, "/c", TimeSpan.FromSeconds(2), Never)).IsEqualTo(GitProviderKind.Unknown);
}
[Test]
public async Task Malformed_gh_json_falls_back_to_gitlab() {
// `gh auth status --json hosts` returned junk → JsonNode.Parse throws → we can't confirm
- // the host is a GitHub host, so best-effort GitLab (unique host avoids memo collisions).
+ // the host is a GitHub host, so best-effort GitLab.
CommandRunner fake = (_, _, _, _) => Task.FromResult("{not valid json");
- await Assert.That(await GitProviderRouter.ResolveAsync("malformed-json.example.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab);
+ await Assert.That(await Router.ResolveAsync("malformed-json.example.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab);
}
[Test]
public async Task Null_probe_result_falls_back_to_gitlab() {
// Probe failed / timed out (runner returned null) → best-effort GitLab.
CommandRunner fake = (_, _, _, _) => Task.FromResult(null);
- await Assert.That(await GitProviderRouter.ResolveAsync("null-probe.example.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab);
+ await Assert.That(await Router.ResolveAsync("null-probe.example.com", "/c", TimeSpan.FromSeconds(2), fake)).IsEqualTo(GitProviderKind.GitLab);
}
}
diff --git a/test/Capacitor.Cli.Tests.Unit/ProcessHelpersTests.cs b/test/Capacitor.Cli.Tests.Unit/ProcessHelpersTests.cs
index 497371749..a241f9f7b 100644
--- a/test/Capacitor.Cli.Tests.Unit/ProcessHelpersTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/ProcessHelpersTests.cs
@@ -162,7 +162,9 @@ static string Canonical(string p) {
.TrimEnd(Path.DirectorySeparatorChar);
}
+#pragma warning disable RS0030 // the process's own directory is what this reports
await Assert.That(Canonical(reported!)).IsEqualTo(Canonical(Directory.GetCurrentDirectory()));
+#pragma warning restore RS0030
}
[Test]
diff --git a/test/Capacitor.Cli.Tests.Unit/ProviderBudgetSplitTests.cs b/test/Capacitor.Cli.Tests.Unit/ProviderBudgetSplitTests.cs
index 54cb30da0..f215e5547 100644
--- a/test/Capacitor.Cli.Tests.Unit/ProviderBudgetSplitTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/ProviderBudgetSplitTests.cs
@@ -4,15 +4,11 @@
namespace Capacitor.Cli.Tests.Unit;
///
-/// Guards the effective-provider-budget split added in #229: the probe (GitProviderRouter) and the
-/// PR/MR detector share one ceiling, and the detector must run within the budget the probe LEFT
-/// BEHIND — not the full cap. This is timing-dependent in production; an injected timestamp makes
-/// it deterministic so a regression (handing the detector the full cap) is caught in CI.
+/// The probe and the PR/MR detector share one ceiling: the detector runs within what the probe LEFT
+/// BEHIND, not the full cap. Real time decides that split in production, so the timestamp is
+/// injected here — otherwise handing the detector the full cap reads as a fast machine.
///
public class ProviderBudgetSplitTests {
- [Before(Test)]
- public void Reset() => GitProviderRouter.ResetMemoForTests();
-
[Test]
public async Task Detector_gets_the_budget_the_probe_left_behind() {
var providerCap = TimeSpan.FromSeconds(2);
@@ -29,6 +25,7 @@ public async Task Detector_gets_the_budget_the_probe_left_behind() {
// Custom host → the router probes (consuming the injected time); GitLab detector then runs.
await RepositoryDetection.ResolveAndDetectPrAsync(
+ new GitProviderRouter(),
"git.example.com", "owner", "repo", "main", "/cwd", providerCap, run, Timestamp);
await Assert.That(detectorCap).IsNotNull();
@@ -52,6 +49,7 @@ public async Task No_detection_when_probe_exhausts_the_budget() {
};
var pr = await RepositoryDetection.ResolveAndDetectPrAsync(
+ new GitProviderRouter(),
"git.example.com", "owner", "repo", "main", "/cwd", providerCap, run, Timestamp);
await Assert.That(detectorRan).IsFalse();
@@ -75,6 +73,7 @@ public async Task Non_monotonic_timestamp_never_inflates_the_detector_budget() {
};
await RepositoryDetection.ResolveAndDetectPrAsync(
+ new GitProviderRouter(),
"git.example.com", "owner", "repo", "main", "/cwd", providerCap, run, Timestamp);
await Assert.That(detectorCap).IsNotNull();
@@ -111,6 +110,7 @@ public async Task Tracked_branch_lookup_gets_only_what_the_normal_lookup_left()
var run = TrackedBranchRunner(() => now, t => now = t, TimeSpan.FromSeconds(1.5), calls);
await RepositoryDetection.ResolveAndDetectPrAsync(
+ new GitProviderRouter(),
"github.com", "acme", "widget", "local-name", "/cwd", TimeSpan.FromSeconds(2), run, () => now);
var tracked = calls.Single(c => c.Args.StartsWith("gh pr view remote-name", StringComparison.Ordinal));
@@ -125,6 +125,7 @@ public async Task No_tracked_branch_probe_once_the_normal_lookup_spends_the_budg
var run = TrackedBranchRunner(() => now, t => now = t, TimeSpan.FromSeconds(2), calls);
var pr = await RepositoryDetection.ResolveAndDetectPrAsync(
+ new GitProviderRouter(),
"github.com", "acme", "widget", "local-name", "/cwd", TimeSpan.FromSeconds(2), run, () => now);
await Assert.That(pr).IsNull();
diff --git a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionCacheTests.cs b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionCacheTests.cs
index 1b5626e6d..732e42ebf 100644
--- a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionCacheTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionCacheTests.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using Capacitor.Cli.Core;
+using Capacitor.Cli.PrDetection;
namespace Capacitor.Cli.Tests.Unit;
@@ -30,7 +31,7 @@ public async Task GitCacheEntry_v2_is_stale_after_nested_group_bump() {
[Test]
public async Task Detects_nested_gitlab_repo_base_info() {
using var repo = MakeTempRepo("git@gitlab.com:group/sub/project.git");
- var payload = await RepositoryDetection.DetectRepositoryAsync(Config.Root, repo);
+ var payload = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, repo);
await Assert.That(payload).IsNotNull();
await Assert.That(payload!.Owner).IsEqualTo("group/sub");
@@ -57,7 +58,7 @@ public async Task GitCacheEntry_roundtrips_host_and_version() {
[Test]
public async Task Detects_gitlab_repo_base_info() {
using var repo = MakeTempRepo("git@gitlab.com:group/project.git");
- var payload = await RepositoryDetection.DetectRepositoryAsync(Config.Root, repo);
+ var payload = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, repo);
await Assert.That(payload).IsNotNull();
await Assert.That(payload!.Owner).IsEqualTo("group");
diff --git a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionPrSkipTests.cs b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionPrSkipTests.cs
index c5d404252..2b107e6da 100644
--- a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionPrSkipTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionPrSkipTests.cs
@@ -11,9 +11,6 @@ namespace Capacitor.Cli.Tests.Unit;
public class RepositoryDetectionPrSkipTests {
[TempConfigRoot] public required TempConfigRoot Config { get; init; }
- [Before(Test)]
- public void Reset() => GitProviderRouter.ResetMemoForTests();
-
static CommandRunner RecordingRunner(List commands) =>
(cmd, args, _, _) => {
commands.Add(cmd);
@@ -32,7 +29,7 @@ public async Task DetectPullRequest_false_never_spawns_gh_or_glab() {
using var cwd = new TempDir();
var commands = new List();
- var repo = await RepositoryDetection.DetectRepositoryAsync(Config.Root,
+ var repo = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root,
cwd.Path, budget: TimeSpan.FromSeconds(5), detectPullRequest: false, run: RecordingRunner(commands));
await Assert.That(repo).IsNotNull();
@@ -51,7 +48,7 @@ public async Task DetectPullRequest_true_does_probe_the_provider() {
using var cwd = new TempDir();
var commands = new List();
- await RepositoryDetection.DetectRepositoryAsync(Config.Root,
+ await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root,
cwd.Path, budget: TimeSpan.FromSeconds(5), detectPullRequest: true, run: RecordingRunner(commands));
// Proves the flag actually gates the round-trip: with detection ON, the
@@ -65,7 +62,7 @@ public async Task EnrichWithRepositoryInfo_false_never_spawns_gh_or_glab() {
var commands = new List();
var payload = $$"""{"cwd":"{{cwd.Path.Replace("\\", "/")}}","hook_event_name":"session-start"}""";
- var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(Config.Root,
+ var enriched = await RepositoryDetection.EnrichWithRepositoryInfo(new GitProviderRouter(), Config.Root,
payload, budget: TimeSpan.FromSeconds(5), detectPullRequest: false, run: RecordingRunner(commands));
await Assert.That(enriched).Contains("acme");
diff --git a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionTrackedBranchTests.cs b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionTrackedBranchTests.cs
index 1745d46c5..e2125bef7 100644
--- a/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionTrackedBranchTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/RepositoryDetectionTrackedBranchTests.cs
@@ -46,6 +46,7 @@ public async Task Finds_the_PR_by_the_tracked_remote_name_when_the_local_name_mi
var ghCalls = new List();
var payload = await RepositoryDetection.DetectRepositoryAsync(
+ new GitProviderRouter(),
Config.Root, repo, run: RealGitFakeGh(args => args == TrackedLookup ? TrackedPr : null, ghCalls));
await Assert.That(payload!.Branch).IsEqualTo("local-name");
@@ -60,6 +61,7 @@ public async Task A_normal_lookup_hit_never_runs_the_fallback() {
var ghCalls = new List();
var payload = await RepositoryDetection.DetectRepositoryAsync(
+ new GitProviderRouter(),
Config.Root, repo,
run: RealGitFakeGh(args => args == NormalLookup
? """{"number":5,"headRefName":"local-name"}"""
@@ -76,9 +78,9 @@ public async Task A_later_hit_is_a_repository_change_the_watcher_sends() {
var prOpened = false;
var run = RealGitFakeGh(args => prOpened && args == TrackedLookup ? TrackedPr : null);
- var before = await RepositoryDetection.DetectRepositoryAsync(Config.Root, repo, run: run);
+ var before = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, repo, run: run);
prOpened = true;
- var after = await RepositoryDetection.DetectRepositoryAsync(Config.Root, repo, run: run);
+ var after = await RepositoryDetection.DetectRepositoryAsync(new GitProviderRouter(), Config.Root, repo, run: run);
await Assert.That(before!.PrNumber).IsNull();
await Assert.That(after!.PrNumber).IsEqualTo(874);
diff --git a/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/MemoryIndexLiveCertHarness.cs b/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/MemoryIndexLiveCertHarness.cs
index 8488fb51f..437ec4b7a 100644
--- a/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/MemoryIndexLiveCertHarness.cs
+++ b/test/Capacitor.Cli.Tests.Unit/SessionStartMemory/MemoryIndexLiveCertHarness.cs
@@ -97,7 +97,7 @@ public static async Task InitializeAndResolveServerUrlAsync() {
// The operator's REAL root, named rather than resolved: this assembly pins KCAP_CONFIG_DIR at
// a throwaway directory, and a cert that resolved that would read an empty config — the same
// 401-in-a-different-costume the child processes below strip the variable to avoid.
- var profiles = await AppConfig.ResolveForRepo([], ConfigRoot.UnderHome(UserHome.FromEnvironment().Path), ProfileOverrides.None);
+ var profiles = await AppConfig.ResolveForRepo([], ConfigRoot.UnderHome(UserHome.FromEnvironment().Path), ProfileOverrides.None, WorkingDirectory.FromProcess());
return profiles.Resolution.ServerUrl ?? RequiredServerUrl();
}
@@ -644,7 +644,7 @@ static bool IsExecutableFile(string path) {
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = stdin is not null,
- WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory
+ WorkingDirectory = workingDirectory ?? Capacitor.Cli.Core.WorkingDirectory.FromProcess().Path
};
foreach (var arg in args) psi.ArgumentList.Add(arg);
diff --git a/test/Capacitor.Tests.Helpers/TempDir.cs b/test/Capacitor.Tests.Helpers/TempDir.cs
index 0c8f19ec1..bc845d376 100644
--- a/test/Capacitor.Tests.Helpers/TempDir.cs
+++ b/test/Capacitor.Tests.Helpers/TempDir.cs
@@ -76,7 +76,22 @@ public string CreateFile(ReadOnlySpan segments, string content = "") =>
public string CreateFile(string relativePath, string[] lines) =>
Root.CreateFile(relativePath, lines);
+ bool _disposed;
+
+ /// Deleting is best effort; finding it already gone is not. Nothing but this owner may
+ /// remove the directory, so its absence means something reached outside its own — and the
+ /// failures that causes surface far from the cause, in whatever the victim was doing when its
+ /// tree went away.
public void Dispose() {
+ if (_disposed) return;
+
+ _disposed = true;
+
+ if (!Directory.Exists(Path))
+ throw new InvalidOperationException(
+ $"{Path} was gone before its owner disposed it: something outside this fixture " +
+ $"deleted a directory that is not its own.");
+
try { Directory.Delete(Path, recursive: true); } catch { /* best effort */ }
}