feat(ui): warn when the running worker predates the selected repo's merged HEAD

Stamps ClaudeDo.Worker's build with its exact git SHA (SourceRevisionId ->
InformationalVersion) and exposes it via a new GetWorkerBuildInfo hub call.
For the currently selected list, the shell compares that SHA against the
list's git HEAD (GitService.IsAncestorAsync) and shows a persistent footer
banner -- never auto-clearing, never shown on an unrelated repo or when the
ancestry can't be determined -- so "verified against a merge" claims aren't
silently made against a stale process. No auto-restart; the banner just
offers the existing RestartWorkerCommand.
This commit is contained in:
mika kuns
2026-08-06 11:36:26 +02:00
parent 2ad9bdd851
commit 8bc7bc0c4f
14 changed files with 281 additions and 1 deletions
+16
View File
@@ -26,6 +26,22 @@ public sealed class GitService
return stdout.Trim(); return stdout.Trim();
} }
/// <summary>
/// True if <paramref name="ancestorSha"/> is an ancestor of (or equal to) <paramref name="descendantSha"/>,
/// via `git merge-base --is-ancestor`. Null means the answer can't be determined (e.g. the commit is
/// unknown in this repo) — callers must treat that as "unknown", never as "not an ancestor".
/// </summary>
public async Task<bool?> IsAncestorAsync(string repoDir, string ancestorSha, string descendantSha, CancellationToken ct = default)
{
var (exitCode, _, _) = await RunGitAsync(repoDir, ["merge-base", "--is-ancestor", ancestorSha, descendantSha], ct);
return exitCode switch
{
0 => true,
1 => false,
_ => null,
};
}
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default) public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{ {
await WorktreeAddGate.WaitAsync(ct); await WorktreeAddGate.WaitAsync(ct);
@@ -648,6 +648,9 @@
"available": "Update verfügbar: v", "available": "Update verfügbar: v",
"updateNow": "Jetzt aktualisieren", "updateNow": "Jetzt aktualisieren",
"dismiss": "Ausblenden" "dismiss": "Ausblenden"
},
"staleWorker": {
"message": "Der Worker läuft auf einem älteren Stand als der gemergte main-Branch dieses Repos — Neustart nötig, damit gemergte Änderungen wirken."
} }
}, },
"vm": { "vm": {
@@ -648,6 +648,9 @@
"available": "Update available: v", "available": "Update available: v",
"updateNow": "Update now", "updateNow": "Update now",
"dismiss": "Dismiss" "dismiss": "Dismiss"
},
"staleWorker": {
"message": "The worker is running an older build than this repo's merged main — restart it so your merged changes take effect."
} }
}, },
"vm": { "vm": {
@@ -130,6 +130,9 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<string> GetLastPrepLogAsync(); Task<string> GetLastPrepLogAsync();
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync(); Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
/// <summary>Git SHA the running worker was built from (null when offline or the build isn't stamped).</summary>
Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync();
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync(); Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto); Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
Task DeletePrimeScheduleAsync(Guid id); Task DeletePrimeScheduleAsync(Guid id);
+4
View File
@@ -423,6 +423,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<string> GetLastPrepLogAsync() public async Task<string> GetLastPrepLogAsync()
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty; => await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync()
=> TryInvokeAsync<WorkerBuildInfoDto>("GetWorkerBuildInfo");
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>(); => await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
@@ -687,6 +690,7 @@ public sealed record LaunchSpec(
public sealed record ForceRemoveResultDto(bool Removed, string? Reason); public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question); public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
public sealed record WorkerBuildInfoDto(string? BuildSha);
public sealed record OnlineInboxStateDto( public sealed record OnlineInboxStateDto(
bool Enabled, bool Enabled,
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
@@ -34,6 +35,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private readonly UpdateCheckService _updateCheck = null!; private readonly UpdateCheckService _updateCheck = null!;
private readonly InstallerLocator _installerLocator = null!; private readonly InstallerLocator _installerLocator = null!;
private readonly WorkerLocator _workerLocator = null!; private readonly WorkerLocator _workerLocator = null!;
private readonly GitService? _git;
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!; private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!; private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
@@ -102,6 +104,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string? _updateBannerLatestVersion; [ObservableProperty] private string? _updateBannerLatestVersion;
private bool _bannerDismissedThisSession; private bool _bannerDismissedThisSession;
// Persistent (not auto-clearing) banner: the running worker predates the selected list's
// merged HEAD, so "verified" claims made against the current process are stale. ClaudeDo-repo
// only — see RefreshStaleWorkerCheckAsync.
[ObservableProperty] private bool _isStaleWorkerBannerVisible;
[ObservableProperty] [ObservableProperty]
private double _windowWidth = 1280; private double _windowWidth = 1280;
@@ -212,7 +219,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Func<MergeModalViewModel> mergeVmFactory, Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory, Func<RepoImportModalViewModel> repoImportVmFactory,
MissionControlViewModel missionControl, MissionControlViewModel missionControl,
UsagePillViewModel usagePill) UsagePillViewModel usagePill,
GitService? git = null)
{ {
Lists = lists; Tasks = tasks; Details = details; Worker = worker; Lists = lists; Tasks = tasks; Details = details; Worker = worker;
MissionControl = missionControl; MissionControl = missionControl;
@@ -232,7 +240,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_usageMonitorVmFactory = usageMonitorVmFactory; _usageMonitorVmFactory = usageMonitorVmFactory;
_mergeVmFactory = mergeVmFactory; _mergeVmFactory = mergeVmFactory;
_repoImportVmFactory = repoImportVmFactory; _repoImportVmFactory = repoImportVmFactory;
_git = git;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.NotesRequested += () => Details.ShowNotes(); Tasks.NotesRequested += () => Details.ShowNotes();
Tasks.PrepRequested += () => Details.ShowPrep(); Tasks.PrepRequested += () => Details.ShowPrep();
@@ -285,6 +295,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
} }
}; };
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived; Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict; Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
Worker.PrimeFired += OnPrimeFired; Worker.PrimeFired += OnPrimeFired;
_clearTimer.Elapsed += (_, _) => _clearTimer.Elapsed += (_, _) =>
@@ -324,6 +335,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_connectTimer.Dispose(); _connectTimer.Dispose();
_primeStatusTimer.Stop(); _primeStatusTimer.Stop();
_primeStatusTimer.Dispose(); _primeStatusTimer.Dispose();
_staleWorkerCts?.Cancel();
_staleWorkerCts?.Dispose();
} }
private void RefreshBannerFromStatus() private void RefreshBannerFromStatus()
@@ -343,6 +356,57 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
} }
} }
private CancellationTokenSource? _staleWorkerCts;
// Re-evaluates the stale-worker banner for the currently selected list. Cheap (one hub call +
// up to two git subprocesses) and only ever runs for a git-backed list, so it's fine to fire on
// every selection change / reconnect rather than caching.
private async Task RefreshStaleWorkerCheckAsync()
{
_staleWorkerCts?.Cancel();
var cts = new CancellationTokenSource();
_staleWorkerCts = cts;
var stale = await ComputeIsStaleWorkerAsync(Lists?.SelectedList?.WorkingDir, cts.Token);
if (cts.IsCancellationRequested) return;
IsStaleWorkerBannerVisible = stale;
}
private async Task<bool> ComputeIsStaleWorkerAsync(string? workingDir, CancellationToken ct)
{
if (_git is null || Worker is null || string.IsNullOrWhiteSpace(workingDir)) return false;
try
{
var buildInfo = await Worker.GetWorkerBuildInfoAsync();
var buildSha = buildInfo?.BuildSha;
if (string.IsNullOrWhiteSpace(buildSha)) return false;
if (!await _git.IsGitRepoAsync(workingDir, ct)) return false;
var head = await _git.RevParseHeadAsync(workingDir, ct);
var isAncestor = string.Equals(buildSha, head, StringComparison.OrdinalIgnoreCase)
? (bool?)false // equal — never "stale" on a match, and no need to ask git
: await _git.IsAncestorAsync(workingDir, buildSha, head, ct);
return ShouldShowStaleWorkerBanner(buildSha, head, isAncestor);
}
catch
{
// Worker offline, dir no longer a repo, etc. — unknown, so stay quiet.
return false;
}
}
// Pure decision extracted for testability. isAncestor is the tri-state result of
// `git merge-base --is-ancestor buildSha head`: true = worker predates head (stale), false =
// equal or diverged (never claim "stale" on a match or an unrelated history), null = unknown
// (e.g. buildSha isn't a commit this repo knows about — never treat "unknown" as "stale").
internal static bool ShouldShowStaleWorkerBanner(string? buildSha, string? headSha, bool? isAncestor)
{
if (string.IsNullOrWhiteSpace(buildSha) || string.IsNullOrWhiteSpace(headSha)) return false;
if (string.Equals(buildSha, headSha, StringComparison.OrdinalIgnoreCase)) return false;
return isAncestor == true;
}
[RelayCommand] [RelayCommand]
private void OpenMissionControl() private void OpenMissionControl()
{ {
+23
View File
@@ -258,5 +258,28 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Stale-worker notice — bottom-right corner, above the update popup. Persistent
(no auto-dismiss timer): the running worker predates the selected list's merged
HEAD, so a restart is needed before "verified" claims against it are trustworthy. -->
<Border Grid.Row="0" Grid.RowSpan="3"
VerticalAlignment="Bottom" HorizontalAlignment="Right"
Margin="0,0,16,100"
ZIndex="100"
IsVisible="{Binding IsStaleWorkerBannerVisible}"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="14,10">
<StackPanel Spacing="8" Width="260">
<TextBlock Classes="body" Text="{loc:Tr shell.staleWorker.message}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Classes="btn"
Content="{loc:Tr shell.menu.restartWorker}"
Command="{Binding RestartWorkerCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</Grid> </Grid>
</Window> </Window>
@@ -35,4 +35,18 @@
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" /> <InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
</ItemGroup> </ItemGroup>
<!-- Stamps this worker's exact commit into AssemblyInformationalVersion as "+{sha}" so a
running instance can report which commit it was built from (see WorkerHub.GetWorkerBuildInfo).
Independent of MinVer: MinVerVersionOverride (release builds) replaces the base version but
doesn't touch SourceRevisionId, and the SDK appends it to InformationalVersion regardless. -->
<Target Name="SetBuildRevisionFromGit" BeforeTargets="GetAssemblyVersion" Condition="'$(SourceRevisionId)' == ''">
<Exec Command="git rev-parse HEAD"
WorkingDirectory="$(MSBuildProjectDirectory)"
ConsoleToMSBuild="true"
StandardOutputImportance="low"
ContinueOnError="WarnAndContinue">
<Output TaskParameter="ConsoleOutput" PropertyName="SourceRevisionId" />
</Exec>
</Target>
</Project> </Project>
+23
View File
@@ -29,6 +29,10 @@ namespace ClaudeDo.Worker.Hub;
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt); public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
/// <summary>Git SHA the running worker was built from. Null when the build wasn't stamped
/// (e.g. a local dev build without the SourceRevisionId target).</summary>
public record WorkerBuildInfoDto(string? BuildSha);
public record AppSettingsDto( public record AppSettingsDto(
string DefaultClaudeInstructions, string DefaultClaudeInstructions,
string DefaultModel, string DefaultModel,
@@ -159,6 +163,20 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private static readonly string Version = private static readonly string Version =
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
// SourceRevisionId (set by ClaudeDo.Worker.csproj's git-rev-parse build target) is appended
// by the SDK to InformationalVersion as "+{sha}" -- take the segment after the LAST '+' so
// this stays correct even when MinVer's own pre-release metadata already contains a '+'.
private static readonly string? BuildSha = ParseBuildSha(
Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion);
internal static string? ParseBuildSha(string? informationalVersion)
{
if (string.IsNullOrEmpty(informationalVersion)) return null;
var plusIndex = informationalVersion.LastIndexOf('+');
if (plusIndex < 0 || plusIndex == informationalVersion.Length - 1) return null;
return informationalVersion[(plusIndex + 1)..];
}
private readonly QueueService _queue; private readonly QueueService _queue;
private readonly IQueueWaker _waker; private readonly IQueueWaker _waker;
private readonly AgentFileService _agentService; private readonly AgentFileService _agentService;
@@ -325,6 +343,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public string Ping() => $"pong v{Version}"; public string Ping() => $"pong v{Version}";
/// <summary>Lets the UI detect a worker that's still running an older build than the code it's
/// comparing against (e.g. a just-merged main) — a separate diagnostic rather than extending
/// Ping's "pong vX.Y.Z" text, since that format isn't ours to break for any existing caller.</summary>
public WorkerBuildInfoDto GetWorkerBuildInfo() => new(BuildSha);
public IReadOnlyList<ActiveTaskDto> GetActive() public IReadOnlyList<ActiveTaskDto> GetActive()
{ {
return _queue.GetActive() return _queue.GetActive()
@@ -0,0 +1,47 @@
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class StaleWorkerBannerDecisionTests
{
private const string ShaA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private const string ShaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
[Fact]
public void True_when_build_is_a_strict_ancestor_of_head()
{
Assert.True(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: true));
}
[Fact]
public void False_when_build_equals_head_even_if_ancestor_check_says_true()
{
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaA, isAncestor: true));
}
[Fact]
public void False_when_ancestor_check_says_no()
{
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: false));
}
[Fact]
public void False_when_ancestor_check_is_unknown()
{
// Unknown must never be treated as stale.
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: null));
}
[Fact]
public void False_when_build_sha_missing()
{
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(null, ShaB, isAncestor: true));
}
[Fact]
public void False_when_head_sha_missing()
{
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, null, isAncestor: true));
}
}
@@ -150,6 +150,8 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask; public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
public string LastPrepLog = ""; public string LastPrepLog = "";
public virtual Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog); public virtual Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);
public WorkerBuildInfoDto? WorkerBuildInfo;
public virtual Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult(WorkerBuildInfo);
public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask; public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask;
public virtual Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null); public virtual Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null);
@@ -0,0 +1,36 @@
using ClaudeDo.Worker.Hub;
using Xunit;
namespace ClaudeDo.Worker.Tests.Hub;
public sealed class WorkerBuildInfoHubTests
{
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData("1.2.3", null)]
[InlineData("1.2.3+", null)]
[InlineData("1.2.3+abc1234", "abc1234")]
// MinVer's own pre-release metadata can already contain a '+' (e.g. "+devsha"); our
// SourceRevisionId is appended last, so only the segment after the LAST '+' is authoritative.
[InlineData("0.0.0-alpha.0.4+devsha+f5b1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9", "f5b1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9")]
public void ParseBuildSha_ExtractsSegmentAfterLastPlus(string? informationalVersion, string? expected)
{
Assert.Equal(expected, WorkerHub.ParseBuildSha(informationalVersion));
}
[Fact]
public void GetWorkerBuildInfo_ReturnsDto()
{
var hub = new WorkerHub(
null!, null!, null!, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
// No assertion on the actual SHA value (depends on the build environment) — just that
// the call doesn't throw and always returns a DTO, never null.
var info = hub.GetWorkerBuildInfo();
Assert.NotNull(info);
}
}
@@ -317,4 +317,45 @@ public class GitServiceMergeTests : IDisposable
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim()); Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain"))); Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
} }
[Fact]
public async Task IsAncestorAsync_EarlierCommit_ReturnsTrue()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var first = repo.BaseCommit;
File.WriteAllText(Path.Combine(repo.RepoDir, "second.txt"), "second\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: second commit");
var git = new GitService();
var head = (await git.RevParseHeadAsync(repo.RepoDir)).Trim();
Assert.True(await git.IsAncestorAsync(repo.RepoDir, first, head));
Assert.False(await git.IsAncestorAsync(repo.RepoDir, head, first));
}
[Fact]
public async Task IsAncestorAsync_SameCommit_ReturnsTrue()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var git = new GitService();
var head = (await git.RevParseHeadAsync(repo.RepoDir)).Trim();
Assert.True(await git.IsAncestorAsync(repo.RepoDir, head, head));
}
[Fact]
public async Task IsAncestorAsync_UnknownCommit_ReturnsNull()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var git = new GitService();
var head = (await git.RevParseHeadAsync(repo.RepoDir)).Trim();
var bogus = new string('a', 40);
Assert.Null(await git.IsAncestorAsync(repo.RepoDir, bogus, head));
}
} }
@@ -141,6 +141,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask; public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask; public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty); public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult<WorkerBuildInfoDto?>(null);
public Task RefineTaskAsync(string taskId) => Task.CompletedTask; public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null); public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null);
public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask; public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;