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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions BannedSymbols.txt
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/Capacitor.Cli.Core/CapacitorContextServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
15 changes: 9 additions & 6 deletions src/Capacitor.Cli.Core/Config/AppConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Resolve server URL using only the active profile (or KCAP_PROFILE /
Expand Down Expand Up @@ -80,7 +80,8 @@ public static async Task<ProfileContext> ResolveActiveProfile(string[] args, Con
return new(resolved, loaded);
}

public static async Task<ProfileContext> ResolveForRepo(string[] args, ConfigRoot root, ProfileOverrides env, int gitTimeoutMs = 5000) {
public static async Task<ProfileContext> 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;

Expand All @@ -107,7 +108,7 @@ public static async Task<ProfileContext> 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");
Expand All @@ -121,7 +122,7 @@ public static async Task<ProfileContext> ResolveForRepo(string[] args, ConfigRoo
}
}

var remoteUrls = GetGitRemoteUrls(gitTimeoutMs);
var remoteUrls = GetGitRemoteUrls(workdir.Path, gitTimeoutMs);

var resolver = new ProfileResolver(
config,
Expand All @@ -143,9 +144,10 @@ public static async Task<ProfileContext> 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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions src/Capacitor.Cli.Core/Harness/Codex/CodexConfigToml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -493,8 +493,6 @@ static Change Update(string configPath, Func<TomlTable, bool> 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);
Expand Down
20 changes: 20 additions & 0 deletions src/Capacitor.Cli.Core/WorkingDirectory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Capacitor.Cli.Core;

/// <summary>
/// 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.
///
/// <para>Anything shelling out to git passes <see cref="Path"/> as the child's working directory:
/// a child inherits the process cwd otherwise, which puts the ambient value back in the answer.</para>
/// </summary>
public sealed class WorkingDirectory(string path) {
/// <summary>The directory itself. Not guaranteed to exist.</summary>
public string Path { get; } = path;

/// <summary>This process's working directory. Call once, in <c>Main</c> or the composition root.</summary>
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
}
4 changes: 2 additions & 2 deletions src/Capacitor.Cli/Commands/AgentCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal readonly record struct AgentRow(
/// </summary>
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
Expand Down Expand Up @@ -90,7 +90,7 @@ async Task<int> 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)
Expand Down
8 changes: 6 additions & 2 deletions src/Capacitor.Cli/Commands/CommandServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -18,12 +19,12 @@ public static class CommandServices {
/// does not pay for it.
/// </summary>
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);
Expand All @@ -33,6 +34,9 @@ public static IServiceCollection AddCapacitorCli(
services.AddSingleton(_ => WatcherPaths.FromEnvironment(config));
services.AddSingleton<IWatcherSpawner, ProcessWatcherSpawner>();

// Singleton deliberately: per-resolution routers would each start with an empty memo.
services.AddSingleton<GitProviderRouter>();

// 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());
Expand Down
11 changes: 7 additions & 4 deletions src/Capacitor.Cli/Commands/CurateCommand.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
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) {
/// <summary>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.</summary>
const int PageLimit = 100;

public async Task<int> 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.");
return 1;
}

// 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;
Expand Down
10 changes: 6 additions & 4 deletions src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Capacitor.Cli.Core.Harness;

using Capacitor.Cli.Core.Http;
using Capacitor.Cli.PrDetection;

namespace Capacitor.Cli.Commands.Harness;

Expand Down Expand Up @@ -37,7 +38,8 @@ namespace Capacitor.Cli.Commands.Harness;
/// </summary>
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!;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 7 additions & 6 deletions src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Capacitor.Cli.Core.Harness;

using Capacitor.Cli.Core.Http;
using Capacitor.Cli.PrDetection;

namespace Capacitor.Cli.Commands.Harness;

Expand All @@ -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!;

Expand Down Expand Up @@ -277,7 +278,7 @@ internal async Task<bool> ShouldSuppressCaptureAsync(

internal async Task<bool> 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;
}

Expand Down Expand Up @@ -406,13 +407,13 @@ internal async Task<int> 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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// ReSharper disable ShortLivedHttpClient

using Capacitor.Cli.Core.Http;
using Capacitor.Cli.PrDetection;

namespace Capacitor.Cli.Commands.Harness;

Expand Down Expand Up @@ -39,7 +40,8 @@ namespace Capacitor.Cli.Commands.Harness;
/// </remarks>
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!;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -343,7 +345,7 @@ async Task<int> 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
Expand All @@ -353,7 +355,7 @@ async Task<int> 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);
Expand Down
10 changes: 6 additions & 4 deletions src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Capacitor.Cli.Core.Harness;

using Capacitor.Cli.Core.Http;
using Capacitor.Cli.PrDetection;

namespace Capacitor.Cli.Commands.Harness;

Expand Down Expand Up @@ -44,7 +45,8 @@ namespace Capacitor.Cli.Commands.Harness;
/// </remarks>
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!;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
Loading