feat(worker): company unit roots start from a fetched origin base, local base fast-forwarded best-effort
This commit is contained in:
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
|
|||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.AzureDevOps;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Git;
|
using ClaudeDo.Worker.Git;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
@@ -24,16 +25,18 @@ public sealed class WorktreeManager
|
|||||||
private readonly WorkerConfig _cfg;
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly ILogger<WorktreeManager> _logger;
|
private readonly ILogger<WorktreeManager> _logger;
|
||||||
private readonly HubBroadcaster? _broadcaster;
|
private readonly HubBroadcaster? _broadcaster;
|
||||||
|
private readonly AzureDevOpsClient? _azdo;
|
||||||
|
|
||||||
public WorktreeManager(
|
public WorktreeManager(
|
||||||
GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg,
|
GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg,
|
||||||
ILogger<WorktreeManager> logger, HubBroadcaster? broadcaster = null)
|
ILogger<WorktreeManager> logger, HubBroadcaster? broadcaster = null, AzureDevOpsClient? azdo = null)
|
||||||
{
|
{
|
||||||
_git = git;
|
_git = git;
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_cfg = cfg;
|
_cfg = cfg;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
|
_azdo = azdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task BroadcastCreating(string taskId) =>
|
private Task BroadcastCreating(string taskId) =>
|
||||||
@@ -76,8 +79,8 @@ public sealed class WorktreeManager
|
|||||||
if (await _git.EnsureHeadCommitAsync(workingDir, ct))
|
if (await _git.EnsureHeadCommitAsync(workingDir, ct))
|
||||||
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
|
_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 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 name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany);
|
||||||
var branchType = BranchTypeFor(task.CommitType);
|
var branchType = BranchTypeFor(task.CommitType);
|
||||||
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||||
@@ -261,9 +264,10 @@ public sealed class WorktreeManager
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A child of a parent with an Active worktree starts from the unit branch's current head, so it
|
/// 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
|
/// 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/<basis></c> (<see cref="FetchCompanyBaseAsync"/>).
|
||||||
/// </summary>
|
/// </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)
|
if (task.ParentTaskId is not null)
|
||||||
{
|
{
|
||||||
@@ -273,12 +277,83 @@ public sealed class WorktreeManager
|
|||||||
return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
|
return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
|
||||||
}
|
}
|
||||||
using var cfgCtx = _dbFactory.CreateDbContext();
|
using var cfgCtx = _dbFactory.CreateDbContext();
|
||||||
var baseBranch = await cfgCtx.ListConfigs.AsNoTracking()
|
var baseBranch = (await cfgCtx.ListConfigs.AsNoTracking()
|
||||||
.Where(c => c.ListId == task.ListId)
|
.Where(c => c.ListId == task.ListId)
|
||||||
.Select(c => c.BaseBranch)
|
.Select(c => c.BaseBranch)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct)).NullIfBlank();
|
||||||
return string.IsNullOrWhiteSpace(baseBranch)
|
|
||||||
|
if (isCompany && await FetchCompanyBaseAsync(workingDir, baseBranch, ct) is { } fetched)
|
||||||
|
return fetched;
|
||||||
|
|
||||||
|
return baseBranch is null
|
||||||
? await _git.RevParseHeadAsync(workingDir, ct)
|
? await _git.RevParseHeadAsync(workingDir, ct)
|
||||||
: await _git.RevParseAsync(workingDir, baseBranch, 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/<basis></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 (AzureDevOpsException ex) { _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/<basis></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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Git;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.AzureDevOps;
|
||||||
|
|
||||||
|
/// <summary>Spec 2026-10-01 "Basis aktualisieren": a company unit root starts from a fetched origin/<basis>. Real git, local bare repo as origin.</summary>
|
||||||
|
public sealed class CompanyFetchBaseTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly GitRepoFixture _repo = new();
|
||||||
|
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"cd_fetch_{Guid.NewGuid():N}");
|
||||||
|
private readonly string _bare;
|
||||||
|
private readonly string _listName = $"fb{Guid.NewGuid():N}"[..12];
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
|
||||||
|
public CompanyFetchBaseTests()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
GitRepoFixture.RunGit(_repo.RepoDir, "branch", "-m", "main");
|
||||||
|
_bare = Path.Combine(_tempDir, "cdbare_origin.git");
|
||||||
|
GitRepoFixture.RunGit(_tempDir, "clone", "--bare", "-q", _repo.RepoDir, _bare);
|
||||||
|
GitRepoFixture.RunGit(_repo.RepoDir, "remote", "add", "origin", _bare);
|
||||||
|
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir, WorktreeRootStrategy = "sibling", CompanyRemotePattern = "cdbare_" };
|
||||||
|
|
||||||
|
using var ctx = _db.CreateContext();
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = "l1", Name = _listName, WorkingDir = _repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.ListConfigs.Add(new ListConfigEntity { ListId = "l1", BaseBranch = "main" });
|
||||||
|
ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "Root", Number = 1, CommitType = "feat",
|
||||||
|
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(_repo.RepoDir), ".claudedo-worktrees", _listName), true); } catch { }
|
||||||
|
_db.Dispose();
|
||||||
|
_repo.Dispose();
|
||||||
|
try { GitRepoFixture.ForceDeleteDirectory(_tempDir); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pushes one commit to the bare origin from a scratch clone; returns its sha.</summary>
|
||||||
|
internal static string PushCommitToOrigin(string bare, string tempDir)
|
||||||
|
{
|
||||||
|
var clone = Path.Combine(tempDir, $"clone_{Guid.NewGuid():N}");
|
||||||
|
GitRepoFixture.RunGit(tempDir, "clone", "-q", bare, clone);
|
||||||
|
GitRepoFixture.RunGit(clone, "config", "user.name", "test");
|
||||||
|
GitRepoFixture.RunGit(clone, "config", "user.email", "test@example.com");
|
||||||
|
File.WriteAllText(Path.Combine(clone, "upstream.txt"), "from colleague");
|
||||||
|
GitRepoFixture.RunGit(clone, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(clone, "commit", "-q", "-m", "upstream work");
|
||||||
|
GitRepoFixture.RunGit(clone, "push", "-q", "origin", "HEAD:main");
|
||||||
|
return GitRepoFixture.RunGit(clone, "rev-parse", "HEAD").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<WorktreeContext> CreateRoot()
|
||||||
|
{
|
||||||
|
using var ctx = _db.CreateContext();
|
||||||
|
var task = ctx.Tasks.Single(t => t.Id == "t1");
|
||||||
|
var list = ctx.Lists.Single();
|
||||||
|
return new WorktreeManager(new GitService(), _db.CreateFactory(), _cfg, NullLogger<WorktreeManager>.Instance)
|
||||||
|
.CreateAsync(task, list, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Head(string rev = "HEAD") => GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", rev).Trim();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OriginAhead_RootStartsFromOrigin_AndCheckedOutCleanBaseIsFastForwarded()
|
||||||
|
{
|
||||||
|
var upstream = PushCommitToOrigin(_bare, _tempDir);
|
||||||
|
|
||||||
|
var wt = await CreateRoot();
|
||||||
|
|
||||||
|
Assert.Equal(upstream, wt.BaseCommit);
|
||||||
|
Assert.Equal(upstream, Head());
|
||||||
|
Assert.True(File.Exists(Path.Combine(wt.WorktreePath, "upstream.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DirtyCheckout_StaysUntouched_ButRootStillStartsFromOrigin()
|
||||||
|
{
|
||||||
|
var before = Head();
|
||||||
|
var upstream = PushCommitToOrigin(_bare, _tempDir);
|
||||||
|
var readme = Path.Combine(_repo.RepoDir, "README.md");
|
||||||
|
File.WriteAllText(readme, "local WIP");
|
||||||
|
|
||||||
|
var wt = await CreateRoot();
|
||||||
|
|
||||||
|
Assert.Equal(upstream, wt.BaseCommit);
|
||||||
|
Assert.Equal(before, Head());
|
||||||
|
Assert.Equal("local WIP", File.ReadAllText(readme));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BaseNotCheckedOut_LocalBranchIsFastForwarded_CheckoutUnchanged()
|
||||||
|
{
|
||||||
|
GitRepoFixture.RunGit(_repo.RepoDir, "checkout", "-q", "-b", "other");
|
||||||
|
var upstream = PushCommitToOrigin(_bare, _tempDir);
|
||||||
|
|
||||||
|
var wt = await CreateRoot();
|
||||||
|
|
||||||
|
Assert.Equal(upstream, wt.BaseCommit);
|
||||||
|
Assert.Equal(upstream, Head("main"));
|
||||||
|
Assert.Equal("other", GitRepoFixture.RunGit(_repo.RepoDir, "branch", "--show-current").Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchFailure_DoesNotFailTheTask_RootStartsFromLocalBase()
|
||||||
|
{
|
||||||
|
GitRepoFixture.RunGit(_repo.RepoDir, "remote", "set-url", "origin", Path.Combine(_tempDir, "cdbare_missing.git"));
|
||||||
|
var local = Head("main");
|
||||||
|
|
||||||
|
var wt = await CreateRoot();
|
||||||
|
|
||||||
|
Assert.Equal(local, wt.BaseCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PrivateRepo_IsNeverFetched()
|
||||||
|
{
|
||||||
|
_cfg.CompanyRemotePattern = "/_git/";
|
||||||
|
var local = Head("main");
|
||||||
|
PushCommitToOrigin(_bare, _tempDir);
|
||||||
|
|
||||||
|
var wt = await CreateRoot();
|
||||||
|
|
||||||
|
Assert.Equal(local, wt.BaseCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ public sealed class GitRepoFixture : IDisposable
|
|||||||
RunGit(dir, "commit", "-m", "chore: seed");
|
RunGit(dir, "commit", "-m", "chore: seed");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ForceDeleteDirectory(string path)
|
internal static void ForceDeleteDirectory(string path)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path)) return;
|
if (!Directory.Exists(path)) return;
|
||||||
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
|
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
|
||||||
|
|||||||
Reference in New Issue
Block a user