diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 6aa3fe61..3f446b22 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -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; @@ -24,16 +25,18 @@ public sealed class WorktreeManager private readonly WorkerConfig _cfg; private readonly ILogger _logger; private readonly HubBroadcaster? _broadcaster; + private readonly AzureDevOpsClient? _azdo; public WorktreeManager( GitService git, IDbContextFactory dbFactory, WorkerConfig cfg, - ILogger logger, HubBroadcaster? broadcaster = null) + ILogger logger, HubBroadcaster? broadcaster = null, AzureDevOpsClient? azdo = null) { _git = git; _dbFactory = dbFactory; _cfg = cfg; _logger = logger; _broadcaster = broadcaster; + _azdo = azdo; } private Task BroadcastCreating(string taskId) => @@ -76,8 +79,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); @@ -261,9 +264,10 @@ public sealed class WorktreeManager /// /// 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 - /// list_config.base_branch, else the current HEAD. + /// list_config.base_branch, else the current HEAD — in a company repo off a freshly fetched + /// origin/<basis> (). /// - private async Task ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct) + private async Task ResolveBaseCommitAsync(TaskEntity task, string workingDir, bool isCompany, CancellationToken ct) { if (task.ParentTaskId is not null) { @@ -273,12 +277,83 @@ 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 && await FetchCompanyBaseAsync(workingDir, baseBranch, ct) is { } fetched) + return fetched; + + return baseBranch is null ? await _git.RevParseHeadAsync(workingDir, ct) : await _git.RevParseAsync(workingDir, baseBranch, ct); } + + /// + /// Fetches the basis (base_branch, else the ADO repo's default branch, else origin/HEAD) and + /// returns origin/<basis>'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. + /// + private async Task 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); + } + + /// Pulls the local basis up to origin/<basis> without ever touching a dirty or diverged checkout. + 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 TryRevParseAsync(string workingDir, string revision, CancellationToken ct) + { + try { return await _git.RevParseAsync(workingDir, revision, ct); } + catch (InvalidOperationException) { return null; } + } } diff --git a/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyFetchBaseTests.cs b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyFetchBaseTests.cs new file mode 100644 index 00000000..50cfa8e8 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyFetchBaseTests.cs @@ -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; + +/// Spec 2026-10-01 "Basis aktualisieren": a company unit root starts from a fetched origin/<basis>. Real git, local bare repo as origin. +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 { } + } + + /// Pushes one commit to the bare origin from a scratch clone; returns its sha. + 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 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.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); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs index 4631ed3e..89e01b02 100644 --- a/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs @@ -93,7 +93,7 @@ public sealed class GitRepoFixture : IDisposable RunGit(dir, "commit", "-m", "chore: seed"); } - private static void ForceDeleteDirectory(string path) + internal static void ForceDeleteDirectory(string path) { if (!Directory.Exists(path)) return; foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))