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
+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; }
}
}