Files
ClaudeDo/src/ClaudeDo.Installer/Checks/GitCheck.cs
T
mika kuns 45bc324402 feat(installer): add Git, GitIdentity, Port, and WriteAccess preflight checks
Implements IEnvironmentCheck for the four checks derivable without a
Claude CLI probe:
- GitCheck (Error) - resolves git via ExecutableResolver (handles .cmd
  shims), parses `git --version`.
- GitIdentityCheck (Warning) - user.name/user.email presence; Unknown
  (not Failed) if git itself is missing, so it doesn't duplicate GitCheck's
  failure.
- PortCheck (Warning) - loopback bind probe for SignalRPort/ExternalMcpPort;
  resolves the owning process via a new NetstatPortOwnerResolver and treats
  a port held by the running ClaudeDo.Worker (update/repair case) as Ok.
  Both ports are configurable, hence a warning.
- WriteAccessCheck (Error) - create+delete a probe file in InstallDirectory
  and ~/.todo-app (walking up to the first existing parent), not an ACL
  read (ACLs lie on virtualized paths).

Process calls go through a new IProcessRunner wrapping the existing static
ProcessRunner, so checks are fakeable in tests instead of spawning real
processes.

DotnetRuntimeCheck was intentionally not added: per
docs/explore-notes/installer-preflight.md, App/Worker publish
self-contained (no preinstalled runtime needed), and the Installer's own
.NET 8 Desktop Runtime requirement is self-proving - a framework-dependent
apphost can't reach managed code at all if that runtime is missing, so a
check running from inside the process can never observe a failure.

Brings in two prerequisite commits this task builds on that hadn't reached
this branch yet: the IEnvironmentCheck/EnvironmentCheckService scaffolding
and the installer-preflight.md research note.
2026-08-05 19:25:02 +02:00

50 lines
1.9 KiB
C#

using System.Text.RegularExpressions;
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Checks;
/// <summary>Without git, no task can run (worktrees) — blocking.</summary>
public sealed class GitCheck : IEnvironmentCheck
{
public const string CheckId = "git";
private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled);
private readonly IProcessRunner _processRunner;
private readonly string? _pathOverride;
private readonly string? _pathExtOverride;
public GitCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
{
_processRunner = processRunner;
_pathOverride = pathOverride;
_pathExtOverride = pathExtOverride;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
if (resolved is null)
{
return CheckResult.Fail(Id, Severity, "checks.git.title", "git was not found on PATH.",
"checks.git.hint", "https://git-scm.com/downloads");
}
var (fileName, arguments) = ProcessCommand.For(resolved, "--version");
var (exitCode, output) = await _processRunner.RunAsync(fileName, arguments, null, ct);
if (exitCode != 0)
{
return CheckResult.Fail(Id, Severity, "checks.git.title", $"'{resolved.Path}' exited with code {exitCode}.",
"checks.git.hint", "https://git-scm.com/downloads", output);
}
var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim();
return CheckResult.Ok(Id, Severity, "checks.git.title", $"{resolved.Path} — {version}");
}
}