chore(claude-do): merge [C4] Rebase-after-Merge sichtbar machen + WorktreeMaintenanc

ClaudeDo-Task: 0a64e32d-6308-4f2d-a69e-fb8b3cabd01a
This commit is contained in:
mika kuns
2026-08-21 13:51:01 +02:00
8 changed files with 183 additions and 9 deletions
@@ -1290,7 +1290,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
if (progressTaskId != taskId || phase != MergePhaseVerifying) return;
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(elapsedSeconds)));
}
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
void OnOperationProgress(string opKey, string phase, int current, int total)
{
if (opKey != taskId || phase != MergePhaseRebasing) return;
ApproveOp.Report(FormatRebasing(current, total));
}
_worker.MergeProgressEvent += OnMergeProgress;
_worker.OperationProgressEvent += OnOperationProgress;
try
{
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
@@ -1314,15 +1322,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
finally
{
_worker.MergeProgressEvent -= OnMergeProgress;
_worker.OperationProgressEvent -= OnOperationProgress;
}
}
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
private const string MergePhaseVerifying = "verifying";
/// Mirrors TaskMergeService.PhaseRebasing — a hub payload token, not a display string.
private const string MergePhaseRebasing = "rebasing";
private static string FormatElapsed(int seconds) =>
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss");
private static string FormatRebasing(int current, int total) =>
total > 0
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
: Loc.T("ops.worker.rebasingAfterMerge");
// Force the diff to have been opened before a merge can happen — but only when
// there is actually something to inspect (a childless sandbox run with no worktree
// has no diff, so it approves straight through).
@@ -95,6 +95,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
// client, and a transient VM left on that event would outlive its window.
ProgressMessage = Loc.T("vm.merge.progressMerging");
_worker.MergeProgressEvent += OnMergeProgress;
_worker.OperationProgressEvent += OnOperationProgress;
try
{
var result = await _worker.MergeTaskAsync(
@@ -148,6 +149,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
finally
{
_worker.MergeProgressEvent -= OnMergeProgress;
_worker.OperationProgressEvent -= OnOperationProgress;
ProgressMessage = null;
IsBusy = false;
}
@@ -156,6 +158,9 @@ public sealed partial class MergeModalViewModel : ViewModelBase
private void OnMergeProgress(string taskId, string phase, int elapsedSeconds)
{
if (taskId != TaskId) return;
// Rebasing is handled by OnOperationProgress (needs the branch total, which this
// elapsed-seconds-only forwarder drops) -- don't overwrite it with the generic fallback.
if (phase == MergePhaseRebasing) return;
ProgressMessage = phase switch
{
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)),
@@ -163,9 +168,22 @@ public sealed partial class MergeModalViewModel : ViewModelBase
};
}
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
private void OnOperationProgress(string opKey, string phase, int current, int total)
{
if (opKey != TaskId || phase != MergePhaseRebasing) return;
ProgressMessage = total > 0
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
: Loc.T("ops.worker.rebasingAfterMerge");
}
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
private const string MergePhaseVerifying = "verifying";
/// Mirrors TaskMergeService.PhaseRebasing — a hub payload token, not a display string.
private const string MergePhaseRebasing = "rebasing";
private static string FormatElapsed(int seconds) =>
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss");
@@ -64,6 +64,7 @@ public sealed class TaskMergeService
// Phase tokens for the OperationProgress broadcast — stable identifiers, localized by the UI.
public const string PhaseMerging = "merging";
public const string PhaseVerifying = "verifying";
public const string PhaseRebasing = "rebasing";
public const string PreviewClean = "clean";
public const string PreviewConflict = "conflict";
@@ -296,9 +297,15 @@ public sealed class TaskMergeService
var tasks = await new TaskRepository(ctx).GetByListIdAsync(list.Id, ct);
candidates = tasks.Where(t => t.Id != mergedTask.Id && t.Status == TaskStatus.WaitingForReview).ToList();
}
if (candidates.Count == 0) return;
foreach (var candidate in candidates)
await RebaseOneIfOverlappingAsync(candidate, targetBranch, newTargetTip, landedFiles, ct);
// Runs where the stalled "Merging…" phase would otherwise sit unchanged in the UI while
// this loop does real (if best-effort) work — see the correction note in the task spec.
for (var i = 0; i < candidates.Count; i++)
{
await _broadcaster.OperationProgress(mergedTask.Id, PhaseRebasing, i + 1, candidates.Count);
await RebaseOneIfOverlappingAsync(candidates[i], targetBranch, newTargetTip, landedFiles, ct);
}
}
private async Task RebaseOneIfOverlappingAsync(
@@ -1,6 +1,7 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Hub;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Worktrees;
@@ -11,20 +12,31 @@ public sealed class WorktreeMaintenanceService
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks, IReadOnlyList<string> RemovedTaskIds);
public sealed record ForceRemoveResult(bool Removed, string? Reason, bool BranchDeleted);
// opKey/phase for the OperationProgress broadcast. Not task-bound, so opKey is a stable
// string rather than a task id — mirrors "startup-recovery"/"worktree-cleanup" in HubBroadcaster.
public const string OpKey = "worktree-maintenance";
public const string PhaseMaintaining = "maintaining-worktrees";
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly ILogger<WorktreeMaintenanceService> _logger;
private readonly HubBroadcaster? _broadcaster;
public WorktreeMaintenanceService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
ILogger<WorktreeMaintenanceService> logger)
ILogger<WorktreeMaintenanceService> logger,
HubBroadcaster? broadcaster = null)
{
_dbFactory = dbFactory;
_git = git;
_logger = logger;
_broadcaster = broadcaster;
}
private Task ReportProgress(int current, int total) =>
_broadcaster?.OperationProgress(OpKey, PhaseMaintaining, current, total) ?? Task.CompletedTask;
public async Task<CleanupResult> CleanupFinishedAsync(string? listId = null, CancellationToken ct = default)
{
using var context = _dbFactory.CreateDbContext();
@@ -41,13 +53,14 @@ public sealed class WorktreeMaintenanceService
int removed = 0;
var removedTaskIds = new List<string>();
foreach (var row in rows)
for (var i = 0; i < rows.Count; i++)
{
var (rowRemoved, _) = await TryRemoveAsync(row, force: false, ct);
await ReportProgress(i + 1, rows.Count);
var (rowRemoved, _) = await TryRemoveAsync(rows[i], force: false, ct);
if (rowRemoved)
{
removed++;
removedTaskIds.Add(row.TaskId);
removedTaskIds.Add(rows[i].TaskId);
}
}
return new CleanupResult(removed, removedTaskIds);
@@ -70,13 +83,14 @@ public sealed class WorktreeMaintenanceService
int removed = 0;
var removedTaskIds = new List<string>();
foreach (var row in rows)
for (var i = 0; i < rows.Count; i++)
{
var (rowRemoved, _) = await TryRemoveAsync(row, force: true, ct);
await ReportProgress(i + 1, rows.Count);
var (rowRemoved, _) = await TryRemoveAsync(rows[i], force: true, ct);
if (rowRemoved)
{
removed++;
removedTaskIds.Add(row.TaskId);
removedTaskIds.Add(rows[i].TaskId);
}
}
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0, removedTaskIds);