Merge branch 'claudedo/387-unit-branch-teil-3-firmen-modus-fetch-pu'

This commit is contained in:
mika kuns
2026-10-01 12:30:13 +02:00
40 changed files with 2857 additions and 47 deletions
@@ -0,0 +1,187 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace ClaudeDo.Worker.AzureDevOps;
/// <summary>An Azure DevOps Server repo derived from its remote URL; <see cref="ApiBase"/> is collection + project.</summary>
public sealed record AzdoRepo(string ApiBase, string Repo, string WebUrl);
/// <summary>A REST or configuration failure, with a message that can go straight to the user.</summary>
public sealed class AzureDevOpsException : Exception
{
public AzureDevOpsException(string message) : base(message) { }
}
/// <summary>
/// The one HTTP surface to the on-prem Azure DevOps Server, ported from work-conventions' <c>pr-create.ps1</c>.
/// REST, not the az CLI (cloud-only) and not the pullrequestcreate URL (on-prem ignores title/description).
/// </summary>
public sealed class AzureDevOpsClient
{
private const string DefaultApiVersion = "6.0";
// ADO caps a PR description at 4000 characters and rejects anything longer.
private const int MaxDescriptionLength = 4000;
private readonly IHttpClientFactory _httpFactory;
private readonly AzureDevOpsPatStore _pat;
private string _apiVersion = DefaultApiVersion;
public AzureDevOpsClient(IHttpClientFactory httpFactory, AzureDevOpsPatStore pat)
{
_httpFactory = httpFactory;
_pat = pat;
}
/// <summary>The stored PAT, or null.</summary>
public string? Pat => _pat.Store.Read();
/// <summary>
/// Splits an ADO remote at <c>/_git/</c>: everything before it already is the API prefix, so collection
/// and project never need parsing (holds for virtual directories like <c>/tfs/</c> too). Embedded
/// credentials are dropped. Null for anything that is not an http(s) ADO remote.
/// </summary>
public static AzdoRepo? ParseRemote(string? remote)
{
if (string.IsNullOrWhiteSpace(remote)) return null;
var clean = Regex.Replace(remote.Trim(), @"\.git$", "");
clean = Regex.Replace(clean, @"^(https?://)[^/@]+@", "$1");
var m = Regex.Match(clean, @"^(?<base>https?://.+)/_git/(?<repo>[^/]+)$");
return m.Success ? new AzdoRepo(m.Groups["base"].Value, Uri.UnescapeDataString(m.Groups["repo"].Value), clean) : null;
}
/// <summary>Basic auth with an empty user, as ADO expects for a PAT.</summary>
public static string AuthHeaderValue(string pat) => "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes(":" + pat));
/// <summary>
/// Environment for a git network call: never prompt — neither the terminal nor Git Credential Manager's GUI
/// (a background process would hang on either) — and hand the PAT
/// over as <c>http.extraHeader</c> via <c>GIT_CONFIG_*</c> so it never shows up in a command line.
/// </summary>
public static IReadOnlyDictionary<string, string> GitAuthEnv(string? pat)
{
var env = new Dictionary<string, string> { ["GIT_TERMINAL_PROMPT"] = "0", ["GCM_INTERACTIVE"] = "never" };
if (!string.IsNullOrEmpty(pat))
{
env["GIT_CONFIG_COUNT"] = "1";
env["GIT_CONFIG_KEY_0"] = "http.extraHeader";
env["GIT_CONFIG_VALUE_0"] = "Authorization: " + AuthHeaderValue(pat);
}
return env;
}
/// <summary>The API version an out-of-range error names (<c>… supports is 6.0</c>), else null.</summary>
public static string? SupportedVersion(string? error)
{
if (error is null) return null;
var m = Regex.Match(error, @"supports is (?<v>\d+\.\d+)");
return m.Success ? m.Groups["v"].Value : null;
}
/// <summary>The repo's default branch without <c>refs/heads/</c>.</summary>
public async Task<string> GetDefaultBranchAsync(AzdoRepo repo, CancellationToken ct)
{
var json = await SendAsync(repo, HttpMethod.Get, "", null, null, ct);
var branch = json["defaultBranch"]?.GetValue<string>()
?? throw new AzureDevOpsException($"Azure DevOps repo '{repo.Repo}' has no default branch.");
return StripHeads(branch);
}
/// <summary>
/// Reuses the active PR for <paramref name="source"/> if there is one (idempotent — the work-conventions
/// skill may already have opened it), else opens a draft PR into <paramref name="target"/>.
/// </summary>
public async Task<(string Url, bool Created)> EnsureDraftPullRequestAsync(
AzdoRepo repo, string source, string target, string title, string description, CancellationToken ct)
{
var sourceRef = "refs/heads/" + source;
var existing = await SendAsync(repo, HttpMethod.Get, "pullrequests",
$"searchCriteria.sourceRefName={Uri.EscapeDataString(sourceRef)}&searchCriteria.status=active&", null, ct);
if (existing["value"] is JsonArray { Count: > 0 } prs && prs[0]?["pullRequestId"]?.GetValue<int>() is { } existingId)
return (PullRequestUrl(repo, existingId), false);
var body = new JsonObject
{
["sourceRefName"] = sourceRef,
["targetRefName"] = "refs/heads/" + target,
["title"] = title,
["description"] = description.Length <= MaxDescriptionLength ? description : description[..MaxDescriptionLength],
["isDraft"] = true,
};
var created = await SendAsync(repo, HttpMethod.Post, "pullrequests", null, body.ToJsonString(), ct);
var id = created["pullRequestId"]?.GetValue<int>()
?? throw new AzureDevOpsException("Azure DevOps answered the PR request without a pullRequestId.");
return (PullRequestUrl(repo, id), true);
}
private static string PullRequestUrl(AzdoRepo repo, int id) => $"{repo.WebUrl}/pullrequest/{id}";
private static string StripHeads(string branch) =>
branch.StartsWith("refs/heads/", StringComparison.Ordinal) ? branch["refs/heads/".Length..] : branch;
private async Task<JsonObject> SendAsync(
AzdoRepo repo, HttpMethod method, string path, string? query, string? body, CancellationToken ct)
{
var pat = Pat;
if (string.IsNullOrEmpty(pat))
throw new AzureDevOpsException("Kein Azure-DevOps-PAT hinterlegt (Einstellungen → Externe Dienste).");
var baseUrl = $"{repo.ApiBase}/_apis/git/repositories/{Uri.EscapeDataString(repo.Repo)}";
if (path.Length > 0) baseUrl += "/" + path;
// The server names the version it supports in its error; retry once with it instead of making the user configure one.
for (var attempt = 1; ; attempt++)
{
using var request = new HttpRequestMessage(method, $"{baseUrl}?{query}api-version={_apiVersion}");
request.Headers.Authorization = AuthenticationHeaderValue.Parse(AuthHeaderValue(pat));
if (body is not null) request.Content = new StringContent(body, Encoding.UTF8, "application/json");
HttpResponseMessage response;
try { response = await _httpFactory.CreateClient("azdo").SendAsync(request, ct); }
catch (Exception ex) when (!ct.IsCancellationRequested)
{
throw new AzureDevOpsException($"Azure DevOps nicht erreichbar ({repo.ApiBase}): {ex.Message}");
}
using (response)
{
var payload = await response.Content.ReadAsStringAsync(ct);
// A rejected PAT comes back as 203 + an HTML login page, which IsSuccessStatusCode counts as success.
if ((int)response.StatusCode == 203)
throw new AzureDevOpsException("Azure DevOps 203: PAT abgelaufen oder ohne Scope Code (Read & Write).");
if (response.IsSuccessStatusCode)
{
try
{
return JsonNode.Parse(payload) as JsonObject
?? throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps bei {method} {path}.");
}
catch (JsonException ex) { throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps: {ex.Message}"); }
}
var detail = ErrorMessage(payload);
var better = SupportedVersion(detail);
if (attempt == 1 && better is not null && better != _apiVersion)
{
_apiVersion = better;
continue;
}
var hint = (int)response.StatusCode == 401 ?" — PAT abgelaufen oder ohne Scope Code (Read & Write)" : "";
throw new AzureDevOpsException($"Azure DevOps {(int)response.StatusCode} bei {method} {path}{hint}: {detail}");
}
}
}
private static string ErrorMessage(string payload)
{
try
{
if (JsonNode.Parse(payload) is JsonObject o && o["message"]?.GetValue<string>() is { Length: > 0 } msg) return msg;
}
// InvalidOperationException: "message" exists but is not a string.
catch (Exception ex) when (ex is JsonException or InvalidOperationException) { }
return payload.Length <= 500 ? payload : payload[..500];
}
}
@@ -0,0 +1,165 @@
using System.Text;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tickets;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.AzureDevOps;
/// <summary>
/// The company-repo end of a review: push the unit branch and find-or-create its draft PR on Azure DevOps.
/// Never touches task status or worktree state — the task stays WaitingForReview until the ADO merge.
/// </summary>
public sealed class PullRequestPublisher
{
/// <summary>Web front of the Bandel ticket system; the API base URL (an IP) is not browsable.</summary>
public const string TicketWebBase = "https://ticket.fb-tuning.local";
private readonly GitService _git;
private readonly AzureDevOpsClient _azdo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HubBroadcaster _broadcaster;
private readonly WorkerConfig _cfg;
private readonly ILogger<PullRequestPublisher> _logger;
private readonly TicketClientFactory? _tickets;
public PullRequestPublisher(
GitService git, AzureDevOpsClient azdo, IDbContextFactory<ClaudeDoDbContext> dbFactory,
HubBroadcaster broadcaster, WorkerConfig cfg, ILogger<PullRequestPublisher> logger,
TicketClientFactory? tickets = null)
{
_git = git;
_azdo = azdo;
_dbFactory = dbFactory;
_broadcaster = broadcaster;
_cfg = cfg;
_logger = logger;
_tickets = tickets;
}
/// <summary>Live check of origin against <c>company_remote_pattern</c>.</summary>
public Task<bool> IsCompanyRepoAsync(string workingDir, CancellationToken ct) =>
CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
/// <summary>Why a PR can't be published from <paramref name="workingDir"/>, or null — checked before the verify run so a missing PAT doesn't cost a build.</summary>
public async Task<string?> CheckReadyAsync(string workingDir, CancellationToken ct)
{
if (string.IsNullOrEmpty(_azdo.Pat))
return "Kein Azure-DevOps-PAT hinterlegt (Einstellungen → Externe Dienste) — nichts gepusht.";
var remote = await _git.GetRemoteUrlAsync(workingDir, "origin", ct);
return AzureDevOpsClient.ParseRemote(remote) is null
? $"origin ist kein https-Remote eines Azure DevOps Servers: {remote}"
: null;
}
/// <summary>Push + draft PR; <paramref name="verifyCommand"/> is the command that passed (null = none ran).</summary>
public async Task<MergeResult> PublishAsync(
TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct)
{
var workingDir = list.WorkingDir!;
if (await CheckReadyAsync(workingDir, ct) is { } notReady) return Blocked(notReady);
var pat = _azdo.Pat!;
var repo = AzureDevOpsClient.ParseRemote(await _git.GetRemoteUrlAsync(workingDir, "origin", ct))!;
var (pushExit, pushErr) = await _git.PushBranchAsync(wt.Path, wt.BranchName, AzureDevOpsClient.GitAuthEnv(pat), ct);
if (pushExit != 0) return Blocked($"push rejected: {pushErr}");
string url;
bool created;
try
{
var target = await ResolveTargetAsync(task.ListId, repo, ct);
if (target == wt.BranchName)
return Blocked($"gepusht, PR fehlgeschlagen: Quell- und Ziel-Branch sind beide '{target}'.");
var ticketId = TicketStatusMap.ParseBandelTicketId(task.TicketRef);
var title = BuildTitle(task, list, ticketId);
var description = BuildDescription(task, ticketId, await ResolveProjectIdAsync(ticketId, ct), verifyCommand);
(url, created) = await _azdo.EnsureDraftPullRequestAsync(repo, wt.BranchName, target, title, description, ct);
}
// InvalidOperationException: a field of the ADO answer has an unexpected JSON type.
catch (Exception ex) when (ex is AzureDevOpsException or InvalidOperationException)
{
return Blocked($"gepusht, PR fehlgeschlagen: {ex.Message}");
}
using (var ctx = _dbFactory.CreateDbContext())
await new WorktreeRepository(ctx).SetPullRequestUrlAsync(task.Id, url, ct);
await _broadcaster.WorktreeUpdated(task.Id);
_logger.LogInformation("Task {task_id}: branch {branch} pushed, draft PR {pr_url} ({pr_state})",
task.Id, wt.BranchName, url, created ? "created" : "reused");
return new MergeResult(created ? TaskMergeService.StatusPrCreated : TaskMergeService.StatusPrUpdated,
Array.Empty<string>(), null, url);
}
/// <summary><c>&lt;type&gt;(&lt;list-slug&gt;): &lt;title&gt;, Resolves #&lt;ticket&gt;</c>.</summary>
public static string BuildTitle(TaskEntity task, ListEntity list, int? ticketId)
{
var type = string.IsNullOrWhiteSpace(task.CommitType) ? CommitTypeRegistry.DefaultType : task.CommitType.Trim();
var slug = CommitMessageBuilder.ToSlug(list.Name);
var scope = slug.Length == 0 ? "" : $"({slug})";
var title = $"{type}{scope}: {task.Title.Trim()}";
return ticketId is null ? title : $"{title}, Resolves #{ticketId}";
}
/// <summary>Ticket link (plain <c>Resolves #id</c> without a project id), task description, and only what really ran.</summary>
public static string BuildDescription(TaskEntity task, int? ticketId, int? projectId, string? verifyCommand)
{
var sb = new StringBuilder();
if (ticketId is { } id)
{
sb.Append(projectId is { } pid
? $"Resolves [#{id}]({TicketWebBase}/board/project/{pid}/ticket/{id})"
: $"Resolves #{id}");
sb.Append("\n\n");
}
if (!string.IsNullOrWhiteSpace(task.Description))
sb.Append(task.Description.Trim()).Append("\n\n");
sb.Append(verifyCommand is null ? "Geprüft: nichts automatisch" : $"Geprüft: `{verifyCommand}` grün");
return sb.ToString();
}
private async Task<string> ResolveTargetAsync(string listId, AzdoRepo repo, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var baseBranch = await ctx.ListConfigs.AsNoTracking()
.Where(c => c.ListId == listId)
.Select(c => c.BaseBranch)
.FirstOrDefaultAsync(ct);
return baseBranch.NullIfBlank() ?? await _azdo.GetDefaultBranchAsync(repo, ct);
}
/// <summary>
/// The project an item lives in: a ticket's parent is its project, a task's parent is a ticket — so walk
/// up until <c>parentTicketID</c> is null (live-checked 2026-10-01). Null when unknown or unreachable.
/// </summary>
private async Task<int?> ResolveProjectIdAsync(int? ticketId, CancellationToken ct)
{
if (ticketId is null || _tickets?.Create() is not { } client) return null;
try
{
var id = ticketId.Value;
for (var hop = 0; hop < 3; hop++)
{
var detail = await client.GetTicketAsync(id, ct);
if (detail.ParentTicketId is not { } parent) return hop == 0 ? null : id;
id = parent;
}
}
catch (TicketApiException ex)
{
_logger.LogWarning("Project of ticket {ticket_id} unknown, PR links no ticket: {reason}", ticketId, ex.Message);
}
return null;
}
private static MergeResult Blocked(string reason) =>
new(TaskMergeService.StatusBlocked, Array.Empty<string>(), reason);
}
+39 -4
View File
@@ -36,6 +36,8 @@ Worker/
Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
DailyPrepPrompt, PrimeScheduleValidation, NextDueCalculator, PrimeScheduleSignal
Online/ — optional Online Inbox sync (off by default; zero network when disabled)
AzureDevOps/ — company-repo push + draft PR: AzureDevOpsClient (REST, port of pr-create.ps1),
PullRequestPublisher (push, find-or-create draft PR, pull_request_url)
Tickets/ — Bandel ticket-system integration (per-list import + status write-back),
inactive without ticket_api_base_url: TicketSystemConfig/TicketClientFactory,
BandelTicketClient, TicketDtos, TicketStatusMap, TicketStatusSync,
@@ -151,8 +153,8 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low-
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
- **WorktreeManager** — worktrees on `<typ>/<nummer>-<slug>` branches, same leaf as the folder (`NameFor`, slug capped at 40 chars, umlauts transliterated). Typ from `BranchTypeFor` (`feat`→`feature`, `fix/refactor/docs/test/chore` as-is, anything else `chore`). Nummer = ticket id (own `TicketRef` or the parent's), else the task number — in a company repo (`CompanyRepo`, origin matches `company_remote_pattern`) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base = `rev-parse <unit branch>` for a child whose parent has an Active worktree, else `list_config.base_branch`, else the checked-out HEAD. Commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
- **WorktreeManager** — worktrees on `<typ>/<nummer>-<slug>` branches, same leaf as the folder (`NameFor`, slug capped at 40 chars, umlauts transliterated). Typ from `BranchTypeFor` (`feat`→`feature`, `fix/refactor/docs/test/chore` as-is, anything else `chore`). Nummer = ticket id (own `TicketRef` or the parent's), else the task number — in a company repo (`CompanyRepo`, origin matches `company_remote_pattern`) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base = `rev-parse <unit branch>` for a child whose parent has an Active worktree, else `list_config.base_branch`, else the checked-out HEAD — in a company repo a unit root first fetches its basis (see Azure DevOps below). Commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId` (no trailer in company repos — the convention forbids signature trailers and nothing reads it); `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs
@@ -273,10 +275,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding.
- `db_path`, `sandbox_root`, `log_root`
- `worktree_root_strategy` (`sibling` | `central`), `central_worktree_root`
- `queue_backstop_interval_ms` (30000) — also the gate/throttle recovery timer
- `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar.
- `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar. Schaltet Fetch vor dem Unit-Root, Approve = Push + Draft-PR und Commits ohne Trailer (siehe Azure DevOps).
- `signalr_port` (47821), `claude_bin`, `ticket_api_base_url` — the two UI-writable fields
(`claude_bin`: Settings → Dateien → CLAUDE CLI, hub `GetClaudeBin`/`SetClaudeBin`;
`ticket_api_base_url`: Settings → Ticketsystem, hub `GetTicketSettings`/`SetTicketApiBaseUrl`).
`ticket_api_base_url`: Settings → Externe Dienste, hub `GetTicketSettings`/`SetTicketApiBaseUrl`).
Both edit the DI-singleton `WorkerConfig` in place *and* persist via their own `SaveKey` call
(`SaveClaudeBin`/`SaveTicketApiBaseUrl`), so each applies without a restart. Every other field
here is hand-edit only.
@@ -341,6 +343,39 @@ distinguish the two singletons). The token never touches `worker.config.json` or
`TicketStatusMap.NameOf` supplies the canonical name.
⚠️ The base URL must be the **IP** — `bandelapis.fb-tuning.local` still refuses the connection.
## Azure DevOps (company repos)
Active when origin matches `company_remote_pattern` (live `git remote get-url`, no cache). The PAT
(scope Code Read & Write) is DPAPI-encrypted at `~/.claudeDo/azdo.pat` (DI marker
`AzureDevOpsPatStore`), set via Settings → Externe Dienste (hub `GetAzureDevOpsSettings` /
`SetAzureDevOpsToken` / `ClearAzureDevOpsToken` / `TestAzureDevOpsConnection` — the test reads the
first list whose origin matches and parses). Deliberately independent of the work-conventions
plugin's PAT file.
- **`AzureDevOpsClient`** — the only HTTP surface. `ParseRemote` splits at `/_git/` (API base =
everything before it, embedded credentials stripped, http(s) only). `GitAuthEnv(pat)` is the
env for every git network call: `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`, PAT as
`http.extraHeader` via `GIT_CONFIG_COUNT/KEY_0/VALUE_0` — never on a command line. API version
starts at 6.0 and retries once with the version an out-of-range error names. ⚠️ A rejected PAT
is HTTP **203** + an HTML login page, which `IsSuccessStatusCode` counts as success — handled
explicitly.
- **Fetch before a unit root** (`WorktreeManager.FetchCompanyBaseAsync`, once per unit root, never
per child): basis = `base_branch` → ADO `defaultBranch` → `origin/HEAD`; `git fetch origin
+refs/heads/<b>:refs/remotes/origin/<b>`; the worktree starts from `origin/<b>`. Local `<b>` is
pulled along best-effort: not checked out → `git fetch . origin/<b>:<b>` (no network, git
refuses a non-ff), checked out + clean → `merge --ff-only`, dirty/diverged → warning only. A
failed fetch warns and continues from the local branch — it never fails the task.
- **Approve** → `TaskMergeService.ApproveCompanyAsync` → `PullRequestPublisher`; details in
[review-merge](../../docs/explore-notes/review-merge.md). PR title
`<type>(<list-slug>): <title>, Resolves #<ticket>`; description
`Resolves [#id](https://ticket.fb-tuning.local/board/project/<p>/ticket/<id>)` (project id = walk
`GET /api/ticket/{id}` up until `parentTicketID` is null — a ticket's parent is its project, a
task's parent is a ticket, live-checked 2026-10-01; unknown → plain `Resolves #id`), the task
description, `Geprüft: …` with only what actually ran. Target = `base_branch`, else the ADO
default branch.
- Not done on purpose: PR status polling / auto-`Done`, other PR providers, worktree cleanup on
`Done`, fetch in private repos, a UI for `company_remote_pattern`.
## Notes
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
+4 -1
View File
@@ -8,7 +8,7 @@ namespace ClaudeDo.Worker;
/// <summary>
/// Persists a single secret encrypted with DPAPI (CurrentUser scope), one instance per file.
/// Windows-only. Used for the Online-Inbox refresh token (~/.claudeDo/online-inbox.token) and
/// the ticket-system PAT (~/.claudeDo/ticket.pat).
/// the ticket-system PAT (~/.claudeDo/ticket.pat) and the Azure DevOps PAT (~/.claudeDo/azdo.pat).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class DpapiTokenStore
@@ -55,3 +55,6 @@ public sealed record OnlineRefreshTokenStore(DpapiTokenStore Store);
/// DI-Marker: der Personal Access Token des Ticketsystems.
public sealed record TicketPatStore(DpapiTokenStore Store);
/// DI-Marker: der Personal Access Token für Azure DevOps (Push + Draft-PR im Firmen-Repo).
public sealed record AzureDevOpsPatStore(DpapiTokenStore Store);
+8 -1
View File
@@ -636,7 +636,10 @@ public sealed class ExternalMcpService
"means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
"the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
"reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
"delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)]
"delivered something. In a company repo (origin on the Azure DevOps Server) approve does not merge: it runs " +
"the verify command in the task's worktree (skipVerify is ignored), pushes the branch and opens or reuses a " +
"draft PR — mergeStatus 'pr_created'/'pr_updated', mergeMessage = the PR URL, the task stays WaitingForReview " +
"and needs no verify_merges." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)]
public async Task<ReviewTaskResult> ReviewTask(
string taskId,
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
@@ -736,6 +739,10 @@ public sealed class ExternalMcpService
mergeMessage = "merged; verify deferred — the task stays WaitingForReview until " +
"verify_merges (with its id) passes";
}
else if (r.Status is TaskMergeService.StatusPrCreated or TaskMergeService.StatusPrUpdated)
{
mergeMessage = r.PullRequestUrl;
}
else if (r.Status == TaskMergeService.StatusConflict)
{
if (leaveConflictsInTree)
+49 -3
View File
@@ -18,6 +18,7 @@ using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.AzureDevOps;
using ClaudeDo.Worker.Tickets;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Usage.Interfaces;
@@ -76,6 +77,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly HandlerRoundState? _roundState;
private readonly ConnectedClientTracker? _clientTracker;
private readonly RunningOperationRegistry? _operations;
private readonly AzureDevOpsPatStore? _azdoPat;
private readonly AzureDevOpsClient? _azdo;
public WorkerHub(
QueueService queue,
@@ -118,7 +121,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
HandlerRestartCoordinator? handlerRestart = null,
HandlerRoundState? roundState = null,
ConnectedClientTracker? clientTracker = null,
RunningOperationRegistry? operations = null)
RunningOperationRegistry? operations = null,
AzureDevOpsPatStore? azdoPat = null,
AzureDevOpsClient? azdo = null)
{
_queue = queue;
_waker = waker;
@@ -161,6 +166,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_roundState = roundState;
_clientTracker = clientTracker;
_operations = operations;
_azdoPat = azdoPat;
_azdo = azdo;
}
// Tracks connected UI clients so an out-of-band caller (external MCP's start_list_handler)
@@ -509,7 +516,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
=> HubGuard(async () =>
{
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty, t.IsCompanyRepo);
});
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
@@ -721,7 +728,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None, autostash);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "approve failed");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage, r.PullRequestUrl);
});
public async Task RejectReviewToQueue(string taskId, string feedback)
@@ -1428,6 +1435,45 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
}
}
public AzureDevOpsSettingsDto GetAzureDevOpsSettings() => new(_azdoPat?.Store.Exists() == true);
public void SetAzureDevOpsToken(string token)
{
if (_azdoPat is null) return;
if (string.IsNullOrWhiteSpace(token)) _azdoPat.Store.Clear();
else _azdoPat.Store.Save(token.Trim());
}
public void ClearAzureDevOpsToken() => _azdoPat?.Store.Clear();
/// <summary>Reads the first company repo's ADO repository (default branch). Errors are answers, not throws — same reason as TestTicketConnection.</summary>
public async Task<AzureDevOpsConnectionDto> TestAzureDevOpsConnection()
{
if (_azdo is null) return new AzureDevOpsConnectionDto(false, null, null, "Azure DevOps ist im Worker nicht registriert.");
try
{
var git = _git ?? new Data.Git.GitService();
List<string> dirs;
await using (var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted))
dirs = await ctx.Lists.AsNoTracking().Where(l => l.WorkingDir != null).Select(l => l.WorkingDir!).ToListAsync(Context.ConnectionAborted);
foreach (var dir in dirs)
{
if (!Directory.Exists(dir)) continue;
var remote = await git.GetRemoteUrlAsync(dir, "origin", Context.ConnectionAborted);
if (!Git.CompanyRepo.Matches(remote, _cfg.CompanyRemotePattern) || AzureDevOpsClient.ParseRemote(remote) is not { } repo)
continue;
var branch = await _azdo.GetDefaultBranchAsync(repo, Context.ConnectionAborted);
return new AzureDevOpsConnectionDto(true, repo.Repo, branch, null);
}
return new AzureDevOpsConnectionDto(false, null, null, "Keine Liste zeigt auf ein Firmen-Repo (origin mit " + _cfg.CompanyRemotePattern + ").");
}
catch (Exception ex)
{
return new AzureDevOpsConnectionDto(false, null, null, ex.Message);
}
}
public async Task<List<TicketProjectDto>> GetTicketProjects()
{
var client = _ticketClients.Create();
@@ -3,6 +3,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.AzureDevOps;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
@@ -15,13 +16,15 @@ namespace ClaudeDo.Worker.Lifecycle;
public sealed record MergeResult(
string Status,
IReadOnlyList<string> ConflictFiles,
string? ErrorMessage);
string? ErrorMessage,
string? PullRequestUrl = null);
public sealed record MergeTargets(
string DefaultBranch,
IReadOnlyList<string> LocalBranches,
string DefaultCommitMessage,
bool TargetDirty = false);
bool TargetDirty = false,
bool IsCompanyRepo = false);
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
@@ -76,6 +79,11 @@ public sealed class TaskMergeService
// autostash: true) without matching on prose.
public const string StatusDirtyTarget = "dirty_target";
// Company repo: approve pushed the unit branch and opened (or reused) its draft PR; the task stays
// WaitingForReview, nothing landed locally.
public const string StatusPrCreated = "pr_created";
public const string StatusPrUpdated = "pr_updated";
public const string StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted";
@@ -119,6 +127,7 @@ public sealed class TaskMergeService
private readonly ILogger<TaskMergeService> _logger;
private readonly UnitBranchService? _unitBranch;
private readonly RunningOperationRegistry _operations;
private readonly PullRequestPublisher? _pullRequests;
public TaskMergeService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -128,7 +137,8 @@ public sealed class TaskMergeService
IVerifyCommandRunner verify,
ILogger<TaskMergeService> logger,
UnitBranchService? unitBranch = null,
RunningOperationRegistry? operations = null)
RunningOperationRegistry? operations = null,
PullRequestPublisher? pullRequests = null)
{
_dbFactory = dbFactory;
_git = git;
@@ -138,6 +148,7 @@ public sealed class TaskMergeService
_logger = logger;
_unitBranch = unitBranch;
_operations = operations ?? new RunningOperationRegistry();
_pullRequests = pullRequests;
}
private IDisposable TrackTask(string kind, TaskEntity task) =>
@@ -893,7 +904,8 @@ public sealed class TaskMergeService
var current = await ResolveTargetAsync(list, null, ct);
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
return new MergeTargets(current, branches, defaultMessage, dirty);
var company = _pullRequests is not null && await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct);
return new MergeTargets(current, branches, defaultMessage, dirty, company);
}
/// <summary>Leeres Ziel → <c>list_config.base_branch</c>, sonst der ausgecheckte Branch des Main-Checkouts.</summary>
@@ -1054,6 +1066,12 @@ public sealed class TaskMergeService
}
}
// Kept (parked or reverted) goes the PR way too: a local merge has no place in a company repo.
if (wt is { State: WorktreeState.Active or WorktreeState.Kept } && _pullRequests is not null
&& !string.IsNullOrWhiteSpace(list.WorkingDir)
&& await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct))
return await ApproveCompanyAsync(task, list, wt, verifyCommand, ct, progress);
if (wt is not null && wt.State == WorktreeState.Kept)
{
// Kept means either RevertMergeAsync undid a previous merge, or the user manually
@@ -1137,6 +1155,42 @@ public sealed class TaskMergeService
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify, autostash);
}
/// <summary>
/// Company repo: verify in the unit root's worktree (nothing lands on a local target, so there is no
/// merge to defer the verify to — <c>skipVerify</c> does not apply), then push + draft PR. Red verify
/// pushes nothing. Status, worktree state and ticket stay as they are.
/// </summary>
private async Task<MergeResult> ApproveCompanyAsync(
TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress)
{
if (await _pullRequests!.CheckReadyAsync(list.WorkingDir!, ct) is { } notReady)
return Blocked(notReady);
var ranVerify = !string.IsNullOrWhiteSpace(verifyCommand);
if (ranVerify)
{
await _broadcaster.OperationProgress(task.Id, PhaseVerifying, 0, 0);
var failed = await RunVerifyGateAsync(
verifyCommand, wt.Path, () => TrackTask(OperationKinds.VerifyMerge, task), ct, progress,
elapsed => _ = _broadcaster.OperationProgress(task.Id, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
if (failed is not null) return failed;
}
var result = await ProgressReporter.RunAsync(
_pullRequests.PublishAsync(task, list, wt, ranVerify ? verifyCommand : null, ct),
ProgressReportInterval, progress, "push + draft PR running");
// A branch with an open PR is under review again, whatever parked it before.
if (wt.State == WorktreeState.Kept && result.PullRequestUrl is not null)
{
using (var ctx = _dbFactory.CreateDbContext())
await new WorktreeRepository(ctx).SetStateAsync(task.Id, WorktreeState.Active, ct);
await _broadcaster.WorktreeUpdated(task.Id);
}
return result;
}
/// <summary>
/// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
/// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by
+5
View File
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker;
using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.AzureDevOps;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Findings;
@@ -248,12 +249,16 @@ builder.Services.AddSingleton(cfg.OnlineInbox);
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppData("online-inbox.token")));
builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat")));
builder.Services.AddSingleton(new AzureDevOpsPatStore(DpapiTokenStore.InAppData("azdo.pat")));
#pragma warning restore CA1416
builder.Services.AddSingleton<TicketSystemConfig>();
builder.Services.AddHttpClient("tickets");
builder.Services.AddSingleton<TicketClientFactory>();
builder.Services.AddSingleton<TicketStatusSync>();
builder.Services.AddSingleton<TicketImportService>();
builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddSingleton<AzureDevOpsClient>();
builder.Services.AddSingleton<PullRequestPublisher>();
if (cfg.OnlineInbox.Enabled)
{
@@ -6,7 +6,9 @@ namespace ClaudeDo.Worker.Runner;
public static class CommitMessageBuilder
{
public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId)
/// <summary>Task commit message; <paramref name="includeTrailer"/> is false in company repos, whose convention forbids signature trailers.</summary>
public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId,
bool includeTrailer = true)
{
var slug = ToSlug(listName);
var title = Truncate(taskTitle, 60);
@@ -22,9 +24,11 @@ public static class CommitMessageBuilder
sb.Append(Truncate(taskDescription!.Trim(), 400));
}
// Trailer is always included.
sb.Append("\n\n");
sb.Append($"ClaudeDo-Task: {taskId}");
if (includeTrailer)
{
sb.Append("\n\n");
sb.Append($"ClaudeDo-Task: {taskId}");
}
return sb.ToString();
}
+94 -8
View File
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.AzureDevOps;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
@@ -26,11 +27,12 @@ public sealed class WorktreeManager
private readonly ILogger<WorktreeManager> _logger;
private readonly HubBroadcaster? _broadcaster;
private readonly RunningOperationRegistry _operations;
private readonly AzureDevOpsClient? _azdo;
public WorktreeManager(
GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg,
ILogger<WorktreeManager> logger, HubBroadcaster? broadcaster = null,
RunningOperationRegistry? operations = null)
RunningOperationRegistry? operations = null, AzureDevOpsClient? azdo = null)
{
_git = git;
_dbFactory = dbFactory;
@@ -38,6 +40,7 @@ public sealed class WorktreeManager
_logger = logger;
_broadcaster = broadcaster;
_operations = operations ?? new RunningOperationRegistry();
_azdo = azdo;
}
private Task BroadcastCreating(string taskId) =>
@@ -80,8 +83,8 @@ public sealed class WorktreeManager
if (await _git.EnsureHeadCommitAsync(workingDir, ct))
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
var isCompany = await CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, isCompany, ct);
var name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany);
var branchType = BranchTypeFor(task.CommitType);
var slug = CommitMessageBuilder.ToSlug(list.Name);
@@ -204,8 +207,10 @@ public sealed class WorktreeManager
await _git.AddAllAsync(ctx.WorktreePath, ct);
var isCompany = list.WorkingDir is not null
&& await CompanyRepo.IsCompanyAsync(_git, list.WorkingDir, _cfg.CompanyRemotePattern, ct);
var message = CommitMessageBuilder.Build(
task.CommitType, list.Name, task.Title, task.Description, task.Id);
task.CommitType, list.Name, task.Title, task.Description, task.Id, includeTrailer: !isCompany);
await _git.CommitAsync(ctx.WorktreePath, message, ct);
@@ -264,9 +269,10 @@ public sealed class WorktreeManager
/// <summary>
/// A child of a parent with an Active worktree starts from the unit branch's current head, so it
/// sees every sibling absorbed before it. Unit roots (and legacy children) base off
/// <c>list_config.base_branch</c>, else the current HEAD.
/// <c>list_config.base_branch</c>, else the current HEAD — in a company repo off a freshly fetched
/// <c>origin/&lt;basis&gt;</c> (<see cref="FetchCompanyBaseAsync"/>).
/// </summary>
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, bool isCompany, CancellationToken ct)
{
if (task.ParentTaskId is not null)
{
@@ -276,12 +282,92 @@ public sealed class WorktreeManager
return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
}
using var cfgCtx = _dbFactory.CreateDbContext();
var baseBranch = await cfgCtx.ListConfigs.AsNoTracking()
var baseBranch = (await cfgCtx.ListConfigs.AsNoTracking()
.Where(c => c.ListId == task.ListId)
.Select(c => c.BaseBranch)
.FirstOrDefaultAsync(ct);
return string.IsNullOrWhiteSpace(baseBranch)
.FirstOrDefaultAsync(ct)).NullIfBlank();
if (isCompany)
{
try
{
if (await FetchCompanyBaseAsync(workingDir, baseBranch, ct) is { } fetched) return fetched;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Fetching the company base in {working_dir} failed, continuing with the local branch", workingDir);
}
}
return baseBranch is null
? await _git.RevParseHeadAsync(workingDir, ct)
: await _git.RevParseAsync(workingDir, baseBranch, ct);
}
/// <summary>
/// Fetches the basis (<c>base_branch</c>, else the ADO repo's default branch, else <c>origin/HEAD</c>) and
/// returns <c>origin/&lt;basis&gt;</c>'s commit; the local basis is fast-forwarded best-effort. Null when no
/// basis is known or the fetch failed (offline, no credentials) — the caller then uses the local basis,
/// a fetch problem never fails the task.
/// </summary>
private async Task<string?> FetchCompanyBaseAsync(string workingDir, string? baseBranch, CancellationToken ct)
{
var remote = await _git.GetRemoteUrlAsync(workingDir, "origin", ct);
var pat = _azdo?.Pat;
var basis = baseBranch;
if (basis is null && _azdo is not null && pat is not null && AzureDevOpsClient.ParseRemote(remote) is { } repo)
{
try { basis = await _azdo.GetDefaultBranchAsync(repo, ct); }
catch (Exception ex) when (ex is AzureDevOpsException or InvalidOperationException) { _logger.LogWarning("Default branch from Azure DevOps unavailable: {reason}", ex.Message); }
}
basis ??= await _git.GetRemoteHeadBranchAsync(workingDir, ct);
if (basis is null)
{
_logger.LogWarning("Company repo {working_dir}: no base branch known, worktree starts from HEAD without fetch", workingDir);
return null;
}
var (fetchExit, fetchErr) = await _git.FetchBranchAsync(workingDir, basis, AzureDevOpsClient.GitAuthEnv(pat), ct);
if (fetchExit != 0)
{
_logger.LogWarning("git fetch origin {branch} failed in {working_dir}, continuing with the local branch: {reason}",
basis, workingDir, fetchErr);
return baseBranch is null ? await TryRevParseAsync(workingDir, basis, ct) : null;
}
await FastForwardLocalBaseAsync(workingDir, basis, ct);
return await _git.RevParseAsync(workingDir, $"refs/remotes/origin/{basis}", ct);
}
/// <summary>Pulls the local basis up to <c>origin/&lt;basis&gt;</c> without ever touching a dirty or diverged checkout.</summary>
private async Task FastForwardLocalBaseAsync(string workingDir, string basis, CancellationToken ct)
{
string? current;
try { current = await _git.GetCurrentBranchAsync(workingDir, ct); }
catch (InvalidOperationException) { current = null; } // detached HEAD
(int ExitCode, string Stderr) result;
if (current == basis)
{
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
{
_logger.LogWarning("Local {branch} in {working_dir} has uncommitted changes, not fast-forwarded to origin", basis, workingDir);
return;
}
result = await _git.MergeFastForwardOnlyAsync(workingDir, $"refs/remotes/origin/{basis}", ct);
}
else
{
if (!(await _git.ListLocalBranchesAsync(workingDir, ct)).Contains(basis)) return;
result = await _git.FastForwardLocalBranchAsync(workingDir, basis, ct);
}
if (result.ExitCode != 0)
_logger.LogWarning("Local {branch} in {working_dir} not fast-forwarded to origin: {reason}", basis, workingDir, result.Stderr);
}
private async Task<string?> TryRevParseAsync(string workingDir, string revision, CancellationToken ct)
{
try { return await _git.RevParseAsync(workingDir, revision, ct); }
catch (InvalidOperationException) { return null; }
}
}